From abf9fa8b474b19bc53d4beed8b4b5accffa295f1 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:50:28 +0100 Subject: [PATCH 1/2] Require authenticated enterprise registries --- README.md | 8 +- docs/architecture.md | 12 +- docs/enterprise-policy.md | 97 ++++++++- docs/proxy-airgap.md | 8 +- docs/security-model.md | 7 +- internal/cli/cli.go | 104 ++++++++-- internal/cli/cli_test.go | 102 +++++++++- internal/policy/policy.go | 356 +++++++++++++++++++++++++++++++-- internal/policy/policy_test.go | 152 +++++++++++++- internal/registry/file.go | 17 +- internal/registry/file_test.go | 47 +++++ main.go | 24 +-- main_test.go | 6 +- 13 files changed, 871 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index d8abc3d..0fd69fa 100644 --- a/README.md +++ b/README.md @@ -694,7 +694,11 @@ insufficiently protected policy fails closed before non-bootstrap work. Schema 1 can require an exact image lock, reject local image-ID locks unless explicitly allowed, and allowlist canonical registry/repository boundaries. -Lower-precedence registry or command-line choices cannot weaken it. See +Policy schema 2 can also require a strict detached Ed25519 signature over the +exact `container-bin.toml` bytes, with machine-owned key validity, revocation +and overlap rotation. Signed registries are read-only to `cb`; updates must be +provisioned with a matching signature by the administrator. Lower-precedence +registry or command-line choices cannot weaken policy. See [enterprise machine policy](docs/enterprise-policy.md) for the schema, ownership rules, normalization behavior and stable diagnostic codes. @@ -750,6 +754,8 @@ configuration, or host project files. Output archives are created exclusively: choose a new filename instead of overwriting an existing backup. Volume data can itself contain package credentials or other secrets, so store and transfer the archive as sensitive data even though ContainerBin requests owner-only file mode. +When present, the detached `container-bin.toml.sig` envelope is included; a +required signed-registry snapshot is re-authenticated before backup. See [proxies, private registries, and air-gapped operation](docs/proxy-airgap.md) for mirror identity rules, disconnected image preparation, and the complete diff --git a/docs/architecture.md b/docs/architecture.md index 7751f3f..d8eeec9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -185,6 +185,14 @@ This preserves the precedence boundary: user/project/CLI layers may choose a request, but only the machine layer can authorize it. Full schema and ownership rules are in [enterprise-policy.md](enterprise-policy.md). +When policy schema 2 requires registry authentication, `registry.Load` passes +the exact file bytes to the policy verifier before parsing. The verifier accepts +only a strict detached Ed25519 envelope beside the registry and a currently +active, non-revoked machine-policy key. Missing signed files do not trigger the +built-in default or `.bak` recovery. Registry-mutating commands are disabled in +this mode because ContainerBin never possesses the administrator's signing key; +lockfile-only operations remain separate. + ## Atomic writes Registry and lock mutations (add, expose, unexpose, uninstall, lock, update, @@ -196,7 +204,9 @@ The next `cb` load automatically recovers `container-bin.toml` or `container-bin.lock` from its `.bak` if the live file is missing, after validating the backup. If the backup is unreadable or otherwise unusable, loading stops with a hard error rather than falling back to defaults or an -unlocked state. +unlocked state. Required signed-registry mode is the intentional exception: a +missing live registry is never restored from an unauthenticated `.bak`; the +administrator must provision the registry/signature pair. Atomic replacement protects file integrity, but it does not protect against lost updates when two `cb` processes read, modify and write the same file. diff --git a/docs/enterprise-policy.md b/docs/enterprise-policy.md index f890d15..2249cd1 100644 --- a/docs/enterprise-policy.md +++ b/docs/enterprise-policy.md @@ -31,7 +31,7 @@ root and may not be group- or world-writable. ContainerBin never creates or edits this file. Provision it and its ACL/mode with the machine's normal administrator configuration-management mechanism. -## Schema 1 +## Schema 1 — image-origin and lock constraints ```toml policy_version = 1 @@ -110,5 +110,96 @@ Policy failures have a stable bracketed code suitable for log processing: - `policy.repository_denied` The fingerprint hashes the exact policy bytes. It is an audit correlation -value, not a signature. Registry-signature and image-signature policy are -separate roadmap stages and are not implied by schema 1. +value, not a signature. Image-signature policy remains a separate roadmap +stage and is not implied by either schema. + +## Schema 2 — authenticated registry bytes + +Schema 2 retains every schema 1 control and can additionally require a detached +Ed25519 signature for `container-bin.toml`: + +```toml +policy_version = 2 +require_lock = true +allowed_repositories = ["docker.io/library", "ghcr.io/acme"] +require_registry_signature = true +registry_signing_keys = [ + "ops-2026|BASE64_OF_RAW_32_BYTE_ED25519_PUBLIC_KEY|2026-01-01T00:00:00Z|2027-01-01T00:00:00Z", + "ops-2027|BASE64_OF_RAW_32_BYTE_ED25519_PUBLIC_KEY|2026-12-01T00:00:00Z|2028-01-01T00:00:00Z", +] +revoked_registry_key_ids = ["compromised-2025"] +expires_at = "2027-06-01T00:00:00Z" +``` + +`registry_signing_keys` entries are +`KEY_ID|PUBLIC_KEY_BASE64|NOT_BEFORE|EXPIRES_AT`. Key IDs are case-sensitive, +start with a lowercase ASCII letter and then contain only lowercase letters, +digits, `.`, `_` or `-` (64 characters maximum). The public key is canonical +padded base64 of the raw 32-byte Ed25519 public key. Key timestamps are +whole-second UTC RFC 3339 values ending in `Z`; expiry is exclusive. + +Enabling `require_registry_signature` requires at least one currently active, +non-revoked key. Duplicate IDs, duplicate public keys, malformed validity +windows and duplicate revocations reject the complete policy. Revocation wins +over presence in the trusted-key list. Multiple active keys are the supported +rotation window: provision overlapping old/new keys in policy, deploy that +policy, re-sign the registry with the new key, then revoke or remove the old +identity. Never remove the only signer before the new signature is deployed. + +The detached file is exactly `container-bin.toml.sig` beside the registry and +uses this strict envelope: + +```toml +signature_version = 1 +algorithm = "ed25519" +key_id = "ops-2027" +signature = "BASE64_OF_RAW_64_BYTE_ED25519_SIGNATURE" +``` + +The Ed25519 message is the complete byte sequence of `container-bin.toml` +itself—no prehash, canonicalization, newline conversion, BOM removal or parsed +representation. Any comment, whitespace or line-ending change therefore needs +a new signature. The envelope is bounded to 16 KiB, must be a regular +non-symlink file and rejects unknown/duplicate fields, unsupported versions, +other algorithms and noncanonical base64. + +ContainerBin does not generate keys, read a private key or sign registries. +Create the raw Ed25519 signature in the administrator's protected signing +system, construct the envelope, then provision the registry and envelope as one +configuration-management transaction. Private keys must never live beside the +registry or in the machine policy. + +Authentication happens on the raw bytes before TOML parsing, default fallback, +`.bak` recovery, shim reconciliation or Docker use. A missing signed registry +does not fall back to the built-in registry and does not auto-restore an +unsigned backup. The parser acts only on the already authenticated in-memory +bytes, so a later on-disk change cannot alter that invocation's effective +registry. + +Signed mode deliberately makes the registry read-only to ContainerBin. +`cb add`, `cb default set`, `cb expose`, `cb unexpose`, `cb uninstall` and +`cb restore --apply` fail before mutation. An administrator must produce and +provision the new registry/signature pair. `cb install` and `cb setup` skip +registry creation/upgrades but may reconcile shims from an already authenticated +registry; a missing signed registry still fails closed. Read-only commands and +lockfile-only operations remain available. `cb backup` +includes an existing detached envelope and re-verifies the exact snapshot when +signed mode is active; signed-policy `cb restore` can verify and preview that +archive, but applying it remains an administrator provisioning operation. + +Schema 1 remains supported unchanged. Registry-signature fields in schema 1 +are rejected, and policy versions newer than 2 fail closed. This makes rollback +to a ContainerBin build that predates schema 2 fail visibly instead of silently +ignoring the authentication requirement. + +Additional stable error codes are: + +- `policy.registry_signature_missing` +- `policy.registry_signature_invalid` +- `policy.registry_signer_unauthorized` +- `policy.registry_signer_inactive` +- `policy.registry_signed_readonly` + +Policy summaries report whether registry signatures are required plus trusted +and revoked key counts. They never print public-key material or signature +contents. diff --git a/docs/proxy-airgap.md b/docs/proxy-airgap.md index 300c42b..290bcaa 100644 --- a/docs/proxy-airgap.md +++ b/docs/proxy-airgap.md @@ -136,9 +136,11 @@ appears in `docker image ls`. ## What `cb backup` protects -Plain `cb backup` archives `container-bin.toml`, the lockfile when present, and -informational metadata. Add `--state` followed by explicit names from `cb state` -to include selected ContainerBin-managed Docker volumes: +Plain `cb backup` archives `container-bin.toml`, its detached signature when +present, the lockfile when present, and informational metadata. Under signed +registry policy the exact registry/signature snapshot is re-authenticated +before the archive is created. Add `--state` followed by explicit names from +`cb state` to include selected ContainerBin-managed Docker volumes: ```powershell cb state diff --git a/docs/security-model.md b/docs/security-model.md index ab171d1..1965396 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -22,9 +22,10 @@ Inside the boundary (whoever controls these controls execution): An optional administrator-owned machine policy sits above this user-controlled boundary. Its fixed path, owner and permissions are validated before use. It -can require locking and restrict image origins, but schema 1 does not constrain -mounts, environment allowlists or commands and does not authenticate registry -or image signatures. See [enterprise machine policy](enterprise-policy.md). +can require locking, restrict image origins and authenticate exact registry +bytes through a detached Ed25519 signature. It cannot grant mounts, environment +access or commands, and it does not yet authenticate image signatures. See +[enterprise machine policy](enterprise-policy.md). Treat the registry and lockfile like your PowerShell `$PROFILE`: yours, readable, and dangerous to let others edit. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 86fdf03..a8eca99 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -32,13 +32,15 @@ import ( ) func Setup(cfgPath, version string, machinePolicy policy.Policy) error { - if err := registry.EnsureFile(cfgPath); err != nil { - return err - } - if err := registry.AppendMissingDefaultTools(cfgPath, version); err != nil { - return err + if !machinePolicy.RequireRegistrySignature { + if err := registry.EnsureFile(cfgPath); err != nil { + return err + } + if err := registry.AppendMissingDefaultTools(cfgPath, version); err != nil { + return err + } } - reg, _, err := registry.Load() + reg, _, err := registry.Load(machinePolicy.AuthenticateRegistry) if err != nil { return err } @@ -65,6 +67,9 @@ func add(reg registry.Registry, cfgPath string, args []string, install func(regi if (len(args) != 3 && !local) || args[1] != "--image" || args[0] == "" || args[2] == "" { return errors.New("usage: cb add TOOL --image IMAGE [--local]") } + if err := machinePolicy.AuthorizeRegistryMutation("cb add"); err != nil { + return err + } name := strings.ToLower(args[0]) image := args[2] if !registry.ValidToolName(name) { @@ -286,6 +291,9 @@ func Default(reg registry.Registry, cfgPath string, args []string, machinePolicy if len(args) != 3 || args[0] != "set" { return errors.New("usage: cb default | cb default set FAMILY VERSION") } + if err := machinePolicy.AuthorizeRegistryMutation("cb default set"); err != nil { + return err + } family, version := strings.ToLower(args[1]), strings.ToLower(args[2]) if machinePolicy.Managed() { for _, candidate := range reg.Tools { @@ -765,7 +773,7 @@ func exposeSharedVolumeFile(reg registry.Registry, cfgPath string, args []string if err := atomicio.WriteFile(cfgPath, combined, 0644); err != nil { return err } - newReg, _, err := registry.Load() + newReg, _, err := registry.Load(machinePolicy.AuthenticateRegistry) if err != nil { return fmt.Errorf("reload registry: %w", err) } @@ -778,6 +786,9 @@ func exposeSharedVolumeFile(reg registry.Registry, cfgPath string, args []string func Expose(reg registry.Registry, cfgPath string, args []string, machinePolicy policy.Policy) error { const usage = "usage: cb expose TOOL [BINARY ...] | cb expose --shared-file TOOL VOLUME /absolute/container/file" + if err := machinePolicy.AuthorizeRegistryMutation("cb expose"); err != nil { + return err + } if len(args) > 0 && args[0] == "--shared-file" { return exposeSharedVolumeFile(reg, cfgPath, args[1:], machinePolicy) } @@ -852,7 +863,7 @@ func Expose(reg registry.Registry, cfgPath string, args []string, machinePolicy if err := atomicio.WriteFile(cfgPath, combined, 0644); err != nil { return err } - newReg, _, err := registry.Load() + newReg, _, err := registry.Load(machinePolicy.AuthenticateRegistry) if err != nil { return fmt.Errorf("reload registry: %w", err) } @@ -1004,10 +1015,13 @@ func Inspect(reg registry.Registry, args []string, machinePolicy policy.Policy) return nil } -func Unexpose(reg registry.Registry, cfgPath string, args []string) error { +func Unexpose(reg registry.Registry, cfgPath string, args []string, machinePolicy policy.Policy) error { if len(args) == 0 { return errors.New("usage: cb unexpose TOOL [TOOL...]") } + if err := machinePolicy.AuthorizeRegistryMutation("cb unexpose"); err != nil { + return err + } remove := map[string]bool{} for _, a := range args { name := strings.ToLower(a) @@ -1034,10 +1048,13 @@ func Unexpose(reg registry.Registry, cfgPath string, args []string) error { return nil } -func Uninstall(reg registry.Registry, cfgPath string, args []string) error { +func Uninstall(reg registry.Registry, cfgPath string, args []string, machinePolicy policy.Policy) error { if len(args) == 0 { return errors.New("usage: cb uninstall TOOL [TOOL...]") } + if err := machinePolicy.AuthorizeRegistryMutation("cb uninstall"); err != nil { + return err + } remove := map[string]bool{} builtins := registry.Default().Tools for _, a := range args { @@ -1070,11 +1087,19 @@ func Uninstall(reg registry.Registry, cfgPath string, args []string) error { // --- v0.9 image locking ---------------------------------------------------- -func Backup(cfgPath string, args []string, version string) error { +func Backup(cfgPath string, args []string, version string, machinePolicy policy.Policy) error { pathArg, stateNames, err := parseBackupArgs(args) if err != nil { return err } + registryBytes, err := os.ReadFile(cfgPath) + if err != nil { + return err + } + signatureBytes, err := machinePolicy.LoadRegistrySignature(cfgPath, registryBytes) + if err != nil { + return fmt.Errorf("authenticate registry backup snapshot: %w", err) + } dir := filepath.Dir(cfgPath) created := time.Now() path := "" @@ -1101,7 +1126,7 @@ func Backup(cfgPath string, args []string, version string) error { } }() zw := zip.NewWriter(f) - add := func(src, name string, required bool) error { + addFile := func(src, name string, required bool) error { b, err := os.ReadFile(src) if errors.Is(err, os.ErrNotExist) && !required { return nil @@ -1116,12 +1141,27 @@ func Backup(cfgPath string, args []string, version string) error { _, err = w.Write(b) return err } - if err := add(cfgPath, "container-bin.toml", true); err != nil { + addBytes := func(name string, b []byte) error { + w, err := zw.Create(name) + if err != nil { + return err + } + _, err = w.Write(b) + return err + } + if err := addBytes("container-bin.toml", registryBytes); err != nil { zw.Close() f.Close() return err } - if err := add(lockfile.PathFor(cfgPath), "container-bin.lock", false); err != nil { + if signatureBytes != nil { + if err := addBytes("container-bin.toml.sig", signatureBytes); err != nil { + zw.Close() + f.Close() + return err + } + } + if err := addFile(lockfile.PathFor(cfgPath), "container-bin.lock", false); err != nil { zw.Close() f.Close() return err @@ -1205,6 +1245,11 @@ func Restore(cfgPath string, args []string, machinePolicy policy.Policy) error { if err != nil { return err } + if apply { + if err := machinePolicy.AuthorizeRegistryMutation("cb restore --apply"); err != nil { + return err + } + } zr, err := zip.OpenReader(backupPath) if err != nil { return err @@ -1212,7 +1257,7 @@ func Restore(cfgPath string, args []string, machinePolicy policy.Policy) error { defer zr.Close() files := map[string][]byte{} for _, f := range zr.File { - if f.Name != "container-bin.toml" && f.Name != "container-bin.lock" { + if f.Name != "container-bin.toml" && f.Name != "container-bin.toml.sig" && f.Name != "container-bin.lock" { continue } if _, duplicate := files[f.Name]; duplicate { @@ -1233,6 +1278,9 @@ func Restore(cfgPath string, args []string, machinePolicy policy.Policy) error { if !ok { return errors.New("backup does not contain container-bin.toml") } + if err := machinePolicy.AuthenticateRegistrySnapshot(cfgPath, cfg, files["container-bin.toml.sig"]); err != nil { + return fmt.Errorf("backup registry authentication failed: %w", err) + } restoredRegistry, err := registry.ParseTOML(string(cfg)) if err != nil { return fmt.Errorf("backup registry invalid: %w", err) @@ -1271,6 +1319,11 @@ func Restore(cfgPath string, args []string, machinePolicy policy.Policy) error { } fmt.Printf("restore source: %s\n", backupPath) fmt.Printf(" container-bin.toml: %d bytes\n", len(cfg)) + if b, ok := files["container-bin.toml.sig"]; ok { + fmt.Printf(" container-bin.toml.sig: %d bytes\n", len(b)) + } else { + fmt.Println(" container-bin.toml.sig: absent") + } if b, ok := files["container-bin.lock"]; ok { fmt.Printf(" container-bin.lock: %d bytes\n", len(b)) } else { @@ -1301,6 +1354,11 @@ func Restore(cfgPath string, args []string, machinePolicy policy.Policy) error { if err := atomicio.WriteFile(cfgPath, cfg, 0644); err != nil { return err } + if b, ok := files["container-bin.toml.sig"]; ok { + if err := atomicio.WriteFile(cfgPath+".sig", b, 0644); err != nil { + return err + } + } lockPath := lockfile.PathFor(cfgPath) if b, ok := files["container-bin.lock"]; ok { if err := atomicio.WriteFile(lockPath, b, 0644); err != nil { @@ -1561,15 +1619,17 @@ func parseUpdateArgs(args []string) (target, mode string, err error) { // Install creates or upgrades the registry file and reconciles the shim set // from it. It reloads the registry after the upgrade because EnsureFile or // AppendMissingDefaultTools may have just created or extended the file. -func Install(cfgPath, version string) error { - if err := registry.EnsureFile(cfgPath); err != nil { - return err - } - if err := registry.AppendMissingDefaultTools(cfgPath, version); err != nil { - return err +func Install(cfgPath, version string, machinePolicy policy.Policy) error { + if !machinePolicy.RequireRegistrySignature { + if err := registry.EnsureFile(cfgPath); err != nil { + return err + } + if err := registry.AppendMissingDefaultTools(cfgPath, version); err != nil { + return err + } } // Reload in case the file was just created or upgraded. - reg, _, err := registry.Load() + reg, _, err := registry.Load(machinePolicy.AuthenticateRegistry) if err != nil { return err } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 80146c3..a3b4a49 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,6 +2,7 @@ package cli import ( "archive/zip" + "encoding/base64" "errors" "os" "path/filepath" @@ -16,6 +17,58 @@ import ( "github.com/AviBackToBlack/container-bin/internal/registry" ) +func TestSignedRegistryPolicyBlocksRegistryMutationsBeforeSideEffects(t *testing.T) { + machinePolicy := policy.Policy{SchemaVersion: 2, RequireRegistrySignature: true} + reg := registry.Default() + tests := []struct { + name string + run func(string) error + }{ + {"add", func(path string) error { + return add(reg, path, []string{"demo", "--image", "example/demo:1"}, func(registry.Registry) error { return nil }, machinePolicy) + }}, + {"default", func(path string) error { return Default(reg, path, []string{"set", "node", "22"}, machinePolicy) }}, + {"expose", func(path string) error { return Expose(reg, path, []string{"cargo"}, machinePolicy) }}, + {"unexpose", func(path string) error { return Unexpose(reg, path, []string{"demo"}, machinePolicy) }}, + {"uninstall", func(path string) error { return Uninstall(reg, path, []string{"demo"}, machinePolicy) }}, + {"restore", func(path string) error { return Restore(path, []string{"backup.zip", "--apply"}, machinePolicy) }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "container-bin.toml") + err := tc.run(path) + if err == nil || !strings.Contains(err.Error(), "[policy.registry_signed_readonly]") { + t.Fatalf("error = %v, want signed-registry mutation denial", err) + } + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("registry mutation occurred: %v", statErr) + } + }) + } +} + +func TestSignedRegistryInstallAndSetupNeverCreateOrUpgradeRegistry(t *testing.T) { + machinePolicy := policy.Policy{SchemaVersion: 2, RequireRegistrySignature: true} + for _, tc := range []struct { + name string + run func(string) error + }{ + {"install", func(path string) error { return Install(path, "dev", machinePolicy) }}, + {"setup", func(path string) error { return Setup(path, "dev", machinePolicy) }}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "container-bin.toml") + err := tc.run(path) + if err == nil || !strings.Contains(err.Error(), "[policy.registry_signature_missing]") { + t.Fatalf("error = %v, want missing signed registry", err) + } + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("registry was created or upgraded: %v", statErr) + } + }) + } +} + func TestAddRequiresExactShape(t *testing.T) { for _, args := range [][]string{ nil, @@ -297,8 +350,8 @@ shared_volumes = ["global:/cb/npm-global"] run func(string) error want string }{ - {name: "uninstall", run: func(path string) error { return Uninstall(reg, path, []string{"acme"}) }, want: "uninstall requires a concrete tool name"}, - {name: "unexpose", run: func(path string) error { return Unexpose(reg, path, []string{"acme"}) }, want: "unexpose requires a concrete tool name"}, + {name: "uninstall", run: func(path string) error { return Uninstall(reg, path, []string{"acme"}, policy.Policy{}) }, want: "uninstall requires a concrete tool name"}, + {name: "unexpose", run: func(path string) error { return Unexpose(reg, path, []string{"acme"}, policy.Policy{}) }, want: "unexpose requires a concrete tool name"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1170,7 +1223,7 @@ shared_volumes = ["cache:/other"] if err := os.WriteFile(path, []byte(config), 0644); err != nil { t.Fatal(err) } - if err := Unexpose(reg, path, []string{"acme"}); err == nil || !strings.Contains(err.Error(), "not marked as a cb-exposed") { + if err := Unexpose(reg, path, []string{"acme"}, policy.Policy{}); err == nil || !strings.Contains(err.Error(), "not marked as a cb-exposed") { t.Fatalf("unexpose error = %v", err) } data, err := os.ReadFile(path) @@ -1577,6 +1630,40 @@ func TestParseRestoreArgs(t *testing.T) { } } +func TestRestoreAuthenticatesSignedSnapshotBeforeRegistryParsing(t *testing.T) { + dir := t.TempDir() + backup := filepath.Join(dir, "backup.zip") + f, err := os.Create(backup) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + entries := map[string][]byte{ + "container-bin.toml": []byte("not valid registry TOML\n"), + "container-bin.toml.sig": []byte("signature_version = 1\nalgorithm = \"ed25519\"\nkey_id = \"unknown-key\"\nsignature = \"" + base64.StdEncoding.EncodeToString(make([]byte, 64)) + "\"\n"), + } + for name, contents := range entries { + w, createErr := zw.Create(name) + if createErr != nil { + t.Fatal(createErr) + } + if _, writeErr := w.Write(contents); writeErr != nil { + t.Fatal(writeErr) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + machinePolicy := policy.Policy{SchemaVersion: 2, RequireRegistrySignature: true} + err = Restore(filepath.Join(dir, "container-bin.toml"), []string{backup}, machinePolicy) + if err == nil || !strings.Contains(err.Error(), "[policy.registry_signer_unauthorized]") || strings.Contains(err.Error(), "backup registry invalid") { + t.Fatalf("Restore error = %v, want signature rejection before registry parsing", err) + } +} + func TestPlainBackupIsValidAndNeverOverwrites(t *testing.T) { dir := t.TempDir() cfg := filepath.Join(dir, "container-bin.toml") @@ -1584,7 +1671,10 @@ func TestPlainBackupIsValidAndNeverOverwrites(t *testing.T) { if err := os.WriteFile(cfg, []byte(registry.DefaultTOML), 0600); err != nil { t.Fatal(err) } - if err := Backup(cfg, []string{out}, "test"); err != nil { + if err := os.WriteFile(cfg+".sig", []byte("detached-signature-envelope"), 0600); err != nil { + t.Fatal(err) + } + if err := Backup(cfg, []string{out}, "test", policy.Policy{}); err != nil { t.Fatal(err) } zr, err := zip.OpenReader(out) @@ -1596,7 +1686,7 @@ func TestPlainBackupIsValidAndNeverOverwrites(t *testing.T) { seen[f.Name] = true } zr.Close() - for _, name := range []string{"container-bin.toml", "backup-info.txt"} { + for _, name := range []string{"container-bin.toml", "container-bin.toml.sig", "backup-info.txt"} { if !seen[name] { t.Fatalf("backup missing %s", name) } @@ -1605,7 +1695,7 @@ func TestPlainBackupIsValidAndNeverOverwrites(t *testing.T) { if err != nil { t.Fatal(err) } - if err := Backup(cfg, []string{out}, "test"); err == nil || !strings.Contains(err.Error(), "choose a different filename") { + if err := Backup(cfg, []string{out}, "test", policy.Policy{}); err == nil || !strings.Contains(err.Error(), "choose a different filename") { t.Fatalf("existing-backup error = %v", err) } after, err := os.ReadFile(out) diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 10054f1..d88f843 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -5,10 +5,13 @@ package policy import ( "bufio" + "crypto/ed25519" "crypto/sha256" + "encoding/base64" "encoding/hex" "errors" "fmt" + "io" "os" "runtime" "sort" @@ -19,7 +22,12 @@ import ( "github.com/AviBackToBlack/container-bin/internal/toml" ) -const SchemaVersion = 1 +const ( + MaxSchemaVersion = 2 + registrySignatureVersion = 1 + registrySignatureAlgorithm = "ed25519" + maxRegistrySignatureFileSize = 16 << 10 +) type Error struct { Code string @@ -34,13 +42,22 @@ func policyError(code, format string, args ...any) error { } type Policy struct { - Path string - SchemaVersion int - RequireLock bool - AllowLocalImages bool - AllowedRepositories []string - ExpiresAt *time.Time - Fingerprint string + Path string + SchemaVersion int + RequireLock bool + AllowLocalImages bool + AllowedRepositories []string + RequireRegistrySignature bool + ExpiresAt *time.Time + Fingerprint string + registrySigningKeys map[string]registrySigningKey + revokedRegistryKeyIDs map[string]bool +} + +type registrySigningKey struct { + PublicKey ed25519.PublicKey + NotBefore time.Time + ExpiresAt time.Time } func Path() string { @@ -60,8 +77,8 @@ func (p Policy) Summary() string { if p.ExpiresAt != nil { expires = p.ExpiresAt.UTC().Format(time.RFC3339) } - return fmt.Sprintf("managed schema=%d require_lock=%t allow_local_images=%t allowed_repositories=%d expires=%s fingerprint=sha256:%s source=%s", - p.SchemaVersion, p.RequireLock, p.AllowLocalImages, len(p.AllowedRepositories), expires, p.Fingerprint, p.Path) + return fmt.Sprintf("managed schema=%d require_lock=%t allow_local_images=%t allowed_repositories=%d require_registry_signature=%t registry_trusted_keys=%d registry_revoked_keys=%d expires=%s fingerprint=sha256:%s source=%s", + p.SchemaVersion, p.RequireLock, p.AllowLocalImages, len(p.AllowedRepositories), p.RequireRegistrySignature, len(p.registrySigningKeys), len(p.revokedRegistryKeyIDs), expires, p.Fingerprint, p.Path) } func Load() (Policy, error) { @@ -95,6 +112,8 @@ func loadAt(path string, ownership func(string) error, now time.Time) (Policy, e func parse(path string, b []byte, now time.Time) (Policy, error) { p := Policy{Path: path} + var registrySigningKeySpecs, revokedRegistryKeyIDs []string + usedRegistrySignatureFields := false seen := map[string]bool{} sc := bufio.NewScanner(strings.NewReader(string(b))) lineNo := 0 @@ -154,6 +173,35 @@ func parse(path string, b []byte, now time.Time) (Policy, error) { return Policy{}, policyError("syntax", "line %d allowed_repositories: %v", startLine, err) } p.AllowedRepositories = values + case "require_registry_signature": + v, err := toml.ParseBool(raw) + if err != nil { + return Policy{}, policyError("syntax", "line %d require_registry_signature: %v", lineNo, err) + } + p.RequireRegistrySignature = v + usedRegistrySignatureFields = true + case "registry_signing_keys", "revoked_registry_key_ids": + startLine := lineNo + for strings.HasPrefix(strings.TrimSpace(raw), "[") && !arrayValueComplete(raw) { + if !sc.Scan() { + if err := sc.Err(); err != nil { + return Policy{}, policyError("unreadable", "scan %s: %v", path, err) + } + return Policy{}, policyError("syntax", "line %d %s: unterminated array", startLine, key) + } + lineNo++ + raw += "\n" + strings.TrimSpace(toml.StripComment(sc.Text())) + } + values, err := toml.ParseStringArray(raw) + if err != nil { + return Policy{}, policyError("syntax", "line %d %s: %v", startLine, key, err) + } + if key == "registry_signing_keys" { + registrySigningKeySpecs = values + } else { + revokedRegistryKeyIDs = values + } + usedRegistrySignatureFields = true case "expires_at": value, err := toml.ParseQuoted(raw) if err != nil { @@ -171,10 +219,13 @@ func parse(path string, b []byte, now time.Time) (Policy, error) { if err := sc.Err(); err != nil { return Policy{}, policyError("unreadable", "scan %s: %v", path, err) } - if p.SchemaVersion != SchemaVersion { - return Policy{}, policyError("version", "unsupported policy_version %d (supported: %d)", p.SchemaVersion, SchemaVersion) + if p.SchemaVersion < 1 || p.SchemaVersion > MaxSchemaVersion { + return Policy{}, policyError("version", "unsupported policy_version %d (supported: 1-%d)", p.SchemaVersion, MaxSchemaVersion) } - if !p.RequireLock && len(p.AllowedRepositories) == 0 { + if usedRegistrySignatureFields && p.SchemaVersion < 2 { + return Policy{}, policyError("version", "registry signature controls require policy_version 2") + } + if !p.RequireLock && len(p.AllowedRepositories) == 0 && !p.RequireRegistrySignature { return Policy{}, policyError("syntax", "policy has no authorization controls") } if p.ExpiresAt != nil && !now.Before(*p.ExpiresAt) { @@ -195,11 +246,106 @@ func parse(path string, b []byte, now time.Time) (Policy, error) { } sort.Strings(canonical) p.AllowedRepositories = canonical + keys, revoked, err := parseRegistrySigningKeys(registrySigningKeySpecs, revokedRegistryKeyIDs) + if err != nil { + return Policy{}, err + } + p.registrySigningKeys = keys + p.revokedRegistryKeyIDs = revoked + if p.RequireRegistrySignature { + active := 0 + for id, key := range keys { + if !revoked[id] && !now.Before(key.NotBefore) && now.Before(key.ExpiresAt) { + active++ + } + } + if active == 0 { + return Policy{}, policyError("syntax", "require_registry_signature needs at least one currently active, non-revoked registry signing key") + } + } sum := sha256.Sum256(b) p.Fingerprint = hex.EncodeToString(sum[:]) return p, nil } +func parseRegistrySigningKeys(specs, revokedIDs []string) (map[string]registrySigningKey, map[string]bool, error) { + keys := make(map[string]registrySigningKey, len(specs)) + publicKeys := map[string]string{} + for _, spec := range specs { + parts := strings.Split(spec, "|") + if len(parts) != 4 { + return nil, nil, policyError("syntax", "registry_signing_keys entry must be KEY_ID|PUBLIC_KEY_BASE64|NOT_BEFORE|EXPIRES_AT") + } + id := parts[0] + if !validRegistryKeyID(id) { + return nil, nil, policyError("syntax", "invalid registry signing key id %q", id) + } + if _, duplicate := keys[id]; duplicate { + return nil, nil, policyError("syntax", "duplicate registry signing key id %q", id) + } + decoded, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil || base64.StdEncoding.EncodeToString(decoded) != parts[1] || len(decoded) != ed25519.PublicKeySize { + return nil, nil, policyError("syntax", "registry signing key %q has a noncanonical or invalid Ed25519 public key", id) + } + encodedKey := base64.StdEncoding.EncodeToString(decoded) + if otherID, duplicate := publicKeys[encodedKey]; duplicate { + return nil, nil, policyError("syntax", "registry signing keys %q and %q use the same public key", otherID, id) + } + notBefore, err := parsePolicyTimestamp(parts[2]) + if err != nil { + return nil, nil, policyError("syntax", "registry signing key %q not-before: %v", id, err) + } + expiresAt, err := parsePolicyTimestamp(parts[3]) + if err != nil { + return nil, nil, policyError("syntax", "registry signing key %q expiry: %v", id, err) + } + if !notBefore.Before(expiresAt) { + return nil, nil, policyError("syntax", "registry signing key %q expiry must be after not-before", id) + } + publicKeys[encodedKey] = id + keys[id] = registrySigningKey{ + PublicKey: append(ed25519.PublicKey(nil), decoded...), + NotBefore: notBefore, + ExpiresAt: expiresAt, + } + } + + revoked := make(map[string]bool, len(revokedIDs)) + for _, id := range revokedIDs { + if !validRegistryKeyID(id) { + return nil, nil, policyError("syntax", "invalid revoked registry key id %q", id) + } + if revoked[id] { + return nil, nil, policyError("syntax", "duplicate revoked registry key id %q", id) + } + revoked[id] = true + } + return keys, revoked, nil +} + +func validRegistryKeyID(id string) bool { + if len(id) == 0 || len(id) > 64 || id[0] < 'a' || id[0] > 'z' { + return false + } + for _, r := range id[1:] { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '.' && r != '_' && r != '-' { + return false + } + } + return true +} + +func parsePolicyTimestamp(raw string) (time.Time, error) { + if !strings.HasSuffix(raw, "Z") { + return time.Time{}, errors.New("timestamp must be canonical UTC RFC3339 ending in Z") + } + parsed, err := time.Parse(time.RFC3339, raw) + if err != nil || parsed.UTC().Format(time.RFC3339) != raw { + return time.Time{}, fmt.Errorf("timestamp must be canonical UTC RFC3339: %q", raw) + } + return parsed, nil +} + func arrayValueComplete(raw string) bool { inQuote := false escaped := false @@ -223,6 +369,190 @@ func arrayValueComplete(raw string) bool { return false } +// AuthenticateRegistry verifies the detached signature for the exact registry +// bytes before the registry package parses or acts on them. A nil byte slice +// means the registry file is absent; signed mode never substitutes a built-in +// registry or restores an unauthenticated backup in that case. +func (p Policy) AuthenticateRegistry(path string, exactBytes []byte) error { + if !p.RequireRegistrySignature { + return nil + } + _, err := p.LoadRegistrySignature(path, exactBytes) + return err +} + +// LoadRegistrySignature reads a bounded detached envelope for backup and, when +// signed mode is active, verifies it against exactBytes before returning it. +// An optional envelope is preserved in unmanaged backups without granting it +// any trust. +func (p Policy) LoadRegistrySignature(path string, exactBytes []byte) ([]byte, error) { + if exactBytes == nil { + if p.RequireRegistrySignature { + return nil, policyError("registry_signature_missing", "signed registry %s is missing; provision the exact registry and %s together", path, path+".sig") + } + return nil, nil + } + signaturePath := path + ".sig" + info, err := os.Lstat(signaturePath) + if errors.Is(err, os.ErrNotExist) { + if p.RequireRegistrySignature { + return nil, policyError("registry_signature_missing", "detached registry signature %s is missing", signaturePath) + } + return nil, nil + } + if err != nil { + return nil, policyError("registry_signature_invalid", "inspect detached registry signature %s: %v", signaturePath, err) + } + if !info.Mode().IsRegular() { + return nil, policyError("registry_signature_invalid", "detached registry signature %s must be a regular non-symlink file", signaturePath) + } + if info.Size() <= 0 || info.Size() > maxRegistrySignatureFileSize { + return nil, policyError("registry_signature_invalid", "detached registry signature %s has invalid size %d (maximum %d)", signaturePath, info.Size(), maxRegistrySignatureFileSize) + } + f, err := os.Open(signaturePath) + if err != nil { + return nil, policyError("registry_signature_invalid", "open detached registry signature %s: %v", signaturePath, err) + } + raw, readErr := io.ReadAll(io.LimitReader(f, maxRegistrySignatureFileSize+1)) + closeErr := f.Close() + if readErr != nil { + return nil, policyError("registry_signature_invalid", "read detached registry signature %s: %v", signaturePath, readErr) + } + if closeErr != nil { + return nil, policyError("registry_signature_invalid", "close detached registry signature %s: %v", signaturePath, closeErr) + } + if int64(len(raw)) != info.Size() || len(raw) > maxRegistrySignatureFileSize { + return nil, policyError("registry_signature_invalid", "detached registry signature %s changed while being read", signaturePath) + } + if err := p.AuthenticateRegistrySnapshot(path, exactBytes, raw); err != nil { + return nil, err + } + return raw, nil +} + +// AuthenticateRegistrySnapshot verifies registry bytes and an already-read +// detached envelope, for example while validating a backup before parsing it. +func (p Policy) AuthenticateRegistrySnapshot(path string, exactBytes, envelope []byte) error { + if !p.RequireRegistrySignature { + return nil + } + if exactBytes == nil { + return policyError("registry_signature_missing", "signed registry %s is missing", path) + } + if envelope == nil { + return policyError("registry_signature_missing", "detached registry signature for %s is missing", path) + } + return p.authenticateRegistryEnvelope(path, exactBytes, envelope, time.Now()) +} + +func (p Policy) authenticateRegistryEnvelope(path string, exactBytes, envelope []byte, now time.Time) error { + parsed, err := parseRegistrySignatureEnvelope(envelope) + if err != nil { + return policyError("registry_signature_invalid", "%s.sig: %v", path, err) + } + key, trusted := p.registrySigningKeys[parsed.KeyID] + if !trusted || p.revokedRegistryKeyIDs[parsed.KeyID] { + return policyError("registry_signer_unauthorized", "registry signature key id %q is not trusted by the active machine policy", parsed.KeyID) + } + if now.Before(key.NotBefore) || !now.Before(key.ExpiresAt) { + return policyError("registry_signer_inactive", "registry signature key id %q is valid from %s until %s", parsed.KeyID, key.NotBefore.Format(time.RFC3339), key.ExpiresAt.Format(time.RFC3339)) + } + if !ed25519.Verify(key.PublicKey, exactBytes, parsed.Signature) { + return policyError("registry_signature_invalid", "registry signature from key id %q does not match the exact bytes of %s", parsed.KeyID, path) + } + return nil +} + +func (p Policy) AuthorizeRegistryMutation(operation string) error { + if !p.RequireRegistrySignature { + return nil + } + return policyError("registry_signed_readonly", "%s cannot rewrite an administrator-signed registry; provision updated registry bytes and a matching detached signature together", operation) +} + +type registrySignatureEnvelope struct { + KeyID string + Signature []byte +} + +func parseRegistrySignatureEnvelope(raw []byte) (registrySignatureEnvelope, error) { + var envelope registrySignatureEnvelope + seen := map[string]bool{} + version := 0 + algorithm := "" + sc := bufio.NewScanner(strings.NewReader(string(raw))) + lineNo := 0 + for sc.Scan() { + lineNo++ + line := strings.TrimSpace(toml.StripComment(sc.Text())) + if line == "" { + continue + } + if section, ok, err := toml.ParseSectionHeader(line); err != nil { + return registrySignatureEnvelope{}, fmt.Errorf("line %d: %v", lineNo, err) + } else if ok { + return registrySignatureEnvelope{}, fmt.Errorf("line %d: unsupported section %q", lineNo, section) + } + kv := strings.SplitN(line, "=", 2) + if len(kv) != 2 { + return registrySignatureEnvelope{}, fmt.Errorf("line %d: expected key = value", lineNo) + } + key, value := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) + if seen[key] { + return registrySignatureEnvelope{}, fmt.Errorf("line %d: duplicate key %q", lineNo, key) + } + seen[key] = true + switch key { + case "signature_version": + parsed, err := strconv.Atoi(value) + if err != nil { + return registrySignatureEnvelope{}, fmt.Errorf("line %d: signature_version must be an integer", lineNo) + } + version = parsed + case "algorithm": + parsed, err := toml.ParseQuoted(value) + if err != nil { + return registrySignatureEnvelope{}, fmt.Errorf("line %d algorithm: %v", lineNo, err) + } + algorithm = parsed + case "key_id": + parsed, err := toml.ParseQuoted(value) + if err != nil { + return registrySignatureEnvelope{}, fmt.Errorf("line %d key_id: %v", lineNo, err) + } + envelope.KeyID = parsed + case "signature": + encoded, err := toml.ParseQuoted(value) + if err != nil { + return registrySignatureEnvelope{}, fmt.Errorf("line %d signature: %v", lineNo, err) + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil || base64.StdEncoding.EncodeToString(decoded) != encoded || len(decoded) != ed25519.SignatureSize { + return registrySignatureEnvelope{}, fmt.Errorf("line %d: signature must be canonical base64 encoding of a %d-byte Ed25519 signature", lineNo, ed25519.SignatureSize) + } + envelope.Signature = decoded + default: + return registrySignatureEnvelope{}, fmt.Errorf("line %d: unsupported key %q", lineNo, key) + } + } + if err := sc.Err(); err != nil { + return registrySignatureEnvelope{}, err + } + if version != registrySignatureVersion { + return registrySignatureEnvelope{}, fmt.Errorf("unsupported signature_version %d (supported: %d)", version, registrySignatureVersion) + } + if algorithm != registrySignatureAlgorithm { + return registrySignatureEnvelope{}, fmt.Errorf("unsupported signature algorithm %q", algorithm) + } + if !validRegistryKeyID(envelope.KeyID) { + return registrySignatureEnvelope{}, fmt.Errorf("invalid key_id %q", envelope.KeyID) + } + if len(envelope.Signature) != ed25519.SignatureSize { + return registrySignatureEnvelope{}, errors.New("signature is required") + } + return envelope, nil +} + func (p Policy) AuthorizeImage(configured string, locked, local bool) error { if !p.Managed() { return nil diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index 4b97409..aa455ac 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -1,7 +1,11 @@ package policy import ( + "bytes" + "crypto/ed25519" + "encoding/base64" "errors" + "fmt" "os" "path/filepath" "strings" @@ -65,6 +69,119 @@ allowed_repositories = [ } } +func TestRegistrySignaturePolicyVerifiesExactBytesAndRotation(t *testing.T) { + now := time.Date(2029, 1, 1, 0, 0, 0, 0, time.UTC) + keyA := testSigningKey(1) + keyB := testSigningKey(2) + p := parseSigningPolicy(t, now, []string{ + testSigningKeySpec("ops-2028", keyA.Public().(ed25519.PublicKey), "2028-01-01T00:00:00Z", "2030-01-01T00:00:00Z"), + testSigningKeySpec("ops-2029", keyB.Public().(ed25519.PublicKey), "2028-06-01T00:00:00Z", "2031-01-01T00:00:00Z"), + }, nil) + registryBytes := []byte("schema_version = 2\n[tools.demo]\nimage = \"demo:1\"\nprovider = \"stateless\"\n") + for _, signer := range []struct { + id string + key ed25519.PrivateKey + }{{"ops-2028", keyA}, {"ops-2029", keyB}} { + envelope := testSignatureEnvelope(signer.id, ed25519.Sign(signer.key, registryBytes)) + if err := p.authenticateRegistryEnvelope("container-bin.toml", registryBytes, envelope, now); err != nil { + t.Fatalf("rotation signer %s rejected: %v", signer.id, err) + } + } + + mutated := append([]byte(nil), registryBytes...) + mutated[len(mutated)-2] = '2' + err := p.authenticateRegistryEnvelope("container-bin.toml", mutated, testSignatureEnvelope("ops-2028", ed25519.Sign(keyA, registryBytes)), now) + assertPolicyCode(t, err, "registry_signature_invalid") + if summary := p.Summary(); !strings.Contains(summary, "require_registry_signature=true") || !strings.Contains(summary, "registry_trusted_keys=2") { + t.Fatalf("summary does not report registry signature policy: %q", summary) + } +} + +func TestRegistrySignaturePolicyRevocationAndValidity(t *testing.T) { + now := time.Date(2029, 1, 1, 0, 0, 0, 0, time.UTC) + revokedKey := testSigningKey(3) + activeKey := testSigningKey(4) + p := parseSigningPolicy(t, now, []string{ + testSigningKeySpec("revoked-key", revokedKey.Public().(ed25519.PublicKey), "2028-01-01T00:00:00Z", "2030-01-01T00:00:00Z"), + testSigningKeySpec("active-key", activeKey.Public().(ed25519.PublicKey), "2028-01-01T00:00:00Z", "2030-01-01T00:00:00Z"), + }, []string{"revoked-key"}) + registryBytes := []byte("exact registry bytes") + assertPolicyCode(t, p.authenticateRegistryEnvelope("container-bin.toml", registryBytes, testSignatureEnvelope("revoked-key", ed25519.Sign(revokedKey, registryBytes)), now), "registry_signer_unauthorized") + assertPolicyCode(t, p.authenticateRegistryEnvelope("container-bin.toml", registryBytes, testSignatureEnvelope("active-key", ed25519.Sign(activeKey, registryBytes)), time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)), "registry_signer_inactive") + assertPolicyCode(t, p.AuthorizeRegistryMutation("cb add"), "registry_signed_readonly") +} + +func TestAuthenticateRegistryReadsDetachedRegularFile(t *testing.T) { + now := time.Now().UTC() + key := testSigningKey(5) + p := parseSigningPolicy(t, now, []string{ + testSigningKeySpec("filesystem-key", key.Public().(ed25519.PublicKey), now.Add(-time.Hour).Format(time.RFC3339), now.Add(time.Hour).Format(time.RFC3339)), + }, nil) + registryBytes := []byte("signed registry") + path := filepath.Join(t.TempDir(), "container-bin.toml") + if err := p.AuthenticateRegistry(path, nil); err == nil { + t.Fatal("missing signed registry accepted") + } else { + assertPolicyCode(t, err, "registry_signature_missing") + } + assertPolicyCode(t, p.AuthenticateRegistry(path, registryBytes), "registry_signature_missing") + if err := os.WriteFile(path+".sig", testSignatureEnvelope("filesystem-key", ed25519.Sign(key, registryBytes)), 0600); err != nil { + t.Fatal(err) + } + if err := p.AuthenticateRegistry(path, registryBytes); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path+".sig", bytes.Repeat([]byte{'x'}, maxRegistrySignatureFileSize+1), 0600); err != nil { + t.Fatal(err) + } + if _, err := (Policy{}).LoadRegistrySignature(path, registryBytes); err == nil { + t.Fatal("oversized optional detached signature accepted for backup") + } else { + assertPolicyCode(t, err, "registry_signature_invalid") + } +} + +func TestParseRejectsInvalidRegistrySignaturePolicies(t *testing.T) { + now := time.Date(2029, 1, 1, 0, 0, 0, 0, time.UTC) + key := testSigningKey(6).Public().(ed25519.PublicKey) + valid := testSigningKeySpec("valid-key", key, "2028-01-01T00:00:00Z", "2030-01-01T00:00:00Z") + cases := []struct { + name, body, code string + }{ + {"schema one", fmt.Sprintf("policy_version = 1\nrequire_registry_signature = true\nregistry_signing_keys = [%q]\n", valid), "version"}, + {"missing keys", "policy_version = 2\nrequire_registry_signature = true\n", "syntax"}, + {"invalid key id", fmt.Sprintf("policy_version = 2\nrequire_registry_signature = true\nregistry_signing_keys = [%q]\n", strings.Replace(valid, "valid-key", "INVALID", 1)), "syntax"}, + {"duplicate key id", fmt.Sprintf("policy_version = 2\nrequire_registry_signature = true\nregistry_signing_keys = [%q, %q]\n", valid, valid), "syntax"}, + {"duplicate public key", fmt.Sprintf("policy_version = 2\nrequire_registry_signature = true\nregistry_signing_keys = [%q, %q]\n", valid, strings.Replace(valid, "valid-key", "second-key", 1)), "syntax"}, + {"invalid public key", "policy_version = 2\nrequire_registry_signature = true\nregistry_signing_keys = [\"valid-key|not-base64|2028-01-01T00:00:00Z|2030-01-01T00:00:00Z\"]\n", "syntax"}, + {"noncanonical time", fmt.Sprintf("policy_version = 2\nrequire_registry_signature = true\nregistry_signing_keys = [%q]\n", strings.Replace(valid, "2028-01-01T00:00:00Z", "2028-01-01T01:00:00+01:00", 1)), "syntax"}, + {"no active key", fmt.Sprintf("policy_version = 2\nrequire_registry_signature = true\nregistry_signing_keys = [%q]\n", strings.Replace(valid, "2030-01-01T00:00:00Z", "2028-12-31T00:00:00Z", 1)), "syntax"}, + {"duplicate revoked id", fmt.Sprintf("policy_version = 2\nrequire_registry_signature = true\nregistry_signing_keys = [%q]\nrevoked_registry_key_ids = [\"old-key\", \"old-key\"]\n", valid), "syntax"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := parse("policy.toml", []byte(tc.body), now) + assertPolicyCode(t, err, tc.code) + }) + } +} + +func TestParseRegistrySignatureEnvelopeIsStrict(t *testing.T) { + signature := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, ed25519.SignatureSize)) + cases := []string{ + fmt.Sprintf("signature_version = 2\nalgorithm = \"ed25519\"\nkey_id = \"key\"\nsignature = %q\n", signature), + fmt.Sprintf("signature_version = 1\nalgorithm = \"rsa\"\nkey_id = \"key\"\nsignature = %q\n", signature), + fmt.Sprintf("signature_version = 1\nalgorithm = \"ed25519\"\nkey_id = \"INVALID\"\nsignature = %q\n", signature), + "signature_version = 1\nalgorithm = \"ed25519\"\nkey_id = \"key\"\nsignature = \"bad\"\n", + fmt.Sprintf("signature_version = 1\nalgorithm = \"ed25519\"\nkey_id = \"key\"\nsignature = %q\nextra = true\n", signature), + } + for i, raw := range cases { + if _, err := parseRegistrySignatureEnvelope([]byte(raw)); err == nil { + t.Errorf("case %d accepted malformed envelope", i) + } + } +} + func TestLoadAtOwnershipFailureIsCoded(t *testing.T) { path := filepath.Join(t.TempDir(), "policy.toml") if err := os.WriteFile(path, []byte("policy_version = 1\nrequire_lock = true\n"), 0600); err != nil { @@ -96,7 +213,7 @@ func TestParseRejectsInvalidPolicies(t *testing.T) { name, contents, code string }{ {"missing version", "require_lock = true\n", "version"}, - {"unknown version", "policy_version = 2\nrequire_lock = true\n", "version"}, + {"unknown version", "policy_version = 3\nrequire_lock = true\n", "version"}, {"duplicate", "policy_version = 1\nrequire_lock = true\nrequire_lock = false\n", "syntax"}, {"unknown key", "policy_version = 1\nrequire_lock = true\nsurprise = true\n", "syntax"}, {"section", "policy_version = 1\nrequire_lock = true\n[extra]\n", "syntax"}, @@ -169,3 +286,36 @@ func assertPolicyCode(t *testing.T, err error, want string) { t.Fatalf("error = %v, want policy code %q", err, want) } } + +func testSigningKey(seedByte byte) ed25519.PrivateKey { + return ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seedByte}, ed25519.SeedSize)) +} + +func testSigningKeySpec(id string, publicKey ed25519.PublicKey, notBefore, expiresAt string) string { + return strings.Join([]string{id, base64.StdEncoding.EncodeToString(publicKey), notBefore, expiresAt}, "|") +} + +func parseSigningPolicy(t *testing.T, now time.Time, keys, revoked []string) Policy { + t.Helper() + quotedKeys := make([]string, len(keys)) + for i, key := range keys { + quotedKeys[i] = fmt.Sprintf("%q", key) + } + quotedRevoked := make([]string, len(revoked)) + for i, id := range revoked { + quotedRevoked[i] = fmt.Sprintf("%q", id) + } + body := "policy_version = 2\nrequire_registry_signature = true\nregistry_signing_keys = [" + strings.Join(quotedKeys, ", ") + "]\n" + if len(revoked) > 0 { + body += "revoked_registry_key_ids = [" + strings.Join(quotedRevoked, ", ") + "]\n" + } + p, err := parse("policy.toml", []byte(body), now) + if err != nil { + t.Fatal(err) + } + return p +} + +func testSignatureEnvelope(keyID string, signature []byte) []byte { + return []byte(fmt.Sprintf("signature_version = 1\nalgorithm = \"ed25519\"\nkey_id = %q\nsignature = %q\n", keyID, base64.StdEncoding.EncodeToString(signature))) +} diff --git a/internal/registry/file.go b/internal/registry/file.go index ba07db1..1c14dc6 100644 --- a/internal/registry/file.go +++ b/internal/registry/file.go @@ -215,13 +215,25 @@ func upgradeV1Registry(path string, data []byte, reg Registry, cbVersion string) return atomicio.WriteFile(path, []byte(out.String()), 0644) } -func Load() (Registry, string, error) { +type Authenticator func(path string, exactBytes []byte) error + +func Load(authenticate Authenticator) (Registry, string, error) { path, err := Path() if err != nil { return Registry{}, "", err } + return loadAt(path, authenticate) +} + +func loadAt(path string, authenticate Authenticator) (Registry, string, error) { + if authenticate == nil { + return Registry{}, "", errors.New("registry authenticator is required") + } data, err := os.ReadFile(path) if os.IsNotExist(err) { + if err := authenticate(path, nil); err != nil { + return Registry{}, path, err + } rec, err := atomicio.RecoverFromBackup(path, validateBackup) if err != nil { return Registry{}, path, err @@ -236,6 +248,9 @@ func Load() (Registry, string, error) { } else if err != nil { return Registry{}, path, err } + if err := authenticate(path, data); err != nil { + return Registry{}, path, err + } reg, err := ParseTOML(string(data)) return reg, path, err } diff --git a/internal/registry/file_test.go b/internal/registry/file_test.go index 3dba167..50918d1 100644 --- a/internal/registry/file_test.go +++ b/internal/registry/file_test.go @@ -8,6 +8,53 @@ import ( "testing" ) +func TestLoadAtAuthenticatesExactBytesBeforeParsing(t *testing.T) { + path := filepath.Join(t.TempDir(), "container-bin.toml") + raw := []byte("this is not registry TOML\n") + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + wantErr := errors.New("registry signature rejected") + _, gotPath, err := loadAt(path, func(gotPath string, got []byte) error { + if gotPath != path || string(got) != string(raw) { + t.Fatalf("authenticator received (%q, %q), want (%q, %q)", gotPath, got, path, raw) + } + return wantErr + }) + if gotPath != path || !errors.Is(err, wantErr) { + t.Fatalf("loadAt = path %q, error %v; want %q and authenticator error", gotPath, err, path) + } +} + +func TestLoadAtSignedMissingRegistryDoesNotRecoverBackup(t *testing.T) { + path := filepath.Join(t.TempDir(), "container-bin.toml") + if err := os.WriteFile(path+".bak", []byte(DefaultTOML), 0600); err != nil { + t.Fatal(err) + } + wantErr := errors.New("signed registry missing") + _, _, err := loadAt(path, func(gotPath string, got []byte) error { + if gotPath != path || got != nil { + t.Fatalf("missing authenticator input = (%q, %v)", gotPath, got) + } + return wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("loadAt error = %v, want authenticator error", err) + } + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("unauthenticated backup was restored: %v", statErr) + } + if _, statErr := os.Stat(path + ".bak"); statErr != nil { + t.Fatalf("backup was changed: %v", statErr) + } +} + +func TestLoadAtRequiresAuthenticator(t *testing.T) { + if _, _, err := loadAt(filepath.Join(t.TempDir(), "container-bin.toml"), nil); err == nil || !strings.Contains(err.Error(), "authenticator") { + t.Fatalf("loadAt nil authenticator error = %v", err) + } +} + func TestInstallShimCopyFailurePreservesExistingShim(t *testing.T) { dir := t.TempDir() exe := filepath.Join(dir, "container-bin.exe") diff --git a/main.go b/main.go index 32a0244..6936724 100644 --- a/main.go +++ b/main.go @@ -59,7 +59,7 @@ func main() { fatalf("machine policy: %v", err) } - reg, cfgPath, err := loadRegistry() + reg, cfgPath, err := loadRegistry(machinePolicy.AuthenticateRegistry) if err != nil { fatalf("registry: %v", err) } @@ -79,13 +79,13 @@ func main() { switch os.Args[1] { case "install": if err := withMutationLock(cfgPath, func() error { - return cli.Install(cfgPath, version) + return cli.Install(cfgPath, version, machinePolicy) }); err != nil { fatalf("install: %v", err) } case "add": if err := withMutationLock(cfgPath, func() error { - reg, _, err := registry.Load() + reg, _, err := registry.Load(machinePolicy.AuthenticateRegistry) if err != nil { return err } @@ -109,7 +109,7 @@ func main() { } case "backup": if err := withMutationLock(cfgPath, func() error { - return cli.Backup(cfgPath, os.Args[2:], version) + return cli.Backup(cfgPath, os.Args[2:], version, machinePolicy) }); err != nil { fatalf("backup: %v", err) } @@ -132,7 +132,7 @@ func main() { case "default": if len(os.Args) > 2 && os.Args[2] == "set" { if err := withMutationLock(cfgPath, func() error { - fresh, _, err := registry.Load() + fresh, _, err := registry.Load(machinePolicy.AuthenticateRegistry) if err != nil { return err } @@ -165,7 +165,7 @@ func main() { } case "expose": if err := withMutationLock(cfgPath, func() error { - reg, _, err := registry.Load() + reg, _, err := registry.Load(machinePolicy.AuthenticateRegistry) if err != nil { return err } @@ -175,27 +175,27 @@ func main() { } case "unexpose": if err := withMutationLock(cfgPath, func() error { - reg, _, err := registry.Load() + reg, _, err := registry.Load(machinePolicy.AuthenticateRegistry) if err != nil { return err } - return cli.Unexpose(reg, cfgPath, os.Args[2:]) + return cli.Unexpose(reg, cfgPath, os.Args[2:], machinePolicy) }); err != nil { fatalf("unexpose: %v", err) } case "uninstall": if err := withMutationLock(cfgPath, func() error { - reg, _, err := registry.Load() + reg, _, err := registry.Load(machinePolicy.AuthenticateRegistry) if err != nil { return err } - return cli.Uninstall(reg, cfgPath, os.Args[2:]) + return cli.Uninstall(reg, cfgPath, os.Args[2:], machinePolicy) }); err != nil { fatalf("uninstall: %v", err) } case "lock": if err := withMutationLock(cfgPath, func() error { - reg, _, err := registry.Load() + reg, _, err := registry.Load(machinePolicy.AuthenticateRegistry) if err != nil { return err } @@ -205,7 +205,7 @@ func main() { } case "update": if err := withMutationLock(cfgPath, func() error { - reg, _, err := registry.Load() + reg, _, err := registry.Load(machinePolicy.AuthenticateRegistry) if err != nil { return err } diff --git a/main_test.go b/main_test.go index 807a482..8c5fff2 100644 --- a/main_test.go +++ b/main_test.go @@ -49,7 +49,7 @@ func TestBootstrapCommandsSkipHostPolicyAndRegistry(t *testing.T) { loadPolicy = oldLoadPolicy }() - loadRegistry = func() (registry.Registry, string, error) { + loadRegistry = func(registry.Authenticator) (registry.Registry, string, error) { panic("bootstrap command attempted to load the registry") } requireHostFrontend = func() error { @@ -106,7 +106,7 @@ func TestHostBoundaryPrecedesPolicyAndRegistryLoad(t *testing.T) { called = true return errors.New("unsupported host") } - loadRegistry = func() (registry.Registry, string, error) { + loadRegistry = func(registry.Authenticator) (registry.Registry, string, error) { panic("host boundary attempted to load the registry") } loadPolicy = func() (policy.Policy, error) { @@ -147,7 +147,7 @@ func TestSelfUpdateCheckEnforcesHostBoundaryAndSkipsPolicyAndRegistry(t *testing hostChecked = true return nil } - loadRegistry = func() (registry.Registry, string, error) { + loadRegistry = func(registry.Authenticator) (registry.Registry, string, error) { panic("self-update check attempted to load the registry") } loadPolicy = func() (policy.Policy, error) { From 139ffed952ece32224ace692d9b83bc7ab065cdc Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:56:39 +0100 Subject: [PATCH 2/2] Clarify signed registry recovery flows --- README.md | 6 ++-- docs/enterprise-policy.md | 11 ++++--- docs/proxy-airgap.md | 11 ++++--- internal/cli/cli.go | 19 +++++++++-- internal/cli/cli_test.go | 69 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0fd69fa..2105889 100644 --- a/README.md +++ b/README.md @@ -754,8 +754,10 @@ configuration, or host project files. Output archives are created exclusively: choose a new filename instead of overwriting an existing backup. Volume data can itself contain package credentials or other secrets, so store and transfer the archive as sensitive data even though ContainerBin requests owner-only file mode. -When present, the detached `container-bin.toml.sig` envelope is included; a -required signed-registry snapshot is re-authenticated before backup. +When valid and bounded, the detached `container-bin.toml.sig` envelope is +included; a required signed-registry snapshot is re-authenticated before +backup. An invalid optional envelope is skipped with a warning in unmanaged +mode. See [proxies, private registries, and air-gapped operation](docs/proxy-airgap.md) for mirror identity rules, disconnected image preparation, and the complete diff --git a/docs/enterprise-policy.md b/docs/enterprise-policy.md index 2249cd1..e78265a 100644 --- a/docs/enterprise-policy.md +++ b/docs/enterprise-policy.md @@ -182,10 +182,13 @@ Signed mode deliberately makes the registry read-only to ContainerBin. provision the new registry/signature pair. `cb install` and `cb setup` skip registry creation/upgrades but may reconcile shims from an already authenticated registry; a missing signed registry still fails closed. Read-only commands and -lockfile-only operations remain available. `cb backup` -includes an existing detached envelope and re-verifies the exact snapshot when -signed mode is active; signed-policy `cb restore` can verify and preview that -archive, but applying it remains an administrator provisioning operation. +lockfile-only operations remain available. `cb backup` includes a valid bounded +detached envelope and re-verifies the exact snapshot when signed mode is active. +An invalid optional envelope is skipped with a warning when policy is unmanaged; +a required invalid envelope still fails. Signed-policy `cb restore` can verify +and preview that archive, but applying it remains an administrator provisioning +operation. An unmanaged restore of an unsigned archive removes any stale +envelope and its backup. Schema 1 remains supported unchanged. Registry-signature fields in schema 1 are rejected, and policy versions newer than 2 fail closed. This makes rollback diff --git a/docs/proxy-airgap.md b/docs/proxy-airgap.md index 290bcaa..009f9c0 100644 --- a/docs/proxy-airgap.md +++ b/docs/proxy-airgap.md @@ -136,11 +136,12 @@ appears in `docker image ls`. ## What `cb backup` protects -Plain `cb backup` archives `container-bin.toml`, its detached signature when -present, the lockfile when present, and informational metadata. Under signed -registry policy the exact registry/signature snapshot is re-authenticated -before the archive is created. Add `--state` followed by explicit names from -`cb state` to include selected ContainerBin-managed Docker volumes: +Plain `cb backup` archives `container-bin.toml`, a valid bounded detached +signature when present, the lockfile when present, and informational metadata. +Under signed registry policy the exact registry/signature snapshot is +re-authenticated before the archive is created. An invalid optional signature is +skipped with a warning in unmanaged mode. Add `--state` followed by explicit +names from `cb state` to include selected ContainerBin-managed Docker volumes: ```powershell cb state diff --git a/internal/cli/cli.go b/internal/cli/cli.go index a8eca99..3e6ce8b 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1098,7 +1098,11 @@ func Backup(cfgPath string, args []string, version string, machinePolicy policy. } signatureBytes, err := machinePolicy.LoadRegistrySignature(cfgPath, registryBytes) if err != nil { - return fmt.Errorf("authenticate registry backup snapshot: %w", err) + if machinePolicy.RequireRegistrySignature { + return fmt.Errorf("authenticate registry backup snapshot: %w", err) + } + fmt.Printf("warning: detached registry signature not archived: %v\n", err) + signatureBytes = nil } dir := filepath.Dir(cfgPath) created := time.Now() @@ -1343,7 +1347,11 @@ func Restore(cfgPath string, args []string, machinePolicy policy.Policy) error { } } if !apply { - fmt.Println("\nDry run only. Re-run with --apply to perform the reported restore.") + if machinePolicy.RequireRegistrySignature { + fmt.Println("\nDry run only. Signed registry policy forbids `cb restore --apply`; have an administrator provision the archived registry/signature pair.") + } else { + fmt.Println("\nDry run only. Re-run with --apply to perform the reported restore.") + } return nil } if restoreState { @@ -1351,6 +1359,13 @@ func Restore(cfgPath string, args []string, machinePolicy policy.Policy) error { return err } } + if _, signed := files["container-bin.toml.sig"]; !signed { + for _, stale := range []string{cfgPath + ".sig", cfgPath + ".sig.bak"} { + if err := os.Remove(stale); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove stale detached registry signature %s: %w", stale, err) + } + } + } if err := atomicio.WriteFile(cfgPath, cfg, 0644); err != nil { return err } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index a3b4a49..b6f3b46 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1664,6 +1664,75 @@ func TestRestoreAuthenticatesSignedSnapshotBeforeRegistryParsing(t *testing.T) { } } +func TestUnsignedRestoreRemovesStaleDetachedSignature(t *testing.T) { + dir := t.TempDir() + backup := filepath.Join(dir, "backup.zip") + f, err := os.Create(backup) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + w, err := zw.Create("container-bin.toml") + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(registry.DefaultTOML)); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + cfg := filepath.Join(dir, "container-bin.toml") + for _, file := range []struct { + path, contents string + }{{cfg, "old registry"}, {cfg + ".sig", "old signature"}, {cfg + ".sig.bak", "old signature backup"}} { + if err := os.WriteFile(file.path, []byte(file.contents), 0600); err != nil { + t.Fatal(err) + } + } + if err := Restore(cfg, []string{backup, "--apply"}, policy.Policy{}); err != nil { + t.Fatal(err) + } + for _, stale := range []string{cfg + ".sig", cfg + ".sig.bak"} { + if _, err := os.Stat(stale); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stale signature %s remains: %v", stale, err) + } + } +} + +func TestUnmanagedBackupSkipsInvalidOptionalSignature(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "container-bin.toml") + out := filepath.Join(dir, "backup.zip") + if err := os.WriteFile(cfg, []byte(registry.DefaultTOML), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cfg+".sig", make([]byte, 20<<10), 0600); err != nil { + t.Fatal(err) + } + output, err := captureStdout(func() error { return Backup(cfg, []string{out}, "test", policy.Policy{}) }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(output, "warning: detached registry signature not archived") { + t.Fatalf("backup output missing optional-signature warning: %q", output) + } + zr, err := zip.OpenReader(out) + if err != nil { + t.Fatal(err) + } + defer zr.Close() + for _, entry := range zr.File { + if entry.Name == "container-bin.toml.sig" { + t.Fatal("invalid optional signature was archived") + } + } +} + func TestPlainBackupIsValidAndNeverOverwrites(t *testing.T) { dir := t.TempDir() cfg := filepath.Join(dir, "container-bin.toml")