diff --git a/README.md b/README.md index d87ab5e..8297caf 100644 --- a/README.md +++ b/README.md @@ -727,14 +727,23 @@ signer/administrator-key SHA-256, keyless issuer where applicable, the authenticated bundle SHA-256, canonical UTC verification time, verifier identity/hash and the complete effective machine-policy fingerprint. Partial, duplicate, malformed, cross-repository or local-image evidence is rejected. -For an online policy-covered repository, `cb lock` and `cb update` resolve the +For a policy-covered repository, `cb lock` and `cb update` resolve the exact digest first, execute only the authenticated staged cosign snapshot, download bounded signature bundles for that immutable reference, and locally reverify each bundle against the exact digest, cosign predicate, and configured identity/key. The lockfile is promoted to schema 2 only when exactly one bundle passes. Zero or multiple matching bundles, verifier failure, malformed output, or unavailable policy material aborts the refresh without a digest-only -fallback. Project-scoped refresh preserves unrelated schema-2 evidence. Runtime +fallback. Project-scoped refresh preserves unrelated schema-2 evidence. +Schema-4 `offline-bundle` rules additionally require a pinned Sigstore +TrustedRoot. ContainerBin authenticates and privately stages those exact bytes, +then invokes cosign with offline/new-bundle mode and `--trusted-root`, so missing +transparency proof cannot fall back to Rekor or TUF. This mode requires cosign +3.1.0 or newer, an image published with a new-format Sigstore bundle, and a +registry that supports the OCI 1.1 referrers API; legacy signature objects fail +closed. Registry access is still required to download the image signature +bundle; private-registry credentials are not inherited and require the separate +explicit credential bridge. Runtime executes a policy-covered digest only while its schema-2 evidence matches the current canonical repository, exact digest, signature mechanism/network mode, verifier, signer/key identity, issuer and complete machine-policy fingerprint. @@ -783,11 +792,12 @@ and overlap rotation. Signed registries are read-only to `cb`; updates must be provisioned with a matching signature by the administrator. Policy schema 3 can add repository-bound image-signature requirements. `cb lock` and `cb update` now authenticate and privately stage the pinned verifier and key bytes, run -online verification against the resolved exact repository digest, independently +verification against the resolved exact repository digest, independently validate bounded JSON results, record the result as schema-2 evidence, and authorize runtime use only while that evidence remains fresh against the exact -digest and current effective policy. Offline rules and private-registry -credential bridging remain fail closed. +digest and current effective policy. Policy schema 4 adds pinned Sigstore +TrustedRoot bytes for fail-closed offline-bundle verification. Private-registry +credential bridging remains fail closed. 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. diff --git a/docs/architecture.md b/docs/architecture.md index 29cf2a7..fc0d098 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -231,6 +231,7 @@ lockfile-only operations remain separate. Policy schema 3 adds a canonical repository-bound image-trust rule set plus absolute SHA-256 pins for an external cosign verifier and any public-key files. +Schema 4 additionally pins the exact Sigstore TrustedRoot used by offline rules. Rule lookup reuses Docker Hub normalization and selects the most-specific repository boundary. The policy layer can authenticate the exact configured cosign file as a bounded regular non-symlink file with the pinned digest, but @@ -238,18 +239,18 @@ does not invoke external code. Authentication yields an immutable byte snapshot, not a path that could be replaced between checking and execution. `internal/imagetrust` owns the invocation boundary. It authenticates and stages -only immutable verifier/key snapshots in a protected current-user directory, +only immutable verifier/key/trusted-root snapshots in a protected current-user directory, uses bounded two-minute child processes with a minimal environment, and asks the staged verifier to download signature bundles for one exact canonical `repository@sha256` value. Each bounded bundle is privately staged and passed back to the same verifier for local verification against the exact digest, `https://sigstore.dev/cosign/sign/v1` predicate, and configured identity/key; only those authenticated bundle bytes can become evidence. Staged material is -re-hashed after every use. Only online rules are accepted in this slice. -`offline-bundle` fails before process execution until -policy can pin the complete trusted-root material needed to guarantee a truly -network-independent verification; inherited registry credentials are also not -passed to the verifier yet. +re-hashed after every use. Offline rules additionally require both cosign +offline mode and the exact privately staged TrustedRoot, preventing missing +bundle proof from falling back to transparency-log or TUF access. +Inherited registry credentials are not passed to the verifier; private +registries still require a separate explicit credential bridge. `cb lock` and `cb update` now invoke this boundary after exact digest resolution for every policy-covered repository. Evidence production requires exactly one diff --git a/docs/enterprise-policy.md b/docs/enterprise-policy.md index 6c4b4ab..270b10b 100644 --- a/docs/enterprise-policy.md +++ b/docs/enterprise-policy.md @@ -206,7 +206,7 @@ Policy summaries report whether registry signatures are required plus trusted and revoked key counts. They never print public-key material or signature contents. -## Schema 3 — repository-bound image trust policy +## Schema 3/4 — repository-bound image trust policy Schema 3 retains every earlier control and adds the fail-closed policy contract for Sigstore/cosign image verification. ContainerBin can authenticate the exact @@ -222,13 +222,18 @@ signer/key identity, issuer and verifier hash must exactly match current machine policy. Missing or stale evidence is rejected with `policy.image_trust_unverified` before Docker execution. +Schema 4 adds the complete pinned trusted-root input required by +`offline-bundle` rules. Existing schema-3 offline rules remain parseable but +fail closed before verifier execution, preserving their prior behavior. + The repository includes an opt-in Windows qualification test for this producer -path. Build it with the `image_trust_e2e` tag and set the five +path. Build it with the `image_trust_e2e` tag and set the six `CONTAINERBIN_IMAGE_TRUST_E2E*` variables documented by the test. It calls the real `Lock` and `Update` entry points against a Linux-container Docker Desktop engine, authenticates and privately stages the selected native cosign binary, -verifies the selected public image, and reloads the resulting schema-2 lockfile -after each operation. The test synthesizes an isolated policy and registry in +verifies the selected public image in both online and pinned-root offline mode, +and reloads the resulting schema-2 lockfile after each operation. The test +synthesizes isolated policies and registries in its temporary directory; the build-tagged policy loader skips only the administrator-ownership check and is not compiled into production binaries. Normal CI does not claim this qualification because GitHub-hosted Windows @@ -247,17 +252,20 @@ $env:CONTAINERBIN_IMAGE_TRUST_E2E_COSIGN = "C:\absolute\path\to\cosign.exe" $env:CONTAINERBIN_IMAGE_TRUST_E2E_IMAGE = "registry.example.com/team/signed-image:immutable-tag" $env:CONTAINERBIN_IMAGE_TRUST_E2E_ISSUER = "https://issuer.example" $env:CONTAINERBIN_IMAGE_TRUST_E2E_SUBJECT = "exact-certificate-identity" +$env:CONTAINERBIN_IMAGE_TRUST_E2E_TRUSTED_ROOT = "C:\absolute\path\to\trusted-root.json" & "$env:TEMP\container-bin-image-trust-e2e.test.exe" ` '-test.v' '-test.run=^TestImageTrustLockAndUpdateWindowsDockerDesktop$' ``` ```toml -policy_version = 3 +policy_version = 4 require_lock = true allowed_repositories = ["ghcr.io/acme", "registry.example.com/platform"] cosign_path = "C:\\Program Files\\ContainerBin\\cosign.exe" cosign_sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +cosign_trusted_root_path = "C:\\ProgramData\\ContainerBin\\sigstore\\trusted-root.json" +cosign_trusted_root_sha256 = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" image_trust_rules = [ "ghcr.io/acme|keyless|https://token.actions.githubusercontent.com|https://github.com/acme/tools/.github/workflows/release.yml@refs/tags/v1.2.3|online", "registry.example.com/platform|key|C:\\ProgramData\\ContainerBin\\keys\\platform.pub|abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789|offline-bundle", @@ -272,7 +280,8 @@ immutable byte snapshot rather than an executable path. The invocation layer materializes only that snapshot inside its own protected current-user staging directory and executes the staged copy; validating and then executing the mutable configured pathname would leave a replacement race. It re-hashes -the staged verifier and key after execution and treats mutation as failure. +the staged verifier, key and trusted root after execution and treats mutation +as failure. Each rule has five pipe-delimited fields: @@ -289,10 +298,18 @@ Each rule has five pipe-delimited fields: just as strictly as the cosign executable. - `NETWORK_MODE` is `online` or `offline-bundle`. `online` permits the verifier to obtain required Sigstore material from the network. `offline-bundle` - requires complete bundled evidence and forbids network fallback. The current - internal invocation boundary supports `online` only. It rejects - `offline-bundle` before executing cosign because the schema cannot yet pin the - complete Sigstore trusted-root material needed to guarantee no network use. + requires complete bundled evidence and forbids transparency-log or TUF + fallback. It requires cosign 3.1.0 or newer, an image published with a + new-format Sigstore bundle, and a registry that exposes that bundle through + the OCI 1.1 referrers API. Legacy signature objects and bundles produced + without new-bundle support fail closed rather than falling back online. To + enable an `offline-bundle` rule, use schema 4 and provide both + `cosign_trusted_root_path` and + `cosign_trusted_root_sha256`. The root is authenticated, privately staged and + supplied with `--offline=true`, `--new-bundle-format=true` and + `--trusted-root`; an incomplete bundle fails locally. Trusted-root fields + without an offline rule are + rejected rather than silently ignored. Online execution receives a deliberately minimal environment and does not inherit registry credential/configuration variables. Public-registry @@ -314,11 +331,13 @@ The complete policy-byte fingerprint already covers verifier pins and every trust rule. Lock schema 2 records that fingerprint beside the exact repository, digest, verifier hash, signer/key identity, issuer, bundle hash and verification time, so any policy change will make later lock evidence stale. Policy -summaries report only the rule count and whether cosign is pinned; they do not -print paths, hashes, issuer/subject identities or key material. +summaries report only the rule count and whether cosign and an offline trusted +root are pinned; they do not print paths, hashes, issuer/subject identities or +key material. -Schema 1 and schema 2 remain supported unchanged. Image-trust fields in an -older schema are rejected, and versions newer than 3 fail closed. +Schemas 1-3 remain supported unchanged. Image-trust fields in an older schema +are rejected, offline trusted-root controls require schema 4, and versions +newer than 4 fail closed. The additional stable foundation errors are: diff --git a/docs/roadmap-decisions.md b/docs/roadmap-decisions.md index 91adb50..4fbc466 100644 --- a/docs/roadmap-decisions.md +++ b/docs/roadmap-decisions.md @@ -134,11 +134,12 @@ identity/hash and effective trust-policy fingerprint. Lock schema 2 now defines and strictly validates that evidence shape while preserving schema-1 reads and fail-closed old-binary/new-lock behavior. The -internal verifier executes authenticated staged cosign/key snapshots for online -exact-digest checks and independently validates bounded JSON output. Lock/update -produce that evidence, and runtime accepts it only while the digest, verifier, -signer/key and complete policy fingerprint still match. Offline verification -remains closed until policy can pin complete trusted-root inputs. +internal verifier executes authenticated staged cosign/key snapshots for +exact-digest checks and independently validates bounded JSON output. Schema 4 +pins and stages the complete Sigstore TrustedRoot for `offline-bundle` rules, +which require both cosign offline mode and that exact root. Lock/update produce +the resulting evidence, and runtime accepts it only while the digest, verifier, +signer/key and complete policy fingerprint still match. Runtime still executes the pinned digest and does not invoke cosign on every tool launch. A changed digest, verifier or trust-policy fingerprint makes prior @@ -325,8 +326,9 @@ is not completion. - cosign verifier configuration, per-repository policy, schema-2 storage and online lock-evidence production are implemented; - runtime freshness authorization is implemented; - - offline verification and explicit private-registry credential bridging - remain. + - offline verification is implemented with schema-4 pinned TrustedRoot + inputs; + - explicit private-registry credential bridging remains. 2. **Remaining RM-31 self-update qualification** - selection/check, bounded staging and `gh attestation verify` are merged in diff --git a/docs/roadmap-implementation-requirements.md b/docs/roadmap-implementation-requirements.md index abf9b5d..f706ca5 100644 --- a/docs/roadmap-implementation-requirements.md +++ b/docs/roadmap-implementation-requirements.md @@ -77,7 +77,7 @@ The minimum delivery gate for a code change is: | RM-34 Cargo expose enhancement | **Intentionally deferred** | Existing expose-all/explicit selection are sufficient; reopen only for concrete unmet use case | | Linux/macOS hosts | **Demand-gated** | WSL may factor reusable Linux host code; standalone support needs its own demand and qualification | | Enterprise policy | **Foundation and signed registry shipped / image trust remains** | PRs #75 and #84 shipped the machine-owned constraint layer and authenticated registry; image trust remains | -| Image trust | **Online production and runtime authorization implemented / offline and private-registry work remains** | Add fully pinned offline inputs and an explicit private-registry credential bridge | +| Image trust | **Online/offline production and runtime authorization implemented / private-registry work remains** | Add an explicit private-registry credential bridge | | Plugin/provider architecture | **Intentionally deferred** | Reopen only after at least two real integrations cannot fit the declarative model | | WSL2 | **Installer foundation implemented / runtime qualification remaining** | PRs #77 and #83 shipped the fail-closed host boundary and fixed native layout/state identity; explicit read-only/apply filesystem preparation and the fixed-path native install/config/shim lifecycle are available alongside namespace-prefixed/labeled volume identity with proof-bound exact inspect/create/remove/discovery, Docker Desktop integration proof, bounded control requests, constrained attach and exact context-bound wait transports, while command/frontend wiring, stream/terminal/signal semantics and real WSL qualification remain | | Per-project overlays | **Completed in PR #80** | Add-only digest-bound trust model shipped on the merged enterprise-policy foundation | @@ -555,7 +555,7 @@ old-binary/new-lock tests and security-model documentation. **Implementation status:** lock schema 2 now provides the structured storage, strict repository/digest/evidence validation, schema-1 compatibility, old-parser/new-lock rejection tests and security-model documentation. The -internal online invocation slice authenticates pinned verifier/key snapshots, +internal invocation slice authenticates pinned verifier/key snapshots, stages them under current-user-only permissions, downloads bounded signature bundles for the exact digest with a minimal environment, and locally re-verifies each bundle against the digest, cosign predicate and configured identity/key. @@ -564,9 +564,11 @@ document to schema 2 only after one authenticated transparency bundle can be recorded; zero/multiple bundle results and verifier failures abort without a digest-only fallback. Runtime freshness authorization consumes that evidence only while its repository, digest, verifier, signer/key identity and complete -policy fingerprint match current machine policy. Offline mode remains blocked -until all trusted-root inputs can be pinned, and private-registry credentials -require an explicit non-ambient bridge. +policy fingerprint match current machine policy. Schema 4 pins the complete +Sigstore TrustedRoot input for `offline-bundle` rules; the verifier stages that +exact snapshot and requires both cosign offline mode and the pinned root so +incomplete proof cannot fall back to transparency-log or TUF access. +Private-registry credentials still require an explicit non-ambient bridge. ## Plugin/provider architecture diff --git a/docs/security-model.md b/docs/security-model.md index 8e6d3e1..b713487 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -74,21 +74,23 @@ readable, and dangerous to let others edit. that boundary. - **Repository-bound image trust policy.** Schema 3 pins an external cosign executable and declares exact keyless or public-key trust for canonical - repository boundaries. Lock schema 2 can retain strictly validated, + repository boundaries; schema 4 additionally pins the exact Sigstore + TrustedRoot required by offline rules. Lock schema 2 can retain strictly validated, repository/digest/verifier/policy-bound structured evidence; schema 1 remains the digest-only compatibility format. The invocation layer executes only authenticated verifier/key snapshots from a protected private directory, bounds time and output, scrubs ambient environment state, checks staged bytes again after execution, and re-verifies every downloaded bundle locally against the exact digest, cosign predicate, and configured identity/key. - Lock/update records an online result only when + Offline verification authenticates and stages the pinned root and passes both + offline/new-bundle mode and `--trusted-root`, so incomplete proof cannot fall back + to transparency-log or TUF access. Lock/update records a result only when exactly one authenticated transparency bundle fits the schema-2 evidence contract. Runtime accepts a covered digest only when that evidence still matches the current repository, digest, verifier pin, complete policy fingerprint, mechanism and signer/key identity. Missing or stale evidence - never falls back to digest-only locking. Offline rules refuse process - execution until policy can pin the complete trusted-root and bundle inputs - needed to forbid network fallback. + never falls back to digest-only locking. Private-registry credentials remain + excluded until an explicit non-ambient bridge is implemented. - **Fail-closed host boundary.** Non-bootstrap work currently runs only in a native Windows process. Windows binaries launched through detected WSL interoperability, WSL1, ordinary work on recognized-but-not-yet-enabled native WSL2, diff --git a/internal/cli/image_trust.go b/internal/cli/image_trust.go index dcd244b..c93ad08 100644 --- a/internal/cli/image_trust.go +++ b/internal/cli/image_trust.go @@ -77,7 +77,7 @@ func lockEvidenceFromVerification(result verifiedImageTrust) (*lockfile.ImageTru if result == nil { return nil, errors.New("verifier returned no result") } - if result.NetworkMode() != policy.ImageTrustOnline { + if result.NetworkMode() != policy.ImageTrustOnline && result.NetworkMode() != policy.ImageTrustOfflineBundle { return nil, fmt.Errorf("verification used unsupported network mode %q", result.NetworkMode()) } if result.SignatureCount() < 1 { diff --git a/internal/cli/image_trust_e2e_windows_test.go b/internal/cli/image_trust_e2e_windows_test.go index c31e4c1..3e37160 100644 --- a/internal/cli/image_trust_e2e_windows_test.go +++ b/internal/cli/image_trust_e2e_windows_test.go @@ -27,15 +27,25 @@ func TestImageTrustLockAndUpdateWindowsDockerDesktop(t *testing.T) { image := requiredE2EEnv(t, "CONTAINERBIN_IMAGE_TRUST_E2E_IMAGE") issuer := requiredE2EEnv(t, "CONTAINERBIN_IMAGE_TRUST_E2E_ISSUER") subject := requiredE2EEnv(t, "CONTAINERBIN_IMAGE_TRUST_E2E_SUBJECT") + trustedRootPath := requiredE2EEnv(t, "CONTAINERBIN_IMAGE_TRUST_E2E_TRUSTED_ROOT") if !filepath.IsAbs(cosignPath) { t.Fatalf("CONTAINERBIN_IMAGE_TRUST_E2E_COSIGN must be absolute: %q", cosignPath) } + if !filepath.IsAbs(trustedRootPath) { + t.Fatalf("CONTAINERBIN_IMAGE_TRUST_E2E_TRUSTED_ROOT must be absolute: %q", trustedRootPath) + } cosignBytes, err := os.ReadFile(cosignPath) if err != nil { t.Fatalf("read qualification cosign executable: %v", err) } cosignSum := sha256.Sum256(cosignBytes) cosignSHA256 := hex.EncodeToString(cosignSum[:]) + trustedRootBytes, err := os.ReadFile(trustedRootPath) + if err != nil { + t.Fatalf("read qualification Sigstore TrustedRoot: %v", err) + } + trustedRootSum := sha256.Sum256(trustedRootBytes) + trustedRootSHA256 := hex.EncodeToString(trustedRootSum[:]) dockerOS := strings.TrimSpace(runE2ECommand(t, "docker", "info", "--format", "{{.OSType}}")) if dockerOS != "linux" { @@ -50,7 +60,10 @@ func TestImageTrustLockAndUpdateWindowsDockerDesktop(t *testing.T) { if err != nil { t.Fatalf("qualification image: %v", err) } - dir := t.TempDir() + dir := filepath.Join(t.TempDir(), "online") + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } policyPath := filepath.Join(dir, "policy.toml") rule := strings.Join([]string{repository, "keyless", issuer, subject, "online"}, "|") policyBytes := []byte(fmt.Sprintf( @@ -86,6 +99,33 @@ func TestImageTrustLockAndUpdateWindowsDockerDesktop(t *testing.T) { t.Fatalf("policy-covered Update() on Windows Docker Desktop: %v", err) } assertQualifiedImageTrustLock(t, lockfile.PathFor(cfgPath), image, machinePolicy.Fingerprint) + + offlineDir := filepath.Join(filepath.Dir(dir), "offline") + if err := os.Mkdir(offlineDir, 0700); err != nil { + t.Fatal(err) + } + offlinePolicyPath := filepath.Join(offlineDir, "policy.toml") + offlineRule := strings.Join([]string{repository, "keyless", issuer, subject, "offline-bundle"}, "|") + offlinePolicyBytes := []byte(fmt.Sprintf( + "policy_version = 4\ncosign_path = %s\ncosign_sha256 = %q\ncosign_trusted_root_path = %s\ncosign_trusted_root_sha256 = %q\nimage_trust_rules = [%s]\n", + strconv.Quote(cosignPath), cosignSHA256, strconv.Quote(trustedRootPath), trustedRootSHA256, strconv.Quote(offlineRule), + )) + if err := os.WriteFile(offlinePolicyPath, offlinePolicyBytes, 0600); err != nil { + t.Fatalf("write offline qualification policy: %v", err) + } + offlinePolicy, err := policy.LoadQualificationFile(offlinePolicyPath) + if err != nil { + t.Fatalf("load offline qualification policy: %v", err) + } + offlineConfigPath := filepath.Join(offlineDir, "container-bin.toml") + if err := Lock(reg, offlineConfigPath, nil, offlinePolicy); err != nil { + t.Fatalf("offline policy-covered Lock() on Windows Docker Desktop: %v", err) + } + assertQualifiedImageTrustLock(t, lockfile.PathFor(offlineConfigPath), image, offlinePolicy.Fingerprint) + if err := Update(reg, offlineConfigPath, []string{"image-trust-e2e"}, offlinePolicy); err != nil { + t.Fatalf("offline policy-covered Update() on Windows Docker Desktop: %v", err) + } + assertQualifiedImageTrustLock(t, lockfile.PathFor(offlineConfigPath), image, offlinePolicy.Fingerprint) } func requiredE2EEnv(t *testing.T, name string) string { diff --git a/internal/cli/image_trust_test.go b/internal/cli/image_trust_test.go index b3127c8..1488c08 100644 --- a/internal/cli/image_trust_test.go +++ b/internal/cli/image_trust_test.go @@ -94,6 +94,15 @@ func TestResolveRepositoryImageWithTrustProducesEvidence(t *testing.T) { } } +func TestLockEvidenceAcceptsOfflineBundleVerification(t *testing.T) { + result := validFakeTrustResult() + result.networkMode = policy.ImageTrustOfflineBundle + evidence, err := lockEvidenceFromVerification(result) + if err != nil || evidence == nil || evidence.BundleSHA256 != result.bundles[0] || evidence.PolicyFingerprint != result.policyFingerprint { + t.Fatalf("offline evidence = (%+v, %v)", evidence, err) + } +} + func TestResolveRepositoryImageWithoutTrustKeepsDigestOnlyEntry(t *testing.T) { verified := false entry, err := resolveRepositoryImageWithTrustUsing(context.Background(), "docker.io/library/python:3.13", @@ -128,7 +137,11 @@ func TestResolveRepositoryImageWithTrustFailsClosed(t *testing.T) { return r }(), wantErr: "2 distinct transparency bundles"}, {name: "no signature", result: func() fakeVerifiedImageTrust { r := result; r.signatureCount = 0; return r }(), wantErr: "no authenticated signatures"}, - {name: "offline", result: func() fakeVerifiedImageTrust { r := result; r.networkMode = policy.ImageTrustOfflineBundle; return r }(), wantErr: "unsupported network mode"}, + {name: "unknown network mode", result: func() fakeVerifiedImageTrust { + r := result + r.networkMode = policy.ImageTrustNetworkMode("best-effort") + return r + }(), wantErr: "unsupported network mode"}, } { t.Run(tc.name, func(t *testing.T) { _, err := resolveRepositoryImageWithTrustUsing(context.Background(), configured, selectTrust, resolve, diff --git a/internal/imagetrust/verify.go b/internal/imagetrust/verify.go index 005b220..8c48d48 100644 --- a/internal/imagetrust/verify.go +++ b/internal/imagetrust/verify.go @@ -27,6 +27,7 @@ const ( stagingPrefix = ".container-bin-image-trust-" verifierName = "cosign.exe" publicKeyName = "policy-key.pub" + trustedRootName = "trusted-root.json" verificationTimeout = 2 * time.Minute maxVerifierOutput = 1 << 20 maxSignatureBundles = 32 @@ -92,15 +93,16 @@ type verificationRequest struct { rule policy.ImageTrustRule verifier authenticatedSnapshot key authenticatedSnapshot + trustedRoot authenticatedSnapshot policyFingerprint string } // Verify authenticates and snapshots the administrator-selected verifier and // key material, executes only protected staged copies, and independently // validates cosign's bounded JSON result against the exact resolved digest. -// It currently supports online rules only. Offline rules fail before staging -// or process execution until policy can pin the complete trusted-root material -// required to make cosign's offline claim real rather than cosmetic. +// Offline rules additionally authenticate and stage the administrator-pinned +// Sigstore TrustedRoot, then require cosign's offline mode for every local +// bundle verification so missing proof cannot fall back to network lookup. func Verify(ctx context.Context, machinePolicy policy.Policy, configured, resolved string) (Result, error) { return (verifier{ runner: commandRunner{}, @@ -128,6 +130,13 @@ func (v verifier) Verify(ctx context.Context, machinePolicy policy.Policy, confi return Result{}, err } } + var trustedRootSnapshot policy.FileSnapshot + if rule.NetworkMode == policy.ImageTrustOfflineBundle { + trustedRootSnapshot, err = machinePolicy.AuthenticateImageTrustTrustedRoot() + if err != nil { + return Result{}, err + } + } return v.verifyAuthenticated(ctx, verificationRequest{ resolved: verificationTarget, repository: repository, @@ -135,6 +144,7 @@ func (v verifier) Verify(ctx context.Context, machinePolicy policy.Policy, confi rule: rule, verifier: snapshotFromPolicy(verifierSnapshot), key: snapshotFromPolicy(keySnapshot), + trustedRoot: snapshotFromPolicy(trustedRootSnapshot), policyFingerprint: machinePolicy.Fingerprint, }) } @@ -144,8 +154,11 @@ func snapshotFromPolicy(snapshot policy.FileSnapshot) authenticatedSnapshot { } func (v verifier) verifyAuthenticated(ctx context.Context, request verificationRequest) (result Result, err error) { - if request.rule.NetworkMode != policy.ImageTrustOnline { - return Result{}, fmt.Errorf("image trust rule for %q requires %s verification, which is not available until machine policy can pin complete offline trusted-root material", request.rule.Repository, request.rule.NetworkMode) + if request.rule.NetworkMode != policy.ImageTrustOnline && request.rule.NetworkMode != policy.ImageTrustOfflineBundle { + return Result{}, fmt.Errorf("image trust rule for %q has unsupported network mode %q", request.rule.Repository, request.rule.NetworkMode) + } + if request.rule.NetworkMode == policy.ImageTrustOfflineBundle && request.trustedRoot.Size() == 0 { + return Result{}, fmt.Errorf("image trust rule for %q requires an authenticated offline trusted root", request.rule.Repository) } stageDir, err := v.createStage() @@ -164,6 +177,7 @@ func (v verifier) verifyAuthenticated(ctx context.Context, request verificationR } signer, issuer := request.rule.Subject, request.rule.Issuer var keyPath string + var trustedRootPath string switch request.rule.Mechanism { case policy.ImageTrustKeyless: case policy.ImageTrustKey: @@ -175,6 +189,12 @@ func (v verifier) verifyAuthenticated(ctx context.Context, request verificationR default: return Result{}, fmt.Errorf("image trust rule for %q has unsupported mechanism %q", request.rule.Repository, request.rule.Mechanism) } + if request.rule.NetworkMode == policy.ImageTrustOfflineBundle { + trustedRootPath, err = stageSnapshot(stageDir, trustedRootName, request.trustedRoot, 0o600) + if err != nil { + return Result{}, err + } + } verifyCtx, cancel := context.WithTimeout(ctx, verificationTimeout) defer cancel() @@ -183,6 +203,9 @@ func (v verifier) verifyAuthenticated(ctx context.Context, request verificationR if keyPath != "" { stageErr = errors.Join(stageErr, verifyStagedSnapshot(keyPath, request.key)) } + if trustedRootPath != "" { + stageErr = errors.Join(stageErr, verifyStagedSnapshot(trustedRootPath, request.trustedRoot)) + } if stageErr != nil { return Result{}, fmt.Errorf("authenticated image trust material changed during verification: %w", stageErr) } @@ -221,6 +244,9 @@ func (v verifier) verifyAuthenticated(ctx context.Context, request verificationR } else { args = append(args, "--key="+keyPath) } + if request.rule.NetworkMode == policy.ImageTrustOfflineBundle { + args = append(args, "--offline=true", "--new-bundle-format=true", "--trusted-root="+trustedRootPath) + } verifyStdout, verifyStderr, verifyErr := v.runner.Run(verifyCtx, verifierPath, args, stageDir) stageErr = errors.Join(stageErr, verifyStagedSnapshot(bundlePath, authenticatedSnapshot{contents: bundle, digest: bundleDigest})) if len(verifyStdout) > maxVerifierOutput || len(verifyStderr) > maxVerifierOutput { @@ -240,6 +266,9 @@ func (v verifier) verifyAuthenticated(ctx context.Context, request verificationR if keyPath != "" { stageErr = errors.Join(stageErr, verifyStagedSnapshot(keyPath, request.key)) } + if trustedRootPath != "" { + stageErr = errors.Join(stageErr, verifyStagedSnapshot(trustedRootPath, request.trustedRoot)) + } if stageErr != nil { return Result{}, fmt.Errorf("authenticated image trust material changed during verification: %w", stageErr) } diff --git a/internal/imagetrust/verify_test.go b/internal/imagetrust/verify_test.go index c0c295d..86c7d3e 100644 --- a/internal/imagetrust/verify_test.go +++ b/internal/imagetrust/verify_test.go @@ -162,7 +162,72 @@ func TestVerifyAuthenticatedKeyStagesPinnedKeyAndScrubsSourcePaths(t *testing.T) } } -func TestVerifyAuthenticatedRejectsOfflineBeforeStaging(t *testing.T) { +func TestVerifyAuthenticatedOfflineStagesPinnedRootAndForbidsFallback(t *testing.T) { + digest := "sha256:" + strings.Repeat("a", 64) + issuer := "https://token.actions.githubusercontent.com" + subject := "https://github.com/acme/tool/.github/workflows/release.yml@refs/tags/v1" + bundle := testSignatureBundle(t, "offline") + root := testSnapshot(`{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1"}`) + parent := t.TempDir() + calls := 0 + v := verifier{ + runner: runnerFunc(func(_ context.Context, executable string, args []string, dir string) ([]byte, []byte, error) { + calls++ + if filepath.Dir(executable) != dir { + t.Fatalf("verifier escaped stage: %q", executable) + } + if calls == 1 { + want := []string{"download", "signature", "ghcr.io/acme/tool@" + digest} + if !reflect.DeepEqual(args, want) { + t.Fatalf("download args = %#v, want %#v", args, want) + } + return append(append([]byte(nil), bundle...), '\n'), nil, nil + } + wantRoot := filepath.Join(dir, trustedRootName) + want := []string{ + "verify-blob-attestation", + "--bundle=" + filepath.Join(dir, "bundle-001.sigstore.json"), + "--digest=" + strings.TrimPrefix(digest, "sha256:"), + "--digestAlg=sha256", + "--type=" + cosignPayloadType, + "--certificate-identity=" + subject, + "--certificate-oidc-issuer=" + issuer, + "--offline=true", + "--new-bundle-format=true", + "--trusted-root=" + wantRoot, + } + if calls != 2 || !reflect.DeepEqual(args, want) { + t.Fatalf("offline verify args = %#v, want %#v", args, want) + } + contents, err := os.ReadFile(wantRoot) + if err != nil || !bytes.Equal(contents, root.Bytes()) { + t.Fatalf("staged trusted root = %q, %v", contents, err) + } + return nil, nil, nil + }), + now: time.Now, + createStage: func() (string, error) { + return os.MkdirTemp(parent, stagingPrefix) + }, + } + result, err := v.verifyAuthenticated(context.Background(), verificationRequest{ + resolved: "ghcr.io/acme/tool@" + digest, + repository: "ghcr.io/acme/tool", + digest: digest, + rule: policy.ImageTrustRule{ + Repository: "ghcr.io/acme", Mechanism: policy.ImageTrustKeyless, + Issuer: issuer, Subject: subject, NetworkMode: policy.ImageTrustOfflineBundle, + }, + verifier: testSnapshot("cosign bytes"), + trustedRoot: root, + policyFingerprint: strings.Repeat("b", 64), + }) + if err != nil || calls != 2 || result.NetworkMode() != policy.ImageTrustOfflineBundle || result.SignatureCount() != 1 { + t.Fatalf("offline verification = (%+v, %v), calls=%d", result, err, calls) + } +} + +func TestVerifyAuthenticatedOfflineRejectsMissingTrustedRootBeforeStaging(t *testing.T) { called := false v := verifier{ runner: runnerFunc(func(context.Context, string, []string, string) ([]byte, []byte, error) { @@ -178,7 +243,7 @@ func TestVerifyAuthenticatedRejectsOfflineBeforeStaging(t *testing.T) { _, err := v.verifyAuthenticated(context.Background(), verificationRequest{ rule: policy.ImageTrustRule{Repository: "ghcr.io/acme", Mechanism: policy.ImageTrustKeyless, NetworkMode: policy.ImageTrustOfflineBundle}, }) - if err == nil || !strings.Contains(err.Error(), "complete offline trusted-root material") || called { + if err == nil || !strings.Contains(err.Error(), "authenticated offline trusted root") || called { t.Fatalf("offline verification = %v, called=%t", err, called) } } diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 0263ca2..7723eb4 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -26,13 +26,14 @@ import ( ) const ( - MaxSchemaVersion = 3 + MaxSchemaVersion = 4 ImageTrustVerifierCosign = "cosign" registrySignatureVersion = 1 registrySignatureAlgorithm = "ed25519" maxRegistrySignatureFileSize = 16 << 10 maxCosignVerifierSize = 256 << 20 maxImageTrustPublicKeySize = 1 << 20 + maxImageTrustTrustedRootSize = 4 << 20 ) type ImageTrustMechanism string @@ -120,6 +121,7 @@ type Policy struct { registrySigningKeys map[string]registrySigningKey revokedRegistryKeyIDs map[string]bool cosignVerifier FilePin + cosignTrustedRoot FilePin imageTrustRules []ImageTrustRule } @@ -146,8 +148,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 require_registry_signature=%t registry_trusted_keys=%d registry_revoked_keys=%d image_trust_rules=%d cosign_pinned=%t expires=%s fingerprint=sha256:%s source=%s", - p.SchemaVersion, p.RequireLock, p.AllowLocalImages, len(p.AllowedRepositories), p.RequireRegistrySignature, len(p.registrySigningKeys), len(p.revokedRegistryKeyIDs), len(p.imageTrustRules), p.cosignVerifier.Path != "", 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 image_trust_rules=%d cosign_pinned=%t cosign_trusted_root_pinned=%t expires=%s fingerprint=sha256:%s source=%s", + p.SchemaVersion, p.RequireLock, p.AllowLocalImages, len(p.AllowedRepositories), p.RequireRegistrySignature, len(p.registrySigningKeys), len(p.revokedRegistryKeyIDs), len(p.imageTrustRules), p.cosignVerifier.Path != "", p.cosignTrustedRoot.Path != "", expires, p.Fingerprint, p.Path) } // CosignVerifier returns the administrator-pinned verifier configuration. @@ -188,6 +190,17 @@ func (p Policy) AuthenticateImageTrustPublicKey(ref string) (FileSnapshot, error return authenticatePinnedFile(rule.PublicKey, "image trust public key", maxImageTrustPublicKeySize) } +// AuthenticateImageTrustTrustedRoot proves that the administrator-selected +// Sigstore TrustedRoot is the exact bounded regular non-symlink file pinned by +// policy. Offline verification must stage only this immutable snapshot and +// must never allow cosign to discover mutable or ambient trusted material. +func (p Policy) AuthenticateImageTrustTrustedRoot() (FileSnapshot, error) { + if p.cosignTrustedRoot.Path == "" { + return FileSnapshot{}, policyError("image_trust_verifier_invalid", "cosign trusted root is not configured by machine policy") + } + return authenticatePinnedFile(p.cosignTrustedRoot, "cosign trusted root", maxImageTrustTrustedRootSize) +} + func authenticatePinnedFile(pin FilePin, label string, maxSize int64) (FileSnapshot, error) { info, err := os.Lstat(pin.Path) if err != nil { @@ -294,9 +307,10 @@ 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, imageTrustRuleSpecs []string - var cosignPath, cosignSHA256 string + var cosignPath, cosignSHA256, cosignTrustedRootPath, cosignTrustedRootSHA256 string usedRegistrySignatureFields := false usedImageTrustFields := false + usedOfflineImageTrustFields := false seen := map[string]bool{} sc := bufio.NewScanner(strings.NewReader(string(b))) lineNo := 0 @@ -396,6 +410,18 @@ func parse(path string, b []byte, now time.Time) (Policy, error) { cosignSHA256 = value } usedImageTrustFields = true + case "cosign_trusted_root_path", "cosign_trusted_root_sha256": + value, err := toml.ParseQuoted(raw) + if err != nil { + return Policy{}, policyError("syntax", "line %d %s: %v", lineNo, key, err) + } + if key == "cosign_trusted_root_path" { + cosignTrustedRootPath = value + } else { + cosignTrustedRootSHA256 = value + } + usedImageTrustFields = true + usedOfflineImageTrustFields = true case "image_trust_rules": startLine := lineNo for strings.HasPrefix(strings.TrimSpace(raw), "[") && !arrayValueComplete(raw) { @@ -437,6 +463,9 @@ func parse(path string, b []byte, now time.Time) (Policy, error) { if usedRegistrySignatureFields && p.SchemaVersion < 2 { return Policy{}, policyError("version", "registry signature controls require policy_version 2") } + if usedOfflineImageTrustFields && p.SchemaVersion < 4 { + return Policy{}, policyError("version", "offline image trust controls require policy_version 4") + } if usedImageTrustFields && p.SchemaVersion < 3 { return Policy{}, policyError("version", "image trust controls require policy_version 3") } @@ -493,6 +522,23 @@ func parse(path string, b []byte, now time.Time) (Policy, error) { } p.cosignVerifier = pin } + offlineRules := 0 + for _, rule := range rules { + if rule.NetworkMode == ImageTrustOfflineBundle { + offlineRules++ + } + } + if offlineRules == 0 { + if cosignTrustedRootPath != "" || cosignTrustedRootSHA256 != "" { + return Policy{}, policyError("syntax", "cosign_trusted_root_path and cosign_trusted_root_sha256 require at least one offline-bundle image trust rule") + } + } else if p.SchemaVersion >= 4 { + pin, err := parseFilePin("cosign trusted root", cosignTrustedRootPath, cosignTrustedRootSHA256) + if err != nil { + return Policy{}, err + } + p.cosignTrustedRoot = pin + } p.imageTrustRules = rules sum := sha256.Sum256(b) p.Fingerprint = hex.EncodeToString(sum[:]) @@ -979,9 +1025,12 @@ func (p Policy) authorizeRuntimeImageTrust(configured, resolved string, rule Ima if evidence.Mechanism != rule.Mechanism { return fail("signature mechanism changed") } - if rule.NetworkMode != ImageTrustOnline { + if rule.NetworkMode != ImageTrustOnline && rule.NetworkMode != ImageTrustOfflineBundle { return fail("current network mode %q has no supported runtime evidence producer", rule.NetworkMode) } + if rule.NetworkMode == ImageTrustOfflineBundle && !validSHA256Hex(p.cosignTrustedRoot.SHA256) { + return fail("offline trusted-root identity is unavailable") + } if evidence.Verifier != ImageTrustVerifierCosign || evidence.VerifierSHA256 != p.cosignVerifier.SHA256 { return fail("verifier identity changed") } diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index 9dce7cd..82eb344 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -171,11 +171,13 @@ func TestParseRejectsInvalidRegistrySignaturePolicies(t *testing.T) { func TestImageTrustPolicyCanonicalRulesAndSelection(t *testing.T) { verifierPath := filepath.Join(t.TempDir(), "cosign") keyPath := filepath.Join(t.TempDir(), "keys", "release.pub") + trustedRootPath := filepath.Join(t.TempDir(), "trusted-root.json") verifierHash := strings.Repeat("a", 64) keyHash := strings.Repeat("b", 64) + trustedRootHash := strings.Repeat("c", 64) keylessRule := "GHCR.IO/Acme|keyless|https://token.actions.githubusercontent.com|https://github.com/acme/tools/.github/workflows/release.yml@refs/tags/v1.2.3|online" keyRule := strings.Join([]string{"ghcr.io/acme/release", "key", keyPath, keyHash, "offline-bundle"}, "|") - body := fmt.Sprintf("policy_version = 3\ncosign_path = %q\ncosign_sha256 = %q\nimage_trust_rules = [%q, %q]\n", verifierPath, verifierHash, keylessRule, keyRule) + body := fmt.Sprintf("policy_version = 4\ncosign_path = %q\ncosign_sha256 = %q\ncosign_trusted_root_path = %q\ncosign_trusted_root_sha256 = %q\nimage_trust_rules = [%q, %q]\n", verifierPath, verifierHash, trustedRootPath, trustedRootHash, keylessRule, keyRule) p, err := parse("policy.toml", []byte(body), time.Now()) if err != nil { t.Fatal(err) @@ -200,10 +202,10 @@ func TestImageTrustPolicyCanonicalRulesAndSelection(t *testing.T) { t.Fatal("invalid image reference was treated as an absent trust rule") } summary := p.Summary() - if !strings.Contains(summary, "image_trust_rules=2") || !strings.Contains(summary, "cosign_pinned=true") { + if !strings.Contains(summary, "image_trust_rules=2") || !strings.Contains(summary, "cosign_pinned=true") || !strings.Contains(summary, "cosign_trusted_root_pinned=true") { t.Fatalf("summary does not report image trust: %q", summary) } - for _, secret := range []string{verifierPath, verifierHash, keyPath, keyHash, rule.Subject} { + for _, secret := range []string{verifierPath, verifierHash, keyPath, keyHash, trustedRootPath, trustedRootHash, rule.Subject} { if strings.Contains(summary, secret) { t.Fatalf("summary disclosed image trust material %q: %q", secret, summary) } @@ -326,6 +328,36 @@ func TestAuthenticateImageTrustPublicKeyPinsSelectedRuleBytes(t *testing.T) { } } +func TestAuthenticateImageTrustTrustedRootPinsExactBytes(t *testing.T) { + path := filepath.Join(t.TempDir(), "trusted-root.json") + contents := []byte(`{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1"}`) + if err := os.WriteFile(path, contents, 0600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(contents) + p := Policy{cosignTrustedRoot: FilePin{Path: path, SHA256: hex.EncodeToString(sum[:])}} + snapshot, err := p.AuthenticateImageTrustTrustedRoot() + if err != nil || !bytes.Equal(snapshot.Bytes(), contents) || snapshot.SHA256() != hex.EncodeToString(sum[:]) { + t.Fatalf("AuthenticateImageTrustTrustedRoot() = (%q, %q, %v)", snapshot.Bytes(), snapshot.SHA256(), err) + } + if err := os.WriteFile(path, []byte(`{"changed":true}`), 0600); err != nil { + t.Fatal(err) + } + if !bytes.Equal(snapshot.Bytes(), contents) { + t.Fatal("authenticated trusted-root snapshot changed with its source path") + } + if _, err := p.AuthenticateImageTrustTrustedRoot(); err == nil { + t.Fatal("mutated trusted root was accepted") + } else { + assertPolicyCode(t, err, "image_trust_verifier_invalid") + } + if _, err := (Policy{}).AuthenticateImageTrustTrustedRoot(); err == nil { + t.Fatal("missing trusted root was accepted") + } else { + assertPolicyCode(t, err, "image_trust_verifier_invalid") + } +} + func authenticateCosignError(p Policy) error { _, err := p.AuthenticateCosignVerifier() return err @@ -336,7 +368,10 @@ func TestParseRejectsInvalidImageTrustPolicies(t *testing.T) { keyPath := filepath.Join(t.TempDir(), "release.pub") validHash := strings.Repeat("a", 64) validRule := "ghcr.io/acme|keyless|https://token.actions.githubusercontent.com|https://github.com/acme/tools/.github/workflows/release.yml@refs/tags/v1|online" + offlineRule := "ghcr.io/acme|keyless|https://token.actions.githubusercontent.com|https://github.com/acme/tools/.github/workflows/release.yml@refs/tags/v1|offline-bundle" validVerifier := fmt.Sprintf("cosign_path = %q\ncosign_sha256 = %q\n", verifierPath, validHash) + trustedRootPath := filepath.Join(t.TempDir(), "trusted-root.json") + validTrustedRoot := fmt.Sprintf("cosign_trusted_root_path = %q\ncosign_trusted_root_sha256 = %q\n", trustedRootPath, validHash) policy := func(version int, fields string, rules ...string) string { quoted := make([]string, len(rules)) for i, rule := range rules { @@ -348,6 +383,9 @@ func TestParseRejectsInvalidImageTrustPolicies(t *testing.T) { name, body, code string }{ {"schema two", policy(2, validVerifier, validRule), "version"}, + {"offline missing trusted root", policy(4, validVerifier, offlineRule), "syntax"}, + {"offline incomplete trusted root", policy(4, validVerifier+fmt.Sprintf("cosign_trusted_root_path = %q\n", trustedRootPath), offlineRule), "syntax"}, + {"trusted root without offline rule", policy(4, validVerifier+validTrustedRoot, validRule), "syntax"}, {"missing verifier", policy(3, "", validRule), "syntax"}, {"verifier without rules", policy(3, validVerifier), "syntax"}, {"relative verifier", policy(3, "cosign_path = \"cosign\"\ncosign_sha256 = \""+validHash+"\"\n", validRule), "syntax"}, @@ -369,6 +407,57 @@ func TestParseRejectsInvalidImageTrustPolicies(t *testing.T) { } } +func TestParseReportsSchemaFourForTrustedRootFields(t *testing.T) { + validHash := strings.Repeat("a", 64) + verifierPath := filepath.Join(t.TempDir(), "cosign") + trustedRootPath := filepath.Join(t.TempDir(), "trusted-root.json") + rule := "ghcr.io/acme|keyless|https://token.actions.githubusercontent.com|subject|offline-bundle" + for _, version := range []int{1, 2, 3} { + body := fmt.Sprintf("policy_version = %d\nrequire_lock = true\ncosign_path = %q\ncosign_sha256 = %q\ncosign_trusted_root_path = %q\ncosign_trusted_root_sha256 = %q\nimage_trust_rules = [%q]\n", version, verifierPath, validHash, trustedRootPath, validHash, rule) + _, err := parse("policy.toml", []byte(body), time.Now()) + assertPolicyCode(t, err, "version") + if !strings.Contains(err.Error(), "offline image trust controls require policy_version 4") { + t.Fatalf("policy_version %d returned imprecise version error: %v", version, err) + } + } +} + +func TestParseAcceptsPinnedOfflineImageTrustPolicy(t *testing.T) { + dir := t.TempDir() + verifierPath := filepath.Join(dir, "cosign.exe") + rootPath := filepath.Join(dir, "trusted-root.json") + hash := strings.Repeat("a", 64) + rule := "ghcr.io/acme|keyless|https://token.actions.githubusercontent.com|https://github.com/acme/tools/.github/workflows/release.yml@refs/tags/v1|offline-bundle" + body := fmt.Sprintf("policy_version = 4\ncosign_path = %q\ncosign_sha256 = %q\ncosign_trusted_root_path = %q\ncosign_trusted_root_sha256 = %q\nimage_trust_rules = [%q]\n", verifierPath, hash, rootPath, hash, rule) + p, err := parse("policy.toml", []byte(body), time.Now()) + if err != nil { + t.Fatal(err) + } + selected, ok, err := p.ImageTrustFor("ghcr.io/acme/tool:v1") + if err != nil || !ok || selected.NetworkMode != ImageTrustOfflineBundle || p.cosignTrustedRoot.Path != rootPath || p.cosignTrustedRoot.SHA256 != hash { + t.Fatalf("offline policy = (%+v, %+v, %t, %v)", p, selected, ok, err) + } + if summary := p.Summary(); !strings.Contains(summary, "cosign_trusted_root_pinned=true") || strings.Contains(summary, rootPath) || strings.Contains(summary, hash) { + t.Fatalf("offline policy summary leaks or omits trusted-root state: %q", summary) + } +} + +func TestParsePreservesSchemaThreeOfflineRuleAsFailClosed(t *testing.T) { + verifierPath := filepath.Join(t.TempDir(), "cosign.exe") + hash := strings.Repeat("a", 64) + rule := "ghcr.io/acme|keyless|https://token.actions.githubusercontent.com|subject|offline-bundle" + body := fmt.Sprintf("policy_version = 3\ncosign_path = %q\ncosign_sha256 = %q\nimage_trust_rules = [%q]\n", verifierPath, hash, rule) + p, err := parse("policy.toml", []byte(body), time.Now()) + if err != nil { + t.Fatalf("legacy schema-3 offline rule no longer parses: %v", err) + } + if _, err := p.AuthenticateImageTrustTrustedRoot(); err == nil { + t.Fatal("legacy schema-3 offline rule unexpectedly acquired trusted-root material") + } else { + assertPolicyCode(t, err, "image_trust_verifier_invalid") + } +} + func TestImageTrustPolicyAllowsEvidenceProductionButRuntimeFailsClosed(t *testing.T) { verifierPath := filepath.Join(t.TempDir(), "cosign") verifierHash := strings.Repeat("a", 64) @@ -397,7 +486,7 @@ func TestImageTrustPolicyAllowsEvidenceProductionButRuntimeFailsClosed(t *testin func TestImageTrustDoesNotChangeEarlierSchemaReferenceAuthorization(t *testing.T) { imageID := "sha256:" + strings.Repeat("a", 64) - for _, version := range []int{1, 2, 3} { + for _, version := range []int{1, 2, 3, 4} { p := Policy{SchemaVersion: version} if err := p.AuthorizeImage(imageID, true, false); err != nil { t.Errorf("schema %d image-ID authorization changed without image trust rules: %v", version, err) @@ -452,7 +541,7 @@ func TestParseRejectsInvalidPolicies(t *testing.T) { name, contents, code string }{ {"missing version", "require_lock = true\n", "version"}, - {"unknown version", "policy_version = 4\nrequire_lock = true\n", "version"}, + {"unknown version", "policy_version = 5\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"}, @@ -575,7 +664,7 @@ func TestAuthorizeResolvedImageRequiresFreshTrustEvidence(t *testing.T) { }}, {name: "subject changed", edit: func(_ *Policy, evidence *RuntimeImageTrustEvidence) { evidence.Signer += "-other" }}, {name: "issuer changed", edit: func(_ *Policy, evidence *RuntimeImageTrustEvidence) { evidence.Issuer = "https://issuer.example" }}, - {name: "offline policy", edit: func(policy *Policy, _ *RuntimeImageTrustEvidence) { + {name: "offline policy missing trusted root", edit: func(policy *Policy, _ *RuntimeImageTrustEvidence) { policy.imageTrustRules[0].NetworkMode = ImageTrustOfflineBundle }}, } @@ -595,6 +684,32 @@ func TestAuthorizeResolvedImageRequiresFreshTrustEvidence(t *testing.T) { } } +func TestAuthorizeResolvedImageAcceptsCurrentOfflineEvidence(t *testing.T) { + digest := "sha256:" + strings.Repeat("a", 64) + verifierHash := strings.Repeat("b", 64) + policyFingerprint := strings.Repeat("c", 64) + issuer := "https://token.actions.githubusercontent.com" + subject := "https://github.com/acme/tools/.github/workflows/release.yml@refs/tags/v1" + p := Policy{ + SchemaVersion: 4, + Fingerprint: policyFingerprint, + cosignVerifier: FilePin{Path: "cosign.exe", SHA256: verifierHash}, + cosignTrustedRoot: FilePin{Path: "trusted-root.json", SHA256: strings.Repeat("d", 64)}, + imageTrustRules: []ImageTrustRule{{ + Repository: "ghcr.io/acme", Mechanism: ImageTrustKeyless, + Issuer: issuer, Subject: subject, NetworkMode: ImageTrustOfflineBundle, + }}, + } + evidence := &RuntimeImageTrustEvidence{ + Mechanism: ImageTrustKeyless, Repository: "ghcr.io/acme/tool", Digest: digest, + Signer: subject, Issuer: issuer, Verifier: ImageTrustVerifierCosign, + VerifierSHA256: verifierHash, PolicyFingerprint: policyFingerprint, + } + if err := p.AuthorizeResolvedImage("ghcr.io/acme/tool:v1", "ghcr.io/acme/tool@"+digest, false, evidence); err != nil { + t.Fatalf("fresh offline evidence rejected: %v", err) + } +} + func TestAuthorizeResolvedImageAcceptsCurrentKeyEvidence(t *testing.T) { digest := "sha256:" + strings.Repeat("a", 64) keyHash := strings.Repeat("b", 64)