fix(capability-tokens): canonical signing payload survives JSONB round-trip#168
fix(capability-tokens): canonical signing payload survives JSONB round-trip#168ExpertVagabond wants to merge 2 commits intosint-ai:mainfrom
Conversation
…d-trip (closes sint-ai#166) The signing payload is canonicalized manually at the top level only, so nested objects (constraints, delegationChain) are serialized using JS object-property iteration order. After a token is stored in Postgres JSONB and retrieved, nested key order is not preserved, so re-computing the signing payload produces a different string and signature verification fails with INVALID_SIGNATURE. Replace the hand-rolled top-level-only canonicalization with recursive key sorting (RFC 8785 JCS-style) in a new canonicalJSONStringify helper. computeSigningPayload now also strips the signature field explicitly so the full signed token and the unsigned token produce identical output. BREAKING: tokens signed before this fix may fail validation because the nested-key ordering was non-deterministic. In practice no such tokens are long-lived since any round-trip through JSONB already broke them. Adds 9 tests: 7 unit tests for canonicalJSONStringify + 2 regression tests covering the reordered-constraints / reordered-delegationChain scenarios.
Codify the algorithm sint-ai#168 fixes: keys sorted at every depth, arrays preserve order, undefined omitted. Satisfies the runtime-change guardrail and matches the rule already documented for ledger hashing.
|
Heads-up on CI: |
|
Just synced with main and noticed |
|
Good catch on the diagnosis and the fix is minimal in the right way. Leaving a few notes before we land this — mostly converging on the open questions you already raised. 1. Canonical-JSON duplication (your own flag, worth resolving before merge). 2. Persistence migration follow-up is real. 3. Spec version bump. 4. Test gap (low priority). No vector for 5. CI failures. Verdict: looks ready after (1) lands. (2) and (3) as follow-ups tracked separately. Ping me when the core consolidation is pushed and I'll re-review and merge. |
Closes #166.
The bug
computeSigningPayloadcanonicalizes only the top-level keys and relies on JavaScript property iteration order for anything nested. After a token is stored in PostgresJSONBand retrieved, nested key order is not preserved, so re-computing the payload at verify time produces a different string than what was signed — validation fails withINVALID_SIGNATURE.Reproducer
This hit me against a running
sint-gateway-serverpointed at Postgres:POST /v1/tokensreturns 201, the token is persisted and cached, butPOST /v1/interceptthen denies every request because the retrieved token fails signature verification.The fix
canonicalJSONStringifyinutils.ts: recursive key sort (RFC 8785 JCS-style), omitsundefined, preserves array element order.computeSigningPayloadnow uses it and explicitly strips thesignaturefield, so callers may pass either the unsigned or signed token interchangeably (the APS cross-verify test does the latter).Tests
canonicalJSONStringify(__tests__/canonical-json.test.ts).validator.test.tscovering reorderedconstraintsand reordereddelegationChain.@pshkv/gate-capability-tokens, 257 in@pshkv/gate-policy-gateway, no regressions across the 20+ other packages.Compatibility
Follow-ups (separate issues, not blocking this PR)
packages/persistence/src/pg-token-store.tsrowToTokendrops five optional token fields (modelConstraints,attestationRequirements,verifiableComputeRequirements,executionEnvelope,revocationEndpoint) and three newer ones (behavioralConstraints,passportId,delegationDepth). If any are set at issue time, round-trip still breaks the signature even after this fix. Needs a schema migration.