|
| 1 | +# Design: `signedBy` policy evaluation for tag references |
| 2 | + |
| 3 | +Status: **proposal** — needs a decision before v3 GA. |
| 4 | + |
| 5 | +## Problem |
| 6 | + |
| 7 | +A `signedBy` policy requirement can never be satisfied when the image is |
| 8 | +referenced by tag. |
| 9 | + |
| 10 | +`DefaultSignedByVerifier.Verify` needs the manifest digest, because that is what |
| 11 | +a simple-signing payload binds to (`critical.image.docker-manifest-digest`). It |
| 12 | +obtains one via `parseImageDigest`, which scans the reference for `@` and errors |
| 13 | +out if there is none: |
| 14 | + |
| 15 | +```go |
| 16 | +func parseImageDigest(ref string) (digest.Digest, error) { |
| 17 | + for i := len(ref) - 1; i >= 0; i-- { |
| 18 | + if ref[i] == '@' { /* ... */ } |
| 19 | + } |
| 20 | + return "", fmt.Errorf("reference %s does not contain a digest", ref) |
| 21 | +} |
| 22 | +``` |
| 23 | + |
| 24 | +But policy is evaluated *before* resolution. `Repository.Resolve` and |
| 25 | +`Repository.FetchReference` call `checkPolicy(ctx, reference)` with whatever the |
| 26 | +caller passed in — usually a tag: |
| 27 | + |
| 28 | +```go |
| 29 | +func (r *Repository) Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) { |
| 30 | + if err := r.checkPolicy(ctx, reference); err != nil { |
| 31 | + return ocispec.Descriptor{}, err |
| 32 | + } |
| 33 | + // ... |
| 34 | +} |
| 35 | +``` |
| 36 | + |
| 37 | +So for `registry.example.com/app:v1` under a `signedBy` policy: |
| 38 | +`checkPolicy` → `IsImageAllowed` → `evaluateSignedBy` → `Verify` → |
| 39 | +`parseImageDigest` fails → `Verify` returns an error → the evaluator returns |
| 40 | +`(false, err)` → the pull is denied. Every time, regardless of whether a |
| 41 | +perfectly valid signature exists. |
| 42 | + |
| 43 | +This fails closed, so it is not a security hole. It is a functional gap: the |
| 44 | +feature is unusable for the most common way people name images. |
| 45 | + |
| 46 | +## Why it is not a one-line fix |
| 47 | + |
| 48 | +The obvious move — resolve the tag first, then evaluate policy — inverts the |
| 49 | +current ordering, and the ordering exists for a reason: policy should be able to |
| 50 | +reject a request *before* the client talks to the registry about it. Scope- and |
| 51 | +transport-level requirements (`reject`, `insecureAcceptAnything`) are meaningful |
| 52 | +pre-flight; signature requirements inherently are not. |
| 53 | + |
| 54 | +There is also a re-entrancy hazard. Resolving a tag inside the verifier means |
| 55 | +calling back into `Repository.Resolve`, which calls `checkPolicy` again. The |
| 56 | +`policyCheckedKey` context marker prevents infinite recursion but only if it is |
| 57 | +threaded correctly through the verifier. |
| 58 | + |
| 59 | +## How containers/image handles it |
| 60 | + |
| 61 | +`containers/image` splits the decision in two. `PolicyContext.IsRunningImageAllowed` |
| 62 | +operates on an `UnparsedImage` that has already been fetched far enough to know |
| 63 | +its manifest digest, while reference-level rules are applied earlier against the |
| 64 | +parsed reference. Signature requirements only ever see a resolved image. |
| 65 | + |
| 66 | +## Options |
| 67 | + |
| 68 | +### 1. Two-phase policy evaluation (recommended target) |
| 69 | + |
| 70 | +Split requirements by what they need: |
| 71 | + |
| 72 | +- **Pre-resolve**: `reject`, `insecureAcceptAnything`, and any future |
| 73 | + reference-shaped rule. Evaluated in `checkPolicy` as today. |
| 74 | +- **Post-resolve**: `signedBy`, `sigstoreSigned`. Evaluated after the descriptor |
| 75 | + is known, against `ImageReference` carrying the resolved digest. |
| 76 | + |
| 77 | +Sketch: |
| 78 | + |
| 79 | +```go |
| 80 | +func (r *Repository) Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) { |
| 81 | + if err := r.checkPolicyPreResolve(ctx, reference); err != nil { |
| 82 | + return ocispec.Descriptor{}, err |
| 83 | + } |
| 84 | + desc, err := r.Manifests().Resolve(withPolicyChecked(ctx), reference) |
| 85 | + if err != nil { |
| 86 | + return ocispec.Descriptor{}, err |
| 87 | + } |
| 88 | + if err := r.checkPolicyPostResolve(ctx, reference, desc); err != nil { |
| 89 | + return ocispec.Descriptor{}, err |
| 90 | + } |
| 91 | + return desc, nil |
| 92 | +} |
| 93 | +``` |
| 94 | + |
| 95 | +Cost: the `Evaluator` needs to partition requirements and expose two entry |
| 96 | +points, and every mutating/reading path in `repository.go` needs the second |
| 97 | +call sited correctly. `Fetch` already has a digest, so it feeds the post-resolve |
| 98 | +phase directly. |
| 99 | + |
| 100 | +Risk to watch: a requirement set containing *only* post-resolve requirements |
| 101 | +must not let a pre-resolve pass be mistaken for an allow. The partition must |
| 102 | +track that at least one phase actually evaluated the requirement. |
| 103 | + |
| 104 | +### 2. Verifier resolves the tag itself |
| 105 | + |
| 106 | +Give `DefaultSignedByVerifier` a resolver and have it turn a tag into a digest |
| 107 | +on demand. |
| 108 | + |
| 109 | +Cheaper — no change to the evaluator's shape — but it puts a network call inside |
| 110 | +a policy verifier, needs `withPolicyChecked` threaded through to avoid |
| 111 | +re-entering policy, and means the digest the signature is checked against is |
| 112 | +fetched by a different code path than the one that will actually pull the |
| 113 | +content. That last point is a TOCTOU seam: the tag could move between the |
| 114 | +verifier's resolve and the caller's. |
| 115 | + |
| 116 | +### 3. Document `signedBy` as digest-only |
| 117 | + |
| 118 | +Make `parseImageDigest`'s failure an explicit, documented limitation, and have |
| 119 | +`Verify` return a clearly-worded error naming the constraint. |
| 120 | + |
| 121 | +Cheapest and honest, but it substantially reduces the feature's value — most |
| 122 | +users pull by tag. |
| 123 | + |
| 124 | +## Recommendation |
| 125 | + |
| 126 | +Target **option 1**. It matches containers/image semantics, avoids the TOCTOU |
| 127 | +seam in option 2, and is the only option under which `signedBy` is actually |
| 128 | +usable as specified. |
| 129 | + |
| 130 | +Ship **option 3** as the interim state in the meantime: an explicit error and a |
| 131 | +documented limitation are much better than the current behaviour, where a |
| 132 | +correctly-signed image pulled by tag is denied with a message about digest |
| 133 | +parsing. |
| 134 | + |
| 135 | +Shipping v3 GA with `signedBy` silently unusable for tag references is the |
| 136 | +outcome to avoid. |
0 commit comments