| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | // trapStore fails the test on ANY access. The spec requires an out-of-scope |
| 11 | // promotion to fail locally before any store WRITE; the code manages it |
| 12 | // before any store access at all, and that is what is pinned here — a refusal |
| 13 | // that costs a bucket round trip is a refusal that behaves differently when |
| 14 | // the bucket is unreachable. |
| 15 | type trapStore struct{ t *testing.T } |
| 16 | |
| 17 | func (s trapStore) Get(_ context.Context, key string) ([]byte, string, error) { |
| 18 | s.t.Fatalf("out-of-scope promote read %s — it must refuse before touching the store", key) |
| 19 | return nil, "", nil |
| 20 | } |
| 21 | |
| 22 | func (s trapStore) Put(_ context.Context, key string, _ []byte, _ string) (string, error) { |
| 23 | s.t.Fatalf("out-of-scope promote WROTE %s — the scope check is not a gate", key) |
| 24 | return "", nil |
| 25 | } |
| 26 | |
| 27 | func (s trapStore) List(_ context.Context, prefix string) ([]string, error) { |
| 28 | s.t.Fatalf("out-of-scope promote listed %s — it must refuse before touching the store", prefix) |
| 29 | return nil, nil |
| 30 | } |
| 31 | |
| 32 | func (s trapStore) ListDirs(_ context.Context, prefix string) ([]string, error) { |
| 33 | s.t.Fatalf("out-of-scope promote listed %s — it must refuse before touching the store", prefix) |
| 34 | return nil, nil |
| 35 | } |
| 36 | |
| 37 | func (s trapStore) Delete(_ context.Context, key string) error { |
| 38 | s.t.Fatalf("out-of-scope promote DELETED %s — the scope check is not a gate", key) |
| 39 | return nil |
| 40 | } |
| 41 | |
| 42 | // An out-of-scope channel is refused locally, by every write verb, before the |
| 43 | // store is touched — and the refusal names the scope, so the operator can see |
| 44 | // which key they are holding. |
| 45 | // |
| 46 | // Mutation-mindedness: move the allows() call below the GetManifest read and |
| 47 | // trapStore fails the test on the read; delete it and Put is reached and the |
| 48 | // test fails on the write. Either way this is red. |
| 49 | func TestScopedPromoteRefusesOutOfScopeBeforeTouchingTheStore(t *testing.T) { |
| 50 | _, _, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 90*24*time.Hour) |
| 51 | ctx := context.Background() |
| 52 | s := trapStore{t} |
| 53 | |
| 54 | for _, ch := range []string{"stable", "beta"} { |
| 55 | _, err := sg.PromoteOnLadder(ctx, s, "bab", "kass", ch, "9f3c1ad", PromoteOptions{}) |
| 56 | if err == nil { |
| 57 | t.Fatalf("PromoteOnLadder to %s with a canary/edge key succeeded", ch) |
| 58 | } |
| 59 | assertNamesTheScope(t, err, ch) |
| 60 | |
| 61 | if err := sg.Promote(ctx, s, "bab", "kass", ch, "9f3c1ad"); err == nil { |
| 62 | t.Fatalf("Promote to %s with a canary/edge key succeeded", ch) |
| 63 | } else { |
| 64 | assertNamesTheScope(t, err, ch) |
| 65 | } |
| 66 | |
| 67 | if err := sg.Rollback(ctx, s, "bab", "kass", ch); err == nil { |
| 68 | t.Fatalf("Rollback of %s with a canary/edge key succeeded", ch) |
| 69 | } else { |
| 70 | assertNamesTheScope(t, err, ch) |
| 71 | } |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | // assertNamesTheScope checks a refusal is legible: it says which grant, what |
| 76 | // the grant covers, and which channel was asked for. |
| 77 | func assertNamesTheScope(t *testing.T, err error, channel string) { |
| 78 | t.Helper() |
| 79 | got := err.Error() |
| 80 | for _, want := range []string{"edge-builder", "canary, edge", channel} { |
| 81 | if !strings.Contains(got, want) { |
| 82 | t.Errorf("refusal %q does not name %q", got, want) |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // -hotfix bypasses the LADDER. It can never bypass a grant: the two gates |
| 88 | // answer different questions, and a flag an operator can type is not a way to |
| 89 | // widen a delegation. |
| 90 | // |
| 91 | // Mutation-mindedness: put the allows() check behind `if !opt.Hotfix` and |
| 92 | // this test is the only thing that notices — which would make the whole |
| 93 | // feature bypassable by a documented flag. |
| 94 | func TestHotfixDoesNotWidenAGrant(t *testing.T) { |
| 95 | _, _, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 90*24*time.Hour) |
| 96 | _, err := sg.PromoteOnLadder(context.Background(), trapStore{t}, "bab", "kass", "stable", "9f3c1ad", |
| 97 | PromoteOptions{Hotfix: true}) |
| 98 | if err == nil { |
| 99 | t.Fatal("--hotfix let a canary/edge key promote to stable") |
| 100 | } |
| 101 | assertNamesTheScope(t, err, "stable") |
| 102 | } |
| 103 | |
| 104 | // A scoped key promotes inside its scope through the real store, and the |
| 105 | // pointer it wrote verifies for a fleet configured with the ROOT public key |
| 106 | // and nothing else. That is the whole scheme in one assertion: one anchor, |
| 107 | // two kinds of signer. |
| 108 | func TestScopedPromoteVerifiesAgainstTheRootPubkey(t *testing.T) { |
| 109 | root, rootPub, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 90*24*time.Hour) |
| 110 | ctx := context.Background() |
| 111 | s := NewDirStore(t.TempDir()) |
| 112 | shipRelease(t, s, "bab", "kass", "9f3c1ad", "linux-arm64", []byte("build")) |
| 113 | |
| 114 | if err := sg.Promote(ctx, s, "bab", "kass", "edge", "9f3c1ad"); err != nil { |
| 115 | t.Fatalf("scoped promote to edge: %v", err) |
| 116 | } |
| 117 | p, err := Channel(ctx, s, rootPub, "bab", "kass", "edge") |
| 118 | if err != nil { |
| 119 | t.Fatalf("Channel with the root pubkey: %v", err) |
| 120 | } |
| 121 | if p.Version != "9f3c1ad" { |
| 122 | t.Fatalf("pointer = %+v", p) |
| 123 | } |
| 124 | // The root key still reaches stable, from beta, as it always did. |
| 125 | shipRelease(t, s, "bab", "kass", "aa11bb2", "linux-arm64", []byte("build two")) |
| 126 | if err := Promote(ctx, s, root, "bab", "kass", "beta", "aa11bb2"); err != nil { |
| 127 | t.Fatalf("root promote to beta: %v", err) |
| 128 | } |
| 129 | if err := Promote(ctx, s, root, "bab", "kass", "stable", "aa11bb2"); err != nil { |
| 130 | t.Fatalf("root promote to stable: %v", err) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // The pointer a scoped key replaces was signed by something else — the root |
| 135 | // key, in every real deployment, because that is what promoted the channel |
| 136 | // before the builder existed. Verification of the OUTGOING pointer therefore |
| 137 | // has to be anchored at the root, not at the signing key's own public half, |
| 138 | // and the proof is that history comes forward. |
| 139 | // |
| 140 | // Mutation-mindedness: change Signer.verifyPub to return the signing key's |
| 141 | // public half and this fails at the second promote with "existing pointer … |
| 142 | // signature verification failed", which is exactly what a scoped key would |
| 143 | // hit on its first run against a real channel. |
| 144 | func TestScopedKeyReplacesARootSignedPointerAndCarriesHistory(t *testing.T) { |
| 145 | root, rootPub, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 90*24*time.Hour) |
| 146 | ctx := context.Background() |
| 147 | s := NewDirStore(t.TempDir()) |
| 148 | shipRelease(t, s, "bab", "kass", "9f3c1ad", "linux-arm64", []byte("one")) |
| 149 | shipRelease(t, s, "bab", "kass", "aa11bb2", "linux-arm64", []byte("two")) |
| 150 | |
| 151 | if err := Promote(ctx, s, root, "bab", "kass", "edge", "9f3c1ad"); err != nil { |
| 152 | t.Fatalf("root promote: %v", err) |
| 153 | } |
| 154 | if err := sg.Promote(ctx, s, "bab", "kass", "edge", "aa11bb2"); err != nil { |
| 155 | t.Fatalf("scoped promote over a root-signed pointer: %v", err) |
| 156 | } |
| 157 | p, err := Channel(ctx, s, rootPub, "bab", "kass", "edge") |
| 158 | if err != nil { |
| 159 | t.Fatalf("Channel: %v", err) |
| 160 | } |
| 161 | if p.Version != "aa11bb2" || len(p.History) != 1 || p.History[0] != "9f3c1ad" { |
| 162 | t.Fatalf("pointer = %+v, want aa11bb2 with 9f3c1ad in history", p) |
| 163 | } |
| 164 | |
| 165 | // And back the other way: the root key replaces a scope-signed pointer, |
| 166 | // carrying its history. Neither direction may be a dead end, or a |
| 167 | // deployment could not stop using a scoped key once it started. |
| 168 | shipRelease(t, s, "bab", "kass", "cc33dd4", "linux-arm64", []byte("three")) |
| 169 | if err := Promote(ctx, s, root, "bab", "kass", "edge", "cc33dd4"); err != nil { |
| 170 | t.Fatalf("root promote over a scope-signed pointer: %v", err) |
| 171 | } |
| 172 | p, err = Channel(ctx, s, rootPub, "bab", "kass", "edge") |
| 173 | if err != nil { |
| 174 | t.Fatalf("Channel: %v", err) |
| 175 | } |
| 176 | if p.Version != "cc33dd4" || len(p.History) != 2 { |
| 177 | t.Fatalf("pointer = %+v, want cc33dd4 with two history entries", p) |
| 178 | } |
| 179 | // Rollback with the scoped key returns to the scope-signed version. |
| 180 | if err := sg.Rollback(ctx, s, "bab", "kass", "edge"); err != nil { |
| 181 | t.Fatalf("scoped rollback: %v", err) |
| 182 | } |
| 183 | p, err = Channel(ctx, s, rootPub, "bab", "kass", "edge") |
| 184 | if err != nil { |
| 185 | t.Fatalf("Channel: %v", err) |
| 186 | } |
| 187 | if p.Version != "aa11bb2" { |
| 188 | t.Fatalf("after rollback pointer = %+v, want aa11bb2", p) |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | // End to end, in the shape the feature exists for: mint a scoped edge key, |
| 193 | // promote to edge with it, and let an ordinary adoption pass — which knows |
| 194 | // only CARLOS_RELEASE_PUBKEY — install the build. Then the same key is |
| 195 | // refused stable locally, and a hand-crafted stable grant is refused at |
| 196 | // verification. |
| 197 | func TestScopedEdgeKeyEndToEnd(t *testing.T) { |
| 198 | root, rootPub, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 90*24*time.Hour) |
| 199 | ctx := context.Background() |
| 200 | s := NewDirStore(t.TempDir()) |
| 201 | opt := t.TempDir() |
| 202 | reg := testRegistry(t) |
| 203 | |
| 204 | shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one")) |
| 205 | if err := sg.Promote(ctx, s, "bab", "myapp", "canary/x", "v1"); err != nil { |
| 206 | t.Fatalf("scoped promote: %v", err) |
| 207 | } |
| 208 | addRoute(t, reg, "awake.example.com", "myapp", "canary/x", false) |
| 209 | if err := Pass(ctx, reg, s, rootPub, opt, func(string) error { return nil }); err != nil { |
| 210 | t.Fatalf("adoption pass over a scope-signed pointer: %v", err) |
| 211 | } |
| 212 | if v, _ := LiveVersion(opt, "awake.example.com"); v != "v1" { |
| 213 | t.Fatalf("live version = %q, want v1 — adoption did not accept the scope-signed pointer", v) |
| 214 | } |
| 215 | |
| 216 | // stable is refused locally, without a write. |
| 217 | if err := sg.Promote(ctx, trapStore{t}, "bab", "myapp", "stable", "v1"); err == nil { |
| 218 | t.Fatal("the edge key promoted to stable") |
| 219 | } |
| 220 | |
| 221 | // And a stable grant hand-crafted with the ROOT key — the strongest |
| 222 | // attacker this scheme contemplates, since it is the deployment's own |
| 223 | // signing authority — is refused at verification. |
| 224 | _, scopedPub := testKeys(t) |
| 225 | g := goodGrant(scopedPub) |
| 226 | g.Rungs = []string{RungEdge, RungStable} |
| 227 | sub, err := parseGrantBlob(forgeGrant(t, root, g), "test") |
| 228 | if err != nil { |
| 229 | t.Fatal(err) |
| 230 | } |
| 231 | if _, err := verifyGrant(rootPub, sub); err == nil { |
| 232 | t.Fatal("a root-signed stable grant was honoured") |
| 233 | } |
| 234 | } |
| 235 | |