feat: add deployment foundation and cross-device handoff
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
# Staged Docker repository authentication
|
||||
|
||||
`deployctl verify-repository` accepts strict JSON with `directory` (absolute trusted
|
||||
staging directory), `suite`, `architecture`, and `versions` (exact version strings
|
||||
for docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin and
|
||||
docker-compose-plugin). It does not select a latest version or resolve dependencies.
|
||||
|
||||
The directory must contain `docker.asc`, `Release`, `Release.gpg` and uncompressed
|
||||
`Packages`. The caller obtains these from the Docker Ubuntu repository. No runtime
|
||||
network request is performed by this command. Limits are 1 MiB, 1 MiB, 64 KiB and
|
||||
16 MiB respectively. Files must be nonempty regular files; symlinks are rejected.
|
||||
Trusted ancestors and absence of concurrent writers are required. This is not an
|
||||
atomic filesystem snapshot against hostile writers. Additional staging files are
|
||||
ignored, never executed.
|
||||
|
||||
## Trust and verification
|
||||
|
||||
1. Exact armored key SHA-256 is pinned in source. Initial trust was bootstrapped
|
||||
from `https://download.docker.com/linux/ubuntu/gpg`, not supplied by the request.
|
||||
Primary fingerprint: `9DC858229FC7DD38854AE2D88D81803C0EBFCD88`.
|
||||
Rotation or formatting changes require a reviewed source update; fail closed.
|
||||
2. Linux `/usr/bin/gpg` dearmors into a new private temporary directory; `/usr/bin/gpgv`
|
||||
verifies the detached signature against the copied Release bytes, with that
|
||||
keyring and isolated homedir. No shell, ambient GnuPG config or personal keyring.
|
||||
Each child has a 15-second timeout and bounded output. Errors do not expose raw
|
||||
GnuPG diagnostics or metadata. Non-Linux or missing tools fail closed.
|
||||
3. Successful process exit and a single accepted primary-fingerprint signature
|
||||
are required; weak digests, expired/revoked/bad signatures and unknown status
|
||||
types fail closed. SHA-256/384/512 are accepted.
|
||||
4. Authenticated Release must describe Docker CE and the requested supported Ubuntu
|
||||
suite, architecture and stable component. Date must be within 30 days and no
|
||||
more than 10 minutes in the future; Valid-Until is enforced when present.
|
||||
5. The exact uncompressed stable Packages entry's SHA-256 and byte size must match.
|
||||
Five explicitly requested versions are resolved to authenticated index records;
|
||||
the derived lock is checked by installplan's source/path/version constraints.
|
||||
|
||||
The machine clock, OS and installed GnuPG are trusted. This is not a persistent
|
||||
anti-rollback ledger: an older authentic Release inside the freshness window can
|
||||
pass, and a missing Valid-Until is governed by the local 30-day policy. No online
|
||||
key revocation lookup is performed. Key pin maintenance is an operator responsibility.
|
||||
|
||||
## Result boundaries
|
||||
|
||||
`repositoryAuthenticated: true` attests to these metadata bytes at `verifiedAt`.
|
||||
For `verify-repository`, `packageBytesVerified: false` and `executable: false` remain explicit: no deb bytes,
|
||||
Ubuntu dependency repository, dependency closure, installation scripts, system
|
||||
compatibility or service behavior have been verified. The result is not a signed
|
||||
capability. `plan-environment` still treats a supplied lock as untrusted and does
|
||||
not accept a caller's authentication claim to clear its trust blockers.
|
||||
|
||||
Signature scratch data is removed on normal return; a killed process can leave its own
|
||||
private temporary directory. Deployment writes remain disabled. `writesEnabled`
|
||||
in `version` refers to deployment writes, not verification scratch files.
|
||||
|
||||
`scripts/probe-docker-repository.sh /absolute/path/to/deployctl` is an optional
|
||||
local online integration check. It downloads public metadata over HTTPS into a
|
||||
new temporary directory, tests real authentication and rejects tampering. Versions
|
||||
selected by this test are fixtures, not recommended installation versions. It
|
||||
does not install packages, alter APT, use SSH or touch application data.
|
||||
|
||||
## Verify actual deb bytes
|
||||
|
||||
`verify-artifacts` requires the same request fields as `verify-repository`, plus
|
||||
`artifactDirectory`: an absolute trusted directory containing exactly the five deb
|
||||
files named by the basename of each authenticated `Filename`. No other entries,
|
||||
subdirectories, symlinks or special files are accepted. The two directories must
|
||||
not be concurrently modified. Repository signatures and metadata are verified
|
||||
anew in the same call; the command accepts neither a supplied lock nor trust flags.
|
||||
|
||||
Each file is streamed through SHA-256 with a 64 KiB copy buffer and its signed
|
||||
index size as the read bound (plus one overflow-detection byte). The existing
|
||||
lock policy caps each file at 512 MiB and requires exactly five files. A last-file
|
||||
failure rejects the entire result. No partial successful report is emitted.
|
||||
The verifier never unpacks a deb or executes its contents. It does not change
|
||||
the staged files. Metadata identity checks supplement, not replace, the trusted
|
||||
directory/no concurrent writer requirement.
|
||||
|
||||
Only `verify-artifacts` may set `packageBytesVerified: true`; `executable` stays
|
||||
false. This verifies the selected five Docker package bytes, not the complete
|
||||
Ubuntu dependency closure, package-internal safety or installation compatibility.
|
||||
It is a point-in-time observation: do not reuse this JSON to authorize later
|
||||
execution of paths that may have changed. A future installer must recheck or
|
||||
own immutable verified snapshots under its operation lock.
|
||||
|
||||
Set `DEPLOYCTL_ONLINE_ARTIFACT_PROBE=1` along with
|
||||
`DEPLOYCTL_ONLINE_REPOSITORY_PROBE=1` when running `scripts/verify-linux.sh` to
|
||||
also download the five real public deb fixtures into a new local temporary
|
||||
directory. The optional probe verifies all files, then changes one byte without
|
||||
changing length and asserts rejection. It retains test files for inspection;
|
||||
they are not installed and one is intentionally damaged by the negative test.
|
||||
@@ -0,0 +1,117 @@
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"server-deploy/internal/installplan"
|
||||
)
|
||||
|
||||
// VerifyArtifacts authenticates repository metadata anew before checking the
|
||||
// selected five deb files. It never accepts a caller-asserted authenticated lock.
|
||||
// The staging directories and ancestors must be trusted and not concurrently
|
||||
// modified. This is observation evidence, not a capability to later execute paths.
|
||||
func VerifyArtifacts(metadataDirectory, artifactDirectory, suite, arch string, versions map[string]string, now time.Time) (Result, error) {
|
||||
result, err := Verify(metadataDirectory, suite, arch, versions, now)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := verifyArtifacts(artifactDirectory, result.Lock); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
result.PackageBytesVerified = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func verifyArtifacts(directory string, lock installplan.Lock) error {
|
||||
reject := errors.New("invalid staged Docker artifacts")
|
||||
if _, err := installplan.Validate(lock, lock.Suite, lock.Architecture); err != nil {
|
||||
return reject
|
||||
}
|
||||
if !filepath.IsAbs(directory) {
|
||||
return reject
|
||||
}
|
||||
directory = filepath.Clean(directory)
|
||||
before, err := os.Lstat(directory)
|
||||
if err != nil || !before.IsDir() || before.Mode()&os.ModeSymlink != 0 {
|
||||
return reject
|
||||
}
|
||||
root, err := os.OpenRoot(directory)
|
||||
if err != nil {
|
||||
return reject
|
||||
}
|
||||
defer root.Close()
|
||||
opened, err := root.Stat(".")
|
||||
if err != nil || !os.SameFile(before, opened) {
|
||||
return reject
|
||||
}
|
||||
expected := make(map[string]installplan.Package, len(lock.Packages))
|
||||
for _, p := range lock.Packages {
|
||||
expected[path.Base(p.Filename)] = p
|
||||
}
|
||||
dir, err := root.Open(".")
|
||||
if err != nil {
|
||||
return reject
|
||||
}
|
||||
defer dir.Close()
|
||||
// Enumerate at most the expected count plus one, never an unbounded directory.
|
||||
seen := make(map[string]bool)
|
||||
for {
|
||||
entries, err := dir.ReadDir(1)
|
||||
if err != nil && err != io.EOF {
|
||||
return reject
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return reject
|
||||
}
|
||||
name := entries[0].Name()
|
||||
p, ok := expected[name]
|
||||
if !ok || seen[name] {
|
||||
return reject
|
||||
}
|
||||
seen[name] = true
|
||||
if err := verifyArtifact(root, name, p); err != nil {
|
||||
return reject
|
||||
}
|
||||
}
|
||||
if len(seen) != len(expected) {
|
||||
return reject
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyArtifact(root *os.Root, name string, p installplan.Package) error {
|
||||
reject := errors.New("artifact size or digest mismatch")
|
||||
before, err := root.Lstat(name)
|
||||
if err != nil || !before.Mode().IsRegular() || before.Size() != int64(p.Size) {
|
||||
return reject
|
||||
}
|
||||
file, err := root.Open(name)
|
||||
if err != nil {
|
||||
return reject
|
||||
}
|
||||
defer file.Close()
|
||||
opened, err := file.Stat()
|
||||
if err != nil || !opened.Mode().IsRegular() || !os.SameFile(before, opened) || opened.Size() != before.Size() {
|
||||
return reject
|
||||
}
|
||||
digest := sha256.New()
|
||||
n, err := io.CopyBuffer(digest, io.LimitReader(file, int64(p.Size)+1), make([]byte, 64<<10))
|
||||
if err != nil || n != int64(p.Size) || "sha256:"+hex.EncodeToString(digest.Sum(nil)) != p.Digest {
|
||||
return reject
|
||||
}
|
||||
after, err := file.Stat()
|
||||
if err != nil || after.Size() != opened.Size() || !after.ModTime().Equal(opened.ModTime()) {
|
||||
return reject
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArtifactRejectsSymlinksAndFIFO(t *testing.T) {
|
||||
for _, kind := range []string{"root-link", "file-link", "fifo"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
dir, lock := artifactFixture(t)
|
||||
filename := filepath.Join(dir, path.Base(lock.Packages[0].Filename))
|
||||
switch kind {
|
||||
case "root-link":
|
||||
link := filepath.Join(t.TempDir(), "link")
|
||||
if err := os.Symlink(dir, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir = link + "/"
|
||||
case "file-link":
|
||||
target := filepath.Join(t.TempDir(), "target")
|
||||
if err := os.Rename(filename, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, filename); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "fifo":
|
||||
if err := os.Remove(filename); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := syscall.Mkfifo(filename, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := verifyArtifacts(dir, lock); err == nil {
|
||||
t.Fatal("unsafe file accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"server-deploy/internal/installplan"
|
||||
)
|
||||
|
||||
func artifactFixture(t *testing.T) (string, installplan.Lock) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
index := fixtureIndex()
|
||||
lock, err := Resolve(fixtureRelease(index), index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range lock.Packages {
|
||||
p := &lock.Packages[i]
|
||||
data := []byte("artifact fixture " + p.Name)
|
||||
p.Size = uint64(len(data))
|
||||
p.Digest = fmt.Sprintf("sha256:%x", sha256.Sum256(data))
|
||||
if err := os.WriteFile(filepath.Join(dir, path.Base(p.Filename)), data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir, lock
|
||||
}
|
||||
|
||||
func TestArtifactBytesAndIdentity(t *testing.T) {
|
||||
dir, lock := artifactFixture(t)
|
||||
before := lock
|
||||
before.Packages = append([]installplan.Package(nil), lock.Packages...)
|
||||
if err := verifyArtifacts(dir, lock); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, lock) {
|
||||
t.Fatal("lock mutated")
|
||||
}
|
||||
for _, p := range lock.Packages {
|
||||
data, err := os.ReadFile(filepath.Join(dir, path.Base(p.Filename)))
|
||||
if err != nil || string(data) != "artifact fixture "+p.Name {
|
||||
t.Fatal("artifact modified")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactRejectsTamperWithoutPartialSuccess(t *testing.T) {
|
||||
for _, kind := range []string{"same-size", "truncated", "extra-byte", "missing", "directory", "bad-lock", "relative-root", "extra-file"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
dir, lock := artifactFixture(t)
|
||||
p := lock.Packages[4] // The last artifact must fail the whole set.
|
||||
filename := filepath.Join(dir, path.Base(p.Filename))
|
||||
switch kind {
|
||||
case "same-size":
|
||||
data := []byte("artifact fixture " + p.Name)
|
||||
data[0] = 'X'
|
||||
if err := os.WriteFile(filename, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "truncated":
|
||||
if err := os.Truncate(filename, int64(p.Size)-1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "extra-byte":
|
||||
if err := os.Truncate(filename, int64(p.Size)+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "missing":
|
||||
if err := os.Remove(filename); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "directory":
|
||||
if err := os.Remove(filename); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filename, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "bad-lock":
|
||||
lock.Packages[0].Filename = "../../secret.deb"
|
||||
case "relative-root":
|
||||
dir = "relative"
|
||||
case "extra-file":
|
||||
if err := os.WriteFile(filepath.Join(dir, "unexpected.deb"), []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := verifyArtifacts(dir, lock); err == nil {
|
||||
t.Fatal("invalid artifacts accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"server-deploy/internal/installplan"
|
||||
)
|
||||
|
||||
var metadataError = errors.New("invalid Docker repository metadata")
|
||||
|
||||
var pinnedPackageNames = [...]string{"docker-ce", "docker-ce-cli", "containerd.io", "docker-buildx-plugin", "docker-compose-plugin"}
|
||||
|
||||
// Resolve parses Release bytes already authenticated by the caller and binds
|
||||
// explicitly pinned packages to that Release. It does not verify signatures.
|
||||
func Resolve(release, index []byte, suite, arch string, versions map[string]string, now time.Time) (installplan.Lock, error) {
|
||||
if len(release) > 1<<20 || len(index) > 16<<20 || len(versions) != len(pinnedPackageNames) {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
for _, name := range pinnedPackageNames {
|
||||
if versions[name] == "" {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
}
|
||||
|
||||
var fields map[string]string
|
||||
if err := parseControl(release, func(stanza map[string]string) error {
|
||||
if fields != nil {
|
||||
return metadataError
|
||||
}
|
||||
fields = stanza
|
||||
return nil
|
||||
}); err != nil {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
if fields["suite"] != suite || fields["origin"] != "Docker" || fields["label"] != "Docker CE" || !hasWord(fields["architectures"], arch) || !hasWord(fields["components"], "stable") {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
date, err := time.Parse(time.RFC1123Z, fields["date"])
|
||||
if err != nil || date.After(now.Add(10*time.Minute)) || date.Before(now.Add(-30*24*time.Hour)) {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
if value, ok := fields["valid-until"]; ok {
|
||||
expiry, err := time.Parse(time.RFC1123Z, value)
|
||||
if err != nil || !expiry.After(now) || expiry.Before(date) {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
}
|
||||
// Bind the exact uncompressed index bytes before interpreting any records.
|
||||
indexSum := sha256.Sum256(index)
|
||||
expectedPath := "stable/binary-" + arch + "/Packages"
|
||||
seenPaths := make(map[string]bool)
|
||||
matched := false
|
||||
for _, line := range strings.Split(fields["sha256"], "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
entry := strings.Fields(line)
|
||||
if len(entry) != 3 || !validSHA256(entry[0]) || seenPaths[entry[2]] {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
seenPaths[entry[2]] = true
|
||||
size, err := decimalSize(entry[1])
|
||||
if err != nil {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
if entry[2] == expectedPath {
|
||||
if size != uint64(len(index)) || entry[0] != hex.EncodeToString(indexSum[:]) {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
|
||||
selected := make(map[string]installplan.Package)
|
||||
seenRecords := make(map[[3]string]bool)
|
||||
if err := parseControl(index, func(record map[string]string) error {
|
||||
identity := [3]string{record["package"], record["version"], record["architecture"]}
|
||||
if identity[0] != "" && identity[1] != "" && identity[2] != "" {
|
||||
if seenRecords[identity] {
|
||||
return metadataError
|
||||
}
|
||||
seenRecords[identity] = true
|
||||
}
|
||||
version, target := versions[identity[0]]
|
||||
if !target {
|
||||
return nil
|
||||
}
|
||||
for _, field := range []string{"package", "version", "architecture", "filename", "size", "sha256"} {
|
||||
if record[field] == "" || strings.Contains(record[field], "\n") {
|
||||
return metadataError
|
||||
}
|
||||
}
|
||||
if identity[1] != version || identity[2] != arch {
|
||||
return nil
|
||||
}
|
||||
size, err := decimalSize(record["size"])
|
||||
if err != nil {
|
||||
return metadataError
|
||||
}
|
||||
selected[identity[0]] = installplan.Package{
|
||||
Name: identity[0], Version: version, Filename: record["filename"],
|
||||
Size: size, Digest: "sha256:" + record["sha256"],
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
|
||||
releaseSum := sha256.Sum256(release)
|
||||
lock := installplan.Lock{
|
||||
ProtocolVersion: 1, Repository: "https://download.docker.com/linux/ubuntu",
|
||||
Suite: suite, Architecture: arch, ReleaseDigest: "sha256:" + hex.EncodeToString(releaseSum[:]),
|
||||
}
|
||||
for _, name := range pinnedPackageNames {
|
||||
p, ok := selected[name]
|
||||
if !ok {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
lock.Packages = append(lock.Packages, p)
|
||||
}
|
||||
if _, err := installplan.Validate(lock, suite, arch); err != nil {
|
||||
return installplan.Lock{}, metadataError
|
||||
}
|
||||
return lock, nil
|
||||
}
|
||||
|
||||
// parseControl preserves continuation boundaries and rejects duplicate fields
|
||||
// before invoking visit. Processing one stanza at a time bounds retained values.
|
||||
func parseControl(raw []byte, visit func(map[string]string) error) error {
|
||||
if !utf8.Valid(raw) {
|
||||
return metadataError
|
||||
}
|
||||
text := strings.ReplaceAll(string(raw), "\r\n", "\n")
|
||||
for _, r := range text {
|
||||
if unicode.IsControl(r) && r != '\n' && r != '\t' {
|
||||
return metadataError
|
||||
}
|
||||
}
|
||||
fields := make(map[string]*strings.Builder)
|
||||
current := ""
|
||||
flush := func() error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
stanza := make(map[string]string, len(fields))
|
||||
for name, value := range fields {
|
||||
stanza[name] = value.String()
|
||||
}
|
||||
if err := visit(stanza); err != nil {
|
||||
return err
|
||||
}
|
||||
fields = make(map[string]*strings.Builder)
|
||||
current = ""
|
||||
return nil
|
||||
}
|
||||
for line := range strings.SplitSeq(text, "\n") {
|
||||
if line == "" {
|
||||
if err := flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if line[0] == ' ' || line[0] == '\t' {
|
||||
if current == "" {
|
||||
return metadataError
|
||||
}
|
||||
fields[current].WriteByte('\n')
|
||||
fields[current].WriteString(strings.Trim(line, " \t"))
|
||||
continue
|
||||
}
|
||||
name, value, ok := strings.Cut(line, ":")
|
||||
if !ok || name == "" {
|
||||
return metadataError
|
||||
}
|
||||
for _, r := range name {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-') {
|
||||
return metadataError
|
||||
}
|
||||
}
|
||||
current = strings.ToLower(name)
|
||||
if _, exists := fields[current]; exists {
|
||||
return metadataError
|
||||
}
|
||||
fields[current] = &strings.Builder{}
|
||||
fields[current].WriteString(strings.Trim(value, " \t"))
|
||||
}
|
||||
return flush()
|
||||
}
|
||||
|
||||
func hasWord(value, word string) bool {
|
||||
for _, item := range strings.Fields(value) {
|
||||
if item == word {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func decimalSize(value string) (uint64, error) {
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, metadataError
|
||||
}
|
||||
}
|
||||
size, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil {
|
||||
return 0, metadataError
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"server-deploy/internal/installplan"
|
||||
)
|
||||
|
||||
var fixtureNow = time.Date(2026, 9, 25, 6, 38, 50, 0, time.UTC)
|
||||
|
||||
func fixturePins() map[string]string {
|
||||
return map[string]string{
|
||||
"docker-ce": "5:29.1.0-1~ubuntu.26.04~resolute",
|
||||
"docker-ce-cli": "5:29.1.0-1~ubuntu.26.04~resolute",
|
||||
"containerd.io": "2.1.5-1~ubuntu.26.04~resolute",
|
||||
"docker-buildx-plugin": "0.30.1-1~ubuntu.26.04~resolute",
|
||||
"docker-compose-plugin": "2.40.3-1~ubuntu.26.04~resolute",
|
||||
}
|
||||
}
|
||||
|
||||
// Inline Debian control fixtures retain Docker's Release field layout (notably
|
||||
// no Codename) and epoch-free pool filenames. Artifact hashes are test data;
|
||||
// authentication of Release is outside Resolve's contract.
|
||||
func fixtureRecord(name, version, arch string) string {
|
||||
fileVersion := version
|
||||
if _, after, ok := strings.Cut(version, ":"); ok {
|
||||
fileVersion = after
|
||||
}
|
||||
return fmt.Sprintf("Package: %s\nVersion: %s\nArchitecture: %s\nMaintainer: Docker <support@docker.com>\nFilename: dists/resolute/pool/stable/%s/%s_%s_%s.deb\nSize: 12345\nSHA256: %s\nDescription: Docker package\n continuation with a colon: allowed\n .\n another paragraph\n\n", name, version, arch, arch, name, fileVersion, arch, strings.Repeat("a", 64))
|
||||
}
|
||||
|
||||
func fixtureIndex() []byte {
|
||||
pins := fixturePins()
|
||||
var index strings.Builder
|
||||
for _, name := range []string{"docker-ce", "docker-ce-cli", "containerd.io", "docker-buildx-plugin", "docker-compose-plugin"} {
|
||||
index.WriteString(fixtureRecord(name, pins[name], "amd64"))
|
||||
}
|
||||
return []byte(index.String())
|
||||
}
|
||||
|
||||
func fixtureRelease(index []byte) []byte {
|
||||
return []byte(fmt.Sprintf("Architectures: amd64 arm64 armhf s390x ppc64el\nComponents: stable edge test nightly\nDate: Thu, 24 Sep 2026 06:38:50 +0000\nLabel: Docker CE\nOrigin: Docker\nSuite: resolute\nSHA256:\n %x %d stable/binary-amd64/Packages\n", sha256.Sum256(index), len(index)))
|
||||
}
|
||||
|
||||
func replace(raw []byte, old, new string) []byte {
|
||||
return []byte(strings.Replace(string(raw), old, new, 1))
|
||||
}
|
||||
|
||||
func assertRejected(t *testing.T, release, index []byte, suite, arch string, pins map[string]string, now time.Time) {
|
||||
t.Helper()
|
||||
lock, err := Resolve(release, index, suite, arch, pins, now)
|
||||
if err == nil {
|
||||
t.Fatal("invalid metadata accepted")
|
||||
}
|
||||
if !reflect.DeepEqual(lock, installplan.Lock{}) {
|
||||
t.Fatal("failure returned a partial lock")
|
||||
}
|
||||
// Rejection messages must not disclose any untrusted metadata or pins.
|
||||
if strings.Contains(err.Error(), "PRIVATE-MARKER") {
|
||||
t.Fatal("error echoed metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDockerMetadataChain(t *testing.T) {
|
||||
index := fixtureIndex()
|
||||
release := fixtureRelease(index)
|
||||
lock, err := Resolve(release, index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lock.ProtocolVersion != 1 || lock.Repository != "https://download.docker.com/linux/ubuntu" || lock.Suite != "resolute" || lock.Architecture != "amd64" || lock.ReleaseDigest != fmt.Sprintf("sha256:%x", sha256.Sum256(release)) {
|
||||
t.Fatalf("incorrect release binding: %+v", lock)
|
||||
}
|
||||
if len(lock.Packages) != 5 {
|
||||
t.Fatal("incorrect package count")
|
||||
}
|
||||
want := installplan.Package{Name: "docker-ce", Version: "5:29.1.0-1~ubuntu.26.04~resolute", Filename: "dists/resolute/pool/stable/amd64/docker-ce_29.1.0-1~ubuntu.26.04~resolute_amd64.deb", Digest: "sha256:" + strings.Repeat("a", 64), Size: 12345}
|
||||
if lock.Packages[0] != want {
|
||||
t.Fatalf("wrong selected package: %+v", lock.Packages[0])
|
||||
}
|
||||
for _, p := range lock.Packages {
|
||||
if p.Version != fixturePins()[p.Name] {
|
||||
t.Fatal("pin was not preserved")
|
||||
}
|
||||
}
|
||||
if _, err := installplan.Validate(lock, "resolute", "amd64"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCompatibleControlFormatting(t *testing.T) {
|
||||
for _, mode := range []string{"mixed case", "CRLF", "no final newline", "other versions and architectures", "arm64", "valid expiry", "future boundary", "age boundary"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
index, arch, now := fixtureIndex(), "amd64", fixtureNow
|
||||
switch mode {
|
||||
case "mixed case":
|
||||
index = []byte(strings.ReplaceAll(string(index), "Package:", "pAcKaGe:"))
|
||||
case "CRLF":
|
||||
index = []byte(strings.ReplaceAll(string(index), "\n", "\r\n"))
|
||||
case "no final newline":
|
||||
index = []byte(strings.TrimRight(string(index), "\n"))
|
||||
case "other versions and architectures":
|
||||
index = append(index, fixtureRecord("docker-ce", "5:99.0-1", "amd64")...)
|
||||
index = append(index, fixtureRecord("docker-ce", fixturePins()["docker-ce"], "arm64")...)
|
||||
case "arm64":
|
||||
arch = "arm64"
|
||||
index = []byte(strings.ReplaceAll(string(index), "amd64", arch))
|
||||
case "future boundary":
|
||||
now = fixtureNow.Add(-24*time.Hour - 10*time.Minute)
|
||||
case "age boundary":
|
||||
now = fixtureNow.Add(29 * 24 * time.Hour)
|
||||
}
|
||||
release := fixtureRelease(index)
|
||||
switch mode {
|
||||
case "mixed case":
|
||||
release = replace(release, "SHA256:", "sHa256:")
|
||||
release = replace(release, "Suite:", "sUiTe:")
|
||||
case "CRLF":
|
||||
release = []byte(strings.ReplaceAll(string(release), "\n", "\r\n"))
|
||||
case "no final newline":
|
||||
release = []byte(strings.TrimRight(string(release), "\n"))
|
||||
case "arm64":
|
||||
release = replace(release, "binary-amd64/Packages", "binary-arm64/Packages")
|
||||
case "valid expiry":
|
||||
release = append(release, "Valid-Until: Sat, 26 Sep 2026 06:38:50 +0000\n"...)
|
||||
}
|
||||
lock, err := Resolve(release, index, "resolute", arch, fixturePins(), now)
|
||||
if err != nil || len(lock.Packages) != 5 || lock.Architecture != arch {
|
||||
t.Fatalf("compatible metadata rejected: %v", err)
|
||||
}
|
||||
if lock.ReleaseDigest != fmt.Sprintf("sha256:%x", sha256.Sum256(release)) {
|
||||
t.Fatal("digest did not bind original bytes")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsReleaseMetadata(t *testing.T) {
|
||||
index := fixtureIndex()
|
||||
for name, mutate := range map[string]func([]byte) []byte{
|
||||
"suite": func(r []byte) []byte { return replace(r, "Suite: resolute", "Suite: noble") },
|
||||
"codename cannot replace suite": func(r []byte) []byte { return replace(r, "Suite:", "Codename:") },
|
||||
"origin": func(r []byte) []byte { return replace(r, "Origin: Docker", "Origin: PRIVATE-MARKER") },
|
||||
"label": func(r []byte) []byte { return replace(r, "Label: Docker CE", "Label: Other") },
|
||||
"arch token": func(r []byte) []byte { return replace(r, "amd64 arm64", "xamd64 arm64") },
|
||||
"component token": func(r []byte) []byte { return replace(r, "stable edge", "unstable edge") },
|
||||
"invalid date": func(r []byte) []byte { return replace(r, "Thu, 24 Sep 2026 06:38:50 +0000", "PRIVATE-MARKER") },
|
||||
"future date": func(r []byte) []byte {
|
||||
return replace(r, "Thu, 24 Sep 2026 06:38:50 +0000", "Fri, 25 Sep 2026 06:48:51 +0000")
|
||||
},
|
||||
"stale date": func(r []byte) []byte {
|
||||
return replace(r, "Thu, 24 Sep 2026 06:38:50 +0000", "Wed, 26 Aug 2026 06:38:49 +0000")
|
||||
},
|
||||
"expired": func(r []byte) []byte { return append(r, "Valid-Until: Fri, 25 Sep 2026 06:38:50 +0000\n"...) },
|
||||
"invalid expiry": func(r []byte) []byte { return append(r, "Valid-Until: PRIVATE-MARKER\n"...) },
|
||||
"duplicate case insensitive field": func(r []byte) []byte { return append(r, "oRiGiN: Docker\n"...) },
|
||||
"duplicate unrelated field": func(r []byte) []byte { return append(r, "X-Info: one\nx-info: two\n"...) },
|
||||
"second stanza": func(r []byte) []byte { return append(r, "\nSuite: resolute\n"...) },
|
||||
"MD5 only": func(r []byte) []byte { return replace(r, "SHA256:", "MD5Sum:") },
|
||||
"SHA1 only": func(r []byte) []byte { return replace(r, "SHA256:", "SHA1:") },
|
||||
"compressed only": func(r []byte) []byte { return replace(r, "/Packages", "/Packages.gz") },
|
||||
"path prefix": func(r []byte) []byte { return replace(r, "stable/binary", "./stable/binary") },
|
||||
"checksum arch": func(r []byte) []byte { return replace(r, "binary-amd64", "binary-arm64") },
|
||||
"checksum size": func(r []byte) []byte {
|
||||
return replace(r, fmt.Sprintf(" %d ", len(index)), fmt.Sprintf(" %d ", len(index)+1))
|
||||
},
|
||||
"checksum negative size": func(r []byte) []byte { return replace(r, fmt.Sprintf(" %d ", len(index)), " -1 ") },
|
||||
"checksum extra column": func(r []byte) []byte { return replace(r, "/Packages\n", "/Packages extra\n") },
|
||||
"checksum invalid digest": func(r []byte) []byte {
|
||||
return replace(r, fmt.Sprintf("%x", sha256.Sum256(index)), strings.Repeat("g", 64))
|
||||
},
|
||||
"duplicate checksum entry": func(r []byte) []byte {
|
||||
return append(r, fmt.Sprintf(" %x %d stable/binary-amd64/Packages\n", sha256.Sum256(index), len(index))...)
|
||||
},
|
||||
"conflicting checksum entry": func(r []byte) []byte {
|
||||
return append(r, fmt.Sprintf(" %s %d stable/binary-amd64/Packages\n", strings.Repeat("b", 64), len(index))...)
|
||||
},
|
||||
"duplicate other checksum entry": func(r []byte) []byte {
|
||||
return append(r, strings.Repeat(" "+strings.Repeat("a", 64)+" 1 other/Packages\n", 2)...)
|
||||
},
|
||||
"orphan continuation": func(r []byte) []byte { return append([]byte(" orphan\n"), r...) },
|
||||
"invalid field name": func(r []byte) []byte { return append(r, "Bad Field: value\n"...) },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assertRejected(t, mutate(fixtureRelease(index)), index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
})
|
||||
}
|
||||
for _, field := range []string{"Architectures", "Components", "Date", "Label", "Origin", "Suite"} {
|
||||
t.Run("missing "+field, func(t *testing.T) {
|
||||
r := fixtureRelease(index)
|
||||
lines := strings.Split(string(r), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, field+":") {
|
||||
lines = append(lines[:i], lines[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
assertRejected(t, []byte(strings.Join(lines, "\n")), index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
})
|
||||
}
|
||||
// Both times are in the future relative to now, but expiry precedes Date.
|
||||
r := append(fixtureRelease(index), "Valid-Until: Thu, 24 Sep 2026 06:37:50 +0000\n"...)
|
||||
assertRejected(t, r, index, "resolute", "amd64", fixturePins(), fixtureNow.Add(-24*time.Hour-2*time.Minute))
|
||||
}
|
||||
|
||||
func TestResolveRejectsTamperedIndex(t *testing.T) {
|
||||
index := fixtureIndex()
|
||||
r := fixtureRelease(index)
|
||||
index = replace(index, "Size: 12345", "Size: 12346") // Same byte size, different digest.
|
||||
assertRejected(t, r, index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
}
|
||||
|
||||
func TestResolveRejectsPackageAmbiguityAndInvalidLock(t *testing.T) {
|
||||
for name, mutate := range map[string]func([]byte) []byte{
|
||||
"duplicate field": func(p []byte) []byte {
|
||||
return replace(p, "Package: docker-ce\n", "Package: docker-ce\npAcKaGe: docker-ce\n")
|
||||
},
|
||||
"duplicate unrelated field": func(p []byte) []byte { return replace(p, "Description:", "X-Info: one\nx-info: two\nDescription:") },
|
||||
"duplicate record": func(p []byte) []byte {
|
||||
return append(p, fixtureRecord("docker-ce", fixturePins()["docker-ce"], "amd64")...)
|
||||
},
|
||||
"conflicting record": func(p []byte) []byte {
|
||||
return append(p, strings.Replace(fixtureRecord("docker-ce", fixturePins()["docker-ce"], "amd64"), "Size: 12345", "Size: 999", 1)...)
|
||||
},
|
||||
"missing record": func(p []byte) []byte { return []byte(strings.SplitN(string(p), "\n\n", 2)[1]) },
|
||||
"wrong architecture": func(p []byte) []byte { return replace(p, "Architecture: amd64", "Architecture: all") },
|
||||
"wrong version": func(p []byte) []byte { return replace(p, "Version: 5:29.1.0", "Version: 5:29.2.0") },
|
||||
"unsafe filename": func(p []byte) []byte { return replace(p, "Filename: dists/resolute", "Filename: ../PRIVATE-MARKER") },
|
||||
"wrong filename version": func(p []byte) []byte { return replace(p, "docker-ce_29.1.0", "docker-ce_29.2.0") },
|
||||
"epoch filename": func(p []byte) []byte { return replace(p, "docker-ce_29.1.0", "docker-ce_5:29.1.0") },
|
||||
"wrong filename suite": func(p []byte) []byte { return replace(p, "Filename: dists/resolute", "Filename: dists/noble") },
|
||||
"zero size": func(p []byte) []byte { return replace(p, "Size: 12345", "Size: 0") },
|
||||
"negative size": func(p []byte) []byte { return replace(p, "Size: 12345", "Size: -1") },
|
||||
"overflow size": func(p []byte) []byte { return replace(p, "Size: 12345", "Size: 18446744073709551616") },
|
||||
"excess package size": func(p []byte) []byte { return replace(p, "Size: 12345", "Size: 536870913") },
|
||||
"bad digest": func(p []byte) []byte { return replace(p, "SHA256: "+strings.Repeat("a", 64), "SHA256: PRIVATE-MARKER") },
|
||||
"folded required field": func(p []byte) []byte { return replace(p, "Size: 12345", "Size: 12345\n 6") },
|
||||
"invalid syntax": func(p []byte) []byte { return append(p, "PRIVATE-MARKER\n"...) },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
index := mutate(fixtureIndex())
|
||||
assertRejected(t, fixtureRelease(index), index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
})
|
||||
}
|
||||
for _, field := range []string{"Package", "Version", "Architecture", "Filename", "Size", "SHA256"} {
|
||||
t.Run("missing "+field, func(t *testing.T) {
|
||||
index := replace(fixtureIndex(), field+":", "X-Removed:")
|
||||
assertRejected(t, fixtureRelease(index), index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRequiresExactExplicitPins(t *testing.T) {
|
||||
for _, mode := range []string{"nil", "missing", "extra", "empty", "latest", "engine mismatch", "invalid version"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
pins := fixturePins()
|
||||
switch mode {
|
||||
case "nil":
|
||||
pins = nil
|
||||
case "missing":
|
||||
delete(pins, "containerd.io")
|
||||
case "extra":
|
||||
pins["unexpected"] = "1.0"
|
||||
case "empty":
|
||||
pins["containerd.io"] = ""
|
||||
case "latest":
|
||||
pins["containerd.io"] = "latest"
|
||||
case "engine mismatch":
|
||||
pins["docker-ce-cli"] = "5:29.2.0-1~ubuntu.26.04~resolute"
|
||||
case "invalid version":
|
||||
pins["containerd.io"] = "1;PRIVATE-MARKER"
|
||||
}
|
||||
// Make invalid versions available too: Validate, rather than a missing
|
||||
// match, must enforce version syntax and engine/CLI equality.
|
||||
index := fixtureIndex()
|
||||
for _, name := range []string{"containerd.io", "docker-ce-cli"} {
|
||||
if v := pins[name]; v != "" && v != fixturePins()[name] {
|
||||
index = replace(index, fixtureRecord(name, fixturePins()[name], "amd64"), fixtureRecord(name, v, "amd64"))
|
||||
}
|
||||
}
|
||||
assertRejected(t, fixtureRelease(index), index, "resolute", "amd64", pins, fixtureNow)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsInvalidText(t *testing.T) {
|
||||
for _, invalid := range []string{"\x00", "\x01", "\x1b", "\x7f", "\u0085", "\r", "\xff"} {
|
||||
t.Run(fmt.Sprintf("%x", invalid), func(t *testing.T) {
|
||||
index := fixtureIndex()
|
||||
r := append(fixtureRelease(index), "X-Info: PRIVATE-MARKER"+invalid+"suffix\n"...)
|
||||
assertRejected(t, r, index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
index = append(index, "Package: unrelated\nDescription: PRIVATE-MARKER"+invalid+"suffix\n"...)
|
||||
assertRejected(t, fixtureRelease(index), index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExactByteLimits(t *testing.T) {
|
||||
for _, target := range []string{"release", "index"} {
|
||||
for _, excess := range []int{0, 1} {
|
||||
t.Run(fmt.Sprintf("%s+%d", target, excess), func(t *testing.T) {
|
||||
index := fixtureIndex()
|
||||
if target == "index" {
|
||||
index = append(index, "Package: unrelated\nDescription: "...)
|
||||
index = append(index, strings.Repeat("x", (16<<20)+excess-len(index)-1)...)
|
||||
index = append(index, '\n')
|
||||
}
|
||||
release := fixtureRelease(index)
|
||||
if target == "release" {
|
||||
release = append(release, "X-Padding: "...)
|
||||
release = append(release, strings.Repeat("x", (1<<20)+excess-len(release)-1)...)
|
||||
release = append(release, '\n')
|
||||
}
|
||||
if excess != 0 {
|
||||
assertRejected(t, release, index, "resolute", "amd64", fixturePins(), fixtureNow)
|
||||
} else if _, err := Resolve(release, index, "resolute", "amd64", fixturePins(), fixtureNow); err != nil {
|
||||
t.Fatalf("exact limit rejected: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"server-deploy/internal/installplan"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStagingRejectsLinksAndFIFO(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"docker.asc", "Release", "Release.gpg", "Packages"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("metadata"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
link := filepath.Join(t.TempDir(), "link")
|
||||
if err := os.Symlink(dir, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readStaging(link + "/"); err == nil {
|
||||
t.Fatal("root symlink accepted")
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, "Release")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(dir, "Packages"), filepath.Join(dir, "Release")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readStaging(dir); err == nil {
|
||||
t.Fatal("file symlink accepted")
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, "Release")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := syscall.Mkfifo(filepath.Join(dir, "Release"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readStaging(dir); err == nil {
|
||||
t.Fatal("FIFO accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPGFailureRedactsDiagnostics(t *testing.T) {
|
||||
if _, err := runGPG(t.TempDir(), "/nonexistent/signature-tool-secret"); err == nil || err.Error() != "repository signature tool failed" {
|
||||
t.Fatal("unredacted or absent error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputBound(t *testing.T) {
|
||||
var b cappedOutput
|
||||
if _, err := b.Write(make([]byte, 65536)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.Write([]byte{1}); err == nil {
|
||||
t.Fatal("output limit missing")
|
||||
}
|
||||
if b.Len() != 65536 {
|
||||
t.Fatal("buffer grew beyond limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStagedSignatureIntegration(t *testing.T) {
|
||||
// Explicit opt-in public fixture, downloaded by the metadata-only probe.
|
||||
dir := os.Getenv("DEPLOYCTL_REPOSITORY_FIXTURE")
|
||||
if dir == "" {
|
||||
t.Skip("public signed fixture not supplied; run repository probe for real signature evidence")
|
||||
}
|
||||
files, err := readStaging(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := authenticate(files, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Result comes from the successful real CLI invocation. Recheck the exact
|
||||
// fixture versions before making a semantically valid signature mutation.
|
||||
var response struct {
|
||||
Lock installplan.Lock `json:"lock"`
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "result.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = json.Unmarshal(raw, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions := make(map[string]string)
|
||||
for _, p := range response.Lock.Packages {
|
||||
versions[p.Name] = p.Version
|
||||
}
|
||||
// Unknown Release extension remains valid metadata; only its signature
|
||||
// should reject it. This catches a bypass hidden by syntax failures.
|
||||
files["Release"] = append(bytes.TrimRight(files["Release"], "\n"), []byte("\nX-Verification-Probe: changed\n")...)
|
||||
if _, err := Resolve(files["Release"], files["Packages"], response.Lock.Suite, response.Lock.Architecture, versions, time.Now()); err != nil {
|
||||
t.Fatal("mutation masked by metadata rejection", err)
|
||||
}
|
||||
if err := authenticate(files, time.Now()); err == nil {
|
||||
t.Fatal("tampered signature accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOutputCopyBound(t *testing.T) {
|
||||
var b cappedOutput
|
||||
_, err := io.Copy(&b, io.LimitReader(strings.NewReader(strings.Repeat("x", 65537)), 65537))
|
||||
if err == nil || b.Len() > 65536 {
|
||||
t.Fatal("io.Copy bypassed output limit", b.Len(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWellFormedPackageTamperNeedsRebinding(t *testing.T) {
|
||||
index := fixtureIndex()
|
||||
release := fixtureRelease(index)
|
||||
changed := []byte(strings.Replace(string(index), "SHA256: a", "SHA256: b", 1))
|
||||
if _, err := Resolve(release, changed, "resolute", "amd64", fixturePins(), fixtureNow); err == nil {
|
||||
t.Fatal("unbound index accepted")
|
||||
}
|
||||
if _, err := Resolve(fixtureRelease(changed), changed, "resolute", "amd64", fixturePins(), fixtureNow); err != nil {
|
||||
t.Fatal("tamper test masked by parser rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
const goodStatus = "[GNUPG:] NEWSIG\n[GNUPG:] GOODSIG 7EA0A9C3F273FCD8 Docker\n[GNUPG:] VALIDSIG D3306A018370199E527AE7997EA0A9C3F273FCD8 2026-09-24 1790231932 0 4 0 1 10 00 9DC858229FC7DD38854AE2D88D81803C0EBFCD88\n"
|
||||
|
||||
func TestSignatureStatus(t *testing.T) {
|
||||
now := time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)
|
||||
if err := checkStatus([]byte(goodStatus), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range []string{"", "[GNUPG:] GOODSIG key name\n", goodStatus + goodStatus,
|
||||
strings.ReplaceAll(goodStatus, dockerFingerprint, strings.Repeat("A", 40)),
|
||||
strings.Replace(goodStatus, " 10 00 ", " 2 00 ", 1),
|
||||
strings.Replace(goodStatus, "1790231932 0", "1990231932 0", 1),
|
||||
strings.Replace(goodStatus, "1790231932 0", "1790231932 1790231933", 1),
|
||||
goodStatus + "[GNUPG:] EXPKEYSIG bad\n", goodStatus + "[GNUPG:] BADSIG bad\n",
|
||||
} {
|
||||
if checkStatus([]byte(s), now) == nil {
|
||||
t.Errorf("accepted invalid signature status: %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStaging(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"docker.asc", "Release", "Release.gpg", "Packages"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("bytes"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := readStaging(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readStaging("relative"); err == nil {
|
||||
t.Fatal("relative directory accepted")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "Release.gpg"), make([]byte, 65537), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readStaging(dir); err == nil {
|
||||
t.Fatal("oversize signature accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongKeyFailsBeforeExternalProcess(t *testing.T) {
|
||||
if err := authenticate(map[string][]byte{"docker.asc": []byte("untrusted")}, time.Now()); err == nil {
|
||||
t.Fatal("untrusted key accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package aptrepo authenticates staged Docker APT metadata, never installs it.
|
||||
package aptrepo
|
||||
|
||||
import (
|
||||
"server-deploy/internal/installplan"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
RepositoryAuthenticated bool `json:"repositoryAuthenticated"`
|
||||
PackageBytesVerified bool `json:"packageBytesVerified"`
|
||||
Executable bool `json:"executable"`
|
||||
VerifiedAt time.Time `json:"verifiedAt"`
|
||||
PrimaryFingerprint string `json:"primaryFingerprint"`
|
||||
Lock installplan.Lock `json:"lock"`
|
||||
}
|
||||
|
||||
// Verify requires a trusted staging directory and trusted ancestors, with no
|
||||
// concurrent writers. Returned JSON is evidence, not an execution capability.
|
||||
func Verify(directory, suite, arch string, versions map[string]string, now time.Time) (Result, error) {
|
||||
files, err := readStaging(directory)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := authenticate(files, now); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
lock, err := Resolve(files["Release"], files["Packages"], suite, arch, versions, now)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{ProtocolVersion: 1, RepositoryAuthenticated: true, VerifiedAt: now.UTC().Truncate(time.Second), PrimaryFingerprint: dockerFingerprint, Lock: lock}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user