Skip to content

fix(verifier,gateway): session keys emit a canonical scope (sorted, deduplicated, before signing) — #361 E7 F4 - #482

Draft
LamaSu wants to merge 5 commits into
masterfrom
fix/session-key-canonical-scope
Draft

LamaSu wants to merge 5 commits into
masterfrom
fix/session-key-canonical-scope

Conversation

@LamaSu

@LamaSu LamaSu commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

What

issueSessionKey (and deriveSessionKey, which had the same defect) now emit a canonical scope: allowedActions and contractIds sorted in UTF-16 code-unit order (JS default sort, never localeCompare) and deduplicated, built before signing. So the signed bytes and the emitted SessionKey object are the same form.

POST /api/identity/session answers a malformed scope (a present list that is not a string array) with 400 invalid_scope.

Why

Evidence's #361 (EvidenceBlockV2) commits sessionKeyAuthDigest = sha256(canonicalize(SessionKeyAuthorization)). Cross-family review E7 F4 found that one authorization could have several digests: the issuer emitted the caller's arrays verbatim, while the signature already covered a sorted copy, and that copy kept duplicates. Evidence's ask: #4668. Gateway's answer: #4670.

Not changed (deliberately)

  • canonicalSessionKeyBytes and signature verification are unchanged. Deduping in the canonicalizer would break keys already signed over sorted-with-duplicates arrays; a mutation proves it, and a test pins that legacy keys still verify.
  • Verification stays ORDER-insensitive (the canonicalizer sorts), but it is NOT duplicate-insensitive: adding or removing a duplicate changes the signed preimage (correction from astra pack 145). "One digest per authorization" holds for what the issuer emits. feat(spec): EvidenceBlockV1 v2 producer (evidenceBlockHash for FinalMilestonePackageV2) #361 pins it by rejecting non-canonical spellings at digest time.

Round 2 (astra pack 145, SHIP-WITH-FIXES, F1 MEDIUM)

  • F1: validation read value[i] by index, but canonicalization consumed new Set(value) (the iterator). A direct caller could slip an unvalidated element in through a custom iterator or a changing accessor. Reproduced (3 failing at 7528bb1); fixed in a762445: each element is read once into a validated snapshot, and an own Symbol.iterator is refused. 2 of 2 mutants killed; verifier 30 files / 627; gateway route 13/13.

Evidence

  • Reproduced first (e84210c): the verifier's new test failed 11 of 20, and the gateway route test failed 3 of 4.
  • Mutants, all killed:
    • dedupe removed; sort removed;
    • localeCompare ("B" vs "a", and a surrogate pair vs U+FF5E);
    • scope built after signing; the canonicalizer deduping (breaks legacy keys);
    • the element and array checks removed; derive using the raw scope;
    • the route's 400 removed, the route skipping contractIds, the route echoing the request scope.
  • Suites (capped workers): the verifier passes 30 files / 624 tests; the gateway passes 204 files / 3261 tests (6 pre-existing skips). tsc is clean in both.
  • One existing assertion changed: ephemeral-identity.test.ts test 2 pinned the old verbatim (unsorted) emission. It still uses an exact toEqual, now with the canonical order.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GihjSLwaMhkBW2DAERqsi1

LamaSu and others added 5 commits October 1, 2026 02:44
… E7 F4)

#361 commits sessionKeyAuthDigest = sha256(canonicalize(SessionKeyAuthorization)).
The cross-family review found one authorization with several digests:
issueSessionKey (and deriveSessionKey) put the caller's scope.allowedActions and
scope.contractIds into the emitted key verbatim, and canonicalSessionKeyBytes
sorts a copy for signing but keeps duplicates.

Reproduced first on 75fd440, tests only, no source change:
- verifier ephemeral-identity-canonical-scope.test.ts: 11 failed, 9 passed (20).
  ["workflow_step_complete","evidence_submit","evidence_submit"] with
  ["c2","c1","c2"] is emitted unsorted and duplicated; two spellings of one
  scope emit different arrays; a duplicate changes the signed canonical bytes
  (randomness, time and the session key are pinned, so the only difference in
  the signed JSON is the duplicate inside scope); code-unit order ("B" before
  "a") is not applied; the emitted arrays alias the caller's.
