Files
server-deploy/internal/aptrepo/signature.go
T

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
}