reqrec, console: 8-byte nonce (40-char codes), one canonical code for verify and lookup, record_status found/absent/unavailable (final review I5, I7, I8)
The exemption comment for the edge-refs route now describes what it does: an authenticated, app-scoped read; unsealed or unverifiable codes answer verified:false; forged codes get the gate's 404.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+212
−58
internal/console/apiedgeref.go+38 −36internal/console/apiedgeref_test.go+53 −4internal/console/svcroutes_test.go+5 −3internal/reqrec/ref.go+35 −13internal/reqrec/ref_test.go+81 −2
diff --git a/internal/console/apiedgeref.go b/internal/console/apiedgeref.go| index 1b4293d92..afc8896b7 100644 |
| --- a/internal/console/apiedgeref.go |
| +++ b/internal/console/apiedgeref.go |
| @@ -12,37 +12,30 @@ import ( |
| "fmt" |
| "log" |
| "net/http" |
| - "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/applog" |
| "github.com/carlosframework/platform/internal/reqrec" |
| ) |
| -// apiEdgeRef is the wire shape of one explained code. Record is null when |
| -// the edge record is gone (sampled, evicted, or past retention); the seal |
| -// still tells the owner which rule fired and when. |
| +// apiEdgeRef is the wire shape of one explained code. The seal's fields |
| +// (code, verified, at, tier, rule_hash) are always there: they come from the |
| +// code itself, so they tell the owner which rule fired and when whatever |
| +// happened to the record. RecordStatus says what happened to it: |
| +// |
| +// found Record is the edge record carrying this code |
| +// absent the logs were read and hold no such record (sampled away, |
| +// evicted from the box's buffer, or past retention) |
| +// unavailable the logs could not be read (store outage, a bad chunk); |
| +// asking again later may find it |
| type apiEdgeRef struct { |
| - Code string `json:"code"` |
| - Verified bool `json:"verified"` |
| - At time.Time `json:"at"` |
| - Tier string `json:"tier"` |
| - RuleHash string `json:"rule_hash"` |
| - Record *applog.Record `json:"record"` |
| -} |
| - |
| -// canonicalRefCode is the form the edge writes into a record's ref field: |
| -// lower case, no spaces, no dashes. It deliberately does not fold the |
| -// look-alike letters the way ParseRef does, because the edge never writes |
| -// them; a pasted code that only parses by folding finds no record, and the |
| -// answer says so rather than guessing. |
| -func canonicalRefCode(code string) string { |
| - return strings.Map(func(r rune) rune { |
| - if r == ' ' || r == '-' { |
| - return -1 |
| - } |
| - return r |
| - }, strings.ToLower(code)) |
| + Code string `json:"code"` |
| + Verified bool `json:"verified"` |
| + At time.Time `json:"at"` |
| + Tier string `json:"tier"` |
| + RuleHash string `json:"rule_hash"` |
| + RecordStatus string `json:"record_status"` |
| + Record *applog.Record `json:"record"` |
| } |
| // handleAPIEdgeRef answers GET /api/cli/apps/{account}/{app}/edge-refs/{code}. |
| @@ -67,7 +60,13 @@ func (s *Server) handleAPIEdgeRef(w http.ResponseWriter, r *http.Request) { |
| http.NotFound(w, r) |
| return |
| } |
| - canon := canonicalRefCode(code) |
| + // ParseRef accepted it, so it has a canonical form: the spelling the edge |
| + // wrote into the record, look-alike letters folded. |
| + canon, err := reqrec.CanonicalRef(code) |
| + if err != nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| defer cancel() |
| @@ -81,20 +80,23 @@ func (s *Server) handleAPIEdgeRef(w http.ResponseWriter, r *http.Request) { |
| Stream: applog.StreamEdge, |
| Fields: map[string]string{"ref": canon}, |
| }, 1000) |
| - if err != nil { |
| - log.Printf("console: api edge ref %s/%s: %v", acct.Sqid, app, err) |
| - jsonError(w, http.StatusBadGateway, "could not read the logs") |
| - return |
| - } |
| resp := apiEdgeRef{ |
| - Code: canon, |
| - Verified: ref.Verified, |
| - At: ref.At, |
| - Tier: ref.Tier.String(), |
| - RuleHash: fmt.Sprintf("%08x", ref.RuleHash), |
| + Code: canon, |
| + Verified: ref.Verified, |
| + At: ref.At, |
| + Tier: ref.Tier.String(), |
| + RuleHash: fmt.Sprintf("%08x", ref.RuleHash), |
| + RecordStatus: "absent", |
| } |
| - if len(recs) > 0 { |
| + switch { |
| + case err != nil: |
| + // The code still explains itself; a failed read must not take that |
| + // away (spec §7: a block stays explainable when its record is lost). |
| + log.Printf("console: api edge ref %s/%s: %v", acct.Sqid, app, err) |
| + resp.RecordStatus = "unavailable" |
| + case len(recs) > 0: |
| resp.Record = &recs[0] |
| + resp.RecordStatus = "found" |
| } |
| writeJSON(w, http.StatusOK, resp) |
| } |
diff --git a/internal/console/apiedgeref_test.go b/internal/console/apiedgeref_test.go| index 2cb90c874..2a88a93a9 100644 |
| --- a/internal/console/apiedgeref_test.go |
| +++ b/internal/console/apiedgeref_test.go |
| @@ -79,8 +79,8 @@ func TestEdgeRefFound(t *testing.T) { |
| if want := reqrec.RuleHash("probe-paths"); resp.RuleHash != fmt.Sprintf("%08x", want) { |
| t.Fatalf("rule_hash = %q, want %08x", resp.RuleHash, want) |
| } |
| - if resp.Record == nil || resp.Record.Fields["ref"] != code { |
| - t.Fatalf("record: %+v", resp.Record) |
| + if resp.Record == nil || resp.Record.Fields["ref"] != code || resp.RecordStatus != "found" { |
| + t.Fatalf("record %+v, status %q", resp.Record, resp.RecordStatus) |
| } |
| } |
| @@ -93,8 +93,8 @@ func TestEdgeRefRecordGone(t *testing.T) { |
| if status != http.StatusOK { |
| t.Fatalf("status = %d: %s", status, body) |
| } |
| - if !strings.Contains(body, `"record":null`) { |
| - t.Fatalf("want record null: %s", body) |
| + if !strings.Contains(body, `"record":null`) || !strings.Contains(body, `"record_status":"absent"`) { |
| + t.Fatalf("want record null and status absent: %s", body) |
| } |
| var resp apiEdgeRef |
| if err := json.Unmarshal([]byte(body), &resp); err != nil { |
| @@ -158,3 +158,52 @@ func TestEdgeRefPastedUpper(t *testing.T) { |
| t.Fatal("record not found for the pasted form") |
| } |
| } |
| + |
| +// TestEdgeRefLookAlikes: a code read out with o for 0 or i or l for 1, in |
| +// either case, verifies (ParseRef folds them) and so must also find its |
| +// record, and the answer names the code as the edge wrote it. |
| +func TestEdgeRefLookAlikes(t *testing.T) { |
| + fx, token := edgeRefFixture(t, "code-edgeref-lookalike") |
| + at := time.Now().UTC() |
| + var code string |
| + for i := 0; i < 1000 && !(strings.Contains(code, "0") && strings.Contains(code, "1")); i++ { |
| + code = reqrec.NewRef(at, reqrec.TierProbe, "probe-paths", "box1", edgeRefTestKey) |
| + } |
| + if !strings.Contains(code, "0") || !strings.Contains(code, "1") { |
| + t.Fatal("no code with both a 0 and a 1") |
| + } |
| + seedEdgeRecord(t, fx, code, at) |
| + for _, sub := range [][2]string{{"0", "o"}, {"0", "O"}, {"1", "i"}, {"1", "I"}, {"1", "l"}, {"1", "L"}} { |
| + pasted := strings.ReplaceAll(code, sub[0], sub[1]) |
| + resp := getEdgeRef(t, fx, token, pasted) |
| + if !resp.Verified || resp.Code != code || resp.Record == nil || resp.RecordStatus != "found" { |
| + t.Errorf("%s for %s (%s): code %q verified %v record %v status %q", sub[1], sub[0], pasted, resp.Code, resp.Verified, resp.Record != nil, resp.RecordStatus) |
| + } |
| + } |
| +} |
| + |
| +// TestEdgeRefRecordUnavailable: when the logs cannot be read, the code still |
| +// explains itself from its seal, and the answer says the record could not be |
| +// looked up rather than that it is gone. |
| +func TestEdgeRefRecordUnavailable(t *testing.T) { |
| + fx, token := edgeRefFixture(t, "code-edgeref-unavailable") |
| + at := time.Now().UTC() |
| + code := reqrec.NewRef(at, reqrec.TierHard, "dotenv", "box1", edgeRefTestKey) |
| + body := []byte("not a log chunk") |
| + key := mustKey(t)(applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(at), 900, len(body))) |
| + if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, _, raw := rawReq(t, http.MethodGet, edgeRefURL(fx, fx.sqid, "titogo", code), token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("status = %d: %s", status, raw) |
| + } |
| + var resp apiEdgeRef |
| + if err := json.Unmarshal([]byte(raw), &resp); err != nil { |
| + t.Fatal(err) |
| + } |
| + if resp.RecordStatus != "unavailable" || resp.Record != nil || !resp.Verified || resp.Tier != "hard" || |
| + resp.RuleHash != fmt.Sprintf("%08x", reqrec.RuleHash("dotenv")) || resp.Code != code { |
| + t.Fatalf("got %+v", resp) |
| + } |
| +} |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index a1168244b..e9e2d61dc 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -563,9 +563,11 @@ var exemptFromLegacyPin = map[string]bool{ |
| // about an address the caller chose. |
| "GET /api/cli/apps/{account}/{app}/geo": true, |
| // The edge reference code read (apiedgeref.go) is new access on the logs |
| - // read's footing: it reads the same app's edge records, and says nothing |
| - // about a code that does not verify. It did not exist before service |
| - // principals. |
| + // read's footing: an authenticated, app-scoped read of the same app's |
| + // edge records, behind the same instance gate and roles. An unsealed |
| + // code, or a sealed one the console has no mesh key to check, decodes |
| + // and is answered with verified:false; a forged or malformed code gets |
| + // the gate's own 404. It did not exist before service principals. |
| "GET /api/cli/apps/{account}/{app}/edge-refs/{code}": true, |
| // The import door (seeded-instances spec §6.1) is new access on the |
| // create door's footing: every import call takes the instance role, as |
diff --git a/internal/reqrec/ref.go b/internal/reqrec/ref.go| index 292d01497..e539dacc1 100644 |
| --- a/internal/reqrec/ref.go |
| +++ b/internal/reqrec/ref.go |
| @@ -18,7 +18,7 @@ import ( |
| // sink. Sealing it with the deployment's mesh key stops anyone minting a code |
| // that points support at a rule that never fired. |
| // |
| -// Layout, 20 bytes, then lower-case Crockford base32 without padding (32 |
| +// Layout, 25 bytes, then lower-case Crockford base32 without padding (40 |
| // characters, no leftover bits): |
| // |
| // 0 high nibble version (1), low nibble flags (bit 0 = sealed) |
| @@ -26,9 +26,15 @@ import ( |
| // 5 tier |
| // 6-9 RuleHash |
| // 10-11 BoxHash |
| -// 12-14 random nonce, so two blocks in one minute get different codes |
| -// 15-19 first 5 bytes of HMAC-SHA256(key, "carlos-edge-ref\n" + bytes 0-14), |
| +// 12-19 random nonce, so two blocks in one minute get different codes |
| +// 20-24 first 5 bytes of HMAC-SHA256(key, "carlos-edge-ref\n" + bytes 0-19), |
| // zero when unsealed |
| +// |
| +// The nonce is 8 bytes because codes are not sampled: every block mints one, |
| +// and the console finds a record by its code. With 3 bytes, 300 blocks of one |
| +// rule in one minute on one box shared a nonce about once in 370 minutes, |
| +// and a shared code shows one request's record for another. With 8 the |
| +// chance is negligible at any rate a box can serve. |
| // Tier is the protection tier that produced a block. |
| type Tier uint8 |
| @@ -69,10 +75,10 @@ var ( |
| ) |
| const ( |
| - refLen = 20 |
| + refLen = 25 |
| refVersion = 1 |
| flagSealed = 1 |
| - macOffset = 15 |
| + macOffset = 20 |
| macLen = refLen - macOffset |
| refAlphabet = "0123456789abcdefghjkmnpqrstvwxyz" |
| @@ -117,7 +123,7 @@ func NewRef(at time.Time, tier Tier, ruleID, box string, key []byte) string { |
| binary.BigEndian.PutUint16(b[10:12], BoxHash(box)) |
| // crypto/rand does not fail on supported platforms; if it ever did, a |
| // zero nonce only costs uniqueness, not correctness. |
| - _, _ = rand.Read(b[12:15]) |
| + _, _ = rand.Read(b[12:macOffset]) |
| if len(key) > 0 { |
| copy(b[macOffset:], refMAC(key, b[:macOffset])) |
| } |
| @@ -126,6 +132,7 @@ func NewRef(at time.Time, tier Tier, ruleID, box string, key []byte) string { |
| // normalizeRef undoes what people do to a code on its way back to us: case, |
| // spacing, hyphens, and the look-alike letters Crockford folds together. |
| +// It is CanonicalRef without the shape check. |
| func normalizeRef(code string) string { |
| var sb strings.Builder |
| for _, c := range strings.ToLower(code) { |
| @@ -142,20 +149,35 @@ func normalizeRef(code string) string { |
| return sb.String() |
| } |
| +// CanonicalRef returns the one spelling of a pasted code that the edge |
| +// writes into a record's ref field: lower case, no spaces or hyphens, and |
| +// Crockford's look-alikes folded (o to 0, i and l to 1). ParseRef verifies |
| +// this form and the console looks records up by it, so a code that verifies |
| +// always finds its record. A string that cannot be a code (wrong length, a |
| +// letter outside the alphabet) is ErrBadRef; the version, tier and seal are |
| +// ParseRef's to judge. |
| +func CanonicalRef(code string) (string, error) { |
| + s := normalizeRef(code) |
| + if len(s) != refEncoding.EncodedLen(refLen) { |
| + return "", ErrBadRef |
| + } |
| + if b, err := refEncoding.DecodeString(s); err != nil || len(b) != refLen { |
| + return "", ErrBadRef |
| + } |
| + return s, nil |
| +} |
| + |
| // ParseRef decodes a code. With keys given, a sealed code must verify against |
| // one of them (current key first, then the next one during rotation) or it is |
| // ErrForgedRef. With no keys it decodes with Verified=false, which is how a |
| // reader without the mesh key still gets the rule and the time. Empty keys are |
| // ignored, so a caller passing a missing key gets the no-keys behaviour. |
| func ParseRef(code string, keys ...[]byte) (Ref, error) { |
| - s := normalizeRef(code) |
| - if len(s) != refEncoding.EncodedLen(refLen) { |
| - return Ref{}, ErrBadRef |
| - } |
| - b, err := refEncoding.DecodeString(s) |
| - if err != nil || len(b) != refLen { |
| - return Ref{}, ErrBadRef |
| + s, err := CanonicalRef(code) |
| + if err != nil { |
| + return Ref{}, err |
| } |
| + b, _ := refEncoding.DecodeString(s) // CanonicalRef decoded it already |
| if b[0]>>4 != refVersion || b[5] > byte(TierChallenge) { |
| return Ref{}, ErrBadRef |
| } |
diff --git a/internal/reqrec/ref_test.go b/internal/reqrec/ref_test.go| index 4454532e4..a4e85474d 100644 |
| --- a/internal/reqrec/ref_test.go |
| +++ b/internal/reqrec/ref_test.go |
| @@ -12,7 +12,7 @@ func TestRefRoundTrip(t *testing.T) { |
| key := bytes.Repeat([]byte{1}, 32) |
| at := time.Date(2026, 10, 24, 21, 7, 31, 0, time.UTC) |
| code := NewRef(at, TierProbe, "wp-login", "box-ie-1", key) |
| - if len(code) != 32 { |
| + if len(code) != 40 { |
| t.Fatalf("len = %d", len(code)) |
| } |
| r, err := ParseRef(code, key) |
| @@ -82,7 +82,7 @@ func TestRefBadTierAndVersion(t *testing.T) { |
| if _, err := ParseRef(raw(0x10, 1)); err != nil { |
| t.Errorf("valid unsealed: %v", err) |
| } |
| - if _, err := ParseRef(NewRef(time.Now(), TierHard, "r", "b", key)[:31], key); !errors.Is(err, ErrBadRef) { |
| + if _, err := ParseRef(NewRef(time.Now(), TierHard, "r", "b", key)[:39], key); !errors.Is(err, ErrBadRef) { |
| t.Errorf("short code: %v", err) |
| } |
| // A tampered field on a sealed code fails the seal. |
| @@ -97,3 +97,82 @@ func TestRefBadTierAndVersion(t *testing.T) { |
| t.Errorf("tampered: %v", err) |
| } |
| } |
| + |
| +// TestRefNonceIsEightBytes: the nonce is bytes 12-19 and the seal covers all |
| +// of it, so two codes for one rule, tier, minute and box collide only by |
| +// chance in 64 bits, and changing any nonce byte breaks the seal. |
| +func TestRefNonceIsEightBytes(t *testing.T) { |
| + key := bytes.Repeat([]byte{1}, 32) |
| + at := time.Date(2026, 10, 24, 21, 7, 0, 0, time.UTC) |
| + raw := func(code string) []byte { |
| + b, err := refEncoding.DecodeString(code) |
| + if err != nil || len(b) != 25 { |
| + t.Fatalf("decode %q: %d bytes, %v", code, len(b), err) |
| + } |
| + return b |
| + } |
| + a, b := raw(NewRef(at, TierProbe, "r", "box", key)), raw(NewRef(at, TierProbe, "r", "box", key)) |
| + if !bytes.Equal(a[:12], b[:12]) { |
| + t.Fatal("same rule, tier, minute and box gave different headers") |
| + } |
| + if bytes.Equal(a[12:20], b[12:20]) { |
| + t.Fatal("two codes share a nonce") |
| + } |
| + for i := 12; i < 20; i++ { |
| + c := append([]byte(nil), a...) |
| + c[i] ^= 0x01 |
| + if _, err := ParseRef(encodeRef(c), key); !errors.Is(err, ErrForgedRef) { |
| + t.Errorf("nonce byte %d changed: err = %v, want ErrForgedRef", i, err) |
| + } |
| + } |
| +} |
| + |
| +// TestCanonicalRef: every spelling ParseRef accepts has one canonical form, |
| +// the one the edge writes, so verification and record lookup agree on what |
| +// was pasted. |
| +func TestCanonicalRef(t *testing.T) { |
| + key := bytes.Repeat([]byte{1}, 32) |
| + code := codeWith(t, key, '0', '1') |
| + variants := map[string]string{ |
| + "o for 0": strings.ReplaceAll(code, "0", "o"), |
| + "O for 0": strings.ReplaceAll(code, "0", "O"), |
| + "i for 1": strings.ReplaceAll(code, "1", "i"), |
| + "I for 1": strings.ReplaceAll(code, "1", "I"), |
| + "l for 1": strings.ReplaceAll(code, "1", "l"), |
| + "L for 1": strings.ReplaceAll(code, "1", "L"), |
| + "upper, dash": strings.ToUpper(code[:20] + "-" + code[20:]), |
| + "spaces": " " + code[:10] + " " + code[10:] + " ", |
| + } |
| + for what, v := range variants { |
| + got, err := CanonicalRef(v) |
| + if err != nil || got != code { |
| + t.Errorf("%s: CanonicalRef(%q) = %q, %v; want %q", what, v, got, err, code) |
| + } |
| + if r, err := ParseRef(v, key); err != nil || !r.Verified { |
| + t.Errorf("%s: ParseRef: %+v %v", what, r, err) |
| + } |
| + } |
| + for _, bad := range []string{"", "not-a-code", code[:39], code + "0", strings.Replace(code, code[:1], "u", 1)} { |
| + if _, err := CanonicalRef(bad); !errors.Is(err, ErrBadRef) { |
| + t.Errorf("CanonicalRef(%q): err = %v, want ErrBadRef", bad, err) |
| + } |
| + } |
| +} |
| + |
| +// codeWith mints sealed codes until one contains every byte in want, so a |
| +// look-alike substitution test has something to substitute. |
| +func codeWith(t *testing.T, key []byte, want ...byte) string { |
| + t.Helper() |
| + for i := 0; i < 1000; i++ { |
| + code := NewRef(time.Now(), TierProbe, "probe-paths", "box1", key) |
| + ok := true |
| + for _, c := range want { |
| + ok = ok && strings.IndexByte(code, c) >= 0 |
| + } |
| + if ok { |
| + return code |
| + } |
| + } |
| + t.Fatalf("no code containing %q in 1000 tries", want) |
| + return "" |
| +} |