feat(mirror): verify repository OpenPGP signatures
Some checks failed
Go package / build (push) Has been cancelled

- Reject RPM, Debian, and Arch metadata that fails the configured signature policy before trusting its checksums or package paths.
- Preserve the last verified repository generation when signed metadata rotates inconsistently or required files remain unavailable.
- Let operators pin signer keyrings or retrieve unknown issuers from configured keyservers, including archived GnuPG v1 RSA signatures.
- Gate cached server entry points and package misses until a crawl completes under the active signature policy.
This commit is contained in:
James Coleman 2026-09-01 18:22:04 -05:00
parent 5af1dd8928
commit 2889766a20
24 changed files with 1896 additions and 124 deletions

View file

@ -1 +1 @@
0.1.0 0.1.1

View file

@ -117,6 +117,19 @@ crawler:
# which repositories the upstream has dropped since. Zero scans on every # which repositories the upstream has dropped since. Zero scans on every
# run. # run.
discover_cache: 72h discover_cache: 72h
# signature_mode controls OpenPGP checks for signed repository metadata.
# if-present accepts unsigned repositories but rejects a signature that
# cannot be verified. required also rejects missing signatures.
signature_mode: off
# When local public keyrings are configured, only their signers are
# accepted. Without them, repository-provided and keyserver-retrieved keys
# can prove that a data/signature pair matches but not who published it.
gpg_keys: []
# The configured list replaces the defaults. Set it to [] to disable
# keyserver lookup.
keyservers:
- https://keyserver.ubuntu.com
- https://keys.openpgp.org
# Backoff steps applied once a repository stops being requested. The # Backoff steps applied once a repository stops being requested. The
# first tier is repo_crawl_interval; each entry extends the refresh # first tier is repo_crawl_interval; each entry extends the refresh
# interval as the resource ages, and once the total budget elapses the # interval as the resource ages, and once the total budget elapses the

View file

@ -92,6 +92,13 @@ type CrawlerConfig struct {
// DiscoverCache is how long a discovery crawl's results are reused // DiscoverCache is how long a discovery crawl's results are reused
// before the tree is scanned again. // before the tree is scanned again.
DiscoverCache time.Duration `mapstructure:"discover_cache" yaml:"discover_cache" validate:"gte=0"` DiscoverCache time.Duration `mapstructure:"discover_cache" yaml:"discover_cache" validate:"gte=0"`
// SignatureMode controls OpenPGP verification of repository metadata.
SignatureMode string `mapstructure:"signature_mode" yaml:"signature_mode" validate:"oneof=off if-present required"`
// GPGKeys lists local public keyring files used for signature checks.
GPGKeys []string `mapstructure:"gpg_keys" yaml:"gpg_keys"`
// Keyservers lists optional OpenPGP keyservers used to retrieve unknown
// signature issuers.
Keyservers []string `mapstructure:"keyservers" yaml:"keyservers" validate:"omitempty,dive,url"`
} }
// TraceConfig describes the mirror this instance publishes, and is written // TraceConfig describes the mirror this instance publishes, and is written
@ -203,6 +210,15 @@ func loadConfig(configPath string) (*Config, error) {
c.StatePath = "/etc/repo-sync/state.yaml" c.StatePath = "/etc/repo-sync/state.yaml"
} }
} }
if file != "" {
for i, name := range c.Crawler.GPGKeys {
name = strings.TrimSpace(name)
if name != "" && name != "~" && !strings.HasPrefix(name, "~/") && !filepath.IsAbs(name) {
name = filepath.Join(filepath.Dir(file), name)
}
c.Crawler.GPGKeys[i] = name
}
}
if err := c.finalize(); err != nil { if err := c.finalize(); err != nil {
return nil, err return nil, err
@ -292,6 +308,11 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("crawler.missing_mode", "retry") v.SetDefault("crawler.missing_mode", "retry")
v.SetDefault("crawler.missing_retries", 3) v.SetDefault("crawler.missing_retries", 3)
v.SetDefault("crawler.discover_cache", 72*time.Hour) v.SetDefault("crawler.discover_cache", 72*time.Hour)
v.SetDefault("crawler.signature_mode", "off")
v.SetDefault("crawler.keyservers", []string{
"https://keyserver.ubuntu.com",
"https://keys.openpgp.org",
})
v.SetDefault("crawler.refresh_schedule", []time.Duration{ v.SetDefault("crawler.refresh_schedule", []time.Duration{
12 * time.Hour, 24 * time.Hour, 48 * time.Hour, 12 * time.Hour, 24 * time.Hour, 48 * time.Hour,
72 * time.Hour, 96 * time.Hour, 120 * time.Hour, 72 * time.Hour, 96 * time.Hour, 120 * time.Hour,
@ -306,6 +327,12 @@ func (c *Config) finalize() error {
if err := validateConfig(c); err != nil { if err := validateConfig(c); err != nil {
return err return err
} }
for _, raw := range c.Crawler.Keyservers {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("invalid crawler keyserver URL %q", raw)
}
}
if err := c.indexDomains(); err != nil { if err := c.indexDomains(); err != nil {
return err return err
} }
@ -315,6 +342,13 @@ func (c *Config) finalize() error {
// canonicalize normalizes user-supplied values before validation. // canonicalize normalizes user-supplied values before validation.
func (c *Config) canonicalize() { func (c *Config) canonicalize() {
c.StatePath = expandHome(strings.TrimSpace(c.StatePath)) c.StatePath = expandHome(strings.TrimSpace(c.StatePath))
c.Crawler.SignatureMode = strings.ToLower(strings.TrimSpace(c.Crawler.SignatureMode))
for i := range c.Crawler.GPGKeys {
c.Crawler.GPGKeys[i] = expandHome(strings.TrimSpace(c.Crawler.GPGKeys[i]))
}
for i := range c.Crawler.Keyservers {
c.Crawler.Keyservers[i] = strings.TrimRight(strings.TrimSpace(c.Crawler.Keyservers[i]), "/")
}
for i := range c.Domains { for i := range c.Domains {
c.Domains[i].Domain = strings.ToLower(strings.TrimSpace(c.Domains[i].Domain)) c.Domains[i].Domain = strings.ToLower(strings.TrimSpace(c.Domains[i].Domain))
c.Domains[i].Role = strings.ToLower(strings.TrimSpace(c.Domains[i].Role)) c.Domains[i].Role = strings.ToLower(strings.TrimSpace(c.Domains[i].Role))

View file

@ -4,6 +4,9 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// writeConfig writes a temp config file and returns its path. // writeConfig writes a temp config file and returns its path.
@ -140,3 +143,15 @@ func TestInitWithoutServerSections(t *testing.T) {
t.Error("InitServer accepted a configuration with no domains") t.Error("InitServer accepted a configuration with no domains")
} }
} }
// TestKeyserverConfiguration verifies the built-in lookup services can be
// replaced or disabled without retaining entries from the default list.
func TestKeyserverConfiguration(t *testing.T) {
path := writeConfig(t, "crawler:\n keyservers:\n - https://keys.example.com\n")
require.NoError(t, Init(path))
assert.Equal(t, []string{"https://keys.example.com"}, C.Crawler.Keyservers)
path = writeConfig(t, "crawler:\n keyservers: []\n")
require.NoError(t, Init(path))
assert.Empty(t, C.Crawler.Keyservers)
}

View file

@ -71,6 +71,10 @@ func Reload() {
// treat missing optional files differently from transport failures. // treat missing optional files differently from transport failures.
var ErrNotFound = errors.New("upstream file not found") var ErrNotFound = errors.New("upstream file not found")
// ErrForbidden marks an upstream file that returned HTTP 403. Callers may
// tolerate this for optional files on object stores that hide missing keys.
var ErrForbidden = errors.New("upstream file forbidden")
// ErrNotModified marks a conditional request the upstream answered with // ErrNotModified marks a conditional request the upstream answered with
// 304, meaning the local copy is already current. // 304, meaning the local copy is already current.
var ErrNotModified = errors.New("upstream file not modified") var ErrNotModified = errors.New("upstream file not modified")
@ -229,6 +233,17 @@ func lockFile(dst string) func() {
// With stage set the download is written next to dst for a later promote so // With stage set the download is written next to dst for a later promote so
// readers of a live tree never see a partially updated repository. // readers of a live tree never see a partially updated repository.
func File(ctx context.Context, src *Source, reqPath, dst string, want *Expect, stage, verifyExisting bool) (FileState, error) { func File(ctx context.Context, src *Source, reqPath, dst string, want *Expect, stage, verifyExisting bool) (FileState, error) {
return file(ctx, src, reqPath, dst, want, stage, verifyExisting, false)
}
// FileFresh downloads reqPath without conditionally reusing the current
// destination. It is used when related upstream files must be refetched as
// one generation after a consistency check fails.
func FileFresh(ctx context.Context, src *Source, reqPath, dst string, want *Expect, stage bool) (FileState, error) {
return file(ctx, src, reqPath, dst, want, stage, false, true)
}
func file(ctx context.Context, src *Source, reqPath, dst string, want *Expect, stage, verifyExisting, force bool) (FileState, error) {
// Serialize writers to this destination so concurrent callers, such // Serialize writers to this destination so concurrent callers, such
// as the mirror server's on-demand fetches and background crawls, // as the mirror server's on-demand fetches and background crawls,
// cannot interleave partial downloads of the same file. // cannot interleave partial downloads of the same file.
@ -259,7 +274,7 @@ func File(ctx context.Context, src *Source, reqPath, dst string, want *Expect, s
} }
for { for {
state, err := download(ctx, src, reqPath, dst, want, stage, resumeFrom) state, err := download(ctx, src, reqPath, dst, want, stage, resumeFrom, force)
if err != nil && resumeFrom > 0 && !errors.Is(err, ErrNotFound) && ctx.Err() == nil { if err != nil && resumeFrom > 0 && !errors.Is(err, ErrNotFound) && ctx.Err() == nil {
// The partial may not be a prefix of the current upstream file; // The partial may not be a prefix of the current upstream file;
// retry once from scratch. // retry once from scratch.
@ -274,12 +289,12 @@ func File(ctx context.Context, src *Source, reqPath, dst string, want *Expect, s
// download performs one transfer attempt for File, appending to the // download performs one transfer attempt for File, appending to the
// partial file when resuming from a prior failure. // partial file when resuming from a prior failure.
func download(ctx context.Context, src *Source, reqPath, dst string, want *Expect, stage bool, resumeFrom int64) (FileState, error) { func download(ctx context.Context, src *Source, reqPath, dst string, want *Expect, stage bool, resumeFrom int64, force bool) (FileState, error) {
// Files without published checksums cannot be verified locally, so an // Files without published checksums cannot be verified locally, so an
// existing copy is revalidated with a conditional request instead of // existing copy is revalidated with a conditional request instead of
// being re-downloaded every run. // being re-downloaded every run.
var modifiedSince time.Time var modifiedSince time.Time
if want == nil { if want == nil && !force {
if info, err := os.Stat(dst); err == nil && info.Mode().IsRegular() { if info, err := os.Stat(dst); err == nil && info.Mode().IsRegular() {
modifiedSince = info.ModTime() modifiedSince = info.ModTime()
} }

View file

@ -99,6 +99,10 @@ func (s *Source) Get(ctx context.Context, reqPath string, o GetOptions) (*http.R
resp.Body.Close() resp.Body.Close()
return nil, fmt.Errorf("fetch %s: %w", target, ErrNotFound) return nil, fmt.Errorf("fetch %s: %w", target, ErrNotFound)
} }
if resp.StatusCode == http.StatusForbidden {
resp.Body.Close()
return nil, fmt.Errorf("fetch %s: %w", target, ErrForbidden)
}
if resp.StatusCode == http.StatusNotModified && !o.ModifiedSince.IsZero() { if resp.StatusCode == http.StatusNotModified && !o.ModifiedSince.IsZero() {
resp.Body.Close() resp.Body.Close()
return nil, fmt.Errorf("fetch %s: %w", target, ErrNotModified) return nil, fmt.Errorf("fetch %s: %w", target, ErrNotModified)

View file

@ -3,10 +3,14 @@ package fetch
import ( import (
"context" "context"
"errors" "errors"
"net/http"
"net/http/httptest"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/grmrgecko/repo-sync/internal/testrepos" "github.com/grmrgecko/repo-sync/internal/testrepos"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestParseMirrorlist verifies URL extraction, comment skipping, and // TestParseMirrorlist verifies URL extraction, comment skipping, and
@ -47,6 +51,20 @@ func TestSourceFailover(t *testing.T) {
} }
} }
// TestSourceForbidden verifies HTTP 403 remains distinct from a missing file
// so only callers fetching optional object-store keys can ignore it.
func TestSourceForbidden(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
}))
t.Cleanup(srv.Close)
_, err := NewSource([]string{srv.URL}).Get(context.Background(), "private", GetOptions{})
require.Error(t, err)
assert.ErrorIs(t, err, ErrForbidden)
assert.NotErrorIs(t, err, ErrNotFound)
}
// TestParseMetalink verifies base URL extraction from metalink documents. // TestParseMetalink verifies base URL extraction from metalink documents.
func TestParseMetalink(t *testing.T) { func TestParseMetalink(t *testing.T) {
body := `<?xml version="1.0"?><metalink xmlns="urn:ietf:params:xml:ns:metalink">` + body := `<?xml version="1.0"?><metalink xmlns="urn:ietf:params:xml:ns:metalink">` +

5
go.mod
View file

@ -3,6 +3,7 @@ module github.com/grmrgecko/repo-sync
go 1.26.4 go 1.26.4
require ( require (
github.com/ProtonMail/go-crypto v1.4.1
github.com/alecthomas/kong v1.16.0 github.com/alecthomas/kong v1.16.0
github.com/coreos/go-systemd/v22 v22.7.0 github.com/coreos/go-systemd/v22 v22.7.0
github.com/go-playground/validator/v10 v10.30.3 github.com/go-playground/validator/v10 v10.30.3
@ -10,12 +11,15 @@ require (
github.com/klauspost/compress v1.19.1 github.com/klauspost/compress v1.19.1
github.com/sirupsen/logrus v1.9.4 github.com/sirupsen/logrus v1.9.4
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/ulikunitz/xz v0.5.16 github.com/ulikunitz/xz v0.5.16
golang.org/x/net v0.57.0 golang.org/x/net v0.57.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/cloudflare/circl v1.6.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
@ -23,6 +27,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect

4
go.sum
View file

@ -1,9 +1,13 @@
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.16.0 h1:g92/kUxBcdcTPOM79yE63viJgtcp5dNyrB3/O2cjYT4= github.com/alecthomas/kong v1.16.0 h1:g92/kUxBcdcTPOM79yE63viJgtcp5dNyrB3/O2cjYT4=
github.com/alecthomas/kong v1.16.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= github.com/alecthomas/kong v1.16.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=

View file

@ -16,8 +16,117 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/clearsign"
) )
// SigningKey is a generated OpenPGP key used by signed repository fixtures.
type SigningKey struct {
entity *openpgp.Entity
}
// NewSigningKey generates an OpenPGP signing key for a test repository.
func NewSigningKey(t *testing.T) *SigningKey {
t.Helper()
entity, err := openpgp.NewEntity("Repository test key", "", "repo@example.com", nil)
if err != nil {
t.Fatal(err)
}
return &SigningKey{entity: entity}
}
// PublicKey returns the armored public keyring for the signing key.
func (k *SigningKey) PublicKey(t *testing.T) []byte {
t.Helper()
var out bytes.Buffer
w, err := armor.Encode(&out, openpgp.PublicKeyType, nil)
if err != nil {
t.Fatal(err)
}
if err := k.entity.Serialize(w); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
// Sign returns an armored detached signature over data.
func (k *SigningKey) Sign(t *testing.T, data []byte) []byte {
t.Helper()
var out bytes.Buffer
if err := openpgp.ArmoredDetachSign(&out, k.entity, bytes.NewReader(data), nil); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
// SignBinary returns a binary detached signature over data.
func (k *SigningKey) SignBinary(t *testing.T, data []byte) []byte {
t.Helper()
var out bytes.Buffer
if err := openpgp.DetachSign(&out, k.entity, bytes.NewReader(data), nil); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
// ClearSign returns an armored cleartext signature over data.
func (k *SigningKey) ClearSign(t *testing.T, data []byte) []byte {
t.Helper()
var out bytes.Buffer
w, err := clearsign.Encode(&out, k.entity.PrivateKey, nil)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(data); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
// SignRPMRepo replaces the fixture signature and publishes its public key.
func (k *SigningKey) SignRPMRepo(t *testing.T, dir string) {
t.Helper()
repomd, err := os.ReadFile(filepath.Join(dir, "repodata", "repomd.xml"))
if err != nil {
t.Fatal(err)
}
WriteFile(t, filepath.Join(dir, "repodata", "repomd.xml.asc"), k.Sign(t, repomd))
WriteFile(t, filepath.Join(dir, "repodata", "repomd.xml.key"), k.PublicKey(t))
}
// SignDebRepo signs the fixture Release file in both apt formats.
func (k *SigningKey) SignDebRepo(t *testing.T, dir string) {
t.Helper()
release, err := os.ReadFile(filepath.Join(dir, "dists", "test", "Release"))
if err != nil {
t.Fatal(err)
}
WriteFile(t, filepath.Join(dir, "dists", "test", "InRelease"), k.ClearSign(t, release))
WriteFile(t, filepath.Join(dir, "dists", "test", "Release.gpg"), k.SignBinary(t, release))
}
// SignArchRepo signs the fixture database and each package with binary
// detached signatures, matching files published by Arch mirrors.
func (k *SigningKey) SignArchRepo(t *testing.T, dir, name string, packages map[string][]byte) {
t.Helper()
database, err := os.ReadFile(filepath.Join(dir, name+".db"))
if err != nil {
t.Fatal(err)
}
WriteFile(t, filepath.Join(dir, name+".db.sig"), k.SignBinary(t, database))
for filename, data := range packages {
WriteFile(t, filepath.Join(dir, filename+".sig"), k.SignBinary(t, data))
}
}
// WriteFile creates a file with parent directories under a fixture tree. // WriteFile creates a file with parent directories under a fixture tree.
func WriteFile(t *testing.T, name string, data []byte) { func WriteFile(t *testing.T, name string, data []byte) {
t.Helper() t.Helper()

View file

@ -42,7 +42,6 @@ var archExtras = []string{
".db.tar.gz", ".db.tar.gz",
".files.tar.gz", ".files.tar.gz",
".links.tar.gz", ".links.tar.gz",
".db.sig",
".db.tar.gz.sig", ".db.tar.gz.sig",
".files.sig", ".files.sig",
".files.tar.gz.sig", ".files.tar.gz.sig",
@ -55,6 +54,17 @@ func syncArch(ctx context.Context, src *fetch.Source, repoURL, destDir string, o
miss := opts.newMissing(destDir) miss := opts.newMissing(destDir)
tr := newTrace(opts) tr := newTrace(opts)
ctx = tr.track(ctx) ctx = tr.track(ctx)
mode := opts.SignatureMode
if mode == "" {
mode = SignatureOff
}
signed := mode != SignatureOff
var stagedPaths []string
defer func() {
for _, filename := range stagedPaths {
_ = os.Remove(filename + fetch.StagedSuffix)
}
}()
// Determine the database name, which matches the repository rather // Determine the database name, which matches the repository rather
// than any fixed path. // than any fixed path.
@ -70,31 +80,135 @@ func syncArch(ctx context.Context, src *fetch.Source, repoURL, destDir string, o
if err != nil { if err != nil {
return err return err
} }
stagedPaths = append(stagedPaths, dbDst)
if _, err := fetch.File(ctx, src, name+".db", dbDst, nil, true, false); err != nil { if _, err := fetch.File(ctx, src, name+".db", dbDst, nil, true, false); err != nil {
return fmt.Errorf("fetch %s.db: %w", name, err) return fmt.Errorf("fetch %s.db: %w", name, err)
} }
dbSigDst, err := fetch.LocalJoin(destDir, name+".db.sig")
if err != nil {
return err
}
stagedPaths = append(stagedPaths, dbSigDst)
dbSigState, err := fetch.File(ctx, src, name+".db.sig", dbSigDst, nil, true, false)
if err != nil && !errors.Is(err, fetch.ErrNotFound) && !errors.Is(err, fetch.ErrForbidden) {
return fmt.Errorf("fetch %s.db.sig: %w", name, err)
}
if err != nil {
dbSigState = fetch.FileMissing
}
var verifier *signatureVerifier
if signed {
verifier, err = newSignatureVerifier(opts)
if err != nil {
return err
}
}
if signed && dbSigState != fetch.FileMissing {
for attempt := 0; attempt < 2; attempt++ {
fingerprint, verifyErr := verifier.verifyDetached(ctx, fetch.StagedOrFinal(dbDst), fetch.StagedOrFinal(dbSigDst), "")
if verifyErr == nil {
log.WithField("fingerprint", fingerprint).Debug("Verified pacman database signature.")
break
}
if attempt == 1 {
return fmt.Errorf("verify %s.db signature after refetch: %w", name, verifyErr)
}
log.WithError(verifyErr).Warn("Staged pacman database signature failed verification; refetching the pair.")
if _, err := fetch.FileFresh(ctx, src, name+".db", dbDst, nil, true); err != nil {
return fmt.Errorf("refetch %s.db: %w", name, err)
}
if _, err := fetch.FileFresh(ctx, src, name+".db.sig", dbSigDst, nil, true); err != nil {
return fmt.Errorf("refetch %s.db.sig: %w", name, err)
}
}
}
pkgs, err := readArchDB(fetch.StagedOrFinal(dbDst)) pkgs, err := readArchDB(fetch.StagedOrFinal(dbDst))
if err != nil { if err != nil {
return err return err
} }
// Download packages with their detached signatures. Signatures have no // Download packages with their detached signatures. Signed modes stage
// published checksums, so an existing file is trusted as-is. // both members so no unverified package becomes visible.
var jobs []fetch.Job var jobs []fetch.Job
for _, pkg := range pkgs { for _, pkg := range pkgs {
dst, err := fetch.LocalJoin(destDir, pkg.filename) dst, err := fetch.LocalJoin(destDir, pkg.filename)
if err != nil { if err != nil {
return err return err
} }
jobs = append(jobs, fetch.Job{ReqPath: pkg.filename, Dst: dst, Want: pkg.expect()}) var signatureExpectation *fetch.Expect
jobs = append(jobs, fetch.Job{ReqPath: pkg.filename + ".sig", Dst: dst + ".sig", Want: &fetch.Expect{Size: -1}, Optional: true}) if !signed {
signatureExpectation = &fetch.Expect{Size: -1}
}
stagedPaths = append(stagedPaths, dst, dst+".sig")
jobs = append(jobs, fetch.Job{ReqPath: pkg.filename, Dst: dst, Want: pkg.expect(), Stage: signed})
jobs = append(jobs, fetch.Job{ReqPath: pkg.filename + ".sig", Dst: dst + ".sig", Want: signatureExpectation, Optional: true, Stage: signed})
} }
log.WithField("packages", len(pkgs)).Info("Synchronizing packages.") log.WithField("packages", len(pkgs)).Info("Synchronizing packages.")
var packageStates []fetch.FileState
if opts.DryRun { if opts.DryRun {
fetch.PlanJobs(jobs, opts.Verify, keep) fetch.PlanJobs(jobs, opts.Verify || signed, keep)
} else if _, err := fetch.Many(ctx, src, jobs, opts.Workers, opts.Verify, keep, miss); err != nil { } else {
packageStates, err = fetch.Many(ctx, src, jobs, opts.Workers, opts.Verify || signed, keep, miss)
if err != nil {
return fmt.Errorf("fetch packages: %w", err) return fmt.Errorf("fetch packages: %w", err)
} }
}
// Verify all package pairs before publishing any of them. Required mode
// applies to package signatures; official Arch mirrors commonly omit a
// detached signature for the repository database itself.
if signed && !opts.DryRun {
for i, pkg := range pkgs {
packageJob := jobs[i*2]
signatureJob := jobs[i*2+1]
if packageStates[i*2] == fetch.FileMissing {
continue
}
if packageStates[i*2+1] == fetch.FileMissing {
if mode == SignatureRequired {
return fmt.Errorf("package signature is required but %s is missing", signatureJob.ReqPath)
}
continue
}
for attempt := 0; attempt < 2; attempt++ {
fingerprint, verifyErr := verifier.verifyDetached(ctx, fetch.StagedOrFinal(packageJob.Dst), fetch.StagedOrFinal(signatureJob.Dst), "")
if verifyErr == nil {
log.WithFields(log.Fields{"fingerprint": fingerprint, "package": pkg.filename}).Debug("Verified package signature.")
break
}
if attempt == 1 {
return fmt.Errorf("verify package signature %s after refetch: %w", signatureJob.ReqPath, verifyErr)
}
log.WithError(verifyErr).WithField("package", pkg.filename).Warn("Staged package signature failed verification; refetching the pair.")
if _, err := fetch.FileFresh(ctx, src, packageJob.ReqPath, packageJob.Dst, packageJob.Want, true); err != nil {
return fmt.Errorf("refetch package %s: %w", packageJob.ReqPath, err)
}
if _, err := fetch.FileFresh(ctx, src, signatureJob.ReqPath, signatureJob.Dst, signatureJob.Want, true); err != nil {
return fmt.Errorf("refetch package signature %s: %w", signatureJob.ReqPath, err)
}
}
}
if err := miss.Finish(); err != nil {
return err
}
for pass := 1; pass >= 0; pass-- {
for i, job := range jobs {
if i%2 != pass {
continue
}
if packageStates[i] == fetch.FileMissing {
if i%2 == 1 {
fetch.RemoveStale(job.Dst)
}
continue
}
if err := fetch.PromoteStaged(job.Dst); err != nil {
return err
}
}
}
}
// Stage the companion metadata files that exist upstream. A dry run // Stage the companion metadata files that exist upstream. A dry run
// only counts them, as none are needed for planning. // only counts them, as none are needed for planning.
@ -104,6 +218,7 @@ func syncArch(ctx context.Context, src *fetch.Source, repoURL, destDir string, o
if err != nil { if err != nil {
return err return err
} }
stagedPaths = append(stagedPaths, dst)
extraJobs = append(extraJobs, fetch.Job{ReqPath: name + suffix, Dst: dst, Optional: true, Stage: true}) extraJobs = append(extraJobs, fetch.Job{ReqPath: name + suffix, Dst: dst, Optional: true, Stage: true})
} }
if opts.DryRun { if opts.DryRun {
@ -128,6 +243,15 @@ func syncArch(ctx context.Context, src *fetch.Source, repoURL, destDir string, o
// Promote the database last so the published metadata chain is // Promote the database last so the published metadata chain is
// complete. // complete.
if dbSigState == fetch.FileMissing {
if !opts.DryRun {
fetch.RemoveStale(dbSigDst)
}
} else if err := fetch.PromoteOrDiscard(dbSigDst, opts.DryRun); err != nil {
return err
} else {
keep.Add(dbSigDst)
}
if err := fetch.PromoteOrDiscard(dbDst, opts.DryRun); err != nil { if err := fetch.PromoteOrDiscard(dbDst, opts.DryRun); err != nil {
return err return err
} }
@ -142,8 +266,11 @@ func syncArch(ctx context.Context, src *fetch.Source, repoURL, destDir string, o
fetch.PruneTree(destDir, keep, opts.PruneGrace, opts.DryRun) fetch.PruneTree(destDir, keep, opts.PruneGrace, opts.DryRun)
} }
// The database is published either way; missing packages only decide if signed {
// whether the run reports itself as failed. return nil
}
// Unsigned mode publishes the database before reporting package files
// that remained unavailable after retries.
return miss.Finish() return miss.Finish()
} }

View file

@ -10,9 +10,12 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"github.com/grmrgecko/repo-sync/fetch" "github.com/grmrgecko/repo-sync/fetch"
"github.com/grmrgecko/repo-sync/internal/testrepos" "github.com/grmrgecko/repo-sync/internal/testrepos"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestParseDesc verifies field extraction from a pacman desc entry. // TestParseDesc verifies field extraction from a pacman desc entry.
@ -33,6 +36,43 @@ func TestParseDesc(t *testing.T) {
} }
} }
// TestSyncArchSignedPackages verifies Arch binary signatures before replacing
// any package or repository database in the live tree.
func TestSyncArchSignedPackages(t *testing.T) {
www := t.TempDir()
repoDir := filepath.Join(www, "core", "os", "x86_64")
packages := testrepos.BuildArchRepo(t, repoDir, "core")
key := testrepos.NewSigningKey(t)
key.SignArchRepo(t, repoDir, "core", packages)
srv := testrepos.ServeDir(t, www)
dest := t.TempDir()
opts := &Options{
Type: RepoArch,
Destination: dest,
Workers: 2,
SignatureMode: SignatureRequired,
GPGKeyData: [][]byte{key.PublicKey(t)},
}
repoURL := srv.URL + "/core/os/x86_64"
require.NoError(t, syncOne(context.Background(), repoURL, opts.Type, opts))
var packageName string
for packageName = range packages {
break
}
localSig := filepath.Join(dest, "core", "os", "x86_64", packageName+".sig")
published := requireReadFile(t, localSig)
upstreamSig := filepath.Join(repoDir, packageName+".sig")
require.NoError(t, os.WriteFile(upstreamSig, []byte("invalid signature"), 0644))
future := time.Now().Add(2 * time.Second)
require.NoError(t, os.Chtimes(upstreamSig, future, future))
require.Error(t, syncOne(context.Background(), repoURL, opts.Type, opts))
assert.Equal(t, published, requireReadFile(t, localSig), "a bad package signature must not replace the verified pair")
assert.NoFileExists(t, localSig+fetch.StagedSuffix)
}
// TestReadArchDB verifies database parsing for both compressed and plain // TestReadArchDB verifies database parsing for both compressed and plain
// tar archives. // tar archives.
func TestReadArchDB(t *testing.T) { func TestReadArchDB(t *testing.T) {

View file

@ -45,6 +45,178 @@ type debRelease struct {
files map[string]*debFile files map[string]*debFile
} }
// debReleaseSet tracks one staged set of apt release documents.
type debReleaseSet struct {
names []string
dsts map[string]string
missing map[string]bool
data []byte
authenticated bool
}
// cleanup removes unpublished release files left by a failed synchronization.
func (r *debReleaseSet) cleanup() {
for _, dst := range r.dsts {
_ = os.Remove(dst + fetch.StagedSuffix)
}
}
// stageDebRelease stages and verifies the apt release documents before any
// checksums or package paths are trusted.
func stageDebRelease(ctx context.Context, src *fetch.Source, destSuite, suitePrefix string, opts *Options) (*debReleaseSet, error) {
r := &debReleaseSet{
names: []string{"InRelease", "Release", "Release.gpg"},
dsts: map[string]string{},
missing: map[string]bool{},
}
for _, name := range r.names {
dst, err := fetch.LocalJoin(destSuite, name)
if err != nil {
return nil, err
}
r.dsts[name] = dst
if err := r.fetch(ctx, src, suitePrefix, name, false); err != nil {
r.cleanup()
return nil, err
}
}
mode := opts.SignatureMode
if mode == "" {
mode = SignatureOff
}
if mode == SignatureOff {
return r.selectData(false)
}
verifier, err := newSignatureVerifier(opts)
if err != nil {
r.cleanup()
return nil, err
}
// InRelease is self-contained, so a mismatch only needs that document
// refetched. Detached Release signatures retry both members together.
if !r.missing["InRelease"] {
for attempt := 0; attempt < 2; attempt++ {
data, err := os.ReadFile(fetch.StagedOrFinal(r.dsts["InRelease"]))
if err != nil {
r.cleanup()
return nil, err
}
plaintext, fingerprint, verifyErr := verifier.verifyClearsigned(ctx, data)
if verifyErr == nil {
r.data = plaintext
r.authenticated = true
log.WithField("fingerprint", fingerprint).Debug("Verified InRelease signature.")
break
}
if attempt == 1 {
r.cleanup()
return nil, fmt.Errorf("verify InRelease signature after refetch: %w", verifyErr)
}
log.WithError(verifyErr).Warn("Staged InRelease signature failed verification; refetching.")
if err := r.fetch(ctx, src, suitePrefix, "InRelease", true); err != nil {
r.cleanup()
return nil, err
}
if r.missing["InRelease"] {
break
}
}
}
if !r.missing["Release.gpg"] {
if r.missing["Release"] {
r.cleanup()
return nil, errors.New("repository serves Release.gpg without Release")
}
for attempt := 0; attempt < 2; attempt++ {
fingerprint, verifyErr := verifier.verifyDetached(ctx, fetch.StagedOrFinal(r.dsts["Release"]), fetch.StagedOrFinal(r.dsts["Release.gpg"]), "")
if verifyErr == nil {
log.WithField("fingerprint", fingerprint).Debug("Verified Release.gpg signature.")
if r.missing["InRelease"] {
r.authenticated = true
}
break
}
if attempt == 1 {
r.cleanup()
return nil, fmt.Errorf("verify Release.gpg signature after refetch: %w", verifyErr)
}
log.WithError(verifyErr).Warn("Staged Release signature failed verification; refetching the pair.")
for _, name := range []string{"Release", "Release.gpg"} {
if err := r.fetch(ctx, src, suitePrefix, name, true); err != nil {
r.cleanup()
return nil, err
}
}
if r.missing["Release"] {
r.cleanup()
return nil, errors.New("release disappeared while refetching its signature pair")
}
if r.missing["Release.gpg"] {
break
}
}
}
if r.missing["InRelease"] {
if r.missing["Release"] {
r.cleanup()
return nil, errors.New("repository serves neither InRelease nor Release")
}
if r.missing["Release.gpg"] && mode == SignatureRequired {
r.cleanup()
return nil, errors.New("repository signature is required but Release.gpg is missing")
}
data, err := os.ReadFile(fetch.StagedOrFinal(r.dsts["Release"]))
if err != nil {
r.cleanup()
return nil, err
}
r.data = data
}
return r, nil
}
func (r *debReleaseSet) fetch(ctx context.Context, src *fetch.Source, suitePrefix, name string, fresh bool) error {
var err error
if fresh {
_, err = fetch.FileFresh(ctx, src, suitePrefix+name, r.dsts[name], nil, true)
} else {
_, err = fetch.File(ctx, src, suitePrefix+name, r.dsts[name], nil, true, false)
}
switch {
case err == nil:
r.missing[name] = false
return nil
case errors.Is(err, fetch.ErrNotFound), errors.Is(err, fetch.ErrForbidden):
r.missing[name] = true
return nil
default:
return fmt.Errorf("fetch %s: %w", name, err)
}
}
func (r *debReleaseSet) selectData(authenticated bool) (*debReleaseSet, error) {
name := "InRelease"
if r.missing[name] {
name = "Release"
}
if r.missing[name] {
r.cleanup()
return nil, errors.New("repository serves neither InRelease nor Release")
}
data, err := os.ReadFile(fetch.StagedOrFinal(r.dsts[name]))
if err != nil {
r.cleanup()
return nil, err
}
r.data = data
r.authenticated = authenticated
return r, nil
}
// releaseSumFields maps Release checksum block names to algorithm names, // releaseSumFields maps Release checksum block names to algorithm names,
// including the MD5sum casing some repositories use. // including the MD5sum casing some repositories use.
var releaseSumFields = map[string]string{ var releaseSumFields = map[string]string{
@ -139,45 +311,13 @@ func syncDeb(ctx context.Context, src *fetch.Source, repoURL string, opts *Optio
// the archive's other suites. // the archive's other suites.
miss := opts.newMissing(destSuite) miss := opts.newMissing(destSuite)
// Stage the release files; they are promoted last so a live tree keeps // Authenticate release checksums before selecting any index or pool file.
// a consistent metadata chain. releaseSet, err := stageDebRelease(ctx, src, destSuite, suitePrefix, opts)
releaseNames := []string{"InRelease", "Release", "Release.gpg"}
releaseStates := make([]fetch.FileState, len(releaseNames))
for i, name := range releaseNames {
dst, err := fetch.LocalJoin(destSuite, name)
if err != nil { if err != nil {
return err return err
} }
state, err := fetch.File(ctx, src, suitePrefix+name, dst, nil, true, false) defer releaseSet.cleanup()
if err != nil { rel, err := parseRelease(releaseSet.data)
if errors.Is(err, fetch.ErrNotFound) {
// The upstream dropped this release file; drop the local
// copy so outdated metadata is never served.
if !opts.DryRun {
fetch.RemoveStale(dst)
}
continue
}
return fmt.Errorf("fetch %s: %w", name, err)
}
releaseStates[i] = state
}
// Parse the strongest release document available, which may be the
// promoted copy when the upstream reported it unchanged.
var relData []byte
switch {
case releaseStates[0] != fetch.FileMissing:
relData, err = os.ReadFile(fetch.StagedOrFinal(filepath.Join(destSuite, "InRelease")))
case releaseStates[1] != fetch.FileMissing:
relData, err = os.ReadFile(fetch.StagedOrFinal(filepath.Join(destSuite, "Release")))
default:
return errors.New("repository serves neither InRelease nor Release")
}
if err != nil {
return err
}
rel, err := parseRelease(relData)
if err != nil { if err != nil {
return err return err
} }
@ -190,7 +330,7 @@ func syncDeb(ctx context.Context, src *fetch.Source, repoURL string, opts *Optio
for _, f := range sortedFiles(rel.files) { for _, f := range sortedFiles(rel.files) {
// Releases commonly list their own release files; those are // Releases commonly list their own release files; those are
// already staged above and must not be fetched twice. // already staged above and must not be fetched twice.
if slices.Contains(releaseNames, f.path) { if slices.Contains(releaseSet.names, f.path) {
continue continue
} }
if !includeIndexFile(f.path, opts.Components, opts.Architectures) { if !includeIndexFile(f.path, opts.Components, opts.Architectures) {
@ -216,8 +356,9 @@ func syncDeb(ctx context.Context, src *fetch.Source, repoURL string, opts *Optio
} }
indexJobs = append(indexJobs, job) indexJobs = append(indexJobs, job)
} }
fetch.PlanJobs(plannedJobs, opts.Verify, keep) verifyFiles := opts.Verify || releaseSet.authenticated
states, err := fetch.Many(ctx, src, indexJobs, opts.Workers, opts.Verify, keep, nil) fetch.PlanJobs(plannedJobs, verifyFiles, keep)
states, err := fetch.Many(ctx, src, indexJobs, opts.Workers, verifyFiles, keep, nil)
if err != nil { if err != nil {
return fmt.Errorf("fetch suite indexes: %w", err) return fmt.Errorf("fetch suite indexes: %w", err)
} }
@ -271,10 +412,15 @@ func syncDeb(ctx context.Context, src *fetch.Source, repoURL string, opts *Optio
} }
log.WithField("files", len(poolJobs)).Info("Synchronizing pool files.") log.WithField("files", len(poolJobs)).Info("Synchronizing pool files.")
if opts.DryRun { if opts.DryRun {
fetch.PlanJobs(poolJobs, opts.Verify, keep) fetch.PlanJobs(poolJobs, verifyFiles, keep)
} else if _, err := fetch.Many(ctx, src, poolJobs, opts.Workers, opts.Verify, keep, miss); err != nil { } else if _, err := fetch.Many(ctx, src, poolJobs, opts.Workers, verifyFiles, keep, miss); err != nil {
return fmt.Errorf("fetch pool files: %w", err) return fmt.Errorf("fetch pool files: %w", err)
} }
if releaseSet.authenticated {
if err := miss.Finish(); err != nil {
return err
}
}
// Promote the staged indexes now the pool files they reference exist. // Promote the staged indexes now the pool files they reference exist.
for i, job := range indexJobs { for i, job := range indexJobs {
@ -291,9 +437,12 @@ func syncDeb(ctx context.Context, src *fetch.Source, repoURL string, opts *Optio
// Promote the release files last to complete the metadata chain. // Promote the release files last to complete the metadata chain.
for _, name := range []string{"Release.gpg", "Release", "InRelease"} { for _, name := range []string{"Release.gpg", "Release", "InRelease"} {
dst, err := fetch.LocalJoin(destSuite, name) dst := releaseSet.dsts[name]
if err != nil { if releaseSet.missing[name] {
return err if !opts.DryRun {
fetch.RemoveStale(dst)
}
continue
} }
if err := fetch.PromoteOrDiscard(dst, opts.DryRun); err != nil { if err := fetch.PromoteOrDiscard(dst, opts.DryRun); err != nil {
return err return err
@ -320,8 +469,9 @@ func syncDeb(ctx context.Context, src *fetch.Source, repoURL string, opts *Optio
} }
} }
// The suite is published either way; missing pool files only decide if releaseSet.authenticated {
// whether the run reports itself as failed. return nil
}
return miss.Finish() return miss.Finish()
} }

View file

@ -6,9 +6,12 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/grmrgecko/repo-sync/fetch" "github.com/grmrgecko/repo-sync/fetch"
"github.com/grmrgecko/repo-sync/internal/testrepos" "github.com/grmrgecko/repo-sync/internal/testrepos"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestParseRelease verifies field extraction and checksum block merging. // TestParseRelease verifies field extraction and checksum block merging.
@ -38,6 +41,40 @@ func TestParseRelease(t *testing.T) {
} }
} }
// TestSyncDebSignedRelease verifies apt cleartext and detached signatures
// before replacing the live release generation.
func TestSyncDebSignedRelease(t *testing.T) {
www := t.TempDir()
repoDir := filepath.Join(www, "debian")
testrepos.BuildDebRepo(t, repoDir)
key := testrepos.NewSigningKey(t)
key.SignDebRepo(t, repoDir)
srv := testrepos.ServeDir(t, www)
dest := t.TempDir()
opts := &Options{
Type: RepoDeb,
Destination: dest,
Workers: 2,
SignatureMode: SignatureRequired,
GPGKeyData: [][]byte{key.PublicKey(t)},
}
repoURL := srv.URL + "/debian/dists/test"
require.NoError(t, syncOne(context.Background(), repoURL, opts.Type, opts))
local := filepath.Join(dest, "debian", "dists", "test", "InRelease")
published := requireReadFile(t, local)
upstream := filepath.Join(repoDir, "dists", "test", "InRelease")
tampered := bytes.Replace(requireReadFile(t, upstream), []byte("Origin: Test"), []byte("Origin: Pest"), 1)
require.NoError(t, os.WriteFile(upstream, tampered, 0644))
future := time.Now().Add(2 * time.Second)
require.NoError(t, os.Chtimes(upstream, future, future))
require.Error(t, syncOne(context.Background(), repoURL, opts.Type, opts))
assert.Equal(t, published, requireReadFile(t, local), "a bad InRelease must not replace the verified release")
assert.NoFileExists(t, local+fetch.StagedSuffix)
}
// TestIndexArch verifies architecture extraction from release file paths. // TestIndexArch verifies architecture extraction from release file paths.
func TestIndexArch(t *testing.T) { func TestIndexArch(t *testing.T) {
cases := map[string]string{ cases := map[string]string{

View file

@ -83,6 +83,16 @@ type Options struct {
Workers int Workers int
// Verify re-verifies checksums of existing local files. // Verify re-verifies checksums of existing local files.
Verify bool Verify bool
// SignatureMode controls OpenPGP verification of repository metadata.
SignatureMode SignatureMode
// GPGKeys lists local public keyring files used for signature checks.
GPGKeys []string
// GPGKeyData carries public keyrings preloaded from one configuration
// snapshot. The mirror server uses it to keep reloads out of a crawl.
GPGKeyData [][]byte
// Keyservers lists optional OpenPGP keyservers used to retrieve an unknown
// signature issuer. Retrieved keys prove pairing but are not trust anchors.
Keyservers []string
// Prune deletes local files no longer part of the repository. // Prune deletes local files no longer part of the repository.
Prune bool Prune bool
// PruneGrace defers pruning a file until it has been unreferenced for // PruneGrace defers pruning a file until it has been unreferenced for

View file

@ -12,6 +12,278 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
const (
rpmRepomdPath = "repodata/repomd.xml"
rpmSigPath = "repodata/repomd.xml.asc"
rpmKeyPath = "repodata/repomd.xml.key"
)
// rpmRoot is one staged repomd generation and its optional signature
// material.
type rpmRoot struct {
repomdDst string
sigDst string
keyDst string
sigMissing bool
keyMissing bool
authenticated bool
}
// rpmPublishedFile is a rollback copy of one live root metadata file.
type rpmPublishedFile struct {
path string
data []byte
mode os.FileMode
exists bool
}
// rpmWithdrawnPackage is a stale package hidden until root publication
// succeeds or restored if publication fails.
type rpmWithdrawnPackage struct {
path string
backup string
}
// cleanup removes unpublished files left from staging a repomd generation.
func (r *rpmRoot) cleanup() {
for _, name := range []string{r.repomdDst, r.sigDst, r.keyDst} {
_ = os.Remove(name + fetch.StagedSuffix)
}
}
// stageRPMRoot stages and optionally verifies repomd.xml with its detached
// signature. A failed check refetches the complete set once because an
// upstream rotation can occur between the requests.
func stageRPMRoot(ctx context.Context, src *fetch.Source, destDir string, opts *Options) (*rpmRoot, error) {
r := &rpmRoot{}
var err error
if r.repomdDst, err = fetch.LocalJoin(destDir, rpmRepomdPath); err != nil {
return nil, err
}
if r.sigDst, err = fetch.LocalJoin(destDir, rpmSigPath); err != nil {
return nil, err
}
if r.keyDst, err = fetch.LocalJoin(destDir, rpmKeyPath); err != nil {
return nil, err
}
mode := opts.SignatureMode
if mode == "" {
mode = SignatureOff
}
var verifier *signatureVerifier
if mode != SignatureOff {
verifier, err = newSignatureVerifier(opts)
if err != nil {
return nil, err
}
}
for attempt := 0; attempt < 2; attempt++ {
fresh := attempt > 0
file := fetch.File
if fresh {
file = func(ctx context.Context, src *fetch.Source, reqPath, dst string, want *fetch.Expect, stage, _ bool) (fetch.FileState, error) {
return fetch.FileFresh(ctx, src, reqPath, dst, want, stage)
}
}
if _, err := file(ctx, src, rpmRepomdPath, r.repomdDst, nil, true, false); err != nil {
r.cleanup()
return nil, fmt.Errorf("fetch repomd.xml: %w", err)
}
r.sigMissing = false
r.keyMissing = false
for _, extra := range []struct {
req string
dst string
missing *bool
}{
{rpmSigPath, r.sigDst, &r.sigMissing},
{rpmKeyPath, r.keyDst, &r.keyMissing},
} {
_, err := file(ctx, src, extra.req, extra.dst, nil, true, false)
switch {
case err == nil:
case errors.Is(err, fetch.ErrNotFound), errors.Is(err, fetch.ErrForbidden):
*extra.missing = true
case err != nil:
r.cleanup()
return nil, fmt.Errorf("fetch %s: %w", extra.req, err)
}
}
if mode == SignatureOff {
return r, nil
}
if r.sigMissing {
if mode == SignatureRequired {
r.cleanup()
return nil, errors.New("repository signature is required but repomd.xml.asc is missing")
}
return r, nil
}
keyPath := ""
if !r.keyMissing {
keyPath = fetch.StagedOrFinal(r.keyDst)
}
fingerprint, verifyErr := verifier.verifyDetached(
ctx,
fetch.StagedOrFinal(r.repomdDst),
fetch.StagedOrFinal(r.sigDst),
keyPath,
)
if verifyErr == nil {
r.authenticated = true
log.WithField("fingerprint", fingerprint).Debug("Verified repomd.xml signature.")
return r, nil
}
if !fresh {
log.WithError(verifyErr).Warn("Staged repomd.xml signature failed verification; refetching the pair.")
continue
}
r.cleanup()
return nil, fmt.Errorf("verify repomd.xml signature after refetch: %w", verifyErr)
}
return nil, errors.New("unable to stage repomd.xml")
}
// snapshotRPMRoot reads the current live root set before publication.
func snapshotRPMRoot(paths ...string) ([]rpmPublishedFile, error) {
files := make([]rpmPublishedFile, 0, len(paths))
for _, name := range paths {
file := rpmPublishedFile{path: name}
info, err := os.Stat(name)
if os.IsNotExist(err) {
files = append(files, file)
continue
}
if err != nil {
return nil, err
}
file.data, err = os.ReadFile(name)
if err != nil {
return nil, err
}
file.mode = info.Mode()
file.exists = true
files = append(files, file)
}
return files, nil
}
// restoreRPMRoot replaces a partially published root with its prior files.
func restoreRPMRoot(files []rpmPublishedFile) error {
var errs []error
for _, file := range files {
if !file.exists {
if err := os.Remove(file.path); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
continue
}
tmp := file.path + ".rollback" + fetch.StagedSuffix
if err := os.WriteFile(tmp, file.data, file.mode); err != nil {
errs = append(errs, err)
continue
}
if err := os.Rename(tmp, file.path); err != nil {
_ = os.Remove(tmp)
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// withdrawMissingPackages hides checksum-invalid packages the authenticated
// root no longer provides upstream.
func withdrawMissingPackages(jobs []fetch.Job, states []fetch.FileState) ([]rpmWithdrawnPackage, error) {
var withdrawn []rpmWithdrawnPackage
for i, state := range states {
if state != fetch.FileMissing {
continue
}
if _, err := os.Stat(jobs[i].Dst); os.IsNotExist(err) {
continue
} else if err != nil {
return withdrawn, err
}
backup := jobs[i].Dst + ".withdrawn" + fetch.StagedSuffix
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
return withdrawn, err
}
if err := os.Rename(jobs[i].Dst, backup); err != nil {
return withdrawn, err
}
withdrawn = append(withdrawn, rpmWithdrawnPackage{path: jobs[i].Dst, backup: backup})
}
return withdrawn, nil
}
// finishWithdrawnPackages removes hidden packages after publication or puts
// them back when publication fails.
func finishWithdrawnPackages(files []rpmWithdrawnPackage, published bool) error {
var errs []error
for _, file := range files {
if published {
if err := os.Remove(file.backup); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
continue
}
if err := os.Rename(file.backup, file.path); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// publishRPMRoot publishes signature material immediately before repomd.xml.
// Missing optional files are removed only after the replacement is ready.
func publishRPMRoot(r *rpmRoot, keep *fetch.KeepSet, dryRun bool) (retErr error) {
var published []rpmPublishedFile
if !dryRun {
var err error
published, err = snapshotRPMRoot(r.keyDst, r.sigDst, r.repomdDst)
if err != nil {
return err
}
defer func() {
if retErr != nil {
retErr = errors.Join(retErr, restoreRPMRoot(published))
}
}()
}
for _, extra := range []struct {
dst string
missing bool
}{
{r.keyDst, r.keyMissing},
{r.sigDst, r.sigMissing},
} {
if extra.missing {
if !dryRun {
if err := os.Remove(extra.dst); err != nil && !os.IsNotExist(err) {
return err
}
}
continue
}
if err := fetch.PromoteOrDiscard(extra.dst, dryRun); err != nil {
return err
}
if _, err := os.Stat(extra.dst); err == nil {
keep.Add(extra.dst)
}
}
if err := fetch.PromoteOrDiscard(r.repomdDst, dryRun); err != nil {
return err
}
keep.Add(r.repomdDst)
return nil
}
// repoMD is the partial XML schema for a yum repomd.xml file. Only the // repoMD is the partial XML schema for a yum repomd.xml file. Only the
// fields the sync needs are decoded; unknown elements are ignored so // fields the sync needs are decoded; unknown elements are ignored so
// upstream additions do not break parsing. // upstream additions do not break parsing.
@ -58,16 +330,13 @@ func syncRPM(ctx context.Context, src *fetch.Source, destDir string, opts *Optio
tr := newTrace(opts) tr := newTrace(opts)
ctx = tr.track(ctx) ctx = tr.track(ctx)
// Stage repomd.xml so clients of a live tree keep a consistent view of // Authenticate the root metadata before using it to select any files.
// the old repository until everything else is in place. root, err := stageRPMRoot(ctx, src, destDir, opts)
repomdDst, err := fetch.LocalJoin(destDir, "repodata/repomd.xml")
if err != nil { if err != nil {
return err return err
} }
if _, err := fetch.File(ctx, src, "repodata/repomd.xml", repomdDst, nil, true, false); err != nil { defer root.cleanup()
return fmt.Errorf("fetch repomd.xml: %w", err) md, err := readRepomd(fetch.StagedOrFinal(root.repomdDst))
}
md, err := readRepomd(fetch.StagedOrFinal(repomdDst))
if err != nil { if err != nil {
return err return err
} }
@ -111,8 +380,9 @@ func syncRPM(ctx context.Context, src *fetch.Source, destDir string, opts *Optio
} }
jobs = append(jobs, job) jobs = append(jobs, job)
} }
fetch.PlanJobs(planned, opts.Verify, keep) verifyMetadata := opts.Verify || root.authenticated
if _, err := fetch.Many(ctx, src, jobs, opts.Workers, opts.Verify, keep, nil); err != nil { fetch.PlanJobs(planned, verifyMetadata, keep)
if _, err := fetch.Many(ctx, src, jobs, opts.Workers, verifyMetadata, keep, nil); err != nil {
return fmt.Errorf("fetch repository metadata: %w", err) return fmt.Errorf("fetch repository metadata: %w", err)
} }
if primary == nil { if primary == nil {
@ -169,54 +439,42 @@ func syncRPM(ctx context.Context, src *fetch.Source, destDir string, opts *Optio
} }
} }
log.WithField("packages", len(jobs)).Info("Synchronizing packages.") log.WithField("packages", len(jobs)).Info("Synchronizing packages.")
verifyPackages := opts.Verify || root.authenticated
var packageStates []fetch.FileState
if opts.DryRun { if opts.DryRun {
fetch.PlanJobs(jobs, opts.Verify, keep) fetch.PlanJobs(jobs, verifyPackages, keep)
} else if _, err := fetch.Many(ctx, src, jobs, opts.Workers, opts.Verify, keep, miss); err != nil { } else {
packageStates, err = fetch.Many(ctx, src, jobs, opts.Workers, verifyPackages, keep, miss)
if err != nil {
return fmt.Errorf("fetch packages: %w", err) return fmt.Errorf("fetch packages: %w", err)
} }
}
// A dry run discards the indexes it staged for parsing. // A dry run discards the indexes it staged for parsing.
for _, dst := range parseDsts { for _, dst := range parseDsts {
_ = os.Remove(dst + fetch.StagedSuffix) _ = os.Remove(dst + fetch.StagedSuffix)
} }
// Stage the optional signature material so it is published together // Keep the previous root live until every referenced package is present
// with the repomd.xml it signs, never beside the previous index. // under the configured missing-file policy.
var sigDsts []string if err := miss.Finish(); err != nil {
for _, extra := range []string{"repodata/repomd.xml.asc", "repodata/repomd.xml.key"} { return err
dst, err := fetch.LocalJoin(destDir, extra) }
var withdrawn []rpmWithdrawnPackage
if root.authenticated {
withdrawn, err = withdrawMissingPackages(jobs, packageStates)
if err != nil { if err != nil {
return err _ = finishWithdrawnPackages(withdrawn, false)
} return fmt.Errorf("withdraw stale packages: %w", err)
_, err = fetch.File(ctx, src, extra, dst, nil, true, false)
switch {
case err == nil:
sigDsts = append(sigDsts, dst)
case errors.Is(err, fetch.ErrNotFound):
// The upstream dropped the signature; drop the local copy so
// a stale signature is never served beside a new repomd.xml.
if !opts.DryRun {
fetch.RemoveStale(dst)
}
default:
return fmt.Errorf("fetch %s: %w", extra, err)
} }
} }
// Publish the verified root only after every referenced file is ready.
// Promote the signatures and then repomd.xml so the published metadata if err := publishRPMRoot(root, keep, opts.DryRun); err != nil {
// chain is complete. return errors.Join(err, finishWithdrawnPackages(withdrawn, false))
for _, dst := range sigDsts {
if err := fetch.PromoteOrDiscard(dst, opts.DryRun); err != nil {
return err
} }
if _, err := os.Stat(dst); err == nil { if err := finishWithdrawnPackages(withdrawn, true); err != nil {
keep.Add(dst) log.WithError(err).Warn("Unable to remove withdrawn package files.")
} }
}
if err := fetch.PromoteOrDiscard(repomdDst, opts.DryRun); err != nil {
return err
}
keep.Add(repomdDst)
// Publish the traces, upstream's included, before pruning so the keep // Publish the traces, upstream's included, before pruning so the keep
// set covers them. // set covers them.
@ -227,9 +485,7 @@ func syncRPM(ctx context.Context, src *fetch.Source, destDir string, opts *Optio
fetch.PruneTree(destDir, keep, opts.PruneGrace, opts.DryRun) fetch.PruneTree(destDir, keep, opts.PruneGrace, opts.DryRun)
} }
// The metadata is published either way; missing packages only decide return nil
// whether the run reports itself as failed.
return miss.Finish()
} }
// rpmDelta is one delta package referenced from prestodelta metadata. // rpmDelta is one delta package referenced from prestodelta metadata.

View file

@ -3,13 +3,18 @@ package mirror
import ( import (
"bytes" "bytes"
"context" "context"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"github.com/grmrgecko/repo-sync/fetch" "github.com/grmrgecko/repo-sync/fetch"
"github.com/grmrgecko/repo-sync/internal/testrepos" "github.com/grmrgecko/repo-sync/internal/testrepos"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// syncRPMFixture builds a served RPM repository, synchronizes it, and // syncRPMFixture builds a served RPM repository, synchronizes it, and
@ -148,6 +153,149 @@ func TestSyncRPMDroppedSignature(t *testing.T) {
} }
} }
// TestSyncRPMMissingKeyForbidden verifies S3's 403 response for a missing
// optional key does not abort synchronization.
func TestSyncRPMMissingKeyForbidden(t *testing.T) {
www := t.TempDir()
repoDir := filepath.Join(www, "repos", "el9")
testrepos.BuildRPMRepo(t, repoDir)
files := http.FileServer(http.Dir(www))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/repos/el9/repodata/repomd.xml.key" {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
files.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
dest := t.TempDir()
opts := &Options{Type: RepoRPM, Destination: dest, Workers: 2}
err := syncOne(context.Background(), srv.URL+"/repos/el9", opts.Type, opts)
require.NoError(t, err)
local := filepath.Join(dest, "repos", "el9", "repodata")
assert.FileExists(t, filepath.Join(local, "repomd.xml"))
assert.FileExists(t, filepath.Join(local, "repomd.xml.asc"))
assert.NoFileExists(t, filepath.Join(local, "repomd.xml.key"))
}
// TestSyncRPMSignedPair verifies repository keys and keyserver retrieval,
// retries a split upstream rotation, and preserves the live pair when the
// upstream remains inconsistent.
func TestSyncRPMSignedPair(t *testing.T) {
www := t.TempDir()
repoDir := filepath.Join(www, "repos", "el9")
testrepos.BuildRPMRepo(t, repoDir)
key := testrepos.NewSigningKey(t)
key.SignRPMRepo(t, repoDir)
repomdPath := filepath.Join(repoDir, "repodata", "repomd.xml")
firstRepomd, err := os.ReadFile(repomdPath)
require.NoError(t, err)
firstSig := key.Sign(t, firstRepomd)
var phase atomic.Int32
var signatureRequests atomic.Int32
secondRepomd := append(append([]byte(nil), firstRepomd...), '\n')
secondSig := key.Sign(t, secondRepomd)
thirdRepomd := append(append([]byte(nil), secondRepomd...), '\n')
files := http.FileServer(http.Dir(www))
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/repos/el9/repodata/repomd.xml":
switch phase.Load() {
case 0:
_, _ = w.Write(firstRepomd)
case 1:
_, _ = w.Write(secondRepomd)
default:
_, _ = w.Write(thirdRepomd)
}
case "/repos/el9/repodata/repomd.xml.asc":
request := signatureRequests.Add(1)
if phase.Load() == 1 && request > 1 {
_, _ = w.Write(secondSig)
return
}
if phase.Load() == 2 {
_, _ = w.Write(secondSig)
return
}
_, _ = w.Write(firstSig)
default:
files.ServeHTTP(w, r)
}
}))
t.Cleanup(upstream.Close)
dest := t.TempDir()
opts := &Options{
Type: RepoRPM,
Destination: dest,
Workers: 2,
SignatureMode: SignatureIfPresent,
}
repoURL := upstream.URL + "/repos/el9"
require.NoError(t, syncOne(context.Background(), repoURL, opts.Type, opts))
local := filepath.Join(dest, "repos", "el9", "repodata")
assert.Equal(t, firstRepomd, requireReadFile(t, filepath.Join(local, "repomd.xml")))
// Remove the adjacent key so the rotation resolves its signer through
// the configured keyserver.
require.NoError(t, os.Remove(filepath.Join(repoDir, "repodata", "repomd.xml.key")))
publicKey := key.PublicKey(t)
otherKey := testrepos.NewSigningKey(t)
otherPublicKey := otherKey.PublicKey(t)
unrelatedKeyserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(otherPublicKey)
}))
t.Cleanup(unrelatedKeyserver.Close)
keyserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "get", r.URL.Query().Get("op"))
assert.NotEmpty(t, r.URL.Query().Get("search"))
_, _ = w.Write(publicKey)
}))
t.Cleanup(keyserver.Close)
opts.Keyservers = []string{unrelatedKeyserver.URL, keyserver.URL}
phase.Store(1)
signatureRequests.Store(0)
require.NoError(t, syncOne(context.Background(), repoURL, opts.Type, opts))
assert.GreaterOrEqual(t, signatureRequests.Load(), int32(2))
assert.Equal(t, secondRepomd, requireReadFile(t, filepath.Join(local, "repomd.xml")))
assert.Equal(t, secondSig, requireReadFile(t, filepath.Join(local, "repomd.xml.asc")))
phase.Store(2)
signatureRequests.Store(0)
err = syncOne(context.Background(), repoURL, opts.Type, opts)
require.Error(t, err)
assert.Equal(t, secondRepomd, requireReadFile(t, filepath.Join(local, "repomd.xml")))
assert.Equal(t, secondSig, requireReadFile(t, filepath.Join(local, "repomd.xml.asc")))
assert.NoFileExists(t, filepath.Join(local, "repomd.xml"+fetch.StagedSuffix))
// Configured keyrings pin the accepted signer, so repository and
// keyserver keys cannot override an operator-managed key.
keyPath := filepath.Join(t.TempDir(), "trusted.asc")
testrepos.WriteFile(t, keyPath, otherPublicKey)
opts.GPGKeys = []string{keyPath}
phase.Store(1)
signatureRequests.Store(1)
err = syncOne(context.Background(), repoURL, opts.Type, opts)
require.Error(t, err)
assert.Contains(t, err.Error(), "not present in the configured GPG keys")
assert.Equal(t, secondRepomd, requireReadFile(t, filepath.Join(local, "repomd.xml")))
}
// requireReadFile reads an asserted fixture result.
func requireReadFile(t *testing.T, name string) []byte {
t.Helper()
data, err := os.ReadFile(name)
require.NoError(t, err)
return data
}
// TestSyncRPMChecksumMismatch verifies a package whose served content does // TestSyncRPMChecksumMismatch verifies a package whose served content does
// not match the published checksum fails the synchronization. // not match the published checksum fails the synchronization.
func TestSyncRPMChecksumMismatch(t *testing.T) { func TestSyncRPMChecksumMismatch(t *testing.T) {

366
mirror/signature.go Normal file
View file

@ -0,0 +1,366 @@
package mirror
import (
"bytes"
"context"
"crypto"
"crypto/rsa"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"net/url"
"os"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
pgparmor "github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/clearsign"
pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/grmrgecko/repo-sync/fetch"
)
// SignatureMode controls OpenPGP verification of repository metadata.
type SignatureMode string
const (
// SignatureOff mirrors signatures without checking them.
SignatureOff SignatureMode = "off"
// SignatureIfPresent accepts unsigned repositories but verifies every
// signature the upstream publishes.
SignatureIfPresent SignatureMode = "if-present"
// SignatureRequired refuses repository metadata without a valid signature.
SignatureRequired SignatureMode = "required"
)
// ParseSignatureMode validates a signature policy name.
func ParseSignatureMode(value string) (SignatureMode, error) {
mode := SignatureMode(strings.ToLower(strings.TrimSpace(value)))
switch mode {
case SignatureOff, SignatureIfPresent, SignatureRequired:
return mode, nil
default:
return "", fmt.Errorf("unsupported signature mode %q", value)
}
}
// signatureVerifier verifies detached signatures with local, repository, and
// optionally keyserver-provided public keys.
type signatureVerifier struct {
keys openpgp.EntityList
keyservers []string
pinned bool
}
// legacySignature is the parsed subset of an OpenPGP version 3 detached RSA
// signature needed by CentOS 7-era repositories.
type legacySignature struct {
issuer uint64
hash crypto.Hash
hashTag [2]byte
hashed []byte
signature []byte
}
// newSignatureVerifier loads the operator-configured public keys.
func newSignatureVerifier(opts *Options) (*signatureVerifier, error) {
v := &signatureVerifier{keyservers: opts.Keyservers, pinned: len(opts.GPGKeys) > 0 || len(opts.GPGKeyData) > 0}
for _, data := range opts.GPGKeyData {
keys, err := readPublicKeys(data)
if err != nil {
return nil, fmt.Errorf("parse configured GPG key: %w", err)
}
v.keys = append(v.keys, keys...)
}
for _, name := range opts.GPGKeys {
data, err := os.ReadFile(name)
if err != nil {
return nil, fmt.Errorf("read GPG key %s: %w", name, err)
}
keys, err := readPublicKeys(data)
if err != nil {
return nil, fmt.Errorf("parse GPG key %s: %w", name, err)
}
v.keys = append(v.keys, keys...)
}
return v, nil
}
// readPublicKeys accepts armored and binary OpenPGP public keyrings.
func readPublicKeys(data []byte) (openpgp.EntityList, error) {
if bytes.HasPrefix(bytes.TrimSpace(data), []byte("-----BEGIN PGP")) {
return openpgp.ReadArmoredKeyRing(bytes.NewReader(data))
}
return openpgp.ReadKeyRing(bytes.NewReader(data))
}
// decodedSignature returns the packet stream from an armored or binary
// detached signature.
func decodedSignature(data []byte) ([]byte, error) {
if !bytes.HasPrefix(bytes.TrimSpace(data), []byte("-----BEGIN PGP")) {
return data, nil
}
block, err := pgparmor.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
if block.Type != openpgp.SignatureType {
return nil, fmt.Errorf("armor contains %q instead of a signature", block.Type)
}
return io.ReadAll(block.Body)
}
// parseLegacySignature parses the version 3 signature packet format emitted
// by GnuPG v1. Modern signature packets return ok=false for normal handling.
func parseLegacySignature(data []byte) (sig legacySignature, ok bool, err error) {
opaque, err := packet.NewOpaqueReader(bytes.NewReader(data)).Next()
if err != nil {
return sig, false, err
}
if opaque.Tag != 2 || len(opaque.Contents) == 0 || opaque.Contents[0] != 3 {
return sig, false, nil
}
body := opaque.Contents
if len(body) < 21 || body[1] != 5 {
return sig, true, errors.New("invalid OpenPGP version 3 signature packet")
}
if body[2] != byte(packet.SigTypeBinary) {
return sig, true, fmt.Errorf("unsupported OpenPGP version 3 signature type %d", body[2])
}
if body[15] != byte(packet.PubKeyAlgoRSA) && body[15] != byte(packet.PubKeyAlgoRSASignOnly) {
return sig, true, fmt.Errorf("unsupported OpenPGP version 3 public key algorithm %d", body[15])
}
hash, supported := openpgp.HashIdToHash(body[16])
if !supported || !hash.Available() {
return sig, true, fmt.Errorf("unsupported OpenPGP version 3 hash algorithm %d", body[16])
}
bits := int(binary.BigEndian.Uint16(body[19:21]))
bytesLen := (bits + 7) / 8
if bytesLen == 0 || len(body) != 21+bytesLen {
return sig, true, errors.New("invalid OpenPGP version 3 RSA signature")
}
sig.issuer = binary.BigEndian.Uint64(body[7:15])
sig.hash = hash
copy(sig.hashTag[:], body[17:19])
sig.hashed = append([]byte(nil), body[2:7]...)
sig.signature = append([]byte(nil), body[21:]...)
return sig, true, nil
}
// verifyLegacySignature verifies a version 3 binary-document RSA signature.
func verifyLegacySignature(keys openpgp.EntityList, data []byte, sig legacySignature) (*openpgp.Entity, error) {
h := sig.hash.New()
if _, err := h.Write(data); err != nil {
return nil, err
}
if _, err := h.Write(sig.hashed); err != nil {
return nil, err
}
digest := h.Sum(nil)
if !bytes.Equal(digest[:2], sig.hashTag[:]) {
return nil, errors.New("OpenPGP signature hash tag does not match")
}
for _, entity := range keys {
publicKeys := []*packet.PublicKey{entity.PrimaryKey}
for _, subkey := range entity.Subkeys {
publicKeys = append(publicKeys, subkey.PublicKey)
}
for _, key := range publicKeys {
if key == nil || key.KeyId != sig.issuer {
continue
}
pub, ok := key.PublicKey.(*rsa.PublicKey)
if !ok {
continue
}
signature := sig.signature
if size := pub.Size(); len(signature) < size {
padded := make([]byte, size)
copy(padded[size-len(signature):], signature)
signature = padded
}
if err := rsa.VerifyPKCS1v15(pub, sig.hash, digest, signature); err == nil {
return entity, nil
}
}
}
return nil, pgperrors.ErrUnknownIssuer
}
// signatureLookup identifies the signer requested by the first signature
// packet. Full fingerprints are preferred over 64-bit key IDs.
func signatureLookup(data []byte) (string, error) {
decoded, err := decodedSignature(data)
if err != nil {
return "", err
}
legacy, ok, err := parseLegacySignature(decoded)
if err != nil {
return "", err
}
if ok {
return fmt.Sprintf("%016X", legacy.issuer), nil
}
p, err := packet.NewReader(bytes.NewReader(decoded)).Next()
if err != nil {
return "", err
}
sig, ok := p.(*packet.Signature)
if !ok {
return "", errors.New("detached signature contains no signature packet")
}
if len(sig.IssuerFingerprint) > 0 {
return strings.ToUpper(hex.EncodeToString(sig.IssuerFingerprint)), nil
}
if sig.IssuerKeyId != nil {
return fmt.Sprintf("%016X", *sig.IssuerKeyId), nil
}
return "", errors.New("detached signature has no issuer")
}
// keyserverLookupURL builds a Hockeypuck-compatible exact key lookup.
func keyserverLookupURL(base, issuer string) (string, error) {
u, err := url.Parse(base)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", fmt.Errorf("invalid keyserver URL %q", base)
}
u.Path = strings.TrimRight(u.Path, "/") + "/pks/lookup"
q := u.Query()
q.Set("op", "get")
q.Set("options", "mr")
q.Set("search", "0x"+issuer)
u.RawQuery = q.Encode()
return u.String(), nil
}
// retrieveSigner asks configured keyservers for the signature issuer and
// ignores responses whose keys do not verify the pair.
func (v *signatureVerifier) retrieveSigner(
ctx context.Context,
signature []byte,
base openpgp.EntityList,
verify func(openpgp.EntityList) (*openpgp.Entity, error),
) (*openpgp.Entity, error) {
issuer, err := signatureLookup(signature)
if err != nil {
return nil, err
}
var errs []error
for _, server := range v.keyservers {
lookup, err := keyserverLookupURL(server, issuer)
if err != nil {
errs = append(errs, err)
continue
}
data, err := fetch.ReadURL(ctx, lookup)
if err != nil {
errs = append(errs, fmt.Errorf("fetch key %s from %s: %w", issuer, server, err))
continue
}
keys, err := readPublicKeys(data)
if err != nil {
errs = append(errs, fmt.Errorf("parse key %s from %s: %w", issuer, server, err))
continue
}
candidate := append(append(openpgp.EntityList(nil), base...), keys...)
signer, err := verify(candidate)
if err != nil {
errs = append(errs, fmt.Errorf("verify key %s from %s: %w", issuer, server, err))
continue
}
v.keys = append(v.keys, keys...)
return signer, nil
}
if len(errs) == 0 {
return nil, fmt.Errorf("signature issuer %s is unknown; configure a GPG key or keyserver", issuer)
}
return nil, errors.Join(errs...)
}
// verifyDetachedBytes verifies a detached signature over data and returns the
// matching primary-key fingerprint.
func (v *signatureVerifier) verifyDetachedBytes(ctx context.Context, data, signature, repoKey []byte) (string, error) {
keys := append(openpgp.EntityList(nil), v.keys...)
if len(repoKey) > 0 && !v.pinned {
repoKeys, err := readPublicKeys(repoKey)
if err != nil {
return "", fmt.Errorf("parse repository GPG key: %w", err)
}
keys = append(keys, repoKeys...)
}
decoded, err := decodedSignature(signature)
if err != nil {
return "", err
}
legacy, legacyOK, err := parseLegacySignature(decoded)
if err != nil {
return "", err
}
verify := func(keyring openpgp.EntityList) (*openpgp.Entity, error) {
if legacyOK {
return verifyLegacySignature(keyring, data, legacy)
}
_, signer, err := openpgp.VerifyDetachedSignature(keyring, bytes.NewReader(data), bytes.NewReader(decoded), nil)
return signer, err
}
signer, err := verify(keys)
if errors.Is(err, pgperrors.ErrUnknownIssuer) && len(v.keyservers) > 0 && !v.pinned {
signer, err = v.retrieveSigner(ctx, signature, keys, verify)
}
if err != nil {
if errors.Is(err, pgperrors.ErrUnknownIssuer) {
issuer, lookupErr := signatureLookup(signature)
if lookupErr == nil {
if v.pinned {
return "", fmt.Errorf("signature issuer %s is not present in the configured GPG keys", issuer)
}
return "", fmt.Errorf("signature issuer %s is unknown; configure a GPG key or keyserver", issuer)
}
}
return "", err
}
return strings.ToUpper(hex.EncodeToString(signer.PrimaryKey.Fingerprint)), nil
}
// verifyDetached verifies sigPath against the exact bytes at dataPath and
// returns the matching primary-key fingerprint.
func (v *signatureVerifier) verifyDetached(ctx context.Context, dataPath, sigPath, repoKeyPath string) (string, error) {
data, err := os.ReadFile(dataPath)
if err != nil {
return "", err
}
signature, err := os.ReadFile(sigPath)
if err != nil {
return "", err
}
var repoKey []byte
if repoKeyPath != "" {
repoKey, err = os.ReadFile(repoKeyPath)
if err != nil {
return "", err
}
}
return v.verifyDetachedBytes(ctx, data, signature, repoKey)
}
// verifyClearsigned verifies an OpenPGP cleartext message and returns its
// authenticated plaintext and signer fingerprint.
func (v *signatureVerifier) verifyClearsigned(ctx context.Context, data []byte) ([]byte, string, error) {
block, rest := clearsign.Decode(data)
if block == nil || len(bytes.TrimSpace(rest)) != 0 {
return nil, "", errors.New("invalid OpenPGP clearsigned message")
}
signature, err := io.ReadAll(block.ArmoredSignature.Body)
if err != nil {
return nil, "", err
}
fingerprint, err := v.verifyDetachedBytes(ctx, block.Bytes, signature, nil)
if err != nil {
return nil, "", err
}
return block.Plaintext, fingerprint, nil
}

80
mirror/signature_test.go Normal file
View file

@ -0,0 +1,80 @@
package mirror
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"encoding/binary"
"math/big"
"os"
"path/filepath"
"testing"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestVerifyLegacyDetachedSignature locks the OpenPGP version 3 RSA packet
// shape used by GnuPG v1 to sign archived CentOS 7 repomd.xml files.
func TestVerifyLegacyDetachedSignature(t *testing.T) {
entity, err := openpgp.NewEntity("CentOS fixture", "", "security@example.com", nil)
require.NoError(t, err)
privateKey, ok := entity.PrivateKey.PrivateKey.(*rsa.PrivateKey)
require.True(t, ok)
data := []byte("signed repository metadata\n")
hashed := make([]byte, 5)
hashed[0] = byte(packet.SigTypeBinary)
binary.BigEndian.PutUint32(hashed[1:], 1678292750)
h := crypto.SHA256.New()
_, _ = h.Write(data)
_, _ = h.Write(hashed)
digest := h.Sum(nil)
rsaSignature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, digest)
require.NoError(t, err)
mpi := new(big.Int).SetBytes(rsaSignature)
mpiBytes := mpi.Bytes()
body := []byte{3, 5}
body = append(body, hashed...)
body = binary.BigEndian.AppendUint64(body, entity.PrimaryKey.KeyId)
body = append(body, byte(packet.PubKeyAlgoRSA), 8, digest[0], digest[1])
body = binary.BigEndian.AppendUint16(body, uint16(mpi.BitLen()))
body = append(body, mpiBytes...)
var packetData bytes.Buffer
require.NoError(t, (&packet.OpaquePacket{Tag: 2, Contents: body}).Serialize(&packetData))
var signature bytes.Buffer
armoredSignature, err := armor.Encode(&signature, openpgp.SignatureType, nil)
require.NoError(t, err)
_, err = armoredSignature.Write(packetData.Bytes())
require.NoError(t, err)
require.NoError(t, armoredSignature.Close())
var publicKey bytes.Buffer
armoredKey, err := armor.Encode(&publicKey, openpgp.PublicKeyType, nil)
require.NoError(t, err)
require.NoError(t, entity.Serialize(armoredKey))
require.NoError(t, armoredKey.Close())
dir := t.TempDir()
dataPath := filepath.Join(dir, "repomd.xml")
sigPath := dataPath + ".asc"
keyPath := dataPath + ".key"
require.NoError(t, os.WriteFile(dataPath, data, 0644))
require.NoError(t, os.WriteFile(sigPath, signature.Bytes(), 0644))
require.NoError(t, os.WriteFile(keyPath, publicKey.Bytes(), 0644))
verifier := &signatureVerifier{}
fingerprint, err := verifier.verifyDetached(context.Background(), dataPath, sigPath, keyPath)
require.NoError(t, err)
assert.NotEmpty(t, fingerprint)
require.NoError(t, os.WriteFile(dataPath, []byte("modified repository metadata\n"), 0644))
_, err = verifier.verifyDetached(context.Background(), dataPath, sigPath, keyPath)
assert.Error(t, err)
}

View file

@ -2,7 +2,10 @@ package server
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"io"
"os" "os"
"strings" "strings"
"sync" "sync"
@ -156,11 +159,153 @@ func crawlResource(ctx context.Context, res resource) error {
} }
defer lockResource(res.Key)() defer lockResource(res.Key)()
err := dispatchCrawl(ctx, res) return crawlResourceLocked(ctx, res)
}
// crawlResourceLocked synchronizes a resource whose repository locks are
// already held.
func crawlResourceLocked(ctx context.Context, res resource) error {
settings := signatureSettings{mode: mirror.SignatureOff}
protected := protectedRepositoryKind(res.Kind) && cfg.C.Crawler.SignatureMode != string(mirror.SignatureOff)
var err error
if protected {
settings, err = loadSignatureSettings(cfg.C)
}
if err == nil {
err = dispatchCrawl(ctx, res, settings)
}
state.S.MarkCrawled(res.Key, time.Now(), err) state.S.MarkCrawled(res.Key, time.Now(), err)
if err == nil && protected {
state.S.MarkSignaturePolicy(res.Key, settings.policy)
}
return err return err
} }
// crawlProtectedRepository verifies a repository synchronously before its
// entry point is served under an active signature policy.
func crawlProtectedRepository(ctx context.Context, res resource, artifactsPresent bool, settings signatureSettings, settingsErr error) error {
acquireCrawlSlot()
defer releaseCrawlSlot()
if res.Kind == string(mirror.RepoDeb) && res.Root != res.Path {
defer lockResource("deb-root:" + res.Root)()
}
defer lockResource(res.Key)()
if settingsErr != nil {
state.S.MarkCrawled(res.Key, time.Now(), settingsErr)
return settingsErr
}
if entry, ok := state.S.Entry(res.Key); ok && entry.SignaturePolicy == settings.policy && entry.LastError == "" {
if artifactsPresent {
return nil
}
}
err := dispatchCrawl(ctx, res, settings)
state.S.MarkCrawled(res.Key, time.Now(), err)
if err == nil {
state.S.MarkSignaturePolicy(res.Key, settings.policy)
}
return err
}
// signaturePolicyArtifactsPresent reports whether a repository still has the
// entry-point files required by its active signature policy.
func signaturePolicyArtifactsPresent(conf *cfg.Config, res resource, mode mirror.SignatureMode) bool {
repo, err := fetch.LocalJoin(conf.OnlineDomain().Root, res.Path)
if err != nil {
return false
}
switch mirror.RepoType(res.Kind) {
case mirror.RepoRPM:
repomd, err := fetch.LocalJoin(repo, "repodata/repomd.xml")
if err != nil {
return false
}
if _, exists := regularFile(repomd); !exists {
return false
}
if mode != mirror.SignatureRequired {
return true
}
signature, err := fetch.LocalJoin(repo, "repodata/repomd.xml.asc")
if err != nil {
return false
}
_, exists := regularFile(signature)
return exists
case mirror.RepoDeb:
inRelease, _ := fetch.LocalJoin(repo, "InRelease")
if _, exists := regularFile(inRelease); exists {
return true
}
release, _ := fetch.LocalJoin(repo, "Release")
if _, exists := regularFile(release); !exists {
return false
}
if mode != mirror.SignatureRequired {
return true
}
signature, _ := fetch.LocalJoin(repo, "Release.gpg")
_, exists := regularFile(signature)
return exists
case mirror.RepoArch:
if !strings.HasSuffix(res.ReqPath, ".db") {
return false
}
database, err := fetch.LocalJoin(conf.OnlineDomain().Root, res.ReqPath)
if err != nil {
return false
}
_, exists := regularFile(database)
return exists
default:
return false
}
}
// signatureSettings is one immutable verification configuration used for
// both a crawl and its persisted policy identity.
type signatureSettings struct {
mode mirror.SignatureMode
keyData [][]byte
keyservers []string
policy string
}
// loadSignatureSettings snapshots key contents before a crawl so a reload
// cannot change verification inputs halfway through it.
func loadSignatureSettings(conf *cfg.Config) (signatureSettings, error) {
mode, err := mirror.ParseSignatureMode(conf.Crawler.SignatureMode)
if err != nil {
return signatureSettings{}, err
}
settings := signatureSettings{
mode: mode,
keyservers: append([]string(nil), conf.Crawler.Keyservers...),
}
h := sha256.New()
_, _ = io.WriteString(h, "repository-openpgp-v1\x00"+string(mode)+"\x00")
for _, name := range conf.Crawler.GPGKeys {
_, _ = io.WriteString(h, name+"\x00")
data, err := os.ReadFile(name)
if err != nil {
return signatureSettings{}, fmt.Errorf("read GPG key %s: %w", name, err)
}
settings.keyData = append(settings.keyData, data)
_, _ = h.Write(data)
_, _ = io.WriteString(h, "\x00")
}
for _, server := range settings.keyservers {
_, _ = io.WriteString(h, server+"\x00")
}
settings.policy = hex.EncodeToString(h.Sum(nil))
return settings, nil
}
// protectedRepositoryKind reports formats covered by the OpenPGP policy.
func protectedRepositoryKind(kind string) bool {
return kind == string(mirror.RepoRPM) || kind == string(mirror.RepoDeb) || kind == string(mirror.RepoArch)
}
// pathBelow reports whether a request path sits strictly below a base path. // pathBelow reports whether a request path sits strictly below a base path.
func pathBelow(p, base string) bool { func pathBelow(p, base string) bool {
if p == base { if p == base {
@ -217,7 +362,7 @@ func traceInfo(t cfg.TraceConfig) mirror.TraceInfo {
// dispatchCrawl runs the format-specific synchronization for a resource // dispatchCrawl runs the format-specific synchronization for a resource
// into the online tree. // into the online tree.
func dispatchCrawl(ctx context.Context, res resource) error { func dispatchCrawl(ctx context.Context, res resource, signatures signatureSettings) error {
online := cfg.C.OnlineDomain() online := cfg.C.OnlineDomain()
mount, ok := cfg.C.MountFor(res.Path) mount, ok := cfg.C.MountFor(res.Path)
if !ok { if !ok {
@ -237,7 +382,6 @@ func dispatchCrawl(ctx context.Context, res resource) error {
if err != nil { if err != nil {
return err return err
} }
// Generic files never reach the crawl path: they refresh on demand at // Generic files never reach the crawl path: they refresh on demand at
// serve time and expire through the failure cache and state eviction. // serve time and expire through the failure cache and state eviction.
typ := mirror.RepoType(res.Kind) typ := mirror.RepoType(res.Kind)
@ -248,6 +392,9 @@ func dispatchCrawl(ctx context.Context, res resource) error {
PruneGrace: cfg.C.Crawler.PruneGrace, PruneGrace: cfg.C.Crawler.PruneGrace,
Missing: missing, Missing: missing,
MissingRetries: cfg.C.Crawler.MissingRetries, MissingRetries: cfg.C.Crawler.MissingRetries,
SignatureMode: signatures.mode,
GPGKeyData: signatures.keyData,
Keyservers: signatures.keyservers,
Trace: cfg.C.Trace.Enabled, Trace: cfg.C.Trace.Enabled,
TraceInfo: traceInfo(cfg.C.Trace), TraceInfo: traceInfo(cfg.C.Trace),
} }

View file

@ -19,6 +19,7 @@ import (
cfg "github.com/grmrgecko/repo-sync/config" cfg "github.com/grmrgecko/repo-sync/config"
"github.com/grmrgecko/repo-sync/fetch" "github.com/grmrgecko/repo-sync/fetch"
"github.com/grmrgecko/repo-sync/mirror"
"github.com/grmrgecko/repo-sync/state" "github.com/grmrgecko/repo-sync/state"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@ -209,9 +210,39 @@ func handleOnline(w http.ResponseWriter, r *http.Request, domain cfg.DomainConfi
if res.Kind == kindGeneric { if res.Kind == kindGeneric {
// Files under a registered repository keep it alive and are // Files under a registered repository keep it alive and are
// fetched on demand until its crawl completes. // fetched on demand until its crawl completes.
if state.S.TouchRepoMembers(reqPath, now) { members := state.S.TouchRepoMembers(reqPath, now)
if len(members) > 0 {
if _, exists := regularFile(local); !exists { if _, exists := regularFile(local); !exists {
if err := fetchUpstream(r.Context(), domain.Root, reqPath, false); err != nil { var protectedKey string
var protectedEntry state.Entry
for key, entry := range members {
if protectedRepositoryKind(entry.Kind) && cfg.C.Crawler.SignatureMode != string(mirror.SignatureOff) {
protectedKey = key
protectedEntry = entry
break
}
}
var err error
if protectedKey != "" {
settings, settingsErr := loadSignatureSettings(cfg.C)
protectedRes := resource{
Kind: protectedEntry.Kind,
Key: protectedKey,
Path: protectedEntry.Path,
Root: protectedEntry.Root,
ReqPath: protectedEntry.Path,
}
err = crawlProtectedRepository(r.Context(), protectedRes, false, settings, settingsErr)
if err == nil {
_, exists = regularFile(local)
if !exists {
err = fmt.Errorf("fetch %s: %w", reqPath, fetch.ErrNotFound)
}
}
} else {
err = fetchUpstream(r.Context(), domain.Root, reqPath, false)
}
if err != nil {
writeFetchError(w, err) writeFetchError(w, err)
return return
} }
@ -233,8 +264,30 @@ func handleOnline(w http.ResponseWriter, r *http.Request, domain cfg.DomainConfi
entry, tracked := state.S.MarkRequested(res.Kind, res.Key, res.Path, res.Root, now) entry, tracked := state.S.MarkRequested(res.Kind, res.Key, res.Path, res.Root, now)
_, exists := regularFile(local) _, exists := regularFile(local)
needCrawl := entry.LastCrawled.IsZero() || (!exists && entry.LastError != "") needCrawl := entry.LastCrawled.IsZero() || (!exists && entry.LastError != "")
if !exists { conf := cfg.C
if err := fetchUpstream(r.Context(), domain.Root, reqPath, false); err != nil { protected := protectedRepositoryKind(res.Kind) && conf.Crawler.SignatureMode != string(mirror.SignatureOff)
var signatureSettings signatureSettings
var signatureSettingsErr error
if protected {
signatureSettings, signatureSettingsErr = loadSignatureSettings(conf)
}
artifactsPresent := protected && signaturePolicyArtifactsPresent(conf, res, signatureSettings.mode)
needsSignatureCheck := protected && (signatureSettingsErr != nil || entry.SignaturePolicy != signatureSettings.policy || !artifactsPresent)
if !exists || needsSignatureCheck {
var err error
if protected {
// A protected entry point cannot be served until the crawl has
// verified and published its metadata generation.
err = crawlProtectedRepository(r.Context(), res, artifactsPresent, signatureSettings, signatureSettingsErr)
needCrawl = false
_, exists = regularFile(local)
if err == nil && !exists {
err = fmt.Errorf("fetch %s: %w", reqPath, fetch.ErrNotFound)
}
} else {
err = fetchUpstream(r.Context(), domain.Root, reqPath, false)
}
if err != nil {
// A path whose first contact failed was never shown to be a // A path whose first contact failed was never shown to be a
// repository, so the registration this request created is // repository, so the registration this request created is
// dropped and the scheduler never crawls it. Registrations an // dropped and the scheduler never crawls it. Registrations an

View file

@ -1,6 +1,7 @@
package server package server
import ( import (
"bytes"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -18,6 +19,8 @@ import (
"github.com/grmrgecko/repo-sync/internal/testrepos" "github.com/grmrgecko/repo-sync/internal/testrepos"
"github.com/grmrgecko/repo-sync/mirror" "github.com/grmrgecko/repo-sync/mirror"
"github.com/grmrgecko/repo-sync/state" "github.com/grmrgecko/repo-sync/state"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// serverFixture builds an upstream with one repository of each type plus a // serverFixture builds an upstream with one repository of each type plus a
@ -465,6 +468,103 @@ func TestServeSiblingEntryPointMiss(t *testing.T) {
} }
} }
// TestServeRejectsUnverifiedRPMEntryPoint verifies the first request cannot
// serve cached repomd.xml before its required signature has been checked.
func TestServeRejectsUnverifiedRPMEntryPoint(t *testing.T) {
www := t.TempDir()
repoDir := filepath.Join(www, "repo")
testrepos.BuildRPMRepo(t, repoDir)
key := testrepos.NewSigningKey(t)
key.SignRPMRepo(t, repoDir)
repomd := filepath.Join(repoDir, "repodata", "repomd.xml")
data, err := os.ReadFile(repomd)
if err != nil {
t.Fatal(err)
}
testrepos.WriteFile(t, repomd, append(data, '\n'))
upstream := testrepos.ServeDir(t, www)
onlineRoot := t.TempDir()
local := filepath.Join(onlineRoot, "repo", "repodata", "repomd.xml")
testrepos.WriteFile(t, local, []byte("unverified cached metadata"))
confDir := t.TempDir()
confPath := filepath.Join(confDir, "config.yaml")
testrepos.WriteFile(t, confPath, []byte(fmt.Sprintf(`
state_path: %s/state.yaml
domains:
- {domain: 127.0.0.1, role: online, root: %s}
mounts:
- {path: /, upstream: %s}
crawler:
signature_mode: required
`, confDir, onlineRoot, upstream.URL)))
if err := cfg.Init(confPath); err != nil {
t.Fatal(err)
}
if err := state.Load(); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(Handler())
t.Cleanup(srv.Close)
resp, _ := get(t, srv, "", "/repo/repodata/repomd.xml")
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("repomd status = %d, want 502", resp.StatusCode)
}
got, err := os.ReadFile(local)
if err != nil {
t.Fatal(err)
}
if string(got) != "unverified cached metadata" {
t.Error("failed verification replaced the cached repomd.xml")
}
}
// TestServeRejectsUnverifiedDebEntryPoint verifies a cached InRelease cannot
// bypass the synchronous signature gate on its first request.
func TestServeRejectsUnverifiedDebEntryPoint(t *testing.T) {
www := t.TempDir()
repoDir := filepath.Join(www, "debian")
testrepos.BuildDebRepo(t, repoDir)
key := testrepos.NewSigningKey(t)
key.SignDebRepo(t, repoDir)
inRelease := filepath.Join(repoDir, "dists", "test", "InRelease")
signedRelease, err := os.ReadFile(inRelease)
require.NoError(t, err)
tampered := bytes.Replace(signedRelease, []byte("Origin: Test"), []byte("Origin: Pest"), 1)
require.NoError(t, os.WriteFile(inRelease, tampered, 0644))
upstream := testrepos.ServeDir(t, www)
onlineRoot := t.TempDir()
local := filepath.Join(onlineRoot, "debian", "dists", "test", "InRelease")
testrepos.WriteFile(t, local, []byte("unverified cached metadata"))
confDir := t.TempDir()
keyPath := filepath.Join(confDir, "repository.asc")
testrepos.WriteFile(t, keyPath, key.PublicKey(t))
confPath := filepath.Join(confDir, "config.yaml")
testrepos.WriteFile(t, confPath, []byte(fmt.Sprintf(`
state_path: %s/state.yaml
domains:
- {domain: 127.0.0.1, role: online, root: %s}
mounts:
- {path: /, upstream: %s}
crawler:
signature_mode: required
gpg_keys: [%s]
keyservers: []
`, confDir, onlineRoot, upstream.URL, keyPath)))
require.NoError(t, cfg.Init(confPath))
require.NoError(t, state.Load())
srv := httptest.NewServer(Handler())
t.Cleanup(srv.Close)
resp, _ := get(t, srv, "", "/debian/dists/test/InRelease")
assert.Equal(t, http.StatusBadGateway, resp.StatusCode)
got, err := os.ReadFile(local)
require.NoError(t, err)
assert.Equal(t, []byte("unverified cached metadata"), got, "failed verification must retain the cached InRelease")
}
// TestServeUnresolvedEntryPoint verifies a path that only looks like a // TestServeUnresolvedEntryPoint verifies a path that only looks like a
// repository entry point is not left registered for the scheduler to crawl. // repository entry point is not left registered for the scheduler to crawl.
func TestServeUnresolvedEntryPoint(t *testing.T) { func TestServeUnresolvedEntryPoint(t *testing.T) {

View file

@ -32,6 +32,9 @@ type Entry struct {
NextCrawl time.Time `yaml:"next_crawl,omitempty"` NextCrawl time.Time `yaml:"next_crawl,omitempty"`
LastSeenInInventory time.Time `yaml:"last_seen_in_inventory,omitempty"` LastSeenInInventory time.Time `yaml:"last_seen_in_inventory,omitempty"`
LastError string `yaml:"last_error,omitempty"` LastError string `yaml:"last_error,omitempty"`
// SignaturePolicy records the verification settings applied by the last
// successful crawl.
SignaturePolicy string `yaml:"signature_policy,omitempty"`
} }
// file is the on-disk YAML layout. // file is the on-disk YAML layout.
@ -137,6 +140,19 @@ func (s *Store) MarkCrawled(key string, now time.Time, crawlErr error) {
s.dirty = true s.dirty = true
} }
// MarkSignaturePolicy records the signature policy completed by a successful
// repository crawl.
func (s *Store) MarkSignaturePolicy(key, policy string) {
s.mu.Lock()
defer s.mu.Unlock()
e := s.file.Entries[key]
if e == nil || e.SignaturePolicy == policy {
return
}
e.SignaturePolicy = policy
s.dirty = true
}
// Delete removes a resource from tracking. // Delete removes a resource from tracking.
func (s *Store) Delete(key string) { func (s *Store) Delete(key string) {
s.mu.Lock() s.mu.Lock()
@ -169,14 +185,13 @@ func (s *Store) Snapshot() map[string]Entry {
return out return out
} }
// TouchRepoMembers refreshes the last-requested time of every repository // TouchRepoMembers refreshes every repository whose root covers a request
// whose root covers a request path, keeping repositories alive while their // path and returns copies keyed by their state keys.
// files are fetched. It reports whether any repository matched. func (s *Store) TouchRepoMembers(reqPath string, now time.Time) map[string]Entry {
func (s *Store) TouchRepoMembers(reqPath string, now time.Time) bool {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
matched := false matched := map[string]Entry{}
for _, e := range s.file.Entries { for key, e := range s.file.Entries {
if e.Kind == "generic" || e.Root == "" { if e.Kind == "generic" || e.Root == "" {
continue continue
} }
@ -186,7 +201,7 @@ func (s *Store) TouchRepoMembers(reqPath string, now time.Time) bool {
continue continue
} }
e.LastRequested = now e.LastRequested = now
matched = true matched[key] = *e
s.dirty = true s.dirty = true
} }
return matched return matched

View file

@ -33,6 +33,10 @@ type SyncArgs struct {
Verify bool `help:"Re-verify checksums of files that already exist locally."` Verify bool `help:"Re-verify checksums of files that already exist locally."`
Prune bool `help:"Delete local files that are no longer part of the repository."` Prune bool `help:"Delete local files that are no longer part of the repository."`
SignatureMode string `help:"OpenPGP metadata signature policy: off, if-present, or required; defaults to the configured crawler policy." enum:",off,if-present,required" default:""`
GPGKey []string `help:"Public keyring file used for metadata signature checks; repeatable." type:"existingfile"`
Keyserver []string `help:"OpenPGP keyserver used to retrieve unknown signature issuers; repeatable and defaults to the configured crawler keyservers."`
Exclude []string `help:"Glob of directory or file names discovery never crawls; repeatable, and matched against the path below the crawled URL when it contains a slash, which a leading slash anchors to that URL (e.g. sles, 'yum/docker*', /docker)."` Exclude []string `help:"Glob of directory or file names discovery never crawls; repeatable, and matched against the path below the crawled URL when it contains a slash, which a leading slash anchors to that URL (e.g. sles, 'yum/docker*', /docker)."`
IncludeFile []string `help:"Glob of loose files found outside repositories that discovery mirrors as well; repeatable, and matched by path on the same rules as --exclude (e.g. '*.rpm', 'RPM-GPG-KEY-*', '/*.rpm')."` IncludeFile []string `help:"Glob of loose files found outside repositories that discovery mirrors as well; repeatable, and matched by path on the same rules as --exclude (e.g. '*.rpm', 'RPM-GPG-KEY-*', '/*.rpm')."`
@ -103,10 +107,29 @@ func (s *SyncArgs) options(types []mirror.RepoType) (*mirror.Options, error) {
if s.DiscoverCache < 0 { if s.DiscoverCache < 0 {
s.DiscoverCache = cfg.C.Crawler.DiscoverCache s.DiscoverCache = cfg.C.Crawler.DiscoverCache
} }
if s.SignatureMode == "" {
s.SignatureMode = cfg.C.Crawler.SignatureMode
}
if len(s.GPGKey) == 0 {
s.GPGKey = append([]string(nil), cfg.C.Crawler.GPGKeys...)
}
if len(s.Keyserver) == 0 {
s.Keyserver = append([]string(nil), cfg.C.Crawler.Keyservers...)
}
missing, err := fetch.ParseMissingMode(s.Missing) missing, err := fetch.ParseMissingMode(s.Missing)
if err != nil { if err != nil {
return nil, err return nil, err
} }
signatures, err := mirror.ParseSignatureMode(s.SignatureMode)
if err != nil {
return nil, err
}
for _, raw := range s.Keyserver {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, fmt.Errorf("invalid keyserver URL %q", raw)
}
}
opts := &mirror.Options{ opts := &mirror.Options{
Types: types, Types: types,
URLs: urls, URLs: urls,
@ -120,6 +143,9 @@ func (s *SyncArgs) options(types []mirror.RepoType) (*mirror.Options, error) {
IncludeFiles: s.IncludeFile, IncludeFiles: s.IncludeFile,
Workers: s.Workers, Workers: s.Workers,
Verify: s.Verify, Verify: s.Verify,
SignatureMode: signatures,
GPGKeys: s.GPGKey,
Keyservers: s.Keyserver,
Prune: s.Prune, Prune: s.Prune,
PruneGrace: s.PruneGrace, PruneGrace: s.PruneGrace,
DryRun: s.DryRun, DryRun: s.DryRun,