Skip to content

feat(tsp): pack TSP Rev 3 natively, read Rev 3 and Rev 2 - #253

Merged
stormer78 merged 11 commits into
mainfrom
feat/tsp-rev2-rev3-dual-handler
Sep 17, 2026
Merged

stormer78 merged 11 commits into
mainfrom
feat/tsp-rev2-rev3-dual-handler

Conversation

@stormer78

@stormer78 stormer78 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

What this is

The plugin side of the TSP Rev 2 → Rev 3 cutover. The wallet now packs Rev 3
and only Rev 3
, and reads Rev 3 or Rev 2 by dispatching on the version
marker the wire carries. This is the plugin half of a coordinated cutover; the
VTA half — making Rev 3 the default it reads and packs — landed in VTI
verifiable-trust-infrastructure#1512 (feat(tsp)!: adopt Rev 3, merged
2026-09-16); the VTA answers inbound relationship invites as of
verifiable-trust-infrastructure#1525. So this branch is safe to merge without
dropping the wallet's outbound TSP to REST at the receiver.

Before this, the wallet's Rev-3 frames were unreadable by a VTA still on
affinidi-tsp 0.1.x, so the TSP channel silently fell through to REST
(tsp-channel.ts — "a VTA still on affinidi-tsp 0.1.x cannot read what we
send; that is the cutover, not a bug"). With the VTA now reading Rev 3, TSP to
the VTA completes end-to-end.

Design (per CLAUDE.md's TSP Rev 2 exception)

  • We pack Rev 3 unconditionally. There is no dual-accept arm, no
    if (rev === 2) branch on the pack path, and no negotiation — an outbound
    message has nothing to read, so nothing to negotiate.
  • Rev 2 is a frozen, decode-only codec in its own directory
    (packages/tsp-js/src/rev2/reader.ts), reached by dispatching on the version
    marker (revision.tspeekRevision, reads ≤9 bytes, touches no key). It
    exists because TSP is a ToIP spec with implementations nobody here controls
    (the reference tsp_sdk shipped Rev 3 in 0.10.0 while others are still on
    Rev 2) — not for the VTA, which cuts over with us. It is deleted whole,
    with its arm of the dispatcher, the day the last Rev 2 peer is gone.
  • Rev 3 requires relationships before application messages (§7.2.2), so
    packInvite/unpack XRFI/XRFA relationship forming was added
    (tsp-js/src/relationship.ts, core/src/vta/tsp-relationship.ts).

This is the one legitimate exception to this repo's "no compat folds" rule, and
it stays one precisely because a peer we do not control is on the far side of
Rev 2 — for the VTA and the mediator the answer is no, and no second version
arm belongs anywhere else in the tree.

Scope

  • packages/tsp-js: Rev 3 codec (rev3/{control,direct,envelope,fields,payload}.ts),
    Rev 2 decode-only reader, revision.ts dispatcher, relationship.ts, CESR
    wire + HPKE additions, spec Appendix A vectors + interop/dispatch tests.
  • packages/core: vta/tsp-channel.ts (Rev 3 pack, dual read), new
    vta/tsp-relationship.ts, vta/index.ts export, @openvtc/vti-tsp-js bumped
    to ^0.3.0, tests/tsp.relationship.mjs.
  • CLAUDE.md: the TSP Rev 2 exception documented as the sole compat-fold
    carve-out.

Verification

  • npm run build — clean; dist/manager.js and dist/background.js each a
    single chunk (MV3 + admin-guard invariants hold).
  • npm test1,521 pass / 0 fail (tsp-js 124, core 656, extension 726,
    demo-rp 6, reviewer-demo 9). Includes the real spec Appendix A (YTSP-AAC)
    vectors re-packed byte for byte and the Rev 2/Rev 3 dispatch tests.

Pre-merge checklist (vti-stack-development-guide §9)

  • No new reqwest::Client::new() / bare fetch(); all clients have finite timeouts (R1.2) — N/A to this diff; TSP goes over the existing injected transport.
  • No lock held across a network await (R1.3)
  • No local state committed before its remote effect, or the flow is resumable with an idempotency key (R2.1)
  • Every retry is bounded + backed off; non-idempotent ops are not blind-retried (R1.4) — relationship forming is guarded "at most once at a time".
  • Accept/poll/listen loops survive transient errors (R1.5)
  • Acks/deletes happen only after durable handoff (R1.6) — inbound persist-before-ack path untouched.
  • New/changed wire types: camelCase, deny_unknown_fields where security-relevant, schema registered, all consumers (incl. JS) updated (R3.*) — TSP is CESR/binary; revision dispatched on the wire version marker, errors matched on stable code (E_TSP_REVISION), not strings (R3.7).
  • Config absence = most restrictive; fail-closed if enforcement can't start (R5.*) — an unreadable/unrecognised revision throws rather than guessing.
  • Logs/status claim only what was verified; background-job failures are surfaced (R6.*)
  • "Process dies on the next line" answered for every mutation touched (R2.1)
  • Deviations from this guide flagged explicitly with rule numbers — the Rev 2 decode-only codec is the documented exception to "no compat folds"; see CLAUDE.md and the design note above.

The specification's Rev 3 changed the crypto mode, the version byte, the
long count-code prefix, the ciphertext code and layout, the `-E` count's
meaning, the signature code and every payload layout at once. Nothing a
Rev 2 peer packs can be unpacked by a Rev 3 one or the reverse, and the
protocol negotiates nothing.

The wire carries a version marker, so reading can be dual. `unpack` now
peeks it — a fixed offset, no keys, nine bytes — and dispatches. Packing
cannot be: an inbound message says what it is, an outbound one has to be
decided before a byte exists, and no field says what a peer can read. So
we pack Rev 3 only. A Rev 2 peer cannot read what we send; that is the
cutover, not a bug.

That asymmetry is what keeps this from being the compatibility fold
CLAUDE.md bans. There is no `if (rev === 2)` anywhere on the Rev 3 path.
Rev 2 is a frozen decode-only codec in `src/rev2/`, and it is deleted
whole — with its arm of the dispatcher — the day the last Rev 2 peer is
gone. CLAUDE.md now records why TSP is the one exception: it is a ToIP
specification with implementations nobody here controls, where the VTA
and the mediator cut over with us and other peers do not.

Wire changes, all of them Rev 3's:

  - HPKE-Auth -> HPKE-Base. The sender's key leaves the KEM, so `PackKeys`
    lost `senderEncryptionKey` outright and `UnpackKeys` keeps it as an
    optional Rev 2-only member — HPKE-Auth cannot *open* a message
    without it. `info` is the fixed code `YTSP-`; the AAD is the envelope
    fields, where Rev 2 passed the whole frame as `info` with empty AAD.
  - `YTSP-AAB` -> `YTSP-AAC`, MAJOR.MINOR. Only MAJOR gates, so any other
    MINOR at MAJOR 0 — upstream's `ABA` (64) included — reads as Rev 3.
  - Ciphertext `G` -> `F`, field `enc || ct`. A `C`-coded sealed box
    (§8.3) is recognised and refused by name, never as a corrupt `F`.
  - Long counts `-0X#####` -> `--X#####`.
  - The `-E` count covers all signable content, so the frame is finalized
    after sealing: `encodeEnvelope` is replaced by `encodeFields` +
    `finalizeFrame`, split exactly where the AAD boundary falls.
  - The `X 00 00` marker is deleted; the receiver field is always written.
  - Indexed signature `B#` under length-based counts `-C23 -K22`.
  - Payload frames carry an ESSR sender VID and a padding field, a direct
    body sits in a `-A` stream, `-J` counts bytes not VIDs, and a nested
    inner is raw — so it must be quadlet-aligned.

Two bugs fixed on the way, both latent:

  - `decodeCount` returned long-form counts with the identifier bits still
    in them, so every long-framed message decoded to a wrong length. The
    test covering it asserted the wrong behaviour deliberately, calling it
    "a reference quirk ... benign, TSP frames by cursor position and
    discards this value". Rev 3 makes it fatal: the `-E` and `-Z` counts
    are load-bearing lengths now. affinidi-tsp fixed the same bug
    independently.
  - `MAX_HOPS` was 16 when packing against a decoder that stopped at 10, so
    a 12-hop route packed cleanly and could not be read back here.

Checked three ways, because a round trip proves none of it — encoder and
decoder agree with each other whatever they both get wrong, which is
precisely what Rev 3's one-character changes produce:

  - The specification's own Appendix A vectors run as a suite. Every
    published vector is exercised or named as uncovered, so the list
    cannot quietly shrink.
  - Both directions against affinidi-tsp's `tsp-rev3` branch: its Rev 2
    vector unpacks here, and a message packed here unpacks there with a
    byte-identical thread digest.
  - Pinned bytes for the deterministic parts of what we emit, since a
    sealed message is not reproducible.

Not implemented, each for a stated reason rather than by omission: the
libsodium sealed box, the post-quantum suite, fillable padding, and the
relationship state machine. The last one matters for the cutover — Rev 3
gates application messages on a relationship by default, so a VTA
enforcing §7.2.2 will drop what we send until that layer exists.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Rev 3 §7.2.2 turned relationships from bookkeeping into a precondition:
an endpoint SHOULD drop an application message from a VID it holds no
relationship with. Dropped, not refused — nothing comes back — so a
wallet that skips the handshake does not get an error, it gets a
30-second timeout and a transport that looks broken. The Rust SDK
defaults `tsp_relationship_gating` to true, so this is the live default
on the far side, not a setting someone might turn on.

vti-tsp-js gains the codec and the rules:

  - `packInvite` / `packAccept` / `packCancel`, and `unpack` returning a
    verified `control` message.
  - The §7.2.1 `TSP_Digest`, which is the substance of it: self-addressing
    over the message's own envelope and payload with its own slot filled
    by 33 dummy bytes, carried on the wire, recomputed by the receiver,
    and refused on a mismatch. Rev 2 correlated on a hash of the encrypted
    payload that was never transmitted and so could never be checked.
  - `relationship.ts` — the §7.2/§7.3 state machine, pure: state and event
    in, state or a refusal out, with no storage, clock or keys. That is
    the line between the two packages. `unpack` does not apply it, because
    a codec that mutated relationship state would make receiving a message
    a side effect, and a wallet must be able to look at an invite before
    answering it.

Two rules are encoded rather than commented. An accept's digests are
named `inReplyTo` and `digest` rather than the spec's `Digest` and
`Reply_Digest`, whose order is the reverse of how they read — affinidi-tsp
keeps the spec names and warns about it in a comment, and a name that
cannot spring the trap is stronger than a warning. And the §7.2.3 invite
race is decided on bytes alone: both sides keep the lexicographically
lower digest, because a timestamp or an "ours wins" tie-break would let
the two ends disagree and form two half-relationships.

pnm-core gains the handshake, shaped by what the far side actually does:

  - `ensureRelationship` invites before the first application message, at
    most once at a time — N concurrent first-sends would otherwise invite
    N times, which a peer's state machine refuses as repeated sendInvite
    from pending, failing every request after the first.
  - It is best-effort and never throws. A VTA that does not implement
    control messages never answers, and a wallet that waited for an accept
    before ever sending would have broken itself against every peer that
    does not gate — which is every peer running today.
  - A failed send clears the record. The far side's store is
    `InMemoryRelationshipStore` by default, so a VTA forgets every
    relationship when its process restarts; a wallet that kept believing
    its own record would post into silence indefinitely. Re-inviting costs
    one round trip and cannot go quietly wrong, so the default store here
    is deliberately no more durable than theirs.

Verified against the specification's three published control vectors,
which is what makes the digest derivation trustworthy: our decoder
recomputes the SAID and refuses on mismatch, so a vector that unpacks at
all is a derivation agreeing with the ToIP reference over the envelope,
both VIDs, the type code, the sender field, the nonce, the reply path,
the referral slot and the dummy bytes. The accept vector is checked
against the invite vector rather than itself, so the two-digest order is
pinned to external bytes. Both directions against affinidi-tsp again: an
invite and an accept packed here unpack there with matching digests.

Not implemented, deliberately: composing a referral (§7.2.5). Its
`Signature_new` covers the invite's digest, so composing one needs the
introduced VID's signing key at pack time, and a wallet holding another
VID's private key is not a shape to invite. Referrals are decoded and
exposed unverified, with `referralSignedData` for a caller that can
resolve `VID_new` and check.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…rimitive

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
fix(tsp-js): refuse an XSCS/XCTL body that is not exactly one Bytes primitive
…not adopted

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…vite

fix(tsp-js): resolveAccept, so an accept for an invite never sent is not adopted
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
fix(tsp-js): raise MAX_HOPS to 64 so longer routes interoperate
The merged specification (trustoverip/tswg-tsp-specification f5b8668) moved the vectors to YTSP-AAC, changing every message and the control vectors' SAID digests. Replace the pre-merge YTSP-ABA fixture, fix the tests that asserted MINOR 64, and pin one pre-merge ABA message so reading it stays covered. Comment and changelog text only; library behaviour is unchanged.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Add a test-only ./unsafe-testing subpath whose __unsafeDeterministicPack* functions derive the HPKE-Base ephemeral from a caller-fixed ikmE (RFC 9180 DeriveKeyPair) and can write the NULL VID in the ESSR sender field. The main entry point's packers are unchanged and never forward either knob; a fixed ephemeral key breaks confidentiality, and the subpath exists only to reproduce published vectors.

All six HPKE-Base vectors (direct-hpke-base, control-rfi-direct, control-rfa-direct, control-rfd, nested-direct, routed) now re-pack byte for byte, with every input derived from the vector's printed payload. The sealed-box, signed-only and post-quantum vectors are named with the reason they cannot be.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…istic

feat(tsp-js): merged-spec (AAC) vectors, re-packed byte for byte
@stormer78
stormer78 merged commit 66e9ea1 into main Sep 17, 2026
4 checks passed
@stormer78
stormer78 deleted the feat/tsp-rev2-rev3-dual-handler branch September 17, 2026 09:39
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