- gateway identity-session.test.ts: 3 failed, 1 passed (4). POST
  /api/identity/session returns the same unsorted, duplicated scope.

The passing tests are deliberate characterization and must stay green after the
fix: reordering alone is already absorbed by the canonicalizer's sort, an issued
key verifies end to end, and keys signed over sorted-but-duplicated arrays (what
the old issuer produced) still verify.

Agent: implementer-sessionkey

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GihjSLwaMhkBW2DAERqsi1
…scope (#361 E7 F4)

Both issuers now build scope.allowedActions and scope.contractIds as
[...new Set(xs)].sort() (UTF-16 code-unit order, never localeCompare) BEFORE the
SessionKey struct is signed. The emitted object, the bytes the parent signs and
any digest taken over the emitted authorization now describe one scope, so one
authorization has one digest however the caller spelled the lists. The arrays
are fresh copies: a later edit to the caller's array can no longer change an
issued key.

A scope list that is not an array of strings is refused with a clear Error
before anything is signed ("scope.contractIds[1] must be a string, got null").
A bare string is refused too, since spreading it would turn "abc" into
["a","b","c"].

canonicalSessionKeyBytes is deliberately unchanged. It sorts a copy but does not
de-duplicate, and it must stay that way: a key signed over sorted-with-duplicates
arrays (what the old issuer produced) is verified against exactly those bytes, so
de-duplicating there would break keys that are already in the field. A test
hand-signs such keys, including the unsorted-with-duplicates shape the old
issuer emitted, and pins that they still verify. verifySessionSignedEvent is
untouched.

Existing test changed, one assertion: test 2 in ephemeral-identity.test.ts
passed ["touchstone_response","attestation_sign"] and asserted the same order
back, i.e. it pinned the old verbatim emission this fix removes. It is still an
exact toEqual with the same unsorted input; the expected array is now the
canonical order, with a comment saying why.

Tests added to ephemeral-identity-canonical-scope.test.ts: malformed scope
rejected by both issuers (8 shapes each) and before the signer is reached;
empty lists unchanged; a surrogate pair sorts before U+FF5E (code unit, not code
point). The reproduction tests from e84210c now pass: 39/39 in that file, and
39/39 in the existing file.

Agent: implementer-sessionkey

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GihjSLwaMhkBW2DAERqsi1
…th 400 invalid_scope (#361 E7 F4)

The route validated only principalAgentId and cast the rest of the body, so a
scope list that was not an array of strings went straight to issueSessionKey.
Now that issueSessionKey refuses such a list (a81e3d3), that would surface as a
500 session_key_issuance_failed. Reproduced first, route unchanged: 5 tests
failed with "expected 500 to be 400".

A present allowedActions or contractIds that is not an array of strings is now
a 400 { error: "invalid_scope", message: "scope.contractIds[1] must be a string" }
and issues nothing (no key material in the body). Absent or null lists still
mean the defaults and explicit empty lists still mean empty; tests that pass
both before and after this change pin both.

The scope the route returns is canonical (sorted, de-duplicated) because
issueSessionKey now makes it so; the route needed no change for that, and the
route tests from e84210c pass (13/13 in the file). The route's verify and
revoke handlers are untouched.

Agent: implementer-sessionkey

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GihjSLwaMhkBW2DAERqsi1
…nd canonicalization read different values

canonicalScopeList validated value[i] by index, then built the scope from
`new Set(value)`, which reads the caller's array a second time through its
iterator. A direct service caller (JSON cannot carry these) can supply a
custom Symbol.iterator, or an index accessor that changes between reads,
and get an unvalidated element signed. Three tests (iterator, accessor,
allowedActions) fail at 7528bb1: 3 failed | 39 passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GihjSLwaMhkBW2DAERqsi1
…hot (astra pack 145 F1)

canonicalScopeList now reads each indexed element exactly once into a local,
validates it, and copies it into a fresh snapshot; only the snapshot is
de-duplicated and sorted, so what is signed is exactly what was validated.
An array carrying its own Symbol.iterator is refused outright (defense in
depth). Accessors that change between reads can no longer smuggle an
unvalidated element into the signed scope. The canonicalizer and
verification are unchanged. Scope tests 42/42 (3 failed at 7528bb1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GihjSLwaMhkBW2DAERqsi1

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant