carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
4 "bytes"
5 "context"
6 "encoding/hex"
7 "log"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/release"
13)
14
15// captureLog redirects the standard logger for the duration of a test —
16// synchronous callers only (mintMeshKey, loadMeshKeys direct calls here are
17// never run concurrently with the read), same shape certsync_test.go uses.
18func captureLog(t *testing.T) *bytes.Buffer {
19 t.Helper()
20 var buf bytes.Buffer
21 prev := log.Writer()
22 log.SetOutput(&buf)
23 t.Cleanup(func() { log.SetOutput(prev) })
24 return &buf
25}
26
27// meshTestStore is a fresh directory-backed store, the same DirStore every
28// other edge test in this package uses in place of live S3.
29func meshTestStore(t *testing.T) release.Store {
30 t.Helper()
31 return release.NewDirStore(t.TempDir())
32}
33
34func TestMintMeshKeyIsCreateOnly(t *testing.T) {
35 s := meshTestStore(t)
36 if err := mintMeshKey(context.Background(), s); err != nil {
37 t.Fatal(err)
38 }
39 first, _, _ := s.Get(context.Background(), meshKeyKey)
40 if err := mintMeshKey(context.Background(), s); err != nil {
41 t.Fatalf("second mint must be success-by-other: %v", err)
42 }
43 second, _, _ := s.Get(context.Background(), meshKeyKey)
44 if !bytes.Equal(first, second) {
45 t.Fatal("second mint replaced the key")
46 }
47}
48
49// The exported wrapper is the seam internal/host's housekeeper duty calls
50// through — prove it actually reaches the same key, not a copy of the logic.
51func TestMintMeshKeyExportedWrapperMintsTheSameKey(t *testing.T) {
52 s := meshTestStore(t)
53 if err := MintMeshKey(context.Background(), s); err != nil {
54 t.Fatal(err)
55 }
56 body, _, err := s.Get(context.Background(), meshKeyKey)
57 if err != nil {
58 t.Fatalf("get %s: %v", meshKeyKey, err)
59 }
60 if len(body) != meshKeyBytes*2 { // hex-encoded
61 t.Fatalf("mesh key body length = %d, want %d (hex of %d random bytes)", len(body), meshKeyBytes*2, meshKeyBytes)
62 }
63}
64
65// A lost race is the steady state of every tick but the first, forever — it
66// must not log anything, matching housekeeper.go's own silence on election
67// contention. Only the first, real mint gets a line.
68func TestMintMeshKeySecondMintIsSilent(t *testing.T) {
69 buf := captureLog(t)
70 s := meshTestStore(t)
71 if err := mintMeshKey(context.Background(), s); err != nil {
72 t.Fatal(err)
73 }
74 first := buf.String()
75 if !strings.Contains(first, "minted") {
76 t.Fatalf("first mint log = %q, want it to mention minting", first)
77 }
78 buf.Reset()
79 if err := mintMeshKey(context.Background(), s); err != nil {
80 t.Fatalf("second mint must be success-by-other: %v", err)
81 }
82 if got := buf.String(); got != "" {
83 t.Fatalf("second mint (a lost CAS race) logged %q, want silence", got)
84 }
85}
86
87func TestHopSignatureRoundTripsAndBoundsSkew(t *testing.T) {
88 key := []byte("0123456789abcdef0123456789abcdef")
89 now := time.Unix(1_766_000_000, 0)
90 h := signHop(key, "app.example.com", now)
91 if !verifyHop(key, "app.example.com", h, now.Add(29*time.Second)) {
92 t.Fatal("within skew must verify")
93 }
94 if verifyHop(key, "app.example.com", h, now.Add(31*time.Second)) {
95 t.Fatal("past skew must refuse")
96 }
97 if verifyHop(key, "other.example.com", h, now) {
98 t.Fatal("host is bound into the mac")
99 }
100 if verifyHop([]byte("wrong-key-wrong-key-wrong-key-00"), "app.example.com", h, now) {
101 t.Fatal("wrong key must refuse")
102 }
103}
104
105// Skew is bounded in both directions — a clock that runs fast must be
106// refused exactly like one that runs slow.
107func TestHopSignatureRefusesFutureSkewToo(t *testing.T) {
108 key := []byte("0123456789abcdef0123456789abcdef")
109 now := time.Unix(1_766_000_000, 0)
110 h := signHop(key, "app.example.com", now)
111 if verifyHop(key, "app.example.com", h, now.Add(-31*time.Second)) {
112 t.Fatal("verifying 31s before the signature's timestamp must refuse")
113 }
114 if !verifyHop(key, "app.example.com", h, now.Add(-29*time.Second)) {
115 t.Fatal("verifying 29s before the signature's timestamp must still verify")
116 }
117}
118
119// A malformed header (no ":", a non-numeric timestamp, non-hex mac) must
120// refuse, never panic — it arrives off the wire from a hop this box does not
121// control.
122func TestVerifyHopRefusesMalformedHeaders(t *testing.T) {
123 key := []byte("0123456789abcdef0123456789abcdef")
124 now := time.Unix(1_766_000_000, 0)
125 for _, header := range []string{
126 "",
127 "nocolon",
128 "notanumber:deadbeef",
129 "1766000000:not-hex-zzzz",
130 "1766000000:",
131 } {
132 if verifyHop(key, "app.example.com", header, now) {
133 t.Fatalf("verifyHop(%q) = true, want false", header)
134 }
135 }
136}
137
138// The MAC is over the CANONICAL host, so a signer's and a verifier's
139// differing-but-equivalent forms of the same host must both verify against
140// the same signature: Task 4 signs one form (e.g. a home-map lookup key,
141// unlikely to carry a port), Task 3 verifies a literal net/http request Host
142// (may carry a port, whatever case and trailing dot the client sent) —
143// divergence here would be spurious refusals presenting as regional
144// flakiness, not a security property either side intends.
145func TestHopMACCanonicalizesHost(t *testing.T) {
146 key := []byte("0123456789abcdef0123456789abcdef")
147 now := time.Unix(1_766_000_000, 0)
148 canonical := signHop(key, "app.example.com", now)
149
150 // A signature made for the canonical host verifies against every
151 // equivalent form a verifier might see the request host in.
152 for _, host := range []string{
153 "app.example.com",
154 "APP.EXAMPLE.COM",
155 "App.Example.Com",
156 "app.example.com.",
157 "app.example.com:443",
158 "app.example.com.:8443",
159 "APP.EXAMPLE.COM.:443",
160 } {
161 if !verifyHop(key, host, canonical, now) {
162 t.Fatalf("verifyHop(%q) against a signature made for the canonical host = false, want true", host)
163 }
164 }
165
166 // And signing under any of those same equivalent forms produces a
167 // signature that verifies against the canonical form too — the
168 // canonicalization is symmetric, not a one-sided allowance.
169 for _, host := range []string{"APP.EXAMPLE.COM", "app.example.com.", "app.example.com:9443"} {
170 h := signHop(key, host, now)
171 if !verifyHop(key, "app.example.com", h, now) {
172 t.Fatalf("a signature made for host %q must verify against the canonical form", host)
173 }
174 }
175
176 // A genuinely different host must still refuse.
177 if verifyHop(key, "other.example.com", canonical, now) {
178 t.Fatal("a different host must still refuse — canonicalization must not collapse distinct hosts")
179 }
180}
181
182// ---- rotation dual-accept (plan's global constraint: the READ side ships
183// now — verifyHopEitherKey and signingKey are what Task 3/4's request path
184// calls once it has loaded both mirrored keys) --------------------------------
185
186func TestSignHopPrefersNextKeyWhenPresent(t *testing.T) {
187 current := []byte("current-key-current-key-current")
188 next := []byte("next-key-next-key-next-key-next")
189 if got := signingKey(current, next); !bytes.Equal(got, next) {
190 t.Fatal("signingKey must prefer .next when it exists")
191 }
192 if got := signingKey(current, nil); !bytes.Equal(got, current) {
193 t.Fatal("signingKey must fall back to current when there is no .next")
194 }
195}
196
197func TestVerifyHopEitherKeyAcceptsSignaturesUnderCurrentOrNext(t *testing.T) {
198 current := []byte("current-key-current-key-current")
199 next := []byte("next-key-next-key-next-key-next-")
200 wrong := []byte("wrong-key-wrong-key-wrong-key-00")
201 now := time.Unix(1_766_000_000, 0)
202 host := "app.example.com"
203
204 // sign with .next -> verifies
205 hNext := signHop(signingKey(current, next), host, now)
206 if !verifyHopEitherKey(current, next, host, hNext, now) {
207 t.Fatal("a signature made with .next must verify while .next exists")
208 }
209
210 // sign with old key while .next exists -> still verifies (dual-accept)
211 hOld := signHop(current, host, now)
212 if !verifyHopEitherKey(current, next, host, hOld, now) {
213 t.Fatal("a signature made with the pre-rotation key must still verify while .next exists")
214 }
215
216 // wrong key -> refuses
217 hWrong := signHop(wrong, host, now)
218 if verifyHopEitherKey(current, next, host, hWrong, now) {
219 t.Fatal("a signature made with neither key must refuse")
220 }
221
222 // no rotation in flight (next is nil): only current verifies.
223 if !verifyHopEitherKey(current, nil, host, hOld, now) {
224 t.Fatal("current key must verify with no rotation in flight")
225 }
226 if verifyHopEitherKey(current, nil, host, hNext, now) {
227 t.Fatal("a .next signature must not verify once .next is gone")
228 }
229}
230
231func TestLoadMeshKeysReadsBothMirroredObjects(t *testing.T) {
232 ctx := context.Background()
233 s := meshTestStore(t)
234
235 // Before the housekeeper's first tick: neither object exists, and that
236 // is quiet — not an error.
237 current, next, err := loadMeshKeys(ctx, s)
238 if err != nil {
239 t.Fatalf("loadMeshKeys before minting: %v", err)
240 }
241 if current != nil || next != nil {
242 t.Fatalf("loadMeshKeys before minting = (%x, %x), want (nil, nil)", current, next)
243 }
244
245 if err := mintMeshKey(ctx, s); err != nil {
246 t.Fatal(err)
247 }
248 current, next, err = loadMeshKeys(ctx, s)
249 if err != nil {
250 t.Fatalf("loadMeshKeys after minting: %v", err)
251 }
252 if len(current) != meshKeyBytes {
253 t.Fatalf("current key length = %d, want %d raw bytes", len(current), meshKeyBytes)
254 }
255 if next != nil {
256 t.Fatal("next key must be nil outside a rotation window")
257 }
258
259 // A rotation in flight: .next mirrored alongside the current key.
260 nextBody := []byte(hex.EncodeToString(bytes.Repeat([]byte{0xab}, meshKeyBytes)))
261 if _, err := s.Put(ctx, meshKeyNextKey, nextBody, ""); err != nil {
262 t.Fatal(err)
263 }
264 current, next, err = loadMeshKeys(ctx, s)
265 if err != nil {
266 t.Fatalf("loadMeshKeys during rotation: %v", err)
267 }
268 if len(current) != meshKeyBytes || len(next) != meshKeyBytes {
269 t.Fatalf("key lengths = (%d, %d), want both %d", len(current), len(next), meshKeyBytes)
270 }
271}
272
273// A malformed .next — hand-written during a rotation, so a bad upload is a
274// real possibility — must not take the live current key down with it: the
275// safe response is to treat it as no rotation in flight (loudly logged),
276// never to refuse every hop fleet-wide over one bad object.
277func TestLoadMeshKeysToleratesAnUnreadableNext(t *testing.T) {
278 ctx := context.Background()
279 s := meshTestStore(t)
280 if err := mintMeshKey(ctx, s); err != nil {
281 t.Fatal(err)
282 }
283 if _, err := s.Put(ctx, meshKeyNextKey, []byte("not-valid-hex-zzzz"), ""); err != nil {
284 t.Fatal(err)
285 }
286
287 buf := captureLog(t)
288 current, next, err := loadMeshKeys(ctx, s)
289 if err != nil {
290 t.Fatalf("loadMeshKeys with a malformed .next must not fail: %v", err)
291 }
292 if len(current) != meshKeyBytes {
293 t.Fatalf("current key length = %d, want %d — a malformed .next took the live key down with it", len(current), meshKeyBytes)
294 }
295 if next != nil {
296 t.Fatal("a malformed .next must be treated as absent (no rotation in flight), not surfaced")
297 }
298 if got := buf.String(); !strings.Contains(got, meshKeyNextKey) {
299 t.Fatalf("log = %q, want a loud mention of %s — this is an operator mistake worth paging on", got, meshKeyNextKey)
300 }
301
302 // And a signature made under the still-good current key verifies fine —
303 // the tolerance is not merely "does not error", it's "hop auth keeps
304 // working".
305 now := time.Unix(1_766_000_000, 0)
306 h := signHop(current, "app.example.com", now)
307 if !verifyHopEitherKey(current, next, "app.example.com", h, now) {
308 t.Fatal("hop auth must keep working off the current key despite the malformed .next")
309 }
310}
311
312// ---- the key half of the home-map mirror ------------------------------------
313//
314// Task 3 folds the mesh-key read into the home map's 30s poll rather than
315// giving it a loop of its own: one list-and-get pass keeps both halves of a
316// hop's answer (who homes this host, and what key proves the hop) from ever
317// being read from two different moments in the bucket's life.
318
319func TestHomeMapRefreshMirrorsTheMeshKeys(t *testing.T) {
320 ctx := context.Background()
321 s := meshTestStore(t)
322 m := newHomeMap(s, "annie")
323
324 // Before the housekeeper's first tick: no key, and every hop fails
325 // closed — refreshing is still not an error.
326 if err := m.Refresh(ctx); err != nil {
327 t.Fatalf("refresh before minting: %v", err)
328 }
329 if cur, next := m.meshKeys(); cur != nil || next != nil {
330 t.Fatalf("keys before minting = (%x, %x), want (nil, nil)", cur, next)
331 }
332
333 if err := mintMeshKey(ctx, s); err != nil {
334 t.Fatal(err)
335 }
336 if err := m.Refresh(ctx); err != nil {
337 t.Fatalf("refresh after minting: %v", err)
338 }
339 cur, next := m.meshKeys()
340 if len(cur) != meshKeyBytes || next != nil {
341 t.Fatalf("keys after minting = (%d bytes, %v), want the current key alone", len(cur), next)
342 }
343
344 // A rotation in flight: dual-accept means a signature under EITHER key is
345 // good, which is what the request path calls (verifyHopEitherKey, never
346 // the single-key verifyHop).
347 nextBody := []byte(hex.EncodeToString(bytes.Repeat([]byte{0xcd}, meshKeyBytes)))
348 if _, err := s.Put(ctx, meshKeyNextKey, nextBody, ""); err != nil {
349 t.Fatal(err)
350 }
351 if err := m.Refresh(ctx); err != nil {
352 t.Fatalf("refresh during rotation: %v", err)
353 }
354 cur, next = m.meshKeys()
355 if len(cur) != meshKeyBytes || len(next) != meshKeyBytes {
356 t.Fatalf("keys during rotation = (%d, %d), want both mirrored", len(cur), len(next))
357 }
358 now := time.Now()
359 for _, k := range [][]byte{cur, next} {
360 if !verifyHopEitherKey(cur, next, "app.example.com", signHop(k, "app.example.com", now), now) {
361 t.Fatal("a hop signed under either mirrored key must verify while both are mirrored")
362 }
363 }
364}
365
366// The keys ride the same last-good posture as the host fold: a bucket blip
367// must not disarm hop verification fleet-wide.
368func TestHomeMapKeepsLastGoodMeshKeysWhenTheBucketIsDown(t *testing.T) {
369 ctx := context.Background()
370 inner := meshTestStore(t)
371 s := &listFailingStore{Store: inner}
372 if err := mintMeshKey(ctx, inner); err != nil {
373 t.Fatal(err)
374 }
375 m := newHomeMap(s, "annie")
376 if err := m.Refresh(ctx); err != nil {
377 t.Fatalf("refresh: %v", err)
378 }
379 good, _ := m.meshKeys()
380
381 s.setFail(true)
382 if err := m.Refresh(ctx); err == nil {
383 t.Fatal("a bucket blip must be reported, not swallowed")
384 }
385 cur, _ := m.meshKeys()
386 if !bytes.Equal(cur, good) {
387 t.Fatal("the last good mesh key must stand through a bucket blip")
388 }
389}
390