feat(tsp): pack TSP Rev 3 natively, read Rev 3 and Rev 2 - #253
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, merged2026-09-16); the VTA answers inbound relationship invites as of
verifiable-trust-infrastructure#1525. So this branch is safe to merge withoutdropping 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 wesend; 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)
if (rev === 2)branch on the pack path, and no negotiation — an outboundmessage has nothing to read, so nothing to negotiate.
(
packages/tsp-js/src/rev2/reader.ts), reached by dispatching on the versionmarker (
revision.ts→peekRevision, reads ≤9 bytes, touches no key). Itexists because TSP is a ToIP spec with implementations nobody here controls
(the reference
tsp_sdkshipped Rev 3 in 0.10.0 while others are still onRev 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.
packInvite/unpackXRFI/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.tsdispatcher,relationship.ts, CESRwire + HPKE additions, spec Appendix A vectors + interop/dispatch tests.
packages/core:vta/tsp-channel.ts(Rev 3 pack, dual read), newvta/tsp-relationship.ts,vta/index.tsexport,@openvtc/vti-tsp-jsbumpedto
^0.3.0,tests/tsp.relationship.mjs.CLAUDE.md: the TSP Rev 2 exception documented as the sole compat-foldcarve-out.
Verification
npm run build— clean;dist/manager.jsanddist/background.jseach asingle chunk (MV3 + admin-guard invariants hold).
npm test— 1,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)
reqwest::Client::new()/ barefetch(); all clients have finite timeouts (R1.2) — N/A to this diff; TSP goes over the existing injected transport.code(E_TSP_REVISION), not strings (R3.7).