package edge import ( "bytes" "context" "encoding/hex" "log" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" ) // captureLog redirects the standard logger for the duration of a test — // synchronous callers only (mintMeshKey, loadMeshKeys direct calls here are // never run concurrently with the read), same shape certsync_test.go uses. func captureLog(t *testing.T) *bytes.Buffer { t.Helper() var buf bytes.Buffer prev := log.Writer() log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(prev) }) return &buf } // meshTestStore is a fresh directory-backed store, the same DirStore every // other edge test in this package uses in place of live S3. func meshTestStore(t *testing.T) release.Store { t.Helper() return release.NewDirStore(t.TempDir()) } func TestMintMeshKeyIsCreateOnly(t *testing.T) { s := meshTestStore(t) if err := mintMeshKey(context.Background(), s); err != nil { t.Fatal(err) } first, _, _ := s.Get(context.Background(), meshKeyKey) if err := mintMeshKey(context.Background(), s); err != nil { t.Fatalf("second mint must be success-by-other: %v", err) } second, _, _ := s.Get(context.Background(), meshKeyKey) if !bytes.Equal(first, second) { t.Fatal("second mint replaced the key") } } // The exported wrapper is the seam internal/host's housekeeper duty calls // through — prove it actually reaches the same key, not a copy of the logic. func TestMintMeshKeyExportedWrapperMintsTheSameKey(t *testing.T) { s := meshTestStore(t) if err := MintMeshKey(context.Background(), s); err != nil { t.Fatal(err) } body, _, err := s.Get(context.Background(), meshKeyKey) if err != nil { t.Fatalf("get %s: %v", meshKeyKey, err) } if len(body) != meshKeyBytes*2 { // hex-encoded t.Fatalf("mesh key body length = %d, want %d (hex of %d random bytes)", len(body), meshKeyBytes*2, meshKeyBytes) } } // A lost race is the steady state of every tick but the first, forever — it // must not log anything, matching housekeeper.go's own silence on election // contention. Only the first, real mint gets a line. func TestMintMeshKeySecondMintIsSilent(t *testing.T) { buf := captureLog(t) s := meshTestStore(t) if err := mintMeshKey(context.Background(), s); err != nil { t.Fatal(err) } first := buf.String() if !strings.Contains(first, "minted") { t.Fatalf("first mint log = %q, want it to mention minting", first) } buf.Reset() if err := mintMeshKey(context.Background(), s); err != nil { t.Fatalf("second mint must be success-by-other: %v", err) } if got := buf.String(); got != "" { t.Fatalf("second mint (a lost CAS race) logged %q, want silence", got) } } func TestHopSignatureRoundTripsAndBoundsSkew(t *testing.T) { key := []byte("0123456789abcdef0123456789abcdef") now := time.Unix(1_766_000_000, 0) h := signHop(key, "app.example.com", now) if !verifyHop(key, "app.example.com", h, now.Add(29*time.Second)) { t.Fatal("within skew must verify") } if verifyHop(key, "app.example.com", h, now.Add(31*time.Second)) { t.Fatal("past skew must refuse") } if verifyHop(key, "other.example.com", h, now) { t.Fatal("host is bound into the mac") } if verifyHop([]byte("wrong-key-wrong-key-wrong-key-00"), "app.example.com", h, now) { t.Fatal("wrong key must refuse") } } // Skew is bounded in both directions — a clock that runs fast must be // refused exactly like one that runs slow. func TestHopSignatureRefusesFutureSkewToo(t *testing.T) { key := []byte("0123456789abcdef0123456789abcdef") now := time.Unix(1_766_000_000, 0) h := signHop(key, "app.example.com", now) if verifyHop(key, "app.example.com", h, now.Add(-31*time.Second)) { t.Fatal("verifying 31s before the signature's timestamp must refuse") } if !verifyHop(key, "app.example.com", h, now.Add(-29*time.Second)) { t.Fatal("verifying 29s before the signature's timestamp must still verify") } } // A malformed header (no ":", a non-numeric timestamp, non-hex mac) must // refuse, never panic — it arrives off the wire from a hop this box does not // control. func TestVerifyHopRefusesMalformedHeaders(t *testing.T) { key := []byte("0123456789abcdef0123456789abcdef") now := time.Unix(1_766_000_000, 0) for _, header := range []string{ "", "nocolon", "notanumber:deadbeef", "1766000000:not-hex-zzzz", "1766000000:", } { if verifyHop(key, "app.example.com", header, now) { t.Fatalf("verifyHop(%q) = true, want false", header) } } } // The MAC is over the CANONICAL host, so a signer's and a verifier's // differing-but-equivalent forms of the same host must both verify against // the same signature: Task 4 signs one form (e.g. a home-map lookup key, // unlikely to carry a port), Task 3 verifies a literal net/http request Host // (may carry a port, whatever case and trailing dot the client sent) — // divergence here would be spurious refusals presenting as regional // flakiness, not a security property either side intends. func TestHopMACCanonicalizesHost(t *testing.T) { key := []byte("0123456789abcdef0123456789abcdef") now := time.Unix(1_766_000_000, 0) canonical := signHop(key, "app.example.com", now) // A signature made for the canonical host verifies against every // equivalent form a verifier might see the request host in. for _, host := range []string{ "app.example.com", "APP.EXAMPLE.COM", "App.Example.Com", "app.example.com.", "app.example.com:443", "app.example.com.:8443", "APP.EXAMPLE.COM.:443", } { if !verifyHop(key, host, canonical, now) { t.Fatalf("verifyHop(%q) against a signature made for the canonical host = false, want true", host) } } // And signing under any of those same equivalent forms produces a // signature that verifies against the canonical form too — the // canonicalization is symmetric, not a one-sided allowance. for _, host := range []string{"APP.EXAMPLE.COM", "app.example.com.", "app.example.com:9443"} { h := signHop(key, host, now) if !verifyHop(key, "app.example.com", h, now) { t.Fatalf("a signature made for host %q must verify against the canonical form", host) } } // A genuinely different host must still refuse. if verifyHop(key, "other.example.com", canonical, now) { t.Fatal("a different host must still refuse — canonicalization must not collapse distinct hosts") } } // ---- rotation dual-accept (plan's global constraint: the READ side ships // now — verifyHopEitherKey and signingKey are what Task 3/4's request path // calls once it has loaded both mirrored keys) -------------------------------- func TestSignHopPrefersNextKeyWhenPresent(t *testing.T) { current := []byte("current-key-current-key-current") next := []byte("next-key-next-key-next-key-next") if got := signingKey(current, next); !bytes.Equal(got, next) { t.Fatal("signingKey must prefer .next when it exists") } if got := signingKey(current, nil); !bytes.Equal(got, current) { t.Fatal("signingKey must fall back to current when there is no .next") } } func TestVerifyHopEitherKeyAcceptsSignaturesUnderCurrentOrNext(t *testing.T) { current := []byte("current-key-current-key-current") next := []byte("next-key-next-key-next-key-next-") wrong := []byte("wrong-key-wrong-key-wrong-key-00") now := time.Unix(1_766_000_000, 0) host := "app.example.com" // sign with .next -> verifies hNext := signHop(signingKey(current, next), host, now) if !verifyHopEitherKey(current, next, host, hNext, now) { t.Fatal("a signature made with .next must verify while .next exists") } // sign with old key while .next exists -> still verifies (dual-accept) hOld := signHop(current, host, now) if !verifyHopEitherKey(current, next, host, hOld, now) { t.Fatal("a signature made with the pre-rotation key must still verify while .next exists") } // wrong key -> refuses hWrong := signHop(wrong, host, now) if verifyHopEitherKey(current, next, host, hWrong, now) { t.Fatal("a signature made with neither key must refuse") } // no rotation in flight (next is nil): only current verifies. if !verifyHopEitherKey(current, nil, host, hOld, now) { t.Fatal("current key must verify with no rotation in flight") } if verifyHopEitherKey(current, nil, host, hNext, now) { t.Fatal("a .next signature must not verify once .next is gone") } } func TestLoadMeshKeysReadsBothMirroredObjects(t *testing.T) { ctx := context.Background() s := meshTestStore(t) // Before the housekeeper's first tick: neither object exists, and that // is quiet — not an error. current, next, err := loadMeshKeys(ctx, s) if err != nil { t.Fatalf("loadMeshKeys before minting: %v", err) } if current != nil || next != nil { t.Fatalf("loadMeshKeys before minting = (%x, %x), want (nil, nil)", current, next) } if err := mintMeshKey(ctx, s); err != nil { t.Fatal(err) } current, next, err = loadMeshKeys(ctx, s) if err != nil { t.Fatalf("loadMeshKeys after minting: %v", err) } if len(current) != meshKeyBytes { t.Fatalf("current key length = %d, want %d raw bytes", len(current), meshKeyBytes) } if next != nil { t.Fatal("next key must be nil outside a rotation window") } // A rotation in flight: .next mirrored alongside the current key. nextBody := []byte(hex.EncodeToString(bytes.Repeat([]byte{0xab}, meshKeyBytes))) if _, err := s.Put(ctx, meshKeyNextKey, nextBody, ""); err != nil { t.Fatal(err) } current, next, err = loadMeshKeys(ctx, s) if err != nil { t.Fatalf("loadMeshKeys during rotation: %v", err) } if len(current) != meshKeyBytes || len(next) != meshKeyBytes { t.Fatalf("key lengths = (%d, %d), want both %d", len(current), len(next), meshKeyBytes) } } // A malformed .next — hand-written during a rotation, so a bad upload is a // real possibility — must not take the live current key down with it: the // safe response is to treat it as no rotation in flight (loudly logged), // never to refuse every hop fleet-wide over one bad object. func TestLoadMeshKeysToleratesAnUnreadableNext(t *testing.T) { ctx := context.Background() s := meshTestStore(t) if err := mintMeshKey(ctx, s); err != nil { t.Fatal(err) } if _, err := s.Put(ctx, meshKeyNextKey, []byte("not-valid-hex-zzzz"), ""); err != nil { t.Fatal(err) } buf := captureLog(t) current, next, err := loadMeshKeys(ctx, s) if err != nil { t.Fatalf("loadMeshKeys with a malformed .next must not fail: %v", err) } if len(current) != meshKeyBytes { t.Fatalf("current key length = %d, want %d — a malformed .next took the live key down with it", len(current), meshKeyBytes) } if next != nil { t.Fatal("a malformed .next must be treated as absent (no rotation in flight), not surfaced") } if got := buf.String(); !strings.Contains(got, meshKeyNextKey) { t.Fatalf("log = %q, want a loud mention of %s — this is an operator mistake worth paging on", got, meshKeyNextKey) } // And a signature made under the still-good current key verifies fine — // the tolerance is not merely "does not error", it's "hop auth keeps // working". now := time.Unix(1_766_000_000, 0) h := signHop(current, "app.example.com", now) if !verifyHopEitherKey(current, next, "app.example.com", h, now) { t.Fatal("hop auth must keep working off the current key despite the malformed .next") } } // ---- the key half of the home-map mirror ------------------------------------ // // Task 3 folds the mesh-key read into the home map's 30s poll rather than // giving it a loop of its own: one list-and-get pass keeps both halves of a // hop's answer (who homes this host, and what key proves the hop) from ever // being read from two different moments in the bucket's life. func TestHomeMapRefreshMirrorsTheMeshKeys(t *testing.T) { ctx := context.Background() s := meshTestStore(t) m := newHomeMap(s, "annie") // Before the housekeeper's first tick: no key, and every hop fails // closed — refreshing is still not an error. if err := m.Refresh(ctx); err != nil { t.Fatalf("refresh before minting: %v", err) } if cur, next := m.meshKeys(); cur != nil || next != nil { t.Fatalf("keys before minting = (%x, %x), want (nil, nil)", cur, next) } if err := mintMeshKey(ctx, s); err != nil { t.Fatal(err) } if err := m.Refresh(ctx); err != nil { t.Fatalf("refresh after minting: %v", err) } cur, next := m.meshKeys() if len(cur) != meshKeyBytes || next != nil { t.Fatalf("keys after minting = (%d bytes, %v), want the current key alone", len(cur), next) } // A rotation in flight: dual-accept means a signature under EITHER key is // good, which is what the request path calls (verifyHopEitherKey, never // the single-key verifyHop). nextBody := []byte(hex.EncodeToString(bytes.Repeat([]byte{0xcd}, meshKeyBytes))) if _, err := s.Put(ctx, meshKeyNextKey, nextBody, ""); err != nil { t.Fatal(err) } if err := m.Refresh(ctx); err != nil { t.Fatalf("refresh during rotation: %v", err) } cur, next = m.meshKeys() if len(cur) != meshKeyBytes || len(next) != meshKeyBytes { t.Fatalf("keys during rotation = (%d, %d), want both mirrored", len(cur), len(next)) } now := time.Now() for _, k := range [][]byte{cur, next} { if !verifyHopEitherKey(cur, next, "app.example.com", signHop(k, "app.example.com", now), now) { t.Fatal("a hop signed under either mirrored key must verify while both are mirrored") } } } // The keys ride the same last-good posture as the host fold: a bucket blip // must not disarm hop verification fleet-wide. func TestHomeMapKeepsLastGoodMeshKeysWhenTheBucketIsDown(t *testing.T) { ctx := context.Background() inner := meshTestStore(t) s := &listFailingStore{Store: inner} if err := mintMeshKey(ctx, inner); err != nil { t.Fatal(err) } m := newHomeMap(s, "annie") if err := m.Refresh(ctx); err != nil { t.Fatalf("refresh: %v", err) } good, _ := m.meshKeys() s.setFail(true) if err := m.Refresh(ctx); err == nil { t.Fatal("a bucket blip must be reported, not swallowed") } cur, _ := m.meshKeys() if !bytes.Equal(cur, good) { t.Fatal("the last good mesh key must stand through a bucket blip") } }