Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package apko for openSUSE:Factory checked in at 2026-04-25 21:36:40 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/apko (Old) and /work/SRC/openSUSE:Factory/.apko.new.11940 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "apko" Sat Apr 25 21:36:40 2026 rev:109 rq:1349055 version:1.2.7 Changes: -------- --- /work/SRC/openSUSE:Factory/apko/apko.changes 2026-04-23 17:11:29.961710306 +0200 +++ /work/SRC/openSUSE:Factory/.apko.new.11940/apko.changes 2026-04-25 21:37:18.845631893 +0200 @@ -1,0 +2,8 @@ +Fri Apr 24 06:06:38 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 1.2.7: + * apk: verify package control hash against signed APKINDEX + (#2191) + * apk: guard non-RSA JWKS keys in DiscoverKeys (#2190) + +------------------------------------------------------------------- Old: ---- apko-1.2.6.obscpio New: ---- apko-1.2.7.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ apko.spec ++++++ --- /var/tmp/diff_new_pack.V4YzIa/_old 2026-04-25 21:37:20.017679805 +0200 +++ /var/tmp/diff_new_pack.V4YzIa/_new 2026-04-25 21:37:20.017679805 +0200 @@ -17,7 +17,7 @@ Name: apko -Version: 1.2.6 +Version: 1.2.7 Release: 0 Summary: Build OCI images from APK packages directly without Dockerfile License: Apache-2.0 ++++++ _service ++++++ --- /var/tmp/diff_new_pack.V4YzIa/_old 2026-04-25 21:37:20.057681440 +0200 +++ /var/tmp/diff_new_pack.V4YzIa/_new 2026-04-25 21:37:20.061681604 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/chainguard-dev/apko.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">refs/tags/v1.2.6</param> + <param name="revision">refs/tags/v1.2.7</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.V4YzIa/_old 2026-04-25 21:37:20.089682749 +0200 +++ /var/tmp/diff_new_pack.V4YzIa/_new 2026-04-25 21:37:20.093682913 +0200 @@ -3,6 +3,6 @@ <param name="url">https://github.com/chainguard-dev/apko</param> <param name="changesrevision">861f83f69e6fa9114405a2f7bb5cf6585ad00421</param></service><service name="tar_scm"> <param name="url">https://github.com/chainguard-dev/apko.git</param> - <param name="changesrevision">09b82d635baa11223ba5b28b421069cadcddb5d9</param></service></servicedata> + <param name="changesrevision">a118c3d604107532b5525bd4bee2fb369a6228aa</param></service></servicedata> (No newline at EOF) ++++++ apko-1.2.6.obscpio -> apko-1.2.7.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.6/pkg/apk/apk/implementation.go new/apko-1.2.7/pkg/apk/apk/implementation.go --- old/apko-1.2.6/pkg/apk/apk/implementation.go 2026-04-22 17:22:42.000000000 +0200 +++ new/apko-1.2.7/pkg/apk/apk/implementation.go 2026-04-23 21:44:30.000000000 +0200 @@ -1058,7 +1058,11 @@ } keyName := key.KeyID + ".rsa.pub" - b, err := x509.MarshalPKIXPublicKey(key.Key.(*rsa.PublicKey)) + rsaKey, ok := key.Key.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("unsupported JWKS key type %T for key %q: expected *rsa.PublicKey", key.Key, key.KeyID) + } + b, err := x509.MarshalPKIXPublicKey(rsaKey) if err != nil { return nil, err } else if len(b) == 0 { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.6/pkg/apk/apk/implementation_test.go new/apko-1.2.7/pkg/apk/apk/implementation_test.go --- old/apko-1.2.6/pkg/apk/apk/implementation_test.go 2026-04-22 17:22:42.000000000 +0200 +++ new/apko-1.2.7/pkg/apk/apk/implementation_test.go 2026-04-23 21:44:30.000000000 +0200 @@ -16,6 +16,11 @@ import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/json" "fmt" "io/fs" "net/http" @@ -29,6 +34,7 @@ "testing" "github.com/stretchr/testify/require" + "go.step.sm/crypto/jose" "chainguard.dev/apko/pkg/apk/auth" apkfs "chainguard.dev/apko/pkg/apk/fs" @@ -930,3 +936,58 @@ require.Error(t, err, "should fail with bad auth") require.True(t, called, "did not make request") } + +func TestDiscoverKeysNonRSA(t *testing.T) { + ctx := context.Background() + + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + var jwksURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/apk-configuration": + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"jwks_uri":%q}`, jwksURL) + case "/jwks": + jwks := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &ecKey.PublicKey, KeyID: "ec-test"}}} + require.NoError(t, json.NewEncoder(w).Encode(jwks)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + jwksURL = srv.URL + "/jwks" + + _, err = DiscoverKeys(ctx, srv.Client(), auth.StaticAuth("", "", ""), srv.URL) + require.Error(t, err, "expected typed error, not a panic") + require.Contains(t, err.Error(), "unsupported JWKS key type") +} + +func TestDiscoverKeysRSA(t *testing.T) { + ctx := context.Background() + + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + var jwksURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/apk-configuration": + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"jwks_uri":%q}`, jwksURL) + case "/jwks": + jwks := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &rsaKey.PublicKey, KeyID: "rsa-test"}}} + require.NoError(t, json.NewEncoder(w).Encode(jwks)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + jwksURL = srv.URL + "/jwks" + + keys, err := DiscoverKeys(ctx, srv.Client(), auth.StaticAuth("", "", ""), srv.URL) + require.NoError(t, err) + require.Len(t, keys, 1) + require.Equal(t, "rsa-test.rsa.pub", keys[0].ID) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.6/pkg/apk/apk/install_test.go new/apko-1.2.7/pkg/apk/apk/install_test.go --- old/apko-1.2.6/pkg/apk/apk/install_test.go 2026-04-22 17:22:42.000000000 +0200 +++ new/apko-1.2.7/pkg/apk/apk/install_test.go 2026-04-23 21:44:30.000000000 +0200 @@ -416,7 +416,7 @@ return &testPackage{ pkg: pkg, file: f.Name(), - checksum: base64.StdEncoding.EncodeToString(h.Sum(nil)), + checksum: "Q1" + base64.StdEncoding.EncodeToString(h.Sum(nil)), } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.6/pkg/apk/apk/package_getter.go new/apko-1.2.7/pkg/apk/apk/package_getter.go --- old/apko-1.2.6/pkg/apk/apk/package_getter.go 2026-04-22 17:22:42.000000000 +0200 +++ new/apko-1.2.7/pkg/apk/apk/package_getter.go 2026-04-23 21:44:30.000000000 +0200 @@ -170,6 +170,11 @@ return nil, fmt.Errorf("expanding %s: %w", pkg.PackageName(), err) } + if err := verifyControlHash(pkg, exp.ControlHash); err != nil { + _ = exp.Close() + return nil, err + } + // If we don't have a cache, we're done. if d.cache == nil { return exp, nil @@ -178,6 +183,43 @@ return d.cachePackage(ctx, pkg, exp, cacheDir) } +// sha1File returns the SHA-1 of the file at path. +func sha1File(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + h := sha1.New() //nolint:gosec // this is what apk tools is using + if _, err := io.Copy(h, f); err != nil { + return nil, err + } + return h.Sum(nil), nil +} + +// verifyControlHash compares the SHA-1 of the downloaded package's control +// section against the Q1-prefixed base64 checksum recorded in the signed +// APKINDEX (or lock file). Without this check a compromised mirror or +// poisoned cache could substitute arbitrary package contents even though +// the index itself is signature-verified. +func verifyControlHash(pkg InstallablePackage, controlHash []byte) error { + chk := pkg.ChecksumString() + if !strings.HasPrefix(chk, "Q1") { + return fmt.Errorf("package %q has unexpected checksum format: %q", pkg.PackageName(), chk) + } + expected, err := base64.StdEncoding.DecodeString(chk[2:]) + if err != nil { + return fmt.Errorf("package %q has malformed checksum %q: %w", pkg.PackageName(), chk, err) + } + if len(expected) == 0 { + return fmt.Errorf("package %q has empty checksum", pkg.PackageName()) + } + if !bytes.Equal(expected, controlHash) { + return fmt.Errorf("package %q control hash mismatch: expected %x, got %x", pkg.PackageName(), expected, controlHash) + } + return nil +} + // fetchPackage fetches a package from the network or local filesystem. func (d *defaultPackageGetter) fetchPackage(ctx context.Context, pkg FetchablePackage) (io.ReadCloser, error) { log := clog.FromContext(ctx) @@ -322,8 +364,21 @@ if err != nil { return nil, err } + + // Recompute the hash of the on-disk control file rather than trusting + // the content-addressable filename. A missed check here would let a + // tampered or corrupted cache entry be served without the verification + // that getPackageImpl applies on the fetch path. + ctlHash, err := sha1File(ctl) + if err != nil { + return nil, fmt.Errorf("hashing cached control %q: %w", ctl, err) + } + if err := verifyControlHash(pkg, ctlHash); err != nil { + return nil, fmt.Errorf("cached %q: %w", ctl, err) + } + exp.ControlFile = ctl - exp.ControlHash = checksum + exp.ControlHash = ctlHash exp.ControlSize = cf.Size() control, err := exp.ControlData() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.6/pkg/apk/apk/package_getter_test.go new/apko-1.2.7/pkg/apk/apk/package_getter_test.go --- old/apko-1.2.6/pkg/apk/apk/package_getter_test.go 2026-04-22 17:22:42.000000000 +0200 +++ new/apko-1.2.7/pkg/apk/apk/package_getter_test.go 2026-04-23 21:44:30.000000000 +0200 @@ -2,6 +2,7 @@ import ( "context" + "encoding/base64" "fmt" "io" "net/http" @@ -263,3 +264,89 @@ require.Error(t, err, "unable to expand package") require.True(t, called, "did not make request") } + +// TestGetPackage_ChecksumMismatch confirms that a package served by a repository +// that does not match the checksum recorded in the (signed) APKINDEX is rejected +// rather than silently installed. This guards against compromised mirrors or +// poisoned caches substituting package contents. +func TestGetPackage_ChecksumMismatch(t *testing.T) { + tampered := testPkg + // Flip one byte of the recorded checksum so the downloaded content's + // real control-section SHA-1 will not match. + tampered.Checksum = append([]byte(nil), testPkg.Checksum...) + tampered.Checksum[0] ^= 0xff + + repo := Repository{URI: fmt.Sprintf("%s/%s", testAlpineRepos, testArch)} + repoWithIndex := repo.WithIndex(&APKIndex{Packages: []*Package{&tampered}}) + pkg := NewRepositoryPackage(&tampered, repoWithIndex) + ctx := context.Background() + + tmpDir := t.TempDir() + httpClient := &http.Client{Transport: &testLocalTransport{root: testPrimaryPkgDir, basenameOnly: true}} + a := newDefaultPackageGetter(httpClient, &cache{ + dir: tmpDir, + offline: false, + shared: NewCache(false), + }, auth.DefaultAuthenticators) + + _, err := a.GetPackage(ctx, pkg) + require.Error(t, err, "expected checksum mismatch to be detected") + require.Contains(t, err.Error(), "control hash mismatch") +} + +// TestCachedPackage_TamperedControl confirms that a cache entry whose +// on-disk control file no longer matches its content-addressable filename +// is rejected rather than served. This protects against cache corruption +// or tampering after an entry was originally written. +func TestCachedPackage_TamperedControl(t *testing.T) { + repo := Repository{URI: fmt.Sprintf("%s/%s", testAlpineRepos, testArch)} + repoWithIndex := repo.WithIndex(&APKIndex{Packages: []*Package{&testPkg}}) + pkg := NewRepositoryPackage(&testPkg, repoWithIndex) + ctx := context.Background() + + tmpDir := t.TempDir() + httpClient := &http.Client{Transport: &testLocalTransport{root: testPrimaryPkgDir, basenameOnly: true}} + a := newDefaultPackageGetter(httpClient, &cache{ + dir: tmpDir, + offline: false, + shared: NewCache(false), + }, auth.DefaultAuthenticators) + + // Populate the cache. + exp, err := a.GetPackage(ctx, pkg) + require.NoError(t, err, "populating cache") + ctlPath := exp.ControlFile + require.FileExists(t, ctlPath) + + cacheDir := filepath.Dir(ctlPath) + + // Tamper with the cached control file. Overwrite with different bytes + // so its SHA-1 no longer matches the content-addressable filename. + require.NoError(t, os.WriteFile(ctlPath, []byte("tampered"), 0o644)) + + _, err = a.cachedPackage(ctx, pkg, cacheDir) + require.Error(t, err, "expected tampered cache entry to be rejected") + require.Contains(t, err.Error(), "control hash mismatch") +} + +func TestVerifyControlHash(t *testing.T) { + want := make([]byte, 20) + for i := range want { + want[i] = byte(i) + } + pkg := &testPackage{ + pkg: &Package{Name: "example"}, + checksum: "Q1" + base64.StdEncoding.EncodeToString(want), + } + + require.NoError(t, verifyControlHash(pkg, want)) + + bad := append([]byte(nil), want...) + bad[0] ^= 0xff + require.Error(t, verifyControlHash(pkg, bad)) + + require.Error(t, verifyControlHash(&testPackage{pkg: &Package{Name: "x"}, checksum: ""}, want)) + require.Error(t, verifyControlHash(&testPackage{pkg: &Package{Name: "x"}, checksum: "Q1"}, want)) + require.Error(t, verifyControlHash(&testPackage{pkg: &Package{Name: "x"}, checksum: "Q1!!!"}, want)) + require.Error(t, verifyControlHash(&testPackage{pkg: &Package{Name: "x"}, checksum: "raw-no-prefix"}, want)) +} ++++++ apko.obsinfo ++++++ --- /var/tmp/diff_new_pack.V4YzIa/_old 2026-04-25 21:37:20.853713981 +0200 +++ /var/tmp/diff_new_pack.V4YzIa/_new 2026-04-25 21:37:20.865714472 +0200 @@ -1,5 +1,5 @@ name: apko -version: 1.2.6 -mtime: 1776871362 -commit: 09b82d635baa11223ba5b28b421069cadcddb5d9 +version: 1.2.7 +mtime: 1776973470 +commit: a118c3d604107532b5525bd4bee2fb369a6228aa ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/apko/vendor.tar.gz /work/SRC/openSUSE:Factory/.apko.new.11940/vendor.tar.gz differ: char 134, line 2
