170 lines
5.3 KiB
Go
170 lines
5.3 KiB
Go
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
|
|
}
|