package aptrepo import ( "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "io" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "time" ) // Bootstrapped from Docker's official HTTPS key endpoint. Rotation requires a // reviewed code update, never a caller-supplied key or fingerprint override. const dockerFingerprint = "9DC858229FC7DD38854AE2D88D81803C0EBFCD88" const dockerKeySHA256 = "1500c1f56fa9e26b9b8f42452a553675796ade0807cdce11975eb98170b3a570" func readStaging(directory string) (map[string][]byte, error) { fail := errors.New("invalid repository staging files") if !filepath.IsAbs(directory) { return nil, fail } directory = filepath.Clean(directory) info, err := os.Lstat(directory) if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return nil, fail } root, err := os.OpenRoot(directory) if err != nil { return nil, fail } defer root.Close() opened, err := root.Stat(".") if err != nil || !os.SameFile(info, opened) { return nil, fail } result := make(map[string][]byte) for name, limit := range map[string]int64{"docker.asc": 1 << 20, "Release": 1 << 20, "Release.gpg": 65536, "Packages": 16 << 20} { data, err := readFile(root, name, limit) if err != nil { return nil, fail } result[name] = data } return result, nil } func readFile(root *os.Root, name string, limit int64) ([]byte, error) { fail := errors.New("invalid metadata file") before, err := root.Lstat(name) if err != nil || !before.Mode().IsRegular() || before.Size() <= 0 || before.Size() > limit { return nil, fail } f, err := root.Open(name) if err != nil { return nil, fail } defer f.Close() opened, err := f.Stat() if err != nil || !opened.Mode().IsRegular() || !os.SameFile(before, opened) || before.Size() != opened.Size() { return nil, fail } data, err := io.ReadAll(io.LimitReader(f, limit+1)) if err != nil || int64(len(data)) != opened.Size() || int64(len(data)) > limit { return nil, fail } return data, nil } func authenticate(files map[string][]byte, now time.Time) error { sum := sha256.Sum256(files["docker.asc"]) if hex.EncodeToString(sum[:]) != dockerKeySHA256 { return errors.New("repository trust anchor mismatch") } if runtime.GOOS != "linux" { return errors.New("repository authentication requires Linux GnuPG") } // All untrusted bytes are copied to a private snapshot. No caller path reaches // a subprocess, and neither system nor personal keyrings are consulted. dir, err := os.MkdirTemp("", "deployctl-signature-") if err != nil { return errors.New("cannot create signature workspace") } defer os.RemoveAll(dir) // Only our own freshly allocated directory. for _, name := range []string{"docker.asc", "Release", "Release.gpg"} { if err := os.WriteFile(filepath.Join(dir, name), files[name], 0600); err != nil { return errors.New("cannot snapshot repository metadata") } } if _, err := runGPG(dir, "/usr/bin/gpg", "--batch", "--no-options", "--homedir", dir, "--dearmor", "--output", filepath.Join(dir, "docker.gpg"), filepath.Join(dir, "docker.asc")); err != nil { return err } status, err := runGPG(dir, "/usr/bin/gpgv", "--homedir", dir, "--keyring", filepath.Join(dir, "docker.gpg"), "--status-fd", "1", filepath.Join(dir, "Release.gpg"), filepath.Join(dir, "Release")) if err != nil { return err } return checkStatus(status, now) } type cappedOutput struct{ buffer bytes.Buffer } func (b *cappedOutput) Len() int { return b.buffer.Len() } func (b *cappedOutput) Bytes() []byte { return b.buffer.Bytes() } func (b *cappedOutput) Write(p []byte) (int, error) { if len(p) > 65536-b.Len() { return 0, errors.New("signature output exceeds limit") } return b.buffer.Write(p) } func runGPG(dir, program string, args ...string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() cmd := exec.CommandContext(ctx, program, args...) cmd.Dir = dir cmd.Env = []string{"LC_ALL=C", "LANG=C", "HOME=" + dir, "GNUPGHOME=" + dir, "PATH=/usr/bin:/bin"} var output, diagnostics cappedOutput cmd.Stdout = &output cmd.Stderr = &diagnostics cmd.WaitDelay = time.Second if err := cmd.Run(); err != nil { return nil, errors.New("repository signature tool failed") } return output.Bytes(), nil } func checkStatus(status []byte, now time.Time) error { fail := errors.New("repository signature rejected") valid, good := 0, 0 for _, line := range strings.Split(string(status), "\n") { if line == "" { continue } f := strings.Fields(line) if len(f) < 2 || f[0] != "[GNUPG:]" { return fail } switch f[1] { case "NEWSIG", "KEY_CONSIDERED", "SIG_ID": case "GOODSIG": good++ case "VALIDSIG": // fingerprint, date, timestamp, expiry, version, reserved, public-key // algorithm, digest algorithm, signature class, primary fingerprint. if len(f) != 12 || f[11] != dockerFingerprint || (f[9] != "8" && f[9] != "9" && f[9] != "10") || f[10] != "00" { return fail } issued, e1 := strconv.ParseInt(f[4], 10, 64) expiry, e2 := strconv.ParseInt(f[5], 10, 64) if e1 != nil || e2 != nil || issued <= 0 || expiry < 0 || issued > now.Add(10*time.Minute).Unix() || (expiry != 0 && expiry <= now.Unix()) { return fail } valid++ default: return fail // Includes expired/revoked/bad/unknown signatures. } } if valid != 1 || good != 1 { return fail } return nil }