diff --git a/internal/cli/install-keys.go b/internal/cli/install-keys.go index 063be8696..5ab279f19 100644 --- a/internal/cli/install-keys.go +++ b/internal/cli/install-keys.go @@ -2,13 +2,11 @@ package cli import ( "fmt" - "os" - "path/filepath" - "github.com/chainguard-dev/clog" "github.com/spf13/cobra" "chainguard.dev/apko/pkg/apk/apk" + "chainguard.dev/apko/pkg/apk/apk/keyring" ) func installKeys() *cobra.Command { @@ -19,7 +17,6 @@ func installKeys() *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - log := clog.FromContext(ctx) a, err := apk.New(ctx) if err != nil { @@ -29,24 +26,15 @@ func installKeys() *cobra.Command { if err != nil { return err } - for _, repo := range repos { - keys, err := a.DiscoverKeys(ctx, repo) - if err != nil { - return err - } - if err := os.MkdirAll("/etc/apk/keys", 0755); err != nil { - return err - } - for _, key := range keys { - fn := filepath.Join("/etc/apk/keys", key.ID) - if err := os.WriteFile(fn, key.Bytes, 0o644); err != nil { //nolint: gosec - return fmt.Errorf("failed to write key %s: %w", key.ID, err) - } - log.With("repo", repo).Infof("wrote %s", fn) - } + keyRing, err := keyring.NewKeyRing( + keyring.AddRepositories(repos...), + ) + if err != nil { + return fmt.Errorf("creating keyring: %w", err) } - return nil + + return a.DownloadAndStoreKeys(ctx, keyRing) }, } } diff --git a/internal/cli/lock.go b/internal/cli/lock.go index 67c10d6ff..88e6204f5 100644 --- a/internal/cli/lock.go +++ b/internal/cli/lock.go @@ -24,13 +24,13 @@ import ( "slices" "sort" "strings" - "time" "github.com/spf13/cobra" "github.com/chainguard-dev/clog" "chainguard.dev/apko/pkg/apk/apk" + "chainguard.dev/apko/pkg/apk/apk/keyring" "chainguard.dev/apko/pkg/apk/auth" apkfs "chainguard.dev/apko/pkg/apk/fs" "chainguard.dev/apko/pkg/build" @@ -281,87 +281,33 @@ func stripURLScheme(url string) string { func discoverKeysForLock(ctx context.Context, ic *types.ImageConfiguration, archs []types.Architecture) []pkglock.LockKeyring { log := clog.FromContext(ctx) - // Collect all unique repositories - repoSet := make(map[string]struct{}) - for _, repo := range ic.Contents.BuildRepositories { - repoSet[repo] = struct{}{} - } - for _, repo := range ic.Contents.RuntimeOnlyRepositories { - repoSet[repo] = struct{}{} - } - for _, repo := range ic.Contents.Repositories { - repoSet[repo] = struct{}{} + keys, err := keyring.NewKeyRing( + keyring.AddRepositories(ic.Contents.BuildRepositories...), + keyring.AddRepositories(ic.Contents.RuntimeOnlyRepositories...), + keyring.AddRepositories(ic.Contents.Repositories...), + ) + if err != nil { + log.Errorf("adding repositories for key discovery: %v", err) + return nil } - // Map to track discovered keys by URL to avoid duplicates - discoveredKeyMap := make(map[string]pkglock.LockKeyring) - - // Fetch Alpine releases once (cached by HTTP client) - client := &http.Client{} - var alpineReleases *apk.Releases - - // Discover keys for each repository and architecture - for repo := range repoSet { - // Try Alpine-style key discovery - if ver, ok := apk.ParseAlpineVersion(repo); ok { - // Fetch releases.json if not already fetched - if alpineReleases == nil { - releases, err := apk.FetchAlpineReleases(ctx, client) - if err != nil { - log.Warnf("Failed to fetch Alpine releases: %v", err) - continue - } - alpineReleases = releases - } - - branch := alpineReleases.GetReleaseBranch(ver) - if branch == nil { - log.Debugf("Alpine version %s not found in releases", ver) - continue - } - - // Get keys for each architecture - for _, arch := range archs { - log.Debugf("Discovering Alpine keys for %s (version %s, arch %s)", repo, ver, arch.ToAPK()) - urls := branch.KeysFor(arch.ToAPK(), time.Now()) - if len(urls) == 0 { - log.Debugf("No keys found for arch %s and version %s", arch.ToAPK(), ver) - continue - } - - // Add discovered key URLs to the map - for _, u := range urls { - discoveredKeyMap[u] = pkglock.LockKeyring{ - Name: stripURLScheme(u), - URL: u, - } - } - } - } + archStrs := make([]string, 0, len(archs)) + for _, arch := range archs { + archStrs = append(archStrs, arch.ToAPK()) + } - // Try Chainguard-style key discovery - log.Debugf("Attempting Chainguard-style key discovery for %s", repo) - keys, err := apk.DiscoverKeys(ctx, client, auth.DefaultAuthenticators, repo) - if err != nil { - log.Debugf("Chainguard-style key discovery failed for %s: %v", repo, err) - } else if len(keys) > 0 { - log.Debugf("Discovered %d Chainguard-style keys for %s", len(keys), repo) - // For each JWKS key, emit a URL: repository + "/" + KeyID - repoBase := strings.TrimSuffix(repo, "/") - for _, key := range keys { - keyURL := repoBase + "/" + key.ID - discoveredKeyMap[keyURL] = pkglock.LockKeyring{ - Name: stripURLScheme(keyURL), - URL: keyURL, - } - } - } + fetchedKeys, err := keys.FetchKeys(ctx, keyring.NewFetcher(http.DefaultClient, auth.DefaultAuthenticators), archStrs) + if err != nil { + log.Errorf("downloading keys from repositories: %v", err) + return nil } - // Convert map to slice - discoveredKeys := make([]pkglock.LockKeyring, 0, len(discoveredKeyMap)) - for _, key := range discoveredKeyMap { - discoveredKeys = append(discoveredKeys, key) + discoveredKeys := make([]pkglock.LockKeyring, 0, len(fetchedKeys)) + for _, key := range fetchedKeys { + discoveredKeys = append(discoveredKeys, pkglock.LockKeyring{ + Name: stripURLScheme(key.URL), + URL: key.URL, + }) } log.Infof("Discovered %d auto-discovered keys", len(discoveredKeys)) diff --git a/pkg/apk/apk/cache.go b/pkg/apk/apk/cache.go index c25ac84ea..cd39bafa2 100644 --- a/pkg/apk/apk/cache.go +++ b/pkg/apk/apk/cache.go @@ -86,8 +86,6 @@ type Cache struct { etagCache *sync.Map headFlight *singleflight.Group getFlight *singleflight.Group - - discoverKeys *flightCache[string, []Key] } // NewCache returns a new Cache, which allows us to persist the results of HEAD requests @@ -103,9 +101,8 @@ type Cache struct { // requests for the same resource when passing etag=false. func NewCache(etag bool) *Cache { c := &Cache{ - headFlight: &singleflight.Group{}, - getFlight: &singleflight.Group{}, - discoverKeys: newFlightCache[string, []Key](), + headFlight: &singleflight.Group{}, + getFlight: &singleflight.Group{}, } if etag { diff --git a/pkg/apk/apk/const.go b/pkg/apk/apk/const.go index 8cc6702d2..1cf48510c 100644 --- a/pkg/apk/apk/const.go +++ b/pkg/apk/apk/const.go @@ -30,8 +30,5 @@ const ( // which PAX record we use in the tar header paxRecordsChecksumKey = "APK-TOOLS.checksum.SHA1" - // for fetching the alpine keys - alpineReleasesURL = "https://alpinelinux.org/releases.json" - xattrTarPAXRecordsPrefix = "SCHILY.xattr." ) diff --git a/pkg/apk/apk/implementation.go b/pkg/apk/apk/implementation.go index ceb1c2c0f..059b630ee 100644 --- a/pkg/apk/apk/implementation.go +++ b/pkg/apk/apk/implementation.go @@ -18,13 +18,9 @@ import ( "archive/tar" "bytes" "context" - "crypto/rsa" "crypto/sha1" //nolint:gosec // this is what apk tools is using - "crypto/x509" "encoding/base64" "encoding/hex" - "encoding/json" - "encoding/pem" "errors" "fmt" "io" @@ -45,11 +41,11 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "go.step.sm/crypto/jose" "golang.org/x/sync/errgroup" "golang.org/x/sys/unix" "chainguard.dev/apko/internal/tarfs" + "chainguard.dev/apko/pkg/apk/apk/keyring" "chainguard.dev/apko/pkg/apk/auth" "chainguard.dev/apko/pkg/apk/expandapk" apkfs "chainguard.dev/apko/pkg/apk/fs" @@ -237,7 +233,7 @@ func (a *APK) ListInitFiles() []tar.Header { // Returns the list of files and directories and files installed and permissions, // unless those files will be included in the installed database, in which case they can // be retrieved via GetInstalled(). -func (a *APK) InitDB(ctx context.Context, buildRepos ...string) error { +func (a *APK) InitDB(ctx context.Context) error { log := clog.FromContext(ctx) /* equivalent of: "apk add --initdb --arch arch --root root" @@ -309,23 +305,6 @@ func (a *APK) InitDB(ctx context.Context, buildRepos ...string) error { // nothing to add to it; scripts.tar should be empty - // Perform key discovery for the various build-time repositories. - for _, repo := range buildRepos { - if ver, ok := ParseAlpineVersion(repo); ok { - if err := a.fetchAlpineKeys(ctx, ver); err != nil { - var nokeysErr *NoKeysFoundError - if !a.cache.offline && !errors.As(err, &nokeysErr) { - return fmt.Errorf("failed to fetch alpine-keys: %w", err) - } - log.Debugf("ignoring missing keys: %v", err) - } - } - - if err := a.fetchChainguardKeys(ctx, repo); err != nil { - return fmt.Errorf("fetching chainguard keys for %s: %w", repo, err) - } - } - log.Debug("finished initializing apk database") return nil } @@ -490,93 +469,6 @@ func (a *APK) loadSystemKeyring(ctx context.Context, locations ...string) ([]str return nil, errors.New("no suitable keyring directory found") } -// Installs the specified keys into the APK keyring inside the build context. -func (a *APK) InitKeyring(ctx context.Context, keyFiles, extraKeyFiles []string) error { - log := clog.FromContext(ctx) - log.Debug("initializing apk keyring") - - ctx, span := otel.Tracer("go-apk").Start(ctx, "InitKeyring") - defer span.End() - - if err := a.fs.MkdirAll(DefaultKeyRingPath, 0o755); err != nil { - return fmt.Errorf("failed to make keys dir: %w", err) - } - - if len(extraKeyFiles) > 0 { - log.Debugf("appending %d extra keys to keyring", len(extraKeyFiles)) - keyFiles = append(keyFiles, extraKeyFiles...) - } - - var eg errgroup.Group - - for _, element := range keyFiles { - eg.Go(func() error { - log.Debugf("installing key %v", element) - - var asURL *url.URL - var err error - if strings.HasPrefix(element, "https://") || strings.HasPrefix(element, "http://") { - asURL, err = url.Parse(element) - } else { - // Attempt to parse non-https elements into URI's so they are translated into - // file:// URLs allowing them to parse into a url.URL{} - asURL, err = url.Parse(string(uri.New(element))) - } - if err != nil { - return fmt.Errorf("failed to parse key as URI: %w", err) - } - - var data []byte - switch asURL.Scheme { - case "file": //nolint:goconst - data, err = os.ReadFile(element) - if err != nil { - return fmt.Errorf("failed to read apk key: %w", err) - } - case "https", "http": //nolint:goconst - client := a.client - if a.cache != nil { - client = a.cache.client(client, true) - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, asURL.String(), nil) - if err != nil { - return err - } - if err := a.auth.AddAuth(ctx, req); err != nil { - return fmt.Errorf("failed to add auth to request: %w", err) - } - - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to fetch apk key: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode > 299 { - return fmt.Errorf("failed to fetch apk key from %s: http response indicated error code: %d", req.Host, resp.StatusCode) - } - - data, err = io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read apk key response: %w", err) - } - default: - return fmt.Errorf("scheme %s not supported", asURL.Scheme) - } - - // #nosec G306 -- apk keyring must be publicly readable - if err := a.fs.WriteFile(filepath.Join("etc", "apk", "keys", filepath.Base(element)), data, - 0o644); err != nil { - return fmt.Errorf("failed to write apk key: %w", err) - } - - return nil - }) - } - - return eg.Wait() -} - // ResolveWorld determine the target state for the requested dependencies in /etc/apk/world. Does not install anything. func (a *APK) ResolveWorld(ctx context.Context) (toInstall []*RepositoryPackage, conflicts []string, err error) { log := clog.FromContext(ctx) @@ -856,246 +748,6 @@ func (a *APK) InstallPackages(ctx context.Context, sourceDateEpoch *time.Time, a return diffs, nil } -type NoKeysFoundError struct { - arch string - releases []string -} - -func (e *NoKeysFoundError) Error() string { - return fmt.Sprintf("no keys found for arch %s and releases %v", e.arch, e.releases) -} - -// FetchAlpineReleases fetches and returns the Alpine releases metadata from alpinelinux.org. -func FetchAlpineReleases(ctx context.Context, client *http.Client) (*Releases, error) { - u := alpineReleasesURL - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, err - } - // NB: Not setting basic auth, since we know Alpine doesn't support it. - res, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch alpine releases: %w", err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unable to get alpine releases at %s: %v", u, res.Status) - } - b, err := io.ReadAll(res.Body) - if err != nil { - return nil, fmt.Errorf("failed to read alpine releases: %w", err) - } - var releases Releases - if err := json.Unmarshal(b, &releases); err != nil { - return nil, fmt.Errorf("failed to unmarshal alpine releases: %w", err) - } - return &releases, nil -} - -// fetchAlpineKeys fetches the public keys for the repositories in the APK database. -func (a *APK) fetchAlpineKeys(ctx context.Context, alpineVersions ...string) error { - ctx, span := otel.Tracer("go-apk").Start(ctx, "fetchAlpineKeys") - defer span.End() - - client := a.client - if a.cache != nil { - client = a.cache.client(client, true) - } - releases, err := FetchAlpineReleases(ctx, client) - if err != nil { - return err - } - var urls []string - // now just need to get the keys for the desired architecture and releases - for _, version := range alpineVersions { - branch := releases.GetReleaseBranch(version) - if branch == nil { - continue - } - urls = append(urls, branch.KeysFor(a.arch, time.Now())...) - } - if len(urls) == 0 { - return &NoKeysFoundError{arch: a.arch, releases: alpineVersions} - } - // get the keys for each URL and save them to a file with that name - for _, u := range urls { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return err - } - // NB: Not setting basic auth, since we know Alpine doesn't support it. - res, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to fetch alpine key %s: %w", u, err) - } - defer res.Body.Close() - basefilenameEscape := filepath.Base(u) - basefilename, err := url.PathUnescape(basefilenameEscape) - if err != nil { - return fmt.Errorf("failed to unescape key filename %s: %w", basefilenameEscape, err) - } - filename := filepath.Join(keysDirPath, basefilename) - f, err := a.fs.OpenFile(filename, os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return fmt.Errorf("failed to open key file %s: %w", filename, err) - } - defer f.Close() - if _, err := io.Copy(f, res.Body); err != nil { - return fmt.Errorf("failed to write key file %s: %w", filename, err) - } - } - return nil -} - -type Key struct { - ID string - Bytes []byte -} - -// DiscoverKeys fetches the public keys for the repositories in the APK database using chainguard-style discovery. -func DiscoverKeys(ctx context.Context, client *http.Client, auth auth.Authenticator, repository string) ([]Key, error) { - ctx, span := otel.Tracer("go-apk").Start(ctx, "DiscoverKeys") - defer span.End() - - if !strings.HasPrefix(repository, "https://") && !strings.HasPrefix(repository, "http://") { - // Ignore non-remote repositories. - return nil, nil - } - asURL, err := url.Parse(strings.TrimSuffix(repository, "/") + "/apk-configuration") - if err != nil { - return nil, fmt.Errorf("failed to parse repository URL: %w", err) - } - - discoveryRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, asURL.String(), nil) - if err != nil { - return nil, err - } - if err := auth.AddAuth(ctx, discoveryRequest); err != nil { - return nil, err - } - - discoveryResponse, err := client.Do(discoveryRequest) - if err != nil { - return nil, fmt.Errorf("failed to perform key discovery: %w", err) - } - defer discoveryResponse.Body.Close() - switch discoveryResponse.StatusCode { - case http.StatusNotFound: - // This doesn't implement Chainguard-style key discovery. - return nil, nil - - case http.StatusOK: - // proceed! - break - - default: - return nil, fmt.Errorf("chainguard key discovery was unsuccessful for repo %s: %v", repository, discoveryResponse.Status) - } - // Parse our the JWKS URI - var discovery struct { - JWKSURI string `json:"jwks_uri"` - } - if err := json.NewDecoder(discoveryResponse.Body).Decode(&discovery); err != nil { - return nil, fmt.Errorf("failed to unmarshal discovery payload: %w", err) - } - - jwksRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, discovery.JWKSURI, nil) - if err != nil { - return nil, err - } - if err := auth.AddAuth(ctx, jwksRequest); err != nil { - return nil, err - } - jwksResponse, err := client.Do(jwksRequest) - if err != nil { - return nil, fmt.Errorf("failed to fetch JWKS: %w", err) - } - defer jwksResponse.Body.Close() - if jwksResponse.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch JWKS: %v", jwksResponse.Status) - } - - jwks := jose.JSONWebKeySet{} - if err := json.NewDecoder(jwksResponse.Body).Decode(&jwks); err != nil { - return nil, fmt.Errorf("failed to unmarshal JWKS: %w", err) - } - - keys := make([]Key, 0, len(jwks.Keys)) - for _, key := range jwks.Keys { - if key.KeyID == "" { - return nil, fmt.Errorf(`key missing "kid"`) - } - keyName := key.KeyID + ".rsa.pub" - - b, err := x509.MarshalPKIXPublicKey(key.Key.(*rsa.PublicKey)) - if err != nil { - return nil, err - } else if len(b) == 0 { - return nil, fmt.Errorf("empty public key") - } - var buf bytes.Buffer - if err := pem.Encode(&buf, &pem.Block{ - Type: "PUBLIC KEY", - Bytes: b, - }); err != nil { - return nil, fmt.Errorf("failed to pem encode key %s: %w", keyName, err) - } - - keys = append(keys, Key{ - ID: keyName, - Bytes: buf.Bytes(), - }) - } - - return keys, nil -} - -func (a *APK) DiscoverKeys(ctx context.Context, repository string) ([]Key, error) { - client := a.client - if a.cache != nil { - client = a.cache.client(client, false) - - if !a.cache.offline { - rc := retryablehttp.NewClient() - rc.HTTPClient = client - rc.Logger = clog.FromContext(ctx) - client = rc.StandardClient() - } - - return a.cache.shared.discoverKeys.Do(repository, func() ([]Key, error) { - return DiscoverKeys(ctx, client, a.auth, repository) - }) - } - - return DiscoverKeys(ctx, client, a.auth, repository) -} - -// fetchChainguardKeys fetches the public keys for the repositories in the APK database. -func (a *APK) fetchChainguardKeys(ctx context.Context, repository string) error { - ctx, span := otel.Tracer("go-apk").Start(ctx, "fetchChainguardKeys") - defer span.End() - - log := clog.FromContext(ctx) - - if !strings.HasPrefix(repository, "https://") && !strings.HasPrefix(repository, "http://") { - log.Debugf("ignoring non-http(s) repository %s", repository) - return nil - } - - keys, err := a.DiscoverKeys(ctx, repository) - if err != nil { - log.Debugf("ignoring missing keys for %s: %v", repository, err) - } - - for _, key := range keys { - filename := filepath.Join(keysDirPath, key.ID) - if err := a.fs.WriteFile(filename, key.Bytes, 0o644); err != nil { - return fmt.Errorf("failed to write key file %s: %w", filename, err) - } - } - return nil -} - func (a *APK) cachePackage(ctx context.Context, pkg InstallablePackage, exp *expandapk.APKExpanded, cacheDir string) (*expandapk.APKExpanded, error) { _, span := otel.Tracer("go-apk").Start(ctx, "cachePackage", trace.WithAttributes(attribute.String("package", pkg.PackageName()))) defer span.End() @@ -1470,3 +1122,29 @@ func packageRefs(pkgs []*RepositoryPackage) []string { } return names } + +func (a *APK) DownloadAndStoreKeys(ctx context.Context, keys *keyring.KeyRing) error { + client := a.client + if a.cache != nil { + client = a.cache.client(client, false) + } + + fetchedKeys, err := keys.FetchKeys(ctx, keyring.NewFetcher(client, a.auth), []string{a.arch}) + if err != nil { + return err + } + + if err := a.fs.MkdirAll(DefaultKeyRingPath, 0o755); err != nil { + return fmt.Errorf("failed to make keys dir: %w", err) + } + + for _, key := range fetchedKeys { + fn := filepath.Join(DefaultKeyRingPath, key.ID) + + if err := a.fs.WriteFile(fn, key.Bytes, 0o644); err != nil { //nolint: gosec + return fmt.Errorf("failed to write key %s: %w", key.ID, err) + } + } + + return nil +} diff --git a/pkg/apk/apk/implementation_test.go b/pkg/apk/apk/implementation_test.go index 8f4ea0346..ece2bed1c 100644 --- a/pkg/apk/apk/implementation_test.go +++ b/pkg/apk/apk/implementation_test.go @@ -31,6 +31,7 @@ import ( "github.com/stretchr/testify/require" + "chainguard.dev/apko/pkg/apk/apk/keyring" "chainguard.dev/apko/pkg/apk/auth" apkfs "chainguard.dev/apko/pkg/apk/fs" ) @@ -111,8 +112,9 @@ func TestInitDB_ChainguardDiscovery(t *testing.T) { apk, err := New(t.Context(), WithFS(src), WithIgnoreMknodErrors(ignoreMknodErrors)) require.NoError(t, err) - err = apk.InitDB(context.Background(), "https://apk.cgr.dev/chainguard") + apk.InitDB(context.Background()) require.NoError(t, err) + // check all of the contents for _, d := range initDirectories { fi, err := fs.Stat(src, d.path) @@ -138,6 +140,14 @@ func TestInitDB_ChainguardDiscovery(t *testing.T) { } } + keys, err := keyring.NewKeyRing( + keyring.AddRepositories("https://apk.cgr.dev/chainguard"), + ) + require.NoError(t, err) + + apk.DownloadAndStoreKeys(context.Background(), keys) + require.NoError(t, err) + // Confirm that we find at least one discovered key. ent, err := fs.ReadDir(src, "etc/apk/keys") require.NoError(t, err) @@ -502,11 +512,12 @@ func TestInitKeyring(t *testing.T) { require.NoError(t, err) // Add a local file and a remote key - keyfiles := []string{ - keyPath, "https://alpinelinux.org/keys/alpine-devel%40lists.alpinelinux.org-4a6a0840.rsa.pub", - } + keys, err := keyring.NewKeyRing( + keyring.AddKeyPaths(keyPath, "https://alpinelinux.org/keys/alpine-devel%40lists.alpinelinux.org-4a6a0840.rsa.pub"), + ) + require.NoError(t, err) - require.NoError(t, a.InitKeyring(context.Background(), keyfiles, nil)) + require.NoError(t, a.DownloadAndStoreKeys(context.Background(), keys)) // InitKeyring should have copied the local key and remote key to the right place fi, err := src.ReadDir(DefaultKeyRingPath) // should be no error reading them @@ -515,23 +526,26 @@ func TestInitKeyring(t *testing.T) { require.Len(t, fi, 2) // Add an invalid file - keyfiles = []string{ - "/liksdjlksdjlksjlksjdl", - } - require.Error(t, a.InitKeyring(context.Background(), keyfiles, nil)) + keys, err = keyring.NewKeyRing( + keyring.AddKeyPaths("/liksdjlksdjlksjlksjdl"), + ) + require.NoError(t, err) + require.Error(t, a.DownloadAndStoreKeys(context.Background(), keys)) // Add an invalid url - keyfiles = []string{ - "http://sldkjflskdjflklksdlksdlkjslk.net", - } - require.Error(t, a.InitKeyring(context.Background(), keyfiles, nil)) + keys, err = keyring.NewKeyRing( + keyring.AddKeyPaths("http://sldkjflskdjflklksdlksdlkjslk.net"), + ) + require.NoError(t, err) + require.Error(t, a.DownloadAndStoreKeys(context.Background(), keys)) // add a remote key with HTTP Basic Auth - keyfiles = []string{ - "https://user:pass@alpinelinux.org/keys/alpine-devel%40lists.alpinelinux.org-4a6a0840.rsa.pub", - } + keys, err = keyring.NewKeyRing( + keyring.AddKeyPaths("https://user:pass@alpinelinux.org/keys/alpine-devel%40lists.alpinelinux.org-4a6a0840.rsa.pub"), + ) + require.NoError(t, err) tr.requireBasicAuth = true - require.NoError(t, a.InitKeyring(context.Background(), keyfiles, nil)) + require.NoError(t, a.DownloadAndStoreKeys(context.Background(), keys)) t.Run("auth", func(t *testing.T) { called := false @@ -558,7 +572,12 @@ func TestInitKeyring(t *testing.T) { err = a.InitDB(ctx) require.NoError(t, err) - err = a.InitKeyring(ctx, []string{s.URL + "/alpine-devel@lists.alpinelinux.org-4a6a0840.rsa.pub"}, nil) + keys, err = keyring.NewKeyRing( + keyring.AddKeyPaths(s.URL + "/alpine-devel@lists.alpinelinux.org-4a6a0840.rsa.pub"), + ) + require.NoError(t, err) + + err = a.DownloadAndStoreKeys(ctx, keys) require.NoErrorf(t, err, "unable to init keyring") require.True(t, called, "did not make request") }) @@ -573,7 +592,12 @@ func TestInitKeyring(t *testing.T) { err = a.InitDB(ctx) require.NoError(t, err) - err = a.InitKeyring(ctx, []string{s.URL + "/alpine-devel@lists.alpinelinux.org-4a6a0840.rsa.pub"}, nil) + keys, err = keyring.NewKeyRing( + keyring.AddKeyPaths(s.URL + "/alpine-devel@lists.alpinelinux.org-4a6a0840.rsa.pub"), + ) + require.NoError(t, err) + + err = a.DownloadAndStoreKeys(ctx, keys) require.Error(t, err, "should fail with bad auth") require.True(t, called, "did not make request") }) diff --git a/pkg/apk/apk/keyring/alpine.go b/pkg/apk/apk/keyring/alpine.go new file mode 100644 index 000000000..aa4f224c2 --- /dev/null +++ b/pkg/apk/apk/keyring/alpine.go @@ -0,0 +1,88 @@ +// Copyright 2023 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package keyring + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/charmbracelet/log" +) + +// for fetching the alpine keys +const alpineReleasesURL = "https://alpinelinux.org/releases.json" + +type NoKeysFoundError struct { + archs []string + releases []string +} + +func (e *NoKeysFoundError) Error() string { + return fmt.Sprintf("no keys found for arch %v and releases %v", e.archs, e.releases) +} + +func fetchAlpineKeyURLs(ctx context.Context, fetcher Fetcher, archs []string, alpineVersions []string) ([]string, error) { + u := alpineReleasesURL + + // NB: Not setting basic auth, since we know Alpine doesn't support it. + res, err := fetcher(ctx, u, false) + if err != nil { + return nil, fmt.Errorf("failed to fetch alpine releases: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unable to get alpine releases at %s: %v", u, res.Status) + } + + b, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("failed to read alpine releases: %w", err) + } + + var releases Releases + if err := json.Unmarshal(b, &releases); err != nil { + return nil, fmt.Errorf("failed to unmarshal alpine releases: %w", err) + } + + var urls []string + // now just need to get the keys for the desired architecture and releases + for _, version := range alpineVersions { + branch := releases.GetReleaseBranch(version) + if branch == nil { + log.Debugf("Alpine version %s not found in releases", version) + continue + } + + for _, arch := range archs { + archKeyURLs := branch.KeysFor(arch, time.Now()) + if len(archKeyURLs) == 0 { + log.Debugf("No keys found for arch %s and version %s", arch, version) + continue + } + + urls = append(urls, archKeyURLs...) + } + } + if len(urls) == 0 { + return nil, &NoKeysFoundError{archs: archs, releases: alpineVersions} + } + + return urls, nil +} diff --git a/pkg/apk/apk/keyring/alpine_releases.go b/pkg/apk/apk/keyring/alpine_releases.go new file mode 100644 index 000000000..f81d34f17 --- /dev/null +++ b/pkg/apk/apk/keyring/alpine_releases.go @@ -0,0 +1,97 @@ +// Copyright 2023 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package keyring + +import ( + "strings" + "time" +) + +type Releases struct { + Architectures []string `json:"architectures"` + LatestStable string `json:"latest_stable"` + ReleaseBranches []ReleaseBranch `json:"release_branches"` +} + +type ReleaseBranch struct { + Arches []string `json:"arches"` + GitBranch string `json:"git_branch"` + Keys map[string][]RepoKeys `json:"keys"` + ReleaseBranch string `json:"rel_branch"` + Repos []Repo `json:"repos"` +} + +type Repo struct { + Name string `json:"name"` + EOL DateTime `json:"eol_date"` +} + +type RepoKeys struct { + URL string `json:"url"` + Deprecated DateTime `json:"deprecated_since"` +} + +// DateTime wrapper for time.Time because the date format is "YYYY-MM-DD" +type DateTime struct { + time.Time +} + +func (c *DateTime) UnmarshalJSON(b []byte) error { + value := strings.Trim(string(b), `"`) // get rid of bounding quotes `"` + if value == "" || value == "null" { + return nil + } + + t, err := time.Parse("2006-01-02", value) // parse time format "YYYY-MM-DD" + if err != nil { + return err + } + *c = DateTime{t} // set result using the pointer to self + return nil +} + +func (c DateTime) MarshalJSON() ([]byte, error) { + return []byte(`"` + c.Format("2006-01-02") + `"`), nil +} + +// GetReleaseBranch returns the release branch for the given version. If not found, +// nil is returned. +func (r Releases) GetReleaseBranch(version string) *ReleaseBranch { + for _, branch := range r.ReleaseBranches { + if branch.ReleaseBranch == version { + return &branch + } + } + return nil +} + +// KeysFor returns the keys for the given architecture and date. The date is used to check +// for deprecation. +func (r ReleaseBranch) KeysFor(arch string, date time.Time) []string { + var urls []string + keyset, ok := r.Keys[arch] + if !ok { + return urls + } + for _, key := range keyset { + // check if expired + if key.Deprecated.IsZero() || key.Deprecated.After(date) { + // because of a bug in the urls as published; this should have been %40 (@) instead of %20 (space) + key.URL = strings.ReplaceAll(key.URL, "%20", "@") + urls = append(urls, key.URL) + } + } + return urls +} diff --git a/pkg/apk/apk/keyring/discovery.go b/pkg/apk/apk/keyring/discovery.go new file mode 100644 index 000000000..5110ea5a9 --- /dev/null +++ b/pkg/apk/apk/keyring/discovery.go @@ -0,0 +1,114 @@ +// Copyright 2023 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package keyring + +import ( + "bytes" + "context" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "strings" + + "go.opentelemetry.io/otel" + "go.step.sm/crypto/jose" +) + +func fetchJWKSURLFromDiscovery(ctx context.Context, fetcher Fetcher, discoveryURL string) (string, error) { + ctx, span := otel.Tracer("go-apk").Start(ctx, "jwksURLFromDiscovery") + defer span.End() + + discoveryResponse, err := fetcher(ctx, discoveryURL, true) + if err != nil { + return "", fmt.Errorf("failed to perform key discovery: %w", err) + } + defer discoveryResponse.Body.Close() + + switch discoveryResponse.StatusCode { + case http.StatusNotFound: + // This doesn't implement Chainguard-style key discovery. + return "", nil + + case http.StatusOK: + // proceed! + break + + default: + return "", fmt.Errorf("chainguard key discovery was unsuccessful for repo %s: %v", discoveryURL, discoveryResponse.Status) + } + + // Parse our the JWKS URI + var discovery struct { + JWKSURI string `json:"jwks_uri"` + } + if err := json.NewDecoder(discoveryResponse.Body).Decode(&discovery); err != nil { + return "", fmt.Errorf("failed to unmarshal discovery payload: %w", err) + } + + return discovery.JWKSURI, nil +} + +func fetchKeysFromJWKS(ctx context.Context, fetcher Fetcher, jwksURL jwksURLInfo) ([]Key, error) { + jwks := jose.JSONWebKeySet{} + { + jwksResponse, err := fetcher(ctx, jwksURL.url, true) + if err != nil { + return nil, err + } + defer jwksResponse.Body.Close() + + if jwksResponse.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch JWKS: %v", jwksResponse.Status) + } + + if err := json.NewDecoder(jwksResponse.Body).Decode(&jwks); err != nil { + return nil, fmt.Errorf("failed to unmarshal JWKS: %w", err) + } + } + + keys := make([]Key, 0, len(jwks.Keys)) + for _, key := range jwks.Keys { + if key.KeyID == "" { + return nil, fmt.Errorf(`key missing "kid"`) + } + keyName := key.KeyID + ".rsa.pub" + + b, err := x509.MarshalPKIXPublicKey(key.Key.(*rsa.PublicKey)) + if err != nil { + return nil, err + } else if len(b) == 0 { + return nil, fmt.Errorf("empty public key") + } + + var buf bytes.Buffer + if err := pem.Encode(&buf, &pem.Block{ + Type: "PUBLIC KEY", + Bytes: b, + }); err != nil { + return nil, fmt.Errorf("failed to pem encode key %s: %w", keyName, err) + } + + keys = append(keys, Key{ + ID: keyName, + Bytes: buf.Bytes(), + URL: strings.TrimSuffix(jwksURL.discoveryURL, "/apk-configuration") + "/" + key.KeyID + ".rsa.pub", + }) + } + + return keys, nil +} diff --git a/pkg/apk/apk/keyring/fetch.go b/pkg/apk/apk/keyring/fetch.go new file mode 100644 index 000000000..a53c27049 --- /dev/null +++ b/pkg/apk/apk/keyring/fetch.go @@ -0,0 +1,56 @@ +// Copyright 2023 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package keyring + +import ( + "context" + "fmt" + "io" + "net/url" + "path/filepath" + + "github.com/charmbracelet/log" +) + +func fetchKeyFromURL(ctx context.Context, fetcher Fetcher, keyURL string, authenticated bool) (Key, error) { + log.Debugf("installing key %v", keyURL) + + resp, err := fetcher(ctx, keyURL, authenticated) + if err != nil { + return Key{}, fmt.Errorf("failed to fetch apk key: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return Key{}, fmt.Errorf("failed to fetch apk key from %s: http response indicated error code: %d", keyURL, resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return Key{}, fmt.Errorf("failed to read apk key response: %w", err) + } + + basefilenameEscape := filepath.Base(keyURL) + basefilename, err := url.PathUnescape(basefilenameEscape) + if err != nil { + return Key{}, fmt.Errorf("failed to unescape key filename %s: %w", basefilenameEscape, err) + } + + return Key{ + ID: basefilename, + Bytes: data, + URL: keyURL, + }, nil +} diff --git a/pkg/apk/apk/keyring/ring.go b/pkg/apk/apk/keyring/ring.go new file mode 100644 index 000000000..522c3f241 --- /dev/null +++ b/pkg/apk/apk/keyring/ring.go @@ -0,0 +1,312 @@ +// Copyright 2023 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package keyring + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + + "chainguard.dev/apko/pkg/apk/auth" + "go.lsp.dev/uri" + "golang.org/x/sync/errgroup" + "k8s.io/apimachinery/pkg/util/sets" +) + +type KeyRing struct { + alpineVersions sets.Set[string] + discoveryURLs sets.Set[string] + + jwksURLs sets.Set[jwksURLInfo] + keyURLs sets.Set[string] + unauthenticatedKeyURLs sets.Set[string] + keyFiles sets.Set[string] +} + +// for backwards compatibility, we need to keep track of what +// discovery URL yielded what JWKS URL +type jwksURLInfo struct { + url string + discoveryURL string +} + +func NewKeyRing(opts ...KeyRingOption) (*KeyRing, error) { + kr := &KeyRing{ + alpineVersions: sets.New[string](), + discoveryURLs: sets.New[string](), + + jwksURLs: sets.New[jwksURLInfo](), + keyURLs: sets.New[string](), + unauthenticatedKeyURLs: sets.New[string](), + keyFiles: sets.New[string](), + } + + for _, opt := range opts { + if err := opt(kr); err != nil { + return nil, err + } + } + + return kr, nil +} + +type KeyRingOption func(*KeyRing) error + +func AddKeyPaths(keyPaths ...string) KeyRingOption { + return func(kr *KeyRing) error { + for _, keyPath := range keyPaths { + var asURL *url.URL + var err error + if strings.HasPrefix(keyPath, "https://") || strings.HasPrefix(keyPath, "http://") { + asURL, err = url.Parse(keyPath) + } else { + // Attempt to parse non-https elements into URI's so they are translated into + // file:// URLs allowing them to parse into a url.URL{} + asURL, err = url.Parse(string(uri.New(keyPath))) + } + if err != nil { + return fmt.Errorf("failed to parse key as URI: %w", err) + } + + switch asURL.Scheme { + case "file": //nolint:goconst + kr.keyFiles.Insert(keyPath) + case "https", "http": //nolint:goconst + kr.keyURLs.Insert(asURL.String()) + default: + return fmt.Errorf("scheme %s not supported", asURL.Scheme) + } + } + + return nil + } +} + +func AddRepositories(repositories ...string) KeyRingOption { + return func(kr *KeyRing) error { + for _, repository := range repositories { + if !strings.HasPrefix(repository, "https://") && !strings.HasPrefix(repository, "http://") { + // Ignore non-remote repositories. + continue + } + + if version, ok := parseAlpineVersion(repository); ok { + kr.alpineVersions.Insert(version) + } + + discoveryURL, err := url.Parse(strings.TrimSuffix(repository, "/") + "/apk-configuration") + if err != nil { + return fmt.Errorf("failed to parse repository URL: %w", err) + } + kr.discoveryURLs.Insert(discoveryURL.String()) + } + + return nil + } +} + +var repoRE = regexp.MustCompile(`^http[s]?://.+\/alpine\/([^\/]+)\/[^\/]+$`) + +// parseAlpineVersion parses the Alpine version from a repository URL. +// Returns the version string (e.g., "v3.21") and true if successful. +func parseAlpineVersion(repo string) (version string, ok bool) { + parts := repoRE.FindStringSubmatch(repo) + if len(parts) < 2 { + return "", false + } + return parts[1], true +} + +type Key struct { + ID string + Bytes []byte + URL string +} + +type Fetcher func(ctx context.Context, url string, authenticated bool) (*http.Response, error) + +func NewFetcher(client *http.Client, auth auth.Authenticator) Fetcher { + return func(ctx context.Context, url string, authenticated bool) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + if authenticated && auth != nil { + if err := auth.AddAuth(ctx, req); err != nil { + return nil, err + } + } + + return client.Do(req) + } +} + +func (kr *KeyRing) FetchKeys(ctx context.Context, fetcher Fetcher, archs []string) ([]Key, error) { + { + errgroup, gctx := errgroup.WithContext(ctx) + var alpineKeyURLs []string + if len(kr.alpineVersions) > 0 { + errgroup.Go(func() error { + alpineURLs, err := fetchAlpineKeyURLs(gctx, fetcher, archs, kr.alpineVersions.UnsortedList()) + if err != nil { + return fmt.Errorf("failed to fetch alpine key URLs for versions %v: %w", kr.alpineVersions.UnsortedList(), err) + } + + alpineKeyURLs = alpineURLs + return nil + }) + } + + discoveryURLs := kr.discoveryURLs.UnsortedList() + jwksURLs := make([]jwksURLInfo, len(discoveryURLs)) + for i, discoveryURL := range discoveryURLs { + errgroup.Go(func() error { + jwksURL, err := fetchJWKSURLFromDiscovery(gctx, fetcher, discoveryURL) + if err != nil { + return fmt.Errorf("failed to fetch JWKS URL from discovery URL %q: %w", discoveryURL, err) + } + + jwksURLs[i] = jwksURLInfo{ + url: jwksURL, + discoveryURL: discoveryURL, + } + return nil + }) + } + + if err := errgroup.Wait(); err != nil { + return nil, err + } + + kr.unauthenticatedKeyURLs.Insert(alpineKeyURLs...) + kr.jwksURLs.Insert(jwksURLs...) + } + + var keys []Key + { + var mu sync.Mutex + errgroup, gctx := errgroup.WithContext(ctx) + for _, jwksURL := range kr.jwksURLs.UnsortedList() { + if jwksURL.url == "" { + continue // jwksURLs may have empty entries if discovery returned 404 + } + + errgroup.Go(func() error { + jwksKeys, err := fetchKeysFromJWKS(gctx, fetcher, jwksURL) + if err != nil { + return fmt.Errorf("failed to fetch keys from JWKS URL %q: %w", jwksURL, err) + } + + mu.Lock() + defer mu.Unlock() + keys = append(keys, jwksKeys...) + return nil + }) + } + + for _, keyURL := range kr.keyURLs.UnsortedList() { + errgroup.Go(func() error { + key, err := fetchKeyFromURL(gctx, fetcher, keyURL, true) + if err != nil { + return fmt.Errorf("failed to fetch key from URL %q: %w", keyURL, err) + } + + mu.Lock() + defer mu.Unlock() + keys = append(keys, key) + return nil + }) + } + + for _, keyURL := range kr.unauthenticatedKeyURLs.UnsortedList() { + errgroup.Go(func() error { + key, err := fetchKeyFromURL(gctx, fetcher, keyURL, false) + if err != nil { + return fmt.Errorf("failed to fetch key from URL %q (unauthenticated): %w", keyURL, err) + } + + mu.Lock() + defer mu.Unlock() + keys = append(keys, key) + return nil + }) + } + + if err := errgroup.Wait(); err != nil { + return nil, err + } + + for _, keyFile := range kr.keyFiles.UnsortedList() { + keyData, err := os.ReadFile(keyFile) + if err != nil { + return nil, fmt.Errorf("failed to read apk key file %q: %w", keyFile, err) + } + + mu.Lock() + defer mu.Unlock() + keys = append(keys, Key{ + ID: filepath.Base(keyFile), + Bytes: keyData, + URL: keyFile, + }) + } + } + + // sort slice + slices.SortFunc(keys, func(a, b Key) int { + idCompare := strings.Compare(a.ID, b.ID) + if idCompare != 0 { + return idCompare + } + + return bytes.Compare(a.Bytes, b.Bytes) + }) + + // drop any duplicates + keys = slices.CompactFunc(keys, func(a, b Key) bool { + return a.ID == b.ID && bytes.Equal(a.Bytes, b.Bytes) + }) + + // add suffix to duplicate IDs + suffixCounter := 1 + for i, key := range keys { + if i == 0 { + continue + } + + if key.ID != keys[i-1].ID { + suffixCounter = 1 + continue + } + + suffixCounter++ + if strings.HasSuffix(key.ID, ".rsa.pub") { + keys[i].ID = fmt.Sprintf("%s-%d.rsa.pub", strings.TrimSuffix(key.ID, ".rsa.pub"), suffixCounter) + } else { + keys[i].ID = fmt.Sprintf("%s-%d", key.ID, suffixCounter) + } + } + + return keys, nil +} diff --git a/pkg/build/apk.go b/pkg/build/apk.go index 747edf64c..2d68bb2b2 100644 --- a/pkg/build/apk.go +++ b/pkg/build/apk.go @@ -17,7 +17,9 @@ package build import ( "context" "fmt" + "slices" + "chainguard.dev/apko/pkg/apk/apk/keyring" "go.opentelemetry.io/otel" "golang.org/x/sync/errgroup" "k8s.io/apimachinery/pkg/util/sets" @@ -57,21 +59,27 @@ func (bc *Context) initializeApk(ctx context.Context) error { Insert(bc.o.ExtraBuildRepos...). Insert(bc.o.ExtraRepos...), ) - if err := bc.apk.InitDB(ctx, buildRepos...); err != nil { + if err := bc.apk.InitDB(ctx); err != nil { return fmt.Errorf("failed to initialize apk database: %w", err) } var eg errgroup.Group eg.Go(func() error { - keyring := sets.List(sets.New(bc.ic.Contents.Keyring...).Insert(bc.o.ExtraKeyFiles...)) - if err := bc.apk.InitKeyring(ctx, keyring, nil); err != nil { - return fmt.Errorf("failed to initialize apk keyring: %w", err) + keys, err := keyring.NewKeyRing( + keyring.AddRepositories(buildRepos...), + keyring.AddKeyPaths(bc.ic.Contents.Keyring...), + keyring.AddKeyPaths(bc.o.ExtraKeyFiles...), + ) + if err != nil { + return fmt.Errorf("creating keyring: %w", err) } - return nil + + return bc.apk.DownloadAndStoreKeys(ctx, keys) }) eg.Go(func() error { + buildRepos := slices.Clip(buildRepos) // We add auxiliary repository to resolve packages from the base image. if bc.baseimg != nil { buildRepos = append(buildRepos, bc.baseimg.APKIndexPath())