Skip to content

fix(capability-tokens): canonical signing payload survives JSONB round-trip#168

Open
ExpertVagabond wants to merge 2 commits intosint-ai:mainfrom
ExpertVagabond:fix/canonical-signing-payload
Open

fix(capability-tokens): canonical signing payload survives JSONB round-trip#168
ExpertVagabond wants to merge 2 commits intosint-ai:mainfrom
ExpertVagabond:fix/canonical-signing-payload

Conversation

@ExpertVagabond
Copy link
Copy Markdown
Collaborator

Closes #166.

The bug

computeSigningPayload canonicalizes only the top-level keys and relies on JavaScript property iteration order for anything nested. After a token is stored in Postgres JSONB and 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 with INVALID_SIGNATURE.

Reproducer

// Fresh token validates fine.
const token = issueCapabilityToken(req, issuerPriv).value;
validateCapabilityToken(token, { resource, action });
// → ok

// Simulate JSONB round-trip: reconstruct `constraints` with different key order.
const roundTripped = {
  ...token,
  constraints: {
    requiresHumanPresence: token.constraints.requiresHumanPresence,
    maxForceNewtons: token.constraints.maxForceNewtons,
    maxVelocityMps: token.constraints.maxVelocityMps,
  },
};
validateCapabilityToken(roundTripped, { resource, action });
// → INVALID_SIGNATURE

This hit me against a running sint-gateway-server pointed at Postgres: POST /v1/tokens returns 201, the token is persisted and cached, but POST /v1/intercept then denies every request because the retrieved token fails signature verification.

The fix

  • Added canonicalJSONStringify in utils.ts: recursive key sort (RFC 8785 JCS-style), omits undefined, preserves array element order.
  • computeSigningPayload now uses it and explicitly strips the signature field, so callers may pass either the unsigned or signed token interchangeably (the APS cross-verify test does the latter).

Tests

  • 7 unit tests for canonicalJSONStringify (__tests__/canonical-json.test.ts).
  • 2 regression tests in validator.test.ts covering reordered constraints and reordered delegationChain.
  • Full workspace test suite runs clean — 76 tests in @pshkv/gate-capability-tokens, 257 in @pshkv/gate-policy-gateway, no regressions across the 20+ other packages.

Compatibility

⚠️ Breaking for any token signed under the old algorithm. In practice no such tokens are durable — any persisted token already failed validation after the first JSONB round-trip — so this is net positive.

Follow-ups (separate issues, not blocking this PR)

  • packages/persistence/src/pg-token-store.ts rowToToken drops 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.

…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.
@ExpertVagabond
Copy link
Copy Markdown
Collaborator Author

Heads-up on CI: build-and-test (22) and generate-benchmark-report fail because @pshkv/bridge-mqtt runs tsc with no tsconfig present (printed help page, exit 1). This is pre-existing on main — latest CI run on main (24600105722) fails the same way. Nothing in this PR touches that package.

@ExpertVagabond
Copy link
Copy Markdown
Collaborator Author

Just synced with main and noticed packages/core/src/canonical-json.ts already exists upstream with a stricter JCS-style implementation (rejects non-finite numbers, asserts JSON-compatibility). Reasonable reviewer preference would be: delete the canonicalJSONStringify I added in capability-tokens/src/utils.ts and have computeSigningPayload import from @pshkv/core instead. Happy to rework the PR that way — just wanted to flag before a reviewer does. The core impl does reject undefined rather than omitting it, so I'd either need to pre-strip undefined fields or propose a tolerant mode.

@pshkv
Copy link
Copy Markdown
Contributor

pshkv commented Apr 20, 2026

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). packages/core/src/canonical-json.ts already implements a stricter JCS (rejects non-finite numbers, asserts JSON compat, rejects undefined rather than omitting). Two canonicalization implementations in the same repo is exactly the class of bug that produces the next divergent-serialization incident. Preference: add a { tolerateUndefined: true } mode to @sint/core/canonical-json.ts and have capability-tokens import from there. That keeps strict mode as the default for new code and makes the undefined-omitting behavior (which matches JSON.stringify semantics used elsewhere) an explicit opt-in. Can you rework before merge?

2. Persistence migration follow-up is real. packages/persistence/src/pg-token-store.ts rowToToken drops eight optional fields (modelConstraints, attestationRequirements, verifiableComputeRequirements, executionEnvelope, revocationEndpoint, behavioralConstraints, passportId, delegationDepth). Any token issued with those fields set will still fail signature validation post-round-trip even with this PR merged. Tracking as a separate issue so we don't close #166 on a partial fix — I'll open it.

3. Spec version bump. docs/specs/sint-protocol-v1.0.md §4.1.1 is now a normative change. APS, MolTrust, and the AAIF submission packet are cross-verifying against v1.0 — worth versioning this as v1.1 with a "canonical signing payload" changelog entry rather than an in-place edit. Tiny delta, big clarity gain for downstream implementers.

4. Test gap (low priority). No vector for delegationChain.parentTokenId = null vs undefined in a full token context. The primitives test covers nested null preservation but not the token-shaped case where the new fix interacts with undefined-omission and null-preservation in one path. Worth adding a vector when you touch this next.

5. CI failures. build-and-test (22) + generate-benchmark-report are pre-existing on main (@sint/bridge-mqtt missing tsconfig per your note). Not a blocker for this PR — opening a separate issue for the CI unblock so it doesn't cargo-cult into other PRs.

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.

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.

Token roundtrip: POST /v1/tokens issues a token whose signature /v1/intercept rejects (INVALID_SIGNATURE)

3 participants