diff --git a/CLAUDE.md b/CLAUDE.md index d54025a..a7b4f34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,23 @@ Rules that bite hardest here: issue rather than absorbing the old shape here. This is also why a request to another repo should not ask for a deprecation window on this repo's behalf. + **The one exception is TSP Rev 2, and the reason it is one is worth reading + before citing it for anything else.** The rule above rests on "our peers cut + over with us", which is true of the VTA and the mediator and false of the + Trust Spanning Protocol: it is a ToIP specification with implementations + nobody here controls, and the reference `tsp_sdk` shipped Rev 3 in 0.10.0 + while others are still on Rev 2. So `@openvtc/vti-tsp-js` **reads** both and + **packs** only Rev 3. That asymmetry is what keeps it from being the pattern + this rule bans: there is no dual-accept *arm* anywhere on the Rev 3 path, no + `if (rev === 2)` branch, and no negotiation. Rev 2 is a frozen decode-only + codec in its own directory, reached by dispatching on a version marker the + wire actually carries — and it is deleted whole, with its arm of the + dispatcher, the day the last Rev 2 peer is gone. A fold you can delete in one + `rm` is not a fold. **Nothing else in this repo has that shape**; if you are + reaching for this paragraph to justify a second version arm, the honest test + is whether a peer you do not control is on the far side, and for the VTA and + the mediator the answer is no. + - **R3.7 — match errors on stable machine-readable codes, never on strings, and parse error *bodies* before throwing on status.** Any condition this wallet must detect needs a stable field agreed with the Rust side — diff --git a/packages/core/package.json b/packages/core/package.json index 20d151c..dd90c30 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -132,7 +132,7 @@ "@noble/curves": "^2.4.0", "@openvtc/trust-tasks": "^0.19.5", "@openvtc/vti-didcomm-js": "^0.8.0", - "@openvtc/vti-tsp-js": "^0.2.0", + "@openvtc/vti-tsp-js": "^0.3.0", "@scure/base": "^2.2.0", "cbor-x": "^1.6.6" }, diff --git a/packages/core/src/vta/index.ts b/packages/core/src/vta/index.ts index 28a6201..13fc240 100644 --- a/packages/core/src/vta/index.ts +++ b/packages/core/src/vta/index.ts @@ -20,6 +20,7 @@ export * from "./transport.js"; export * from "./trust-task.js"; export * from "./tsp-binding.js"; export * from "./tsp-channel.js"; +export * from "./tsp-relationship.js"; export * from "./tsp-inbound.js"; export * from "./tsp-mediator-transport.js"; export * from "./tsp-vid.js"; diff --git a/packages/core/src/vta/tsp-channel.ts b/packages/core/src/vta/tsp-channel.ts index 0b7c5c3..9289659 100644 --- a/packages/core/src/vta/tsp-channel.ts +++ b/packages/core/src/vta/tsp-channel.ts @@ -11,11 +11,18 @@ // sender VID over TSP; we unpack it and decode with the shared // `parseTrustTaskReply`. // -// pack/unpack + CESR framing + HPKE-Auth live in `@openvtc/vti-tsp-js` (proven +// pack/unpack + CESR framing + HPKE live in `@openvtc/vti-tsp-js` (proven // byte-compatible with affinidi-tsp, the crate the VTA links). This class owns // only the trust-task binding + transport dispatch; the actual send/receive of // packed bytes is an injected `TspTransport` (mediator-backed in production, a // simulator in tests). +// +// **We send spec Rev 3 and read Rev 3 or Rev 2.** The revision is not +// negotiated and cannot be: an inbound message carries a version marker that +// says what it is, an outbound one has nothing to read. So `unpack` here still +// passes the VTA's X25519 public key — which Rev 3 ignores and Rev 2 needs to +// open a message at all — and `pack` does not. A VTA still on affinidi-tsp +// 0.1.x cannot read what we send; that is the cutover, not a bug. import { pack, unpack } from "@openvtc/vti-tsp-js"; @@ -29,6 +36,13 @@ import type { TrustTask } from "./protocol.js"; import { parseTrustTaskReply, signOutboundTask, verifyTrustTaskReply } from "./trust-task.js"; import { asTaskSigner, type ChannelSigner, type TaskSigner } from "./trust-task.js"; import type { SigningIdentity } from "../siop/self-issued.js"; +import { + ensureRelationship, + forgetOnFailure, + MemoryRelationshipStore, + type RelationshipOutcome, + type RelationshipStore, +} from "./tsp-relationship.js"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -39,9 +53,17 @@ export interface TspHolderIdentity { vid: string; /** Ed25519 private key — signs the outer TSP signature. */ signingPrivateKey: Uint8Array; - /** X25519 private key — HPKE-Auth sender authentication + decrypts replies. */ + /** X25519 private key — decrypts replies sealed to us. + * + * Under Rev 3 that is all it does: HPKE-Base does not put the sender's key + * in the KEM, so this is no longer half of our outbound authenticity. */ encryptionPrivateKey: Uint8Array; - /** X25519 public key — the VTA verifies our sender-auth against this. */ + /** X25519 public key. + * + * A Rev 3 counterparty never needs it — it is kept because it is the key a + * peer resolves from our DID, and because a Rev 2 peer's `unpack` cannot + * open our messages without it. Rev 2 is read-only here, so nothing in this + * package sends under it. */ encryptionPublicKey: Uint8Array; } @@ -128,6 +150,19 @@ export interface TspChannelOptions { signing: ChannelSigner; /** Per-request timeout (default 30s). */ timeoutMs?: number; + /** + * Where relationship records live. Defaults to {@link MemoryRelationshipStore}. + * + * Rev 3 gates application messages on a relationship (7.2.2), so this is not + * optional behaviour that can be left off — a gated peer silently drops + * everything until an invite has been accepted. See `tsp-relationship.ts` for + * why the default is deliberately no more durable than the far side's. + */ + relationships?: RelationshipStore; + /** How long to wait for an accept before sending anyway (default 5s). */ + handshakeTimeoutMs?: number; + /** Reports each handshake outcome, for diagnostics. */ + onRelationship?: (outcome: RelationshipOutcome) => void; } const utf8 = new TextEncoder(); @@ -145,6 +180,13 @@ export class TspChannel implements TrustTaskChannel { private readonly vta: TspRemoteEndpoint; private readonly signer: TaskSigner; private readonly timeoutMs: number; + private readonly relationships: RelationshipStore; + private readonly handshakeTimeoutMs: number | undefined; + private readonly onRelationship: ((outcome: RelationshipOutcome) => void) | undefined; + /** In-flight handshake, so N concurrent sends produce one invite rather than + * N — which a peer's state machine would refuse as repeated `sendInvite` + * from `pending`, failing every request after the first. */ + private handshake: Promise | undefined; constructor(opts: TspChannelOptions) { this.signer = asTaskSigner(opts.signing); @@ -152,6 +194,45 @@ export class TspChannel implements TrustTaskChannel { this.holder = opts.holder; this.vta = opts.vta; this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.relationships = opts.relationships ?? new MemoryRelationshipStore(); + this.handshakeTimeoutMs = opts.handshakeTimeoutMs; + this.onRelationship = opts.onRelationship; + } + + /** + * Form the relationship a Rev 3 peer requires, at most once at a time. + * + * Never throws: `ensureRelationship` reports and returns, and the caller + * sends regardless. A peer that does not gate takes the message; a peer that + * does has accepted by now. Refusing to send because a courtesy round trip + * went unanswered would break the wallet against the more permissive peer, + * which is the wrong way round. + */ + private async ensureRelated(): Promise { + if (!this.handshake) { + this.handshake = ensureRelationship({ + transport: this.transport, + holder: this.holder, + vta: this.vta, + store: this.relationships, + ...(this.handshakeTimeoutMs !== undefined + ? { handshakeTimeoutMs: this.handshakeTimeoutMs } + : {}), + ...(this.onRelationship ? { onOutcome: this.onRelationship } : {}), + }).finally(() => { + this.handshake = undefined; + }); + } + await this.handshake; + } + + /** Drop the cached relationship after a failed send. + * + * A send that went nowhere is most often a VTA that restarted and forgot us + * — its store is in-memory by default — so the next attempt should re-invite + * rather than keep posting into the same silence. */ + private async forgetRelationship(): Promise { + await forgetOnFailure(this.relationships, this.holder.vid, this.vta.vid); } /** Seal the envelope to the VTA. Shared by both directions. */ @@ -164,9 +245,13 @@ export class TspChannel implements TrustTaskChannel { // document, so anything that reshaped it here would invalidate every // signature while looking identical on screen. const plaintext = utf8.encode(wrapTspEnvelope(envelope)); + // Rev 3 (spec) seals under HPKE-**Base**, so the holder's own X25519 secret + // no longer enters the KEM and is not passed here. Sender authenticity is + // the ESSR sender field plus the outer Ed25519 signature instead — see + // `@openvtc/vti-tsp-js`'s `rev3/direct.ts`. Adding the key back would not + // be ignored; `PackKeys` does not have the member, which is the point. const packed = await pack(plaintext, this.holder.vid, this.vta.vid, { senderSigningKey: this.holder.signingPrivateKey, - senderEncryptionKey: this.holder.encryptionPrivateKey, receiverEncryptionKey: this.vta.encryptionPublicKey, }); return packed.bytes; @@ -188,10 +273,21 @@ export class TspChannel implements TrustTaskChannel { `${opts.operationLabel ?? envelope.type}: this TSP transport has no one-way send`, ); } - await this.transport.send(await this.packForVta(envelope)); + await this.ensureRelated(); + const packed = await this.packForVta(envelope); + try { + await this.transport.send(packed); + } catch (err) { + await this.forgetRelationship(); + throw err; + } } async send(envelope: TrustTask, opts: SendOpts = {}): Promise { + // 7.2.2: a gated peer drops an application message from a VID it holds no + // relationship with, and drops it *silently*. Doing this first turns what + // would be an unexplained 30-second timeout into a round trip. + await this.ensureRelated(); const packed = { bytes: await this.packForVta(envelope) }; // Set by `claims` when it recognises a frame as this request's reply, so @@ -264,6 +360,9 @@ export class TspChannel implements TrustTaskChannel { // the only evidence is a silent 30s wait, and the difference between // "the VTA never answered" and "it answered something I did not // recognise" is the whole diagnosis. + // The most likely cause of silence is a peer that no longer holds the + // relationship, so drop ours and let the next attempt re-invite. + await this.forgetRelationship(); if (lastDecline) { throw new VtaClientError( (err as VtaClientError).code ?? "e.client.network", diff --git a/packages/core/src/vta/tsp-relationship.ts b/packages/core/src/vta/tsp-relationship.ts new file mode 100644 index 0000000..c562406 --- /dev/null +++ b/packages/core/src/vta/tsp-relationship.ts @@ -0,0 +1,254 @@ +// Forming the TSP relationship a Rev 3 peer requires before it will accept +// anything (7.2.2). +// +// Rev 3 turned relationships from bookkeeping into a precondition: "if an +// endpoint receives an application message destined to one of its legitimate +// VIDs, but it has not established a relationship from the source VID in the +// message to its own VID, it SHOULD drop the message." The message is dropped, +// not refused -- nothing comes back -- so a wallet that skips this does not get +// an error, it gets a 30-second timeout and a transport that looks broken. +// +// `@openvtc/vti-tsp-js` owns the wire form and the state machine, both pure. +// This module owns the part that needs a transport and somewhere to remember +// things, which is why it is here and not there. +// +// -- Why the wallet must not over-trust its own record -- +// +// The far side's relationship state is **in-memory by default**: +// `affinidi-messaging-sdk`'s `RelationshipStore` defaults to +// `InMemoryRelationshipStore`, so unless a deployment supplies a durable one, a +// VTA forgets every relationship it holds when its process restarts. A wallet +// that persisted "we are related" and believed it would then send application +// messages into a silence, indefinitely, with nothing on either side saying why. +// +// So the record here is a cache of a belief about someone else's memory, not a +// fact, and it is treated that way: a failed send clears it (see +// `forgetOnFailure`) and the next attempt re-invites. That is one wasted round +// trip after a VTA restart, against an otherwise permanent silent failure. +// +// -- Why a timed-out handshake still sends -- +// +// The mirror risk is a VTA that does not implement control messages at all: it +// never answers the invite, and a wallet that waited for an accept before ever +// sending would have broken itself against a peer that would have taken the +// message happily. So the handshake is best-effort -- invite, wait briefly, +// proceed either way. A gating peer has accepted by then; a non-gating peer +// never cared; and a gating peer that was slow drops this one message and works +// on the retry, which is strictly better than never sending at all. + +import { + packInvite, + resolveAccept, + unpack, + transition, + type ControlMessage, + type RelationshipState, +} from "@openvtc/vti-tsp-js"; + +import type { TspFrameClaim } from "../didcomm/index.js"; +import type { TspHolderIdentity, TspRemoteEndpoint, TspTransport } from "./tsp-channel.js"; + +/** How long to wait for an accept before sending anyway. Deliberately far + * shorter than a request timeout: this is a courtesy round trip, and a peer + * that has not answered in this long is one that is not going to. */ +export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000; + +/** What we believe about one (ourVid, theirVid) pair. */ +export interface RelationshipRecord { + state: RelationshipState; + /** Hex digest of the invite we sent, when we sent one. The accept echoes it, + * and a later cancellation may name it (7.2.1). */ + ourDigest?: string; + /** Hex digest of the other half -- their invite, or the accept they sent. */ + theirDigest?: string; +} + +/** + * Where relationship records live. + * + * Injected rather than imported because `vta/` sits below `store/` in this + * package's layering, and because durability is a deployment question -- see + * the module note on why persisting harder than the far side does is not + * automatically an improvement. + */ +export interface RelationshipStore { + get(ourVid: string, theirVid: string): Promise; + set(ourVid: string, theirVid: string, record: RelationshipRecord): Promise; + clear(ourVid: string, theirVid: string): Promise; +} + +/** + * The default store: in memory, for the life of the channel. + * + * Matching the far side's own default is the point. A relationship this wallet + * remembered across a service-worker teardown, against a VTA that forgot it on + * restart, is a message dropped in silence -- so the cheap, symmetric answer is + * to re-invite when we come back, which costs one round trip and cannot go + * quietly wrong. + */ +export class MemoryRelationshipStore implements RelationshipStore { + private readonly records = new Map(); + + /** Keyed on the pair, not the peer: one wallet may hold several VIDs, and a + * relationship belongs to a direction rather than to a party. JSON rather + * than a separator character, because a DID may contain most of them. */ + private key(ourVid: string, theirVid: string): string { + return JSON.stringify([ourVid, theirVid]); + } + + async get(ourVid: string, theirVid: string): Promise { + return this.records.get(this.key(ourVid, theirVid)); + } + + async set(ourVid: string, theirVid: string, record: RelationshipRecord): Promise { + this.records.set(this.key(ourVid, theirVid), record); + } + + async clear(ourVid: string, theirVid: string): Promise { + this.records.delete(this.key(ourVid, theirVid)); + } +} + +const toHex = (bytes: Uint8Array): string => + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + +export interface EnsureRelationshipOpts { + transport: TspTransport; + holder: TspHolderIdentity; + vta: TspRemoteEndpoint; + store: RelationshipStore; + handshakeTimeoutMs?: number; + /** Called with a one-line account of what happened, for diagnostics. A + * handshake that silently did nothing is indistinguishable from one that was + * never attempted. */ + onOutcome?: (outcome: RelationshipOutcome) => void; +} + +export type RelationshipOutcome = + | { kind: "alreadyEstablished" } + | { kind: "established"; threadDigest: string } + | { kind: "notAnswered"; reason: string } + | { kind: "refused"; reason: string }; + +/** + * Ensure a relationship with the VTA before an application message is sent. + * + * Best-effort by design -- see the module note. Never throws: every outcome is + * reported through `onOutcome` and the caller proceeds, because the one thing + * worse than an unformed relationship is a wallet that will not send at all. + */ +export async function ensureRelationship( + opts: EnsureRelationshipOpts, +): Promise { + const { transport, holder, vta, store } = opts; + const report = (outcome: RelationshipOutcome): RelationshipOutcome => { + opts.onOutcome?.(outcome); + return outcome; + }; + + const existing = await store.get(holder.vid, vta.vid); + if (existing?.state === "bidirectional") { + return report({ kind: "alreadyEstablished" }); + } + + // `transition` is the specification's own table: it refuses `sendInvite` from + // any state but `none`, which is what stops a re-invite being sent to a peer + // that would reject it as an invalid transition. + const from = existing?.state ?? "none"; + let pending: RelationshipState; + try { + pending = transition(from, "sendInvite"); + } catch (err) { + return report({ kind: "refused", reason: (err as Error).message }); + } + + let invite; + try { + invite = await packInvite(holder.vid, vta.vid, { + senderSigningKey: holder.signingPrivateKey, + receiverEncryptionKey: vta.encryptionPublicKey, + }); + } catch (err) { + return report({ kind: "refused", reason: `could not pack invite: ${(err as Error).message}` }); + } + + // The accept must be sealed by the VTA, be a control message, be an accept, + // and echo *our* invite's digest. The last is the one that matters: on a + // shared socket an accept to somebody else's invite is a frame we can read + // and must not claim. + let declined: string | undefined; + const claims: TspFrameClaim = async (bytes) => { + let reply: { sender: string; messageType: string; control?: ControlMessage }; + try { + reply = await unpack(bytes, { + receiverDecryptionKey: holder.encryptionPrivateKey, + senderSigningKey: vta.signingPublicKey, + }); + } catch (err) { + declined = `unpack failed: ${(err as Error).message}`; + return false; + } + if (reply.sender !== vta.vid) { + declined = `sealed by ${reply.sender}, not the VTA`; + return false; + } + const control = reply.control; + if (reply.messageType !== "control" || !control) { + declined = `not a control message (${reply.messageType})`; + return false; + } + if (control.controlType !== "accept") { + declined = `a ${control.controlType}, not an accept`; + return false; + } + const outcome = resolveAccept(pending, control.inReplyTo, invite.threadDigest); + if (outcome.action === "ignore") { + declined = `an accept that ${outcome.reason}`; + return false; + } + return true; + }; + + await store.set(holder.vid, vta.vid, { + state: pending, + ourDigest: toHex(invite.threadDigest), + }); + + try { + await transport.sendAndAwaitReply(invite.bytes, { + timeoutMs: opts.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS, + claims, + }); + } catch (err) { + // Left at `pending`, not cleared: we did send an invite, and a peer that + // answers late is a peer we are related to. Clearing here would make the + // next send re-invite a VTA that had already accepted, which its own state + // machine refuses as an invalid transition. + const reason = declined + ? `${(err as Error).message} -- last frame declined: ${declined}` + : (err as Error).message; + return report({ kind: "notAnswered", reason }); + } + + await store.set(holder.vid, vta.vid, { + state: transition(pending, "receiveAccept"), + ourDigest: toHex(invite.threadDigest), + }); + return report({ kind: "established", threadDigest: toHex(invite.threadDigest) }); +} + +/** + * Forget the relationship after a send failed. + * + * The far side's state is in-memory by default, so "my message went nowhere" is + * most often "the VTA restarted and no longer knows me". Clearing makes the next + * attempt re-invite; leaving it cached makes every subsequent send fail the same + * silent way. + */ +export async function forgetOnFailure( + store: RelationshipStore, + ourVid: string, + theirVid: string, +): Promise { + await store.clear(ourVid, theirVid); +} diff --git a/packages/core/tests/tsp.relationship.mjs b/packages/core/tests/tsp.relationship.mjs new file mode 100644 index 0000000..6bea89e --- /dev/null +++ b/packages/core/tests/tsp.relationship.mjs @@ -0,0 +1,212 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { TspChannel, buildTrustTask, MemoryRelationshipStore } from "../dist/index.js"; +import { pack, packAccept, unpack } from "@openvtc/vti-tsp-js"; +import { ed25519, x25519 } from "@noble/curves/ed25519.js"; +import { generateSigningIdentity } from "../dist/siop/self-issued.js"; +import { openTspEnvelope, wrapTspEnvelope } from "../dist/vta/tsp-binding.js"; +import { signTrustTask } from "../dist/trust-tasks/sign.js"; + +const fromUtf8 = new TextDecoder(); +const utf8 = new TextEncoder(); + +const VTA_SIGNING = generateSigningIdentity(); +const VTA_VID = VTA_SIGNING.did; + +const LIST = "https://trusttasks.org/spec/vault/list/0.2"; +const LIST_RESP = `${LIST}#response`; + +function tspIdentity(vid) { + const sign = ed25519.utils.randomSecretKey(); + const encr = x25519.utils.randomSecretKey(); + return { vid, signSk: sign, signPk: ed25519.getPublicKey(sign), encSk: encr, encPk: x25519.getPublicKey(encr) }; +} + +/** + * A VTA that speaks Rev 3 relationship control messages. + * + * `gating` mirrors the specification's default (§7.2.2) and the Rust SDK's: + * an application message from a VID it holds no relationship with is **dropped + * silently**, which on this transport means the reply never resolves. + */ +function relationshipVta(vta, holder, { gating = true, answerInvites = true, acceptDigest } = {}) { + const sent = []; + let related = false; + return { + sent, + get related() { + return related; + }, + /** Simulate the VTA process restarting: its store is in-memory by default. */ + restart() { + related = false; + }, + async sendAndAwaitReply(packed, options = {}) { + const req = await unpack(packed, { + receiverDecryptionKey: vta.encSk, + senderSigningKey: holder.signPk, + }); + sent.push(req); + + if (req.messageType === "control") { + if (req.control.controlType !== "invite") throw new Error("unexpected control message"); + if (!answerInvites) throw new Error("timeout: this VTA does not answer invites"); + related = true; + const accept = await packAccept(acceptDigest ?? req.control.digest, vta.vid, holder.vid, { + senderSigningKey: vta.signSk, + receiverEncryptionKey: holder.encPk, + }); + if (options.claims && !(await options.claims(accept.bytes))) { + throw new Error("the channel declined our accept"); + } + return accept.bytes; + } + + // An application message. §7.2.2: drop it if no relationship is held. + if (gating && !related) { + throw new Error("timeout: dropped, no relationship"); + } + const reqDoc = openTspEnvelope(fromUtf8.decode(req.payload)); + const replyDoc = { type: LIST_RESP, payload: { entries: [], truncated: false }, threadId: reqDoc.id }; + await signTrustTask({ envelope: replyDoc, signing: VTA_SIGNING }); + const reply = await pack(utf8.encode(wrapTspEnvelope(replyDoc)), vta.vid, holder.vid, { + senderSigningKey: vta.signSk, + receiverEncryptionKey: holder.encPk, + }); + if (options.claims && !(await options.claims(reply.bytes))) { + throw new Error("the channel declined our reply"); + } + return reply.bytes; + }, + }; +} + +function makeChannel(opts = {}) { + const holder = tspIdentity("did:web:holder.example"); + const vta = { ...tspIdentity(VTA_VID), signSk: VTA_SIGNING.privateKey, signPk: VTA_SIGNING.publicKey }; + const transport = relationshipVta(vta, holder, opts); + const outcomes = []; + const store = new MemoryRelationshipStore(); + const channel = new TspChannel({ + transport, + holder: { + vid: holder.vid, + signingPrivateKey: holder.signSk, + encryptionPrivateKey: holder.encSk, + encryptionPublicKey: holder.encPk, + }, + signing: { + did: holder.vid, + kid: `${holder.vid}#key-2`, + privateKey: holder.signSk, + publicKey: holder.signPk, + }, + vta: { vid: vta.vid, encryptionPublicKey: vta.encPk, signingPublicKey: vta.signPk }, + relationships: store, + handshakeTimeoutMs: 200, + onRelationship: (o) => outcomes.push(o), + }); + return { channel, transport, outcomes, store, holder }; +} + +const task = () => + buildTrustTask(LIST, { contextId: "work" }, { issuer: "did:web:holder.example", recipient: VTA_VID }); + +test("a gating VTA is invited before the first application message, and then answers", async () => { + // Without the invite this send is dropped in silence — which is the whole + // reason the handshake exists, and why this test asserts on the *order* of + // what reached the VTA rather than only on the result. + const { channel, transport, outcomes } = makeChannel({ gating: true }); + + const res = await channel.send(task(), { expectedResponseType: LIST_RESP }); + assert.deepEqual(res, { entries: [], truncated: false }); + + assert.equal(transport.sent.length, 2); + assert.equal(transport.sent[0].messageType, "control", "the invite goes first"); + assert.equal(transport.sent[0].control.controlType, "invite"); + assert.equal(transport.sent[1].messageType, "direct", "then the trust task"); + assert.deepEqual(outcomes, [{ kind: "established", threadDigest: outcomes[0].threadDigest }]); +}); + +test("the relationship is formed once, not once per request", async () => { + const { channel, transport } = makeChannel({ gating: true }); + await channel.send(task(), { expectedResponseType: LIST_RESP }); + await channel.send(task(), { expectedResponseType: LIST_RESP }); + await channel.send(task(), { expectedResponseType: LIST_RESP }); + + const invites = transport.sent.filter((m) => m.messageType === "control"); + assert.equal(invites.length, 1, "three sends, one invite"); +}); + +test("concurrent sends produce one invite, not one each", async () => { + // A peer's state machine refuses `sendInvite` from `pending`, so N parallel + // first-requests inviting N times would fail every one after the first. + const { channel, transport } = makeChannel({ gating: true }); + await Promise.all([ + channel.send(task(), { expectedResponseType: LIST_RESP }), + channel.send(task(), { expectedResponseType: LIST_RESP }), + channel.send(task(), { expectedResponseType: LIST_RESP }), + ]); + const invites = transport.sent.filter((m) => m.messageType === "control"); + assert.equal(invites.length, 1); +}); + +test("a VTA that never answers an invite still gets the application message", async () => { + // The mirror risk. A wallet that refused to send until an accept arrived + // would have broken itself against every peer that does not gate — which is + // every peer running today. + const { channel, transport, outcomes } = makeChannel({ gating: false, answerInvites: false }); + + const res = await channel.send(task(), { expectedResponseType: LIST_RESP }); + assert.deepEqual(res, { entries: [], truncated: false }); + + assert.equal(outcomes[0].kind, "notAnswered"); + assert.equal(transport.sent[1].messageType, "direct", "sent anyway"); +}); + +test("an accept that answers an invite we never sent does not establish the relationship", async () => { + // §7.2.2: the accept's Digest must be the one our invite carried. + const { channel, outcomes, store, holder } = makeChannel({ + gating: false, + acceptDigest: new Uint8Array(32).fill(7), + }); + + await channel.send(task(), { expectedResponseType: LIST_RESP }); + + assert.equal(outcomes[0].kind, "notAnswered"); + assert.match(outcomes[0].reason, /answers an invite we did not send/); + assert.equal((await store.get(holder.vid, VTA_VID)).state, "pending"); +}); + +test("a VTA restart is recovered from by re-inviting, not by failing forever", async () => { + // The far side's relationship store is in-memory by default, so it forgets + // every relationship when it restarts. A wallet that kept believing its own + // record would post into silence indefinitely. + const { channel, transport } = makeChannel({ gating: true }); + await channel.send(task(), { expectedResponseType: LIST_RESP }); + assert.equal(transport.sent.filter((m) => m.messageType === "control").length, 1); + + transport.restart(); + + // The next send is dropped (the VTA no longer knows us) and must clear our + // cached record so the attempt after it re-invites. + await assert.rejects(() => channel.send(task(), { expectedResponseType: LIST_RESP })); + + await channel.send(task(), { expectedResponseType: LIST_RESP }); + assert.equal( + transport.sent.filter((m) => m.messageType === "control").length, + 2, + "re-invited after the restart", + ); +}); + +test("the invite names the holder and the VTA, and carries a fresh 128-bit nonce", async () => { + const { channel, transport, holder } = makeChannel({ gating: true }); + await channel.send(task(), { expectedResponseType: LIST_RESP }); + const invite = transport.sent[0]; + assert.equal(invite.sender, holder.vid); + assert.equal(invite.receiver, VTA_VID); + assert.equal(invite.control.nonce.length, 16); + assert.deepEqual(invite.control.route, [], "no Reply_Path: the accept comes back directly"); +}); diff --git a/packages/tsp-js/CHANGELOG.md b/packages/tsp-js/CHANGELOG.md index a5dcb93..e99e686 100644 --- a/packages/tsp-js/CHANGELOG.md +++ b/packages/tsp-js/CHANGELOG.md @@ -8,6 +8,126 @@ For history before this file, see `git log` on `packages/tsp-js`. ## [Unreleased] +### Added + +- `resolveAccept(state, answeredDigest, ourInviteDigest)`: whether a received + accept answers the invite we have outstanding (§7.2.2). `transition` sees + only the state, so until now every client had to compare the digests itself + or adopt an accept for an invite it never sent. +- `@openvtc/vti-tsp-js/unsafe-testing`, a **test-only** subpath for + byte-reproducible packing: `__unsafeDeterministicPack`, `…PackInvite`, + `…PackAccept`, `…PackCancel`, `…PackNested` and `…PackRouted` take an + `__unsafeIkmE` (RFC 9180 DeriveKeyPair input) for the HPKE-Base ephemeral and + can write the NULL VID in the ESSR sender field. A fixed ephemeral key breaks + confidentiality; the subpath exists so the Appendix A vectors can be + reproduced and is not part of the documented API. The main entry point's + packers are unchanged and never take either knob. With it, all six HPKE-Base + vectors (`direct-hpke-base`, `control-rfi-direct`, `control-rfa-direct`, + `control-rfd`, `nested-direct`, `routed`) re-pack byte for byte. + +### Changed + +- The Appendix A test vectors are the merged specification's + ([tswg-tsp-specification@f5b8668](https://github.com/trustoverip/tswg-tsp-specification/commit/f5b8668952aabe8e541b535fcbdf589484ffc4f4)), + which carry `YTSP-AAC` — the marker this package packs — in place of the + pre-merge `YTSP-ABA` set. Every message and the control vectors' digests + changed; all still open and verify. A pre-merge `ABA` message stays pinned in + the tests: reading it is unchanged. Test-only; no library behaviour changed. + +### Fixed + +- `MAX_HOPS` is 64 (was 10). The specification sets no maximum, and 12-hop + routes packed by every other implementation were refused on decode. The same + bound applies when packing a route and when decoding a hop list or reply path. +- An XSCS/XCTL body that is not exactly one Bytes primitive is refused. It was + read as its first primitive, silently dropping the rest of the `-A##` stream, + and data after the stream was ignored. See + [tswg-tsp-specification#77](https://github.com/trustoverip/tswg-tsp-specification/issues/77). + +## [0.3.0] — Trust Spanning Protocol specification Rev 3 + +**Breaking. This package now packs Rev 3, and a Rev 2 peer cannot read what it +sends.** There is no negotiation and no fallback. 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, +so the two revisions share no frame either side can classify. + +Reading is dual. `unpack` dispatches on the version marker every message +carries — a fixed offset, no keys — and a Rev 2 message is read by a frozen, +decode-only codec in `src/rev2/`. The asymmetry is the design: an inbound +message says what it is, an outbound one has nothing to read, and a dual +*packer* could only be a guess dressed as a protocol. + +### Added + +- `peekRevision` / `describeRevision` — the keyless discriminator, and + `TspRevisionError` (`code: "E_TSP_REVISION"`) with `isRevisionError`. +- `revision` on every `unpack` result, and on `decodeEnvelope`. How a caller + learns what a peer speaks; persisting it per peer belongs above this package. +- `isTsp`, accepting both `0xF8` and the long framing's `0xFB`. +- **Relationship control messages** (§7.2, §7.3): `packInvite`, `packAccept`, + `packCancel`, and `unpack` returning a verified `control` message. Rev 3 gates + application messages on a relationship, so without these a peer enforcing + §7.2.2 drops everything a client sends — silently, since a dropped message + answers nothing. + + The §7.2.1 `TSP_Digest` 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, and recomputed by the receiver, which refuses the + message on a mismatch. Rev 2 correlated on a hash of the encrypted payload + that was never transmitted and so could never be checked. The three published + control vectors exercise the derivation directly. + +- **The §7.2/§7.3 state machine** (`relationship.ts`): `transition`, `canSend`, + `admitsApplicationMessage`, `resolveInviteRace`, `resolveCancel`. Pure — state + and event in, state or a refusal out — with no storage, clock or keys, so the + rules can be tested against the specification rather than against a mock. + `unpack` does not apply them: a codec that mutated relationship state would + make receiving a message a side effect. + +- `XCTL` and `XPAD` are recognised and reported rather than refused as unknown + type codes. `XCTL` carries an upper-layer control payload and is opaque to + TSP, sharing nothing with a relationship-forming message but the word. +- The specification's own Appendix A vectors run as a test suite + (`tests/interop.spec-vectors.mjs`). Every published vector is either + exercised or named as uncovered. + +### Changed — wire format + +- **HPKE-Auth → HPKE-Base.** The sender's key leaves the KEM, so `PackKeys` and + `UnpackKeys` each lost `senderEncryptionKey` — it survives on `UnpackKeys` as + an **optional, Rev 2-only** member, because HPKE-Auth cannot *open* a message + without it. `info` is the fixed code `YTSP-`; the AAD is + `TSP_Version ‖ VID_sndr ‖ VID_rcvr`, where Rev 2 passed the envelope frame as + `info` with empty AAD. +- **Version `YTSP-AAB` → `YTSP-AAC`**, MAJOR.MINOR with MINOR filling the + 12-bit count. Only MAJOR gates processability, so any other MINOR at MAJOR 0 + — including upstream's `ABA` (64) — reads as Rev 3. +- **Ciphertext code `G` → `F`**, and the field is `enc ‖ ct`; Rev 2 put `enc` + last. A `C`-coded sealed box (§8.3) is recognised and refused by name. +- **Long count codes `-0X#####` → `--X#####`.** +- **The `-E` count covers all signable content**, so the frame is finalized + after sealing. `encodeEnvelope` is gone; Rev 3's `encodeFields` + + `finalizeFrame` replace it, and the split is where the AAD boundary falls. +- **The trailing `X 00 00` marker is deleted**; the receiver field is always + written, with `4BAA` meaning absent. +- **The signature is indexed** (`B#`) under length-based counts `-C23 -K22`. +- **Payload layouts** carry an ESSR sender VID and a padding field; a direct + body sits in a `-A` generic stream; `-J` counts bytes rather than VIDs; a + nested inner message is carried raw, so it must be quadlet-aligned. + +### Fixed + +- **`decodeCount` returned long-form counts with the identifier bits still in + them**, so every long-framed message decoded to a wrong length. The test that + covered it asserted the wrong behaviour on purpose, calling it "a reference + quirk ... benign, TSP frames by cursor position and discards this value". + That reasoning was wrong and Rev 3 makes it fatal: the `-E` and `-Z` counts + are now load-bearing lengths. `affinidi-tsp` fixed the same bug independently. +- `MAX_HOPS` was 16 on the packing side against a decoder that stopped at 10, so + a 12-hop route packed cleanly and could not be read back by this library. + + ### Added - **Pluggable key custody for HPKE-Auth and Ed25519 signing.** Two capability diff --git a/packages/tsp-js/README.md b/packages/tsp-js/README.md index da3a48f..f1e1849 100644 --- a/packages/tsp-js/README.md +++ b/packages/tsp-js/README.md @@ -18,28 +18,113 @@ it natively; on React Native, import [`react-native-get-random-values`](https://github.com/LinusU/react-native-get-random-values) once at app startup. +## Revisions: packs Rev 3, reads Rev 3 and Rev 2 + +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 there is no +negotiation in the protocol. + +So this package is deliberately asymmetric: + +- **`unpack` dispatches on the version marker**, which sits at a fixed offset + and needs no keys. A Rev 2 message is read by a frozen, decode-only codec in + `src/rev2/`, and the result reports `revision: "rev2"`. +- **`pack` does not dispatch on anything**, because there is nothing to + dispatch on. An inbound message says what it is; an outbound one has to be + decided before a byte exists, and no field on the wire says what a peer can + read. We pack Rev 3. A Rev 2 peer cannot read it, and nothing here retries or + falls back. + +`unpack`'s `revision` is how a caller learns what a peer actually speaks. +Remembering that per peer belongs above this package — a codec has no business +holding state about who it has talked to. + +## Relationships + +Rev 3 §7.2.2: an endpoint SHOULD **drop** an application message from a VID it +holds no relationship with. Dropped, not refused — nothing comes back — so a +client that skips the handshake sees a timeout, not an error. + +```ts +import { packInvite, packAccept, transition, canSend } from "@openvtc/vti-tsp-js"; + +const invite = await packInvite(ourVid, theirVid, keys); +// `invite.threadDigest` is the exchange's thread id. Keep it: the accept echoes +// it back, and a later cancellation names it. It cannot be known in advance — +// the digest is self-addressing over the envelope this call builds. + +// …on receiving their accept: +const accept = await unpack(bytes, unpackKeys); +accept.control.inReplyTo; // equals invite.threadDigest +``` + +The state machine (`transition`, `canSend`, `admitsApplicationMessage`, +`resolveInviteRace`, `resolveAccept`, `resolveCancel`) is **pure** — state and event in, state or +a refusal out. No storage, no clock, no keys. That is the line: this package +owns what the protocol says happens next, and the client owns where that is +written down. + +`unpack` deliberately does **not** apply it. A codec that mutated relationship +state would make receiving a message a side effect, and the one thing a client +must be able to do is look at an invite before answering it. + +Two rules worth knowing before you use it: + +- **An accept's two digests are not interchangeable.** The wire order is + `Digest` then `Reply_Digest`, and — counter to how those read — the first is + the *invite's* digest echoed and the second is the accept's own. This package + names them `inReplyTo` and `digest` so the trap cannot spring. +- **The invite race (§7.2.3) is decided on bytes.** Both endpoints keep the + invite with the lexicographically lower digest, so simultaneous invites + converge on one exchange. No timestamps, no "ours wins" — either would let the + two sides disagree and form two half-relationships. + ## What it does -A TSP message is **encrypted-then-signed** (ETS): the payload is HPKE-Auth -sealed to the recipient (which also authenticates the sender), then the whole -CESR frame is Ed25519-signed. VIDs are DIDs. This package owns the wire layer — -CESR encode/decode, the `-E` envelope, HPKE seal/open, Ed25519 sign/verify, and -`pack`/`unpack` for Direct, Nested, and Routed messages. - -- **HPKE-Auth** — RFC 9180, `DHKEM(X25519, HKDF-SHA256)` + `HKDF-SHA256` + - `ChaCha20Poly1305`. The `-E` envelope frame (sender VID · receiver VID) is the - HPKE `info`, binding the ciphertext to both parties. The same module also - exposes **base mode** (`hpke.sealBase` / `hpke.openBase`) over the identical - suite, so the ecosystem has one RFC 9180 key schedule rather than one per - caller — `@openvtc/pnm-core` uses it for VTA sealed bundles. -- **CESR** — binary `qb2` framing (selectors `-E`, `-Z`, `B`, `G`, `I`, `A`, `X`; - markers `YTSP`, `XSCS`/`XHOP`, `XRFI`/`XRFA`/`XRFD`). +A TSP message is **encrypted-then-signed** (ETS): the payload is HPKE sealed to +the recipient, then the whole CESR frame is Ed25519-signed. VIDs are DIDs. This +package owns the wire layer — CESR encode/decode, the `-E` envelope, HPKE +seal/open, Ed25519 sign/verify, and `pack`/`unpack` for Direct, Nested, and +Routed messages. + +- **HPKE-Base** (Rev 3) — RFC 9180, `DHKEM(X25519, HKDF-SHA256)` + + `HKDF-SHA256` + `ChaCha20Poly1305`. The fixed code `YTSP-` is the HPKE + `info`, and `TSP_Version ‖ VID_sndr ‖ VID_rcvr` is real AAD. The sender's + key no longer enters the KEM — sender authenticity is the ESSR sender field + plus the outer signature. **HPKE-Auth** remains for reading Rev 2, where the + envelope frame was the `info` and the AAD was empty. +- **CESR** — binary `qb2` framing (selectors `-E`, `-Z`, `-A`, `-J`, `B`, `F`, + `I`; markers `YTSP`, `XSCS`/`XHOP`, `XRFI`/`XRFA`/`XRFD`/`XCTL`/`XPAD`). - **Message modes** — Direct, Nested (metadata privacy), and Routed (multi-hop through a relay/mediator). - -Byte-compatibility is proven by an interop test that unpacks a message packed by -the Rust reference with fixed keys and recovers the plaintext + thread digest -exactly (`tests/interop.rust-vector.mjs`). +- **Relationships** — `XRFI` / `XRFA` / `XRFD`, the §7.2.1 self-addressing + digest, and the §7.2/§7.3 state machine. Rev 3 gates application messages on + a relationship, so this is a precondition for sending anything, not an + optional extra. + +Byte-compatibility is proven in three ways, because a round trip proves none of +it — encoder and decoder agree with each other whatever they both get wrong, +which is exactly the failure mode Rev 3's one-character changes produce: + +- **The specification's own Appendix A vectors** (`YTSP-AAC`, as merged at + [tswg-tsp-specification@f5b8668](https://github.com/trustoverip/tswg-tsp-specification/commit/f5b8668952aabe8e541b535fcbdf589484ffc4f4)) + run as a test suite, fixed and external and produced by the ToIP reference + implementation. Every HPKE-Base vector both opens + (`tests/interop.spec-vectors.mjs`, `tests/control.spec-vectors.mjs`) and + **re-packs byte for byte** from its published `ikmE` + (`tests/interop.spec-vectors-repack.mjs`). Every published vector is either + exercised or named as uncovered, so the list cannot quietly shrink. +- **Both directions against `affinidi-tsp`** — its Rev 2 vector unpacks here + (`tests/interop.rust-vector.mjs`), and a message packed here unpacks there, + thread digest included. +- **Pinned bytes** for the deterministic parts of what we emit, since the sealed + message itself is not reproducible (HPKE draws a fresh ephemeral key). The + vector re-pack fixes that key through a test-only subpath, + `@openvtc/vti-tsp-js/unsafe-testing`, which is deliberately not part of the + API above: a fixed ephemeral key destroys confidentiality, and nothing but a + test reproducing a published vector has a reason to import it. The HPKE implementation is pinned three ways on every CI run: the official CFRG RFC 9180 `mode_auth` vector asserted in-tree (`tests/crypto.cfrg-vector.mjs` — @@ -63,17 +148,31 @@ import { pack, unpack } from "@openvtc/vti-tsp-js"; // Keys are raw 32-byte Ed25519 (signing) / X25519 (encryption) scalars. const packed = await pack(payloadBytes, senderDid, recipientDid, { senderSigningKey, // Ed25519 private — signs the outer frame - senderEncryptionKey, // X25519 private — HPKE-Auth sender authentication - receiverEncryptionKey, // X25519 public — HPKE recipient (seal to) + receiverEncryptionKey, // X25519 public — HPKE-Base recipient (seal to) }); -// packed.bytes: the qb2 TSP message (first byte 0xF8) — send it over any transport. +// packed.bytes: the qb2 TSP message. First byte 0xF8, or 0xFB past ~12 KB — +// Rev 3's `-E` count covers the ciphertext, so large messages are long-framed. +// `isTsp()` accepts both; a classifier that knows only 0xF8 silently drops them. const msg = await unpack(packed.bytes, { receiverDecryptionKey, // X25519 private — our key - senderEncryptionKey, // X25519 public — sender-auth verification senderSigningKey, // Ed25519 public — outer-signature verification + senderEncryptionKey, // X25519 public — OPTIONAL, Rev 2 only (see below) }); -// msg.sender / msg.receiver (proven VIDs) + msg.payload (the recovered bytes). +// msg.sender / msg.receiver (proven VIDs), msg.payload, and msg.revision. +``` + +`senderEncryptionKey` is Rev 2's alone: HPKE-Auth puts the sender's static key +in the KEM, so without it a Rev 2 message cannot be *opened*, let alone +verified. Omit it and a Rev 2 message is refused by name rather than by a +decryption failure. Rev 3 has no use for it at all. + +```ts +import { peekRevision, isRevisionError } from "@openvtc/vti-tsp-js"; + +// Keyless, from the version marker — for routing, metrics, or deciding whether +// you hold the extra key a Rev 2 message needs. +const { revision, minor } = peekRevision(bytes); // "rev3" | "rev2" ``` Multi-hop routing (seal end-to-end to the final recipient, wrap a routing layer @@ -90,7 +189,10 @@ import { packRouted } from "@openvtc/vti-tsp-js"; | `pack` / `unpack` | Direct message seal+sign / verify+open | | `packWithHops` | Lower-level pack with an explicit hop list | | `packRouted` / `packNested` / `nextHop` | Routed (multi-hop) + Nested (metadata-privacy) messages | -| `encodeEnvelope` / `decodeEnvelope` | The `-E` cleartext envelope (also the HPKE `info`) | +| `decodeEnvelope` | The `-E` cleartext envelope, dispatching on revision — keyless, for relays | +| `peekRevision` / `describeRevision` | Which revision framed a message, from its version marker alone | +| `TspRevisionError` / `isRevisionError` | A revision that could not be established or is not spoken; `code: "E_TSP_REVISION"` | +| `isTsp` | Ingress classifier — accepts both `0xF8` and `0xFB` framings | | `sha256` | Thread-digest helper | | `cesr` | Binary CESR frame primitives | | `hpke` | RFC 9180 HPKE seal/open — auth mode (`seal`/`open`) and base mode (`sealBase`/`openBase`). Also importable directly as `@openvtc/vti-tsp-js/hpke`. | @@ -98,9 +200,19 @@ import { packRouted } from "@openvtc/vti-tsp-js"; ## Scope -v1 is **HPKE-Auth only** (classical), matching `affinidi-tsp` — no -post-quantum suite. VID → key resolution is left to the caller (DIDs resolve via -whatever resolver the host app uses). +Classical only, matching `affinidi-tsp`'s default build: no post-quantum suite +(§8.1/§8.2.1's ML-KEM-768/X25519 and ML-DSA-65). VID → key resolution is left to +the caller (DIDs resolve via whatever resolver the host app uses). + +Deliberately **not** implemented, each for a stated reason rather than by +omission: + +| Not here | Why | +| --- | --- | +| The libsodium sealed box (§8.3) | §8 tells new implementations not to use it. A `C`-coded ciphertext is *recognised* and refused by name, so it never reads as a corrupt `F`. | +| Composing a referral (§7.2.5) | A referral's `Signature_new` covers the invite's digest, so composing one needs the *introduced* VID's signing key at pack time. A wallet holding another VID's private key is not a shape this package should invite. Referrals are decoded and exposed unverified, with `referralSignedData` for a caller that can resolve `VID_new` and check. | +| Fillable padding (§7.5) | The field is always written, always empty. Conformant, and leaves the traffic-analysis defence unimplemented rather than half-implemented. | +| Packing Rev 2 | See *Revisions* above. | ## Test diff --git a/packages/tsp-js/package.json b/packages/tsp-js/package.json index cfabe15..ebac166 100644 --- a/packages/tsp-js/package.json +++ b/packages/tsp-js/package.json @@ -1,7 +1,7 @@ { "name": "@openvtc/vti-tsp-js", - "version": "0.2.0", - "description": "Pure-TypeScript TSP (Trust Spanning Protocol) primitives \u2014 byte-compatible with affinidi-tsp. RFC 9180 HPKE seal/open on @noble + binary CESR framing. No WebCrypto, no WASM: runs in browsers, Node, and React Native.", + "version": "0.3.0", + "description": "Pure-TypeScript TSP (Trust Spanning Protocol) primitives \u2014 byte-compatible with affinidi-tsp. Packs spec Rev 3 (HPKE-Base on RFC 9180 + binary CESR framing), reads Rev 3 and Rev 2. No WebCrypto, no WASM: runs in browsers, Node, and React Native.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -13,6 +13,10 @@ "./hpke": { "types": "./dist/crypto/hpke.d.ts", "import": "./dist/crypto/hpke.js" + }, + "./unsafe-testing": { + "types": "./dist/unsafe-testing.d.ts", + "import": "./dist/unsafe-testing.js" } }, "files": [ diff --git a/packages/tsp-js/src/cesr/wire.ts b/packages/tsp-js/src/cesr/wire.ts index c59eb36..7353354 100644 --- a/packages/tsp-js/src/cesr/wire.ts +++ b/packages/tsp-js/src/cesr/wire.ts @@ -1,7 +1,7 @@ // Binary CESR wire primitives for TSP — a faithful TS port of // affinidi-tsp `src/message/wire.rs`, which is itself ported from -// `tsp_sdk::cesr` (v0.9.0-alpha2). Byte-compatible with both, so the JS -// wallet and the Rust VTA frame TSP messages identically. +// `tsp_sdk::cesr`. Byte-compatible with both, so the JS wallet and the Rust +// VTA frame TSP messages identically. // // TSP uses a compact *binary* CESR domain: each frame packs a // `selector | identifier | size` triple into the leading bits of the header @@ -15,6 +15,19 @@ // header + lead zeros + data. // - count code `encodeCount(id, count)` — a `-`-framed group header // carrying a quadlet count. +// +// ── Two revisions ── +// +// The frame *primitives* — fixed data, variable data, the short count code — +// are identical in spec Rev 2 and Rev 3, so they live here once. Exactly two +// things in this file are revision-dependent, and both are decode-side only +// because this package packs Rev 3 and nothing else (see `../revision.ts`): +// +// 1. The long count code's second selector: Rev 2 spelled it `-0X#####`, +// Rev 3 spells it `--X#####`. `decodeCount` takes the form to expect. +// 2. The code table. Rev 3 struck HPKE-Auth's `G` ciphertext and the `X` +// trailing marker and added `F`, `C`, `-A`, `XCTL` and `XPAD`. Both sets +// are named below; the revision modules pick. // CESR base64url selector values (index of the char in the base64url alphabet). const D0 = 52; // '0' @@ -31,8 +44,31 @@ const DASH = 62; // '-' * `DATA_LIMIT = 3 * (1 << 24)`, ~48 MiB). Guards against hostile size headers. */ export const MAX_FIELD_SIZE = 3 * (1 << 24); -/** TSP version `(major, minor, patch)` advertised on the wire. */ -export const TSP_VERSION = { major: 0, minor: 0, patch: 1 } as const; +/** Which spelling of the *long* count code a decoder should expect. + * + * The value is the second selector of the six-byte header. Rev 2 used `0` + * (D0), from a superseded draft of the CESR v2 tables; Rev 3 pins the master + * table for genus `-_AAACAA`, which carries only the double-dash form. One + * character, and encoder and decoder agree with themselves either way — which + * is why `cesr.wire.mjs` pins the bytes rather than round-tripping them. */ +export const LONG_COUNT_REV2 = D0; +/** @see LONG_COUNT_REV2 */ +export const LONG_COUNT_REV3 = DASH; + +/** TSP version `(major, minor)` this package **packs** — Rev 3, `YTSP-AAC`. + * + * MAJOR.MINOR, two components rather than three: MINOR occupies the whole + * 12-bit count. Pre-merge drafts of §9.1 read the three characters as MAJOR, + * MINOR, PATCH and gave `YTSP-ABA`; affinidi-tsp deliberately does not follow + * that reading, and neither do we — see that crate's `TSP_VERSION` for the + * argument. The merged specification's Appendix A vectors carry `YTSP-AAC`, + * the marker packed here. Nothing about interoperating depends on the choice: only MAJOR + * gates processability, it is the same character either way, and no + * implementation refuses a message on MINOR. */ +export const TSP_VERSION = { major: 0, minor: 2 } as const; + +/** The MINOR value Rev 2 carried (`YTSP-AAB`). Read, never written. */ +export const REV2_MINOR = 1; /** Interpret a base64url string as a big-endian integer of its 6-bit symbols. * Only used on ASCII base64url constants ≤ 4 chars (≤ 24 bits), so a JS number @@ -75,39 +111,94 @@ function triplet(stream: Uint8Array, i: number): number | undefined { // ---- TSP identifiers / framing codes (from tsp_sdk::cesr::packet) ---- -/** `B`: var-data plaintext payload / VID / (fixed-data) Ed25519 signature id. */ +/** `B`: var-data plaintext payload / VID / padding / (fixed-data) Ed25519 + * signature id. An empty VID field encodes to `4BAA` — the Rev 3 NULL VID, + * which is how "absent" is spelled for every VID-shaped field. */ export const TSP_PLAINTEXT = cesrInt("B"); // 1 export const TSP_VID = cesrInt("B"); // 1 export const ED25519_SIGNATURE = cesrInt("B"); // 1 -/** `G`: var-data HPKE-Auth ciphertext. */ +/** `G`: var-data HPKE-Auth ciphertext. **Rev 2 only** — Rev 3 strikes the `G` + * codes from the table along with HPKE-Auth itself. */ export const TSP_HPKEAUTH_CIPHERTEXT = cesrInt("G"); // 6 -/** `X`: 2-byte fixed-data marker emitted after the envelope VIDs. */ +/** `F`: var-data HPKE-Base ciphertext (Rev 3 §9.4). */ +export const TSP_HPKE_BASE_CIPHERTEXT = cesrInt("F"); // 5 +/** `C`: var-data libsodium sealed-box ciphertext (Rev 3 §8.3). + * + * Named so a decoder can *recognise* the scheme and say so, rather than fail + * at the `F` selector with "missing ciphertext". We do not implement the + * sealed box: §8 tells new implementations not to use it, and our only TSP + * peers are the VTA and the mediator, which do not send one. */ +export const TSP_SEALED_BOX_CIPHERTEXT = cesrInt("C"); // 2 +/** `X`: 2-byte fixed-data marker emitted after the envelope VIDs. **Rev 2 + * only** — Rev 3 deletes it and always writes the receiver-VID field. */ export const TSP_TMP = cesrInt("X"); // 23 -/** `A`: fixed-data id for a relationship nonce (32 bytes). */ +/** `A`: fixed-data id for a relationship nonce (32 bytes in Rev 2, 16 in + * Rev 3 — the code follows from the payload length). */ export const TSP_NONCE = cesrInt("A"); // 0 /** `I`: fixed-data id for a SHA-256 digest (32 bytes). */ export const TSP_SHA256 = cesrInt("I"); // 8 -/** `-E`: outer count wrapper for an encrypted-then-signed (ETS) envelope. */ +/** `-E`: the envelope frame. + * + * Rev 2's count covered only the header fields; Rev 3's covers *all* signable + * content — version, VIDs and the ciphertext — so it cannot be written until + * the ciphertext size is known. Rev 2's separate `-S` signed-only wrapper is + * gone in Rev 3. */ export const TSP_ETS_WRAPPER = cesrInt("E"); // 4 /** `-Z`: count wrapper for the (to-be-encrypted) CESR payload frame. */ export const TSP_PAYLOAD = cesrInt("Z"); // 25 -/** `-J`: count group for a hop (routing) list. */ -export const TSP_HOP_LIST = cesrInt("J"); +/** `-J`: count group for a hop (routing) list — and in Rev 3 also for the + * reply path and the referral field. + * + * Rev 3 §9.2 changed what the count means: it is the **byte length** of the + * group in quadlets, not the number of VIDs in it. */ +export const TSP_HOP_LIST = cesrInt("J"); // 9 +/** `-A`: generic CESR stream, the container Rev 3 §9.2.3 requires around every + * `XSCS` / `XCTL` upper-layer payload. Rev 2 had no such wrapper. */ +export const TSP_GENERIC_STREAM = cesrInt("A"); // 0 /** `-C`: count attach group for the signature. */ export const TSP_ATTACH_GRP = cesrInt("C"); // 2 /** `-K`: count indexed-signature group for the signature. */ export const TSP_INDEX_SIG_GRP = cesrInt("K"); // 10 /** 3-byte payload-type markers (byte-exact with the reference). */ -export const XSCS = cesrData3("XSCS"); // Direct +export const XSCS = cesrData3("XSCS"); // generic message / Direct export const XHOP = cesrData3("XHOP"); // Nested (empty hops) / Routed export const XRFI = cesrData3("XRFI"); // relationship invite export const XRFA = cesrData3("XRFA"); // relationship accept export const XRFD = cesrData3("XRFD"); // relationship cancel +export const XCTL = cesrData3("XCTL"); // generic control payload (Rev 3) +export const XPAD = cesrData3("XPAD"); // padding-only message (Rev 3) export const YTSP = cesrData3("YTSP"); // TSP version genus marker -const encodedVersion = (): number => (TSP_VERSION.minor << 6) | TSP_VERSION.patch; +/** The TSP protocol code `YTSP-`, used verbatim as the Rev 3 HPKE-Base `info` + * (§8). Five ASCII characters, not the 3-byte binary {@link YTSP} marker — + * Rev 2 passed the whole envelope frame as `info` instead. */ +export const TSP_INFO = new TextEncoder().encode("YTSP-"); + +/** The leading byte of a TSP message framed with a **short** `-E` count code + * (`-E##`). The triplet is `f8 4X XX` — `f8` is the `-` (DASH) selector packed + * with the `E` identifier. */ +export const TSP_MAGIC_BYTE = 0xf8; +/** The leading byte of a TSP message framed with a **long** `-E` count code, + * which is `0xFB` for both revisions' spellings. + * + * Rev 2 could never emit it: its `-E` count covered only the envelope header, + * a couple of dozen quadlets whatever the message size. Rev 3 widened the + * count to cover the ciphertext, so any message past ~12 KB is framed long. + * An ingress classifier that knows only `0xF8` starts dropping large messages + * the moment Rev 3 is switched on. */ +export const TSP_MAGIC_BYTE_LONG = 0xfb; + +/** Cheap ingress classifier: does `bytes` look like a TSP message? + * + * A pre-classifier for routing, not a validator — it inspects only the leading + * byte, and the caller then parses. DIDComm, being JSON or compact JWS, starts + * with `{` (`0x7B`) or `ey…`, so neither byte is ambiguous against it. */ +export function isTsp(bytes: Uint8Array): boolean { + const first = bytes[0]; + return first === TSP_MAGIC_BYTE || first === TSP_MAGIC_BYTE_LONG; +} // ---- Encoding ---- @@ -143,14 +234,17 @@ export function encodeVariableData(identifier: number, payload: Uint8Array, out: for (let i = 0; i < payload.length; i++) out.push(payload[i]!); } -/** Encode a count-code group header carrying `count` quadlets. */ +/** Encode a count-code group header carrying `count` quadlets. + * + * Always the Rev 3 long spelling `--X#####`, because this package packs Rev 3 + * and nothing else. A Rev 2 message is read, never written. */ export function encodeCount(identifier: number, count: number, out: number[]): void { if (count < 4096) { const word = (DASH << 18) | (bits(identifier, 6) << 12) | bits(count, 12); for (const b of beBytes(word)) out.push(b); } else { const word1 = - (DASH << 18) | (D0 << 12) | (bits(identifier, 6) << 6) | bits(count >>> 24, 6); + (DASH << 18) | (LONG_COUNT_REV3 << 12) | (bits(identifier, 6) << 6) | bits(count >>> 24, 6); const word2 = bits(count, 24); for (const b of beBytes(word1)) out.push(b); for (const b of beBytes(word2)) out.push(b); @@ -160,14 +254,22 @@ export function encodeCount(identifier: number, count: number, out: number[]): v /** Encode the TSP version marker (`YTSP` genus + version count code). */ export function encodeVersion(out: number[]): void { for (const b of YTSP) out.push(b); - encodeCount(TSP_VERSION.major, encodedVersion(), out); + encodeCount(TSP_VERSION.major, TSP_VERSION.minor, out); } -/** Encode a hop (routing) list: a `-J` header + one `B` var-data field - * per hop VID. An empty list encodes to just the `-J0` header. */ -export function encodeHops(hops: Uint8Array[], out: number[]): void { - encodeCount(TSP_HOP_LIST, hops.length, out); - for (const hop of hops) encodeVariableData(TSP_VID, hop, out); +/** Encode an *indexed* Ed25519 signature (`B#` + 64 bytes), the Rev 3 §9.5 + * attachment. Rev 2 used the non-indexed fixed-data code `0B` — the same 66 + * bytes with a different two-byte header, which is why only a byte-level test + * catches a regression here. */ +export function encodeIndexedEd25519Signature( + index: number, + signature: Uint8Array, + out: number[], +): void { + const word = (bits(ED25519_SIGNATURE, 6) << 18) | (bits(index, 6) << 12); + const hb = beBytes(word); + out.push(hb[0]!, hb[1]!); + for (let i = 0; i < signature.length; i++) out.push(signature[i]!); } // ---- Decoding ---- @@ -178,11 +280,15 @@ export interface Cursor { } /** Decode a count-code group header for `identifier`. Advances `cur` and - * returns the quadlet count, or undefined on mismatch. */ + * returns the quadlet count, or undefined on mismatch. + * + * `longForm` is the second selector to accept for the six-byte long header — + * {@link LONG_COUNT_REV3} (the default) or {@link LONG_COUNT_REV2}. */ export function decodeCount( identifier: number, stream: Uint8Array, cur: Cursor, + longForm: number = LONG_COUNT_REV3, ): number | undefined { const word = triplet(stream, cur.pos); if (word === undefined) return undefined; @@ -190,7 +296,7 @@ export function decodeCount( const expected = ((DASH << 18) | (bits(identifier, 6) << 12) | bits(index, 12)) >>> 0; const expectedLong = - ((DASH << 18) | (D0 << 12) | (bits(identifier, 6) << 6) | bits(index & 0x3f, 6)) >>> 0; + ((DASH << 18) | (longForm << 12) | (bits(identifier, 6) << 6) | bits(index & 0x3f, 6)) >>> 0; if (word === expected) { cur.pos += 3; return index; @@ -199,7 +305,13 @@ export function decodeCount( const next = triplet(stream, cur.pos + 3); if (next === undefined) return undefined; cur.pos += 6; - return ((index << 24) | next) >>> 0; + // The long count is 30 bits: the high 6 live in the low 6 bits of this + // word, *alongside the identifier*, and the low 24 in the next. `index` + // still carries the identifier in its upper bits, so it must be masked + // before being shifted in — unmasked, the count comes back enormous. Rev 2 + // never emitted a long `-E`, which is why this went unnoticed; Rev 3 frames + // everything past ~12 KB this way. + return (((index & 0x3f) << 24) | next) >>> 0; } return undefined; } @@ -277,30 +389,28 @@ export function decodeVariableData( return stream.slice(range.begin, range.end); } -/** Max hops accepted in a routed message's hop list (bounds a hostile count). */ -export const MAX_HOPS = 10; - -/** Decode a hop (routing) list. Advances `cur` past the `-J` group + hops. */ -export function decodeHops(stream: Uint8Array, cur: Cursor): Uint8Array[] | undefined { - const count = decodeCount(TSP_HOP_LIST, stream, cur); - if (count === undefined) return undefined; - if (count > MAX_HOPS) return undefined; - const hops: Uint8Array[] = []; - for (let i = 0; i < count; i++) { - const hop = decodeVariableData(TSP_VID, stream, cur); - if (hop === undefined) return undefined; - hops.push(hop); - } - return hops; -} +/** Max hops accepted in a routed message's hop list or reply path (bounds a + * hostile count). The spec sets no maximum, so this is a local choice that + * caps interoperability: 64 matches the other affinidi TSP implementations. + * It was 10, which refused 12-hop routes every other implementation opens. */ +export const MAX_HOPS = 64; -/** Decode + validate the TSP version marker. Advances `cur`. Returns whether - * the marker was well-formed. */ -export function decodeVersion(stream: Uint8Array, cur: Cursor): boolean { - if (cur.pos + YTSP.length > stream.length) return false; +/** Read the `YTSP` genus marker and its version count code. Advances `cur`. + * + * Returns the raw `(major, minor)` without judging either: the caller decides + * what to do with them, because "which revision is this?" is the one question + * that has to be answered before anything else can be parsed. */ +export function readVersion( + stream: Uint8Array, + cur: Cursor, +): { major: number; minor: number } | undefined { + if (cur.pos + YTSP.length > stream.length) return undefined; for (let i = 0; i < YTSP.length; i++) { - if (stream[cur.pos + i]! !== YTSP[i]!) return false; + if (stream[cur.pos + i]! !== YTSP[i]!) return undefined; } - cur.pos += YTSP.length; - return decodeCount(TSP_VERSION.major, stream, cur) !== undefined; + const word = triplet(stream, cur.pos + YTSP.length); + if (word === undefined) return undefined; + if (word >>> 18 !== DASH) return undefined; + cur.pos += YTSP.length + 3; + return { major: (word >>> 12) & bitsMask(6), minor: word & bitsMask(12) }; } diff --git a/packages/tsp-js/src/crypto/hpke-noble.ts b/packages/tsp-js/src/crypto/hpke-noble.ts index 8bd5ce1..d5a7407 100644 --- a/packages/tsp-js/src/crypto/hpke-noble.ts +++ b/packages/tsp-js/src/crypto/hpke-noble.ts @@ -115,6 +115,27 @@ function dh(sk: Uint8Array, pk: Uint8Array): Uint8Array { // public `hpke.ts` wrappers deliberately do not forward it. type UnsafeFixedEphemeral = { __unsafeFixedEphemeralSk?: Uint8Array }; +/** + * §7.1.3 DeriveKeyPair for DHKEM(X25519, HKDF-SHA256): `ikm` → `(skE, pkE)`. + * + * Exported for test-vector verification only. RFC 9180 and TSP Appendix A + * publish the ephemeral as `ikmE`, the input to this function, so a vector's + * `enc` can only be reproduced by running it. X25519 needs no rejection + * sampling: every 32-byte string is a valid scalar (clamped at use). + * + * Nothing on a production path calls this. A random ephemeral is drawn from + * `x25519.utils.randomSecretKey()` directly, and deriving one from caller + * input is exactly the reuse the `__unsafe…` hooks warn about. + */ +export function deriveKeyPair(ikm: Uint8Array): { sk: Uint8Array; pk: Uint8Array } { + if (ikm.length < NX25519) { + throw new Error(`tsp: DeriveKeyPair input must be at least ${NX25519} bytes`); + } + const dkpPrk = labeledExtract(KEM_SUITE_ID, EMPTY, "dkp_prk", ikm); + const sk = labeledExpand(KEM_SUITE_ID, dkpPrk, "sk", EMPTY, NX25519); + return { sk, pk: x25519.getPublicKey(sk) }; +} + /** §4.1 Encap (base mode). Exported for test-vector verification. */ export function encap(recipientPk: Uint8Array, unsafe?: UnsafeFixedEphemeral): { sharedSecret: Uint8Array; diff --git a/packages/tsp-js/src/index.ts b/packages/tsp-js/src/index.ts index c7d8890..8e96e42 100644 --- a/packages/tsp-js/src/index.ts +++ b/packages/tsp-js/src/index.ts @@ -1,22 +1,46 @@ // @openvtc/vti-tsp-js — pure-TS TSP primitives, byte-compatible with -// affinidi-tsp (the crate the VTA links). v1 = HPKE-Auth only (RFC 9180, -// DHKEM-X25519 + HKDF-SHA256 + ChaCha20Poly1305) + binary CESR framing. -// No WebCrypto dependency: runs identically in browser, Node, and React -// Native (only `crypto.getRandomValues` is required of the runtime). +// affinidi-tsp (the crate the VTA links). Binary CESR framing + RFC 9180 HPKE +// on @noble. No WebCrypto dependency: runs identically in browser, Node, and +// React Native (only `crypto.getRandomValues` is required of the runtime). // -// Layers (built incrementally): -// cesr/wire — binary CESR frame primitives [done] -// message/envelope — the -E envelope (HPKE info) [done] -// crypto/hpke — HPKE-Auth seal/open via @noble [done] -// crypto/sign — Ed25519 sign/verify via @noble [done] -// message/direct — pack/unpack (seal+sign / verify+open) [done] -// vid — VID → keys resolution [todo] +// ── Revisions ── +// +// This package **packs spec Rev 3** (`YTSP-AAC`: HPKE-Base, `F` ciphertext, +// ESSR sender field, `--X` long counts, indexed signatures) and **reads Rev 3 +// and Rev 2**. `unpack` dispatches on the version marker every message carries; +// `pack` has nothing to dispatch on and does not try. See `revision.ts` for why +// that asymmetry is the whole design, and `rev2/reader.ts` for what a Rev 2 +// message costs us to read. +// +// Layers: +// cesr/wire — binary CESR frame primitives, shared by both revisions +// revision — the keyless version-marker discriminator +// crypto/hpke — HPKE Base + Auth seal/open via @noble +// crypto/sign — Ed25519 sign/verify via @noble +// relationship — the §7.2/§7.3 state machine, pure and storage-free +// rev3/ — Rev 3 envelope, fields, payload frame, control, pack/unpack +// rev2/ — Rev 2 reader; frozen, decode-only +// message/ — the public API and the dispatcher export * as cesr from "./cesr/wire.js"; export * as hpke from "./crypto/hpke.js"; export * as sign from "./crypto/sign.js"; export { - encodeEnvelope, + isTsp, + TSP_MAGIC_BYTE, + TSP_MAGIC_BYTE_LONG, +} from "./cesr/wire.js"; +export { + describeRevision, + isRevisionError, + peekRevision, + KNOWN_MINORS, + SUPPORTED_MAJOR, + TspRevisionError, + type PeekedRevision, + type Revision, +} from "./revision.js"; +export { decodeEnvelope, type Envelope, type DecodedEnvelope, @@ -24,14 +48,37 @@ export { export { pack, packWithHops, + packInvite, + packAccept, + packCancel, unpack, sha256, + type ApplicationKind, + type ControlMessage, + type ControlType, type MessageType, type PackKeys, type UnpackKeys, type PackedMessage, type UnpackedMessage, } from "./message/direct.js"; +export { + admitsApplicationMessage, + canSend, + compareBytes, + InvalidTransitionError, + resolveAccept, + resolveCancel, + resolveInviteRace, + transition, + type AcceptOutcome, + type CancelOutcome, + type InviteRaceOutcome, + type RelationshipEvent, + type RelationshipState, +} from "./relationship.js"; +export { referralSignedData, type Referral } from "./rev3/control.js"; +export { generateNonce } from "./rev3/fields.js"; export { packRouted, packNested, diff --git a/packages/tsp-js/src/message/direct.ts b/packages/tsp-js/src/message/direct.ts index cb17d50..ca47bd3 100644 --- a/packages/tsp-js/src/message/direct.ts +++ b/packages/tsp-js/src/message/direct.ts @@ -1,282 +1,199 @@ -// TSP direct-mode messaging — seal, sign, and CESR-encode a message. -// TS port of affinidi-tsp `src/message/direct.rs` (Direct-mode scope). +// The public message API: pack Rev 3, unpack either. // -// A direct message is HPKE-Auth sealed (encrypt + sender-authenticate) then -// Ed25519-signed. Wire (encrypted-then-signed): +// ── The asymmetry is the design ── // -// -E envelope (= HPKE info): YTSP · B sender-VID · B receiver-VID · X 00 00 -// ciphertext = ct ‖ tag(16) ‖ enc(32) -// -C -K sig(64) Ed25519 over envelope‖ciphertext +// `unpack` dispatches on the version marker a message carries; `pack` does not +// dispatch on anything, because there is nothing to dispatch on. An inbound +// message *says* what revision it is. An outbound one has to be decided before +// a byte exists, and the wire carries no field that would tell us what a peer +// can read — so a dual *packer* could only ever be a guess dressed as a +// protocol. We pack Rev 3. // -// The encrypted plaintext is itself a CESR payload frame: -// -Z XSCS body +// What that costs is exact and worth stating plainly: a Rev 2 peer cannot read +// what we send. Nothing here softens that, retries, or falls back. What it buys +// is that the Rev 3 path has no revision branch in it anywhere — the word +// "rev2" appears in this file and in `../rev2/`, and nowhere else. // -// HPKE binding: the `-E` envelope frame is the HPKE `info`; AEAD AAD is empty. -// -// Supports Direct (trust-tasks) + Nested / Routed (mediator relay). Control -// (relationship FSM) is a follow-up. +// `revision` on the result is how a caller learns what a peer actually speaks. +// Persisting that per peer belongs above this package: a codec has no business +// holding state about who it has talked to. import { sha256 } from "@noble/hashes/sha2.js"; -import * as wire from "../cesr/wire.js"; -import * as hpke from "../crypto/hpke.js"; -import * as sign from "../crypto/sign.js"; -import { encodeEnvelope, decodeEnvelope } from "./envelope.js"; - -const ENC_LEN = 32; -const TAG_LEN = 16; -const SIG_LEN = 64; -const SIG_QUADLETS = Math.ceil(SIG_LEN / 3); // 22 -const EMPTY = new Uint8Array(0); - -const utf8 = new TextEncoder(); -const fromUtf8 = new TextDecoder("utf-8", { fatal: true }); - -/** TSP message kind, recovered from the encrypted payload frame's marker. */ -export type MessageType = "direct" | "nested" | "routed"; - -/** Raw key material for a TSP identity (all 32-byte). */ -export interface PackKeys { - /** Sender's Ed25519 private key (signing). */ - senderSigningKey: Uint8Array; - /** Sender's X25519 private key (HPKE-Auth sender authentication). */ - senderEncryptionKey: Uint8Array; - /** Receiver's X25519 public key (HPKE recipient). */ - receiverEncryptionKey: Uint8Array; -} - +import { unpack as unpackRev2, type Rev2UnpackKeys } from "../rev2/reader.js"; +import { + pack as packRev3, + packAccept as packAcceptRev3, + packCancel as packCancelRev3, + packInvite as packInviteRev3, + packWithHops as packWithHopsRev3, + unpack as unpackRev3, + type PackKeys, + type PackedMessage, +} from "../rev3/direct.js"; +import type { ApplicationKind, ControlMessage, ControlType } from "../rev3/payload.js"; +import type { MessageType } from "../rev3/payload.js"; +import { describeRevision, peekRevision, TspRevisionError, type Revision } from "../revision.js"; + +export type { ApplicationKind, ControlMessage, ControlType, MessageType, PackKeys, PackedMessage }; + +/** Keys needed to unpack a message of either revision. + * + * `senderEncryptionKey` is Rev 2's alone: HPKE-Auth puts the sender's static + * key in the KEM, so a Rev 2 message cannot be *opened* without it, let alone + * verified. It is optional because the Rev 3 path has no use for it at all, + * and a required field that one whole revision ignores teaches the wrong thing + * about what authenticates a Rev 3 sender. Omit it and a Rev 2 message is + * refused by name rather than by a decryption failure. */ export interface UnpackKeys { - /** Receiver's X25519 private key (HPKE recipient). */ + /** Receiver's X25519 private key. */ receiverDecryptionKey: Uint8Array; - /** Sender's X25519 public key (HPKE-Auth sender verification). */ - senderEncryptionKey: Uint8Array; /** Sender's Ed25519 public key (outer signature verification). */ senderSigningKey: Uint8Array; -} - -export interface PackedMessage { - /** Raw wire bytes. */ - bytes: Uint8Array; - /** SHA-256 of the plaintext payload frame — the TSP thread digest. */ - threadDigest: Uint8Array; + /** Sender's X25519 public key. **Rev 2 only** — HPKE-Auth sender + * authentication. */ + senderEncryptionKey?: Uint8Array; } export interface UnpackedMessage { - /** The decrypted message body. For Direct/Nested it's the message/inner; for - * Routed it's the opaque inner message (the route is in `hops`). */ + /** The decrypted message body. For Direct it is the upper-layer payload; for + * Nested/Routed the opaque inner message (the route is in `hops`). */ payload: Uint8Array; - /** Sender VID (from the cleartext envelope). */ + /** Sender VID, from the cleartext envelope. */ sender: string; - /** Receiver VID (from the cleartext envelope). */ + /** Receiver VID, from the cleartext envelope. */ receiver: string; /** The message kind recovered from the payload frame. */ messageType: MessageType; - /** Remaining route for a Routed message (empty for Direct/Nested). */ + /** The recovered relationship-forming message, when there is one (§7.2). + * + * Rev 3 only: Rev 2 messages never decode to one here. Its self-addressing + * digest has already been verified against the frame, so a `control` that is + * present identified itself correctly — but *what to do about it* is the + * caller's, via `relationship.ts`. */ + control?: ControlMessage; + /** Remaining route for a Routed message (empty otherwise). */ hops: string[]; - /** SHA-256 of the decrypted payload frame — the TSP thread digest. */ + /** SHA-256 of the payload frame — the TSP thread digest. */ threadDigest: Uint8Array; + /** Which revision framed this message. A caller that tracks what a peer + * speaks reads it here. */ + revision: Revision; } -function concat(a: Uint8Array, b: Uint8Array): Uint8Array { - const out = new Uint8Array(a.length + b.length); - out.set(a, 0); - out.set(b, a.length); - return out; -} - -function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; - return true; -} - -interface DecodedFrame { - kind: MessageType; - hops: string[]; - body: Uint8Array; -} - -/** Build the CESR payload frame that gets encrypted: - * Direct → `-Z XSCS body` - * Nested → `-Z XHOP -J0 body` - * Routed → `-Z XHOP -J (B hop)* body` */ -function encodePayloadFrame(body: Uint8Array, kind: MessageType, hops: string[]): Uint8Array { - const frameBody: number[] = []; - if (kind === "direct") { - for (const b of wire.XSCS) frameBody.push(b); - } else { - for (const b of wire.XHOP) frameBody.push(b); - wire.encodeHops( - hops.map((h) => utf8.encode(h)), - frameBody, - ); - } - wire.encodeVariableData(wire.TSP_PLAINTEXT, body, frameBody); - - const out: number[] = []; - wire.encodeCount(wire.TSP_PAYLOAD, frameBody.length / 3, out); - for (const b of frameBody) out.push(b); - return new Uint8Array(out); -} - -/** Decode a payload frame into its kind, remaining route, and body. */ -function decodePayloadFrame(frame: Uint8Array): DecodedFrame { - const cur: wire.Cursor = { pos: 0 }; - if (wire.decodeCount(wire.TSP_PAYLOAD, frame, cur) === undefined) { - throw new Error("tsp: missing -Z payload frame"); - } - // Optional ESSR sender-VID: the reference omits it for HPKE-Auth. A non-VID - // marker won't match a `B` var-data field, so this is a tolerant skip. - wire.decodeVariableData(wire.TSP_VID, frame, cur); - - const marker = frame.slice(cur.pos, cur.pos + 3); - if (bytesEqual(marker, wire.XSCS)) { - cur.pos += 3; - const body = wire.decodeVariableData(wire.TSP_PLAINTEXT, frame, cur); - if (body === undefined) throw new Error("tsp: missing payload plaintext"); - return { kind: "direct", hops: [], body }; - } - if (bytesEqual(marker, wire.XHOP)) { - cur.pos += 3; - const hopBytes = wire.decodeHops(frame, cur); - if (hopBytes === undefined) throw new Error("tsp: malformed hop list"); - let hops: string[]; - try { - hops = hopBytes.map((h) => fromUtf8.decode(h)); - } catch { - throw new Error("tsp: hop VID not UTF-8"); - } - const body = wire.decodeVariableData(wire.TSP_PLAINTEXT, frame, cur); - if (body === undefined) throw new Error("tsp: missing payload plaintext"); - return { kind: hops.length === 0 ? "nested" : "routed", hops, body }; - } - throw new Error("tsp: unsupported payload type marker"); -} - -/** Encode the signature frame: `-C -K sig(64)`. */ -function encodeSignatureFrame(signature: Uint8Array, out: number[]): void { - wire.encodeCount(wire.TSP_ATTACH_GRP, SIG_QUADLETS, out); - wire.encodeCount(wire.TSP_INDEX_SIG_GRP, SIG_QUADLETS, out); - wire.encodeFixedData(wire.ED25519_SIGNATURE, signature, out); -} - -/** Decode the signature frame; returns the 64-byte Ed25519 signature. */ -function decodeSignatureFrame(data: Uint8Array, cur: wire.Cursor): Uint8Array { - const a = wire.decodeCount(wire.TSP_ATTACH_GRP, data, cur); - const k = wire.decodeCount(wire.TSP_INDEX_SIG_GRP, data, cur); - if (a !== SIG_QUADLETS || k !== SIG_QUADLETS) { - throw new Error("tsp: unexpected signature group size"); - } - const sig = wire.decodeFixedData(wire.ED25519_SIGNATURE, SIG_LEN, data, cur); - if (sig === undefined) throw new Error("tsp: missing Ed25519 signature"); - return sig; +/** Pack a direct TSP message (Rev 3). */ +export function pack( + body: Uint8Array, + senderVid: string, + receiverVid: string, + keys: PackKeys, +): Promise { + return packRev3(body, senderVid, receiverVid, keys); } -/** - * Pack a direct TSP message: build the envelope (= HPKE info), HPKE-Auth seal - * the payload frame (empty AAD), append `enc`, then Ed25519-sign envelope‖ - * ciphertext. - */ -export async function pack( +/** Pack a message of any kind (Rev 3), carrying a routing `hops` list in the + * payload frame. `hops` must be empty for Direct/Nested. */ +export function packWithHops( body: Uint8Array, + kind: ApplicationKind, + hops: string[], senderVid: string, receiverVid: string, keys: PackKeys, ): Promise { - return packWithHops(body, "direct", [], senderVid, receiverVid, keys); + return packWithHopsRev3(body, kind, hops, senderVid, receiverVid, keys); } /** - * Like {@link pack} but for any message kind, carrying a routing `hops` list in - * the payload frame (used by `pack_routed` for Routed; `hops` must be empty for - * Direct/Nested). + * Pack a relationship-forming invite (`XRFI`, §7.2). + * + * `PackedMessage.threadDigest` is the invite's self-addressing digest, and the + * caller must keep it: it is what the accept echoes back, and what a later + * cancellation names. It cannot be known before packing — the derivation covers + * the envelope this call builds. */ -export async function packWithHops( - body: Uint8Array, - kind: MessageType, - hops: string[], +export function packInvite( senderVid: string, receiverVid: string, keys: PackKeys, + opts: { route?: string[]; nonce?: Uint8Array } = {}, ): Promise { - const envelopeBytes = encodeEnvelope(senderVid, receiverVid); - - const payloadFrame = encodePayloadFrame(body, kind, hops); - const threadDigest = sha256(payloadFrame); - - const sealed = await hpke.seal( - payloadFrame, - EMPTY, - keys.senderEncryptionKey, - keys.receiverEncryptionKey, - envelopeBytes, - ); - // Reference ciphertext layout: ct ‖ tag(16) ‖ enc(32). - const gPayload = concat(sealed.ciphertext, sealed.enc); - - const wireBytes: number[] = []; - for (const b of envelopeBytes) wireBytes.push(b); - wire.encodeVariableData(wire.TSP_HPKEAUTH_CIPHERTEXT, gPayload, wireBytes); + return packInviteRev3(senderVid, receiverVid, keys, opts); +} - const signature = sign.sign(new Uint8Array(wireBytes), keys.senderSigningKey); - encodeSignatureFrame(signature, wireBytes); +/** Pack a relationship-forming accept (`XRFA`) answering `inviteDigest`. */ +export function packAccept( + inviteDigest: Uint8Array, + senderVid: string, + receiverVid: string, + keys: PackKeys, +): Promise { + return packAcceptRev3(inviteDigest, senderVid, receiverVid, keys); +} - return { bytes: new Uint8Array(wireBytes), threadDigest }; +/** Pack a relationship cancellation (`XRFD`) naming either half of the + * relationship it ends (§7.2.1, §7.3). */ +export function packCancel( + relationshipDigest: Uint8Array, + senderVid: string, + receiverVid: string, + keys: PackKeys, +): Promise { + return packCancelRev3(relationshipDigest, senderVid, receiverVid, keys); } /** - * Unpack a direct TSP message: parse the envelope (HPKE info), verify the - * Ed25519 signature over envelope‖ciphertext, split `enc` off the tail, and - * HPKE-Auth open (empty AAD). + * Unpack a TSP message of either revision, dispatching on its version marker. + * + * A parse failure against a frame whose MINOR we do not recognise is re-reported + * as a {@link TspRevisionError} naming both revisions and carrying the + * underlying error. Without that, a frame from a revision we have never seen + * dies wherever its layout first disagrees with ours — which is almost never + * where the actual problem is. */ export async function unpack( wireBytes: Uint8Array, keys: UnpackKeys, ): Promise { - if (wireBytes.length < 48) throw new Error("tsp: message too short"); - - const { envelope, headerLen } = decodeEnvelope(wireBytes); - const envelopeBytes = wireBytes.slice(0, headerLen); - - const cur: wire.Cursor = { pos: headerLen }; - const ctRange = wire.decodeVariableDataRange(wire.TSP_HPKEAUTH_CIPHERTEXT, wireBytes, cur); - if (ctRange === undefined) throw new Error("tsp: missing G ciphertext frame"); - const signedEnd = cur.pos; // signature covers envelope‖ciphertext - - const gLen = ctRange.end - ctRange.begin; - if (gLen > wire.MAX_FIELD_SIZE) throw new Error("tsp: ciphertext too large"); - if (gLen < ENC_LEN + TAG_LEN) throw new Error("tsp: ciphertext truncated"); + const peeked = peekRevision(wireBytes); + + try { + if (peeked.revision === "rev2") { + if (keys.senderEncryptionKey === undefined) { + throw new TspRevisionError( + "tsp: message is Rev 2 (YTSP-AAB), which needs the sender's X25519 public key to open (HPKE-Auth); pass senderEncryptionKey", + peeked.major, + peeked.minor, + ); + } + const rev2Keys: Rev2UnpackKeys = { + receiverDecryptionKey: keys.receiverDecryptionKey, + senderEncryptionKey: keys.senderEncryptionKey, + senderSigningKey: keys.senderSigningKey, + }; + const out = await unpackRev2(wireBytes, rev2Keys); + return { ...out, revision: "rev2" }; + } - const signature = decodeSignatureFrame(wireBytes, cur); - if (cur.pos !== wireBytes.length) throw new Error("tsp: trailing bytes after signature"); - if (!sign.verify(wireBytes.slice(0, signedEnd), signature, keys.senderSigningKey)) { - throw new Error("tsp: signature verification failed"); + const out = await unpackRev3(wireBytes, { + receiverDecryptionKey: keys.receiverDecryptionKey, + senderSigningKey: keys.senderSigningKey, + }); + return { ...out, revision: "rev3" }; + } catch (err) { + // A revision error is already about the revision; re-wrapping would bury it. + if (err instanceof TspRevisionError) throw err; + if (!peeked.recognised) { + throw new TspRevisionError( + `tsp: could not parse a message declaring ${describeRevision(peeked)}; this implementation packs Rev 3 (YTSP-AAC) and reads Rev 2 (YTSP-AAB). Underlying error: ${ + err instanceof Error ? err.message : String(err) + }`, + peeked.major, + peeked.minor, + ); + } + throw err; } - - const gPayload = wireBytes.slice(ctRange.begin, ctRange.end); - const encStart = gPayload.length - ENC_LEN; - const enc = gPayload.slice(encStart); - const ctAndTag = gPayload.slice(0, encStart); - - const payloadFrame = await hpke.open( - ctAndTag, - EMPTY, - enc, - keys.receiverDecryptionKey, - keys.senderEncryptionKey, - envelopeBytes, - ); - const threadDigest = sha256(payloadFrame); - const frame = decodePayloadFrame(payloadFrame); - - return { - payload: frame.body, - sender: envelope.sender, - receiver: envelope.receiver, - messageType: frame.kind, - hops: frame.hops, - threadDigest, - }; } /** SHA-256 (the TSP thread-digest hash). */ diff --git a/packages/tsp-js/src/message/envelope.ts b/packages/tsp-js/src/message/envelope.ts index 94d1b49..7cec542 100644 --- a/packages/tsp-js/src/message/envelope.ts +++ b/packages/tsp-js/src/message/envelope.ts @@ -1,77 +1,58 @@ -// TSP message envelope — the binary-CESR `-E` (encrypted-then-signed) header. -// TS port of affinidi-tsp `src/message/envelope.rs`. +// The public, revision-dispatching envelope decode. // -// The envelope is the cleartext outer frame: TSP version + sender VID + -// receiver VID + a 2-byte TMP marker. Its encoded bytes are used verbatim as -// the HPKE **`info`** (see `direct.ts`), binding sender/receiver to the -// ciphertext. Byte-compatible with tsp-sdk. +// A relay routes on the cleartext envelope and never opens the message, so this +// has to work for both revisions with no keys at all. It reads the version +// marker first (`../revision.ts`) and hands the frame to the matching codec. // -// -E · YTSP · B sender-VID · B receiver-VID · X 00 00 +// There is no public envelope *encode* here: we pack Rev 3 and nothing else, so +// the Rev 3 codec's `encodeFields`/`finalizeFrame` pair is the only way to +// build one, and it is deliberately not reachable through a name that suggests +// a revision-neutral envelope exists. -import * as wire from "../cesr/wire.js"; - -const utf8 = new TextEncoder(); -const fromUtf8 = new TextDecoder("utf-8", { fatal: true }); +import { decodeEnvelope as decodeRev3 } from "../rev3/envelope.js"; +import { decodeRev2Envelope } from "../rev2/reader.js"; +import { peekRevision, type Revision } from "../revision.js"; export interface Envelope { sender: string; + /** Empty string is Rev 3's NULL VID (`4BAA`) — "no receiver named". Rev 2 + * had no such spelling and always names one. */ receiver: string; } export interface DecodedEnvelope { envelope: Envelope; - /** Bytes consumed by the `-E` frame — i.e. the HPKE `info` length. */ + /** Bytes consumed by the envelope fields. + * + * The number means different things per revision and is reported for + * diagnostics, not for arithmetic across them: in Rev 2 it is the whole `-E` + * frame, which is also the HPKE `info`; in Rev 3 it is the offset at which + * the ciphertext field begins, and the AAD is those bytes minus the count + * code. Code that needs either should use the revision's own codec. */ headerLen: number; + /** Which revision framed this message. */ + revision: Revision; + /** MINOR as carried, unjudged. */ + minor: number; } -/** Encode an envelope to its binary-CESR `-E` frame. The returned bytes are the - * HPKE `info` for the message. */ -export function encodeEnvelope(sender: string, receiver: string): Uint8Array { - const body: number[] = []; - wire.encodeVersion(body); - wire.encodeVariableData(wire.TSP_VID, utf8.encode(sender), body); - wire.encodeVariableData(wire.TSP_VID, utf8.encode(receiver), body); - wire.encodeFixedData(wire.TSP_TMP, new Uint8Array([0, 0]), body); - - if (body.length % 3 !== 0) { - throw new Error("tsp: envelope body not a multiple of 3 bytes"); - } - - const out: number[] = []; - wire.encodeCount(wire.TSP_ETS_WRAPPER, body.length / 3, out); - for (const b of body) out.push(b); - return new Uint8Array(out); -} - -/** Decode an envelope from the start of `data`, reporting the `-E` frame length - * (the HPKE `info` byte length). Throws on a malformed frame. */ +/** Decode the cleartext envelope of a TSP message of either revision. */ export function decodeEnvelope(data: Uint8Array): DecodedEnvelope { - const cur: wire.Cursor = { pos: 0 }; - - if (wire.decodeCount(wire.TSP_ETS_WRAPPER, data, cur) === undefined) { - throw new Error("tsp: missing -E envelope wrapper"); + const peeked = peekRevision(data); + if (peeked.revision === "rev2") { + const rev2 = decodeRev2Envelope(data); + return { + envelope: { sender: rev2.sender, receiver: rev2.receiver }, + headerLen: rev2.headerLen, + revision: "rev2", + minor: peeked.minor, + }; } - if (!wire.decodeVersion(data, cur)) { - throw new Error("tsp: missing or malformed version marker"); - } - - const senderBytes = wire.decodeVariableData(wire.TSP_VID, data, cur); - if (senderBytes === undefined) throw new Error("tsp: missing sender VID"); - const receiverBytes = wire.decodeVariableData(wire.TSP_VID, data, cur); - if (receiverBytes === undefined) throw new Error("tsp: missing receiver VID"); - - let sender: string; - let receiver: string; - try { - sender = fromUtf8.decode(senderBytes); - receiver = fromUtf8.decode(receiverBytes); - } catch { - throw new Error("tsp: invalid VID encoding"); - } - - // Consume the 2-byte TMP marker if present (the reference emits it - // unconditionally for encrypted messages). - wire.decodeFixedData(wire.TSP_TMP, 2, data, cur); - - return { envelope: { sender, receiver }, headerLen: cur.pos }; + const rev3 = decodeRev3(data); + return { + envelope: rev3.envelope, + headerLen: rev3.headerLen, + revision: "rev3", + minor: rev3.minor, + }; } diff --git a/packages/tsp-js/src/message/routed.ts b/packages/tsp-js/src/message/routed.ts index 2e8aa12..d135590 100644 --- a/packages/tsp-js/src/message/routed.ts +++ b/packages/tsp-js/src/message/routed.ts @@ -6,15 +6,26 @@ // addressed intermediary reads it. Nested mode is the degenerate wrapper: an // inner packed message carried opaquely to a single intermediary. // +// Rev 3 only, like everything on the packing side. Two changes from Rev 2 reach +// callers: the inner message is carried **raw** rather than inside a `B` +// var-data field, which makes it a caller error to hand one that is not +// quadlet-aligned; and the `-J` count is the hop group's byte length rather +// than the number of hops. +// // Wallet → mediator → VTA is a routed send: pack the trust-task as a Direct // message to the VTA (sealed end-to-end), then `packRouted` it to the mediator // with `route = [vtaVid]`. The mediator opens the routing layer, sees the VTA // as the next (and last) hop, and forwards the opaque inner to it. +import { MAX_HOPS as WIRE_MAX_HOPS } from "../cesr/wire.js"; import { packWithHops, type PackKeys, type PackedMessage } from "./direct.js"; -/** Max hops in a route — bounds memory + forwarding loops. */ -export const MAX_HOPS = 16; +/** Max hops in a route — bounds memory + forwarding loops. + * + * The same number the decoder enforces, deliberately: this used to be 16 + * against a decoder that stopped at 10, so a 12-hop route packed cleanly and + * could not be read back by this very library. */ +export const MAX_HOPS = WIRE_MAX_HOPS; /** * Pack a routed message addressed to `firstHopVid`, carrying `remainingRoute` diff --git a/packages/tsp-js/src/relationship.ts b/packages/tsp-js/src/relationship.ts new file mode 100644 index 0000000..60711bb --- /dev/null +++ b/packages/tsp-js/src/relationship.ts @@ -0,0 +1,220 @@ +// The TSP relationship state machine (§7.2, §7.3). +// +// Where DIDComm's relationships are implicit, TSP's are explicit, and Rev 3 +// made them load-bearing: §7.2.2 says an endpoint SHOULD drop an application +// message from a VID it holds no relationship with. So this is not bookkeeping +// — it decides whether anything we send arrives. +// +// None ──[send RFI]──► Pending ──[receive RFA]──► Bidirectional +// │ │ │ +// │ [receive RFI] │ [receive RFD] │ [send/receive RFD] +// ▼ ▼ ▼ +// InviteReceived None None +// │ +// │ [send RFA] +// ▼ +// Bidirectional +// +// ── Why this is here and not in the wallet ── +// +// Everything in this module is a pure function: state plus event in, state or a +// refusal out. No storage, no clock, no keys. That is the line — this package +// owns *what the protocol says happens next*, and the wallet owns *where that +// is written down*, because only it knows about `chrome.storage` and MV3 +// teardown. Putting the rules here is what lets them be tested against the +// specification rather than against a mock of a store. +// +// It is also why `unpack` does not apply them. A codec that silently mutated +// relationship state would make receiving a message a side effect, and the one +// thing a wallet must be able to do is look at an invite before answering it. + +/** The state of a relationship between two VIDs, from our side. */ +export type RelationshipState = "none" | "pending" | "inviteReceived" | "bidirectional"; + +/** What just happened. */ +export type RelationshipEvent = + | "sendInvite" + | "receiveInvite" + | "sendAccept" + | "receiveAccept" + | "sendCancel" + | "receiveCancel"; + +/** A transition the state machine does not allow. Carries both halves so a + * caller can say what it refused rather than only that it did. */ +export class InvalidTransitionError extends Error { + readonly code = "E_TSP_TRANSITION" as const; + readonly state: RelationshipState; + readonly event: RelationshipEvent; + + constructor(state: RelationshipState, event: RelationshipEvent) { + super(`tsp: cannot ${event} in relationship state ${state}`); + this.name = "InvalidTransitionError"; + this.state = state; + this.event = event; + } +} + +const TRANSITIONS: Record>> = { + none: { + sendInvite: "pending", + receiveInvite: "inviteReceived", + }, + pending: { + receiveAccept: "bidirectional", + receiveCancel: "none", + sendCancel: "none", + }, + inviteReceived: { + sendAccept: "bidirectional", + sendCancel: "none", + // The inviter withdrew before we answered. §7.3 removes the relationship in + // this direction; the mirror case, where we decline, is `sendCancel`. + receiveCancel: "none", + }, + bidirectional: { + sendCancel: "none", + receiveCancel: "none", + }, +}; + +/** Apply a transition, or throw {@link InvalidTransitionError}. */ +export function transition( + state: RelationshipState, + event: RelationshipEvent, +): RelationshipState { + const next = TRANSITIONS[state][event]; + if (next === undefined) throw new InvalidTransitionError(state, event); + return next; +} + +/** May we send an application message in this state? */ +export function canSend(state: RelationshipState): boolean { + return state === "bidirectional"; +} + +/** + * Does this state admit an inbound application message under §7.2.2? + * + * **Any** recorded relationship does, not only a completed one. Receiving an + * invite records the inbound half, and §3.6 lets a sender pack user data + * alongside its invite rather than wait a round trip — so gating on + * `bidirectional` alone would drop messages the specification expects to + * arrive. This asymmetry with {@link canSend} is deliberate on both sides: we + * are strict about what we send and lenient about what we accept, which is the + * only ordering that cannot deadlock. + */ +export function admitsApplicationMessage(state: RelationshipState): boolean { + return state !== "none"; +} + +/** What to do with an invite that arrived while our own was outstanding. */ +export type InviteRaceOutcome = + | { keep: "ours"; reason: string } + | { keep: "theirs"; reason: string }; + +/** + * Resolve the §7.2.3 invite race. + * + * Both endpoints may invite each other for the same VID pair at once. Both keep + * the invite whose digest is lexicographically **lower** and discard the other, + * so the two sides converge on one exchange and one thread id instead of each + * believing it opened the relationship. + * + * The rule only works because both sides compute it the same way on the same + * two values, so this compares bytes and nothing else — no timestamps, no + * "ours wins", no tie-break on VID. A tie means the same digest, which means + * the same message, which cannot be two invites. + */ +export function resolveInviteRace( + ourInviteDigest: Uint8Array, + theirInviteDigest: Uint8Array, +): InviteRaceOutcome { + const cmp = compareBytes(ourInviteDigest, theirInviteDigest); + if (cmp < 0) { + return { keep: "ours", reason: "our invite has the lower digest; discard theirs" }; + } + return { keep: "theirs", reason: "their invite has the lower digest; adopt it and drop ours" }; +} + +/** Lexicographic byte comparison, as §7.2.3's rule requires. */ +export function compareBytes(a: Uint8Array, b: Uint8Array): number { + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i++) { + if (a[i]! !== b[i]!) return a[i]! < b[i]! ? -1 : 1; + } + return a.length - b.length; +} + +/** What a received accept calls for, per §7.2.2. */ +export type AcceptOutcome = + /** It answers our outstanding invite: apply `receiveAccept`. */ + | { action: "adopt" } + /** Drop it silently — it answers nothing we sent. */ + | { action: "ignore"; reason: string }; + +/** + * Decide whether a received accept answers our invite (§7.2.2). + * + * An accept's Digest is copied verbatim from the invite it answers, so it must + * equal the digest of the invite we have outstanding. Anything else — no invite + * outstanding, or a digest naming an invite we never sent — is ignored rather + * than answered, for the same reason {@link resolveCancel} ignores an unknown + * cancellation. {@link transition} alone cannot make this check: it sees the + * state, not the digests. + * + * `answeredDigest` is the accept's Digest; `ourInviteDigest` is the digest of + * the invite we sent, or `undefined` if we hold none. + */ +export function resolveAccept( + state: RelationshipState, + answeredDigest: Uint8Array | undefined, + ourInviteDigest: Uint8Array | undefined, +): AcceptOutcome { + if (state !== "pending" || ourInviteDigest === undefined) { + return { action: "ignore", reason: "answers no invite we have outstanding" }; + } + if (answeredDigest === undefined || compareBytes(answeredDigest, ourInviteDigest) !== 0) { + return { action: "ignore", reason: "answers an invite we did not send" }; + } + return { action: "adopt" }; +} + +/** What a cancellation calls for, per §7.3. */ +export type CancelOutcome = + /** Ignore it entirely — we hold nothing it could be about. */ + | { action: "ignore"; reason: string } + /** Remove our half; send no reply. */ + | { action: "remove" } + /** Remove our half, and answer with a cancellation of our own. */ + | { action: "removeAndReply" }; + +/** + * Decide what a received cancellation calls for (§7.3). + * + * nothing held -> ignore entirely + * one direction only -> remove it, no reply + * bidirectional -> reply with a cancellation, then remove + * + * A cancellation naming a relationship we do not recognise is **ignored rather + * than answered**, and that is a privacy property, not tidiness: answering + * would let anyone probe which relationships we hold by cancelling ones they + * guessed at. + * + * `namedDigest` is the digest the cancellation carries; `knownDigests` are the + * digests of the halves we hold for this peer — §7.2.1 lets a cancellation name + * either, which is why this takes a set rather than one value. + */ +export function resolveCancel( + state: RelationshipState, + namedDigest: Uint8Array | undefined, + knownDigests: readonly Uint8Array[], +): CancelOutcome { + if (state === "none") { + return { action: "ignore", reason: "names no relationship we hold" }; + } + if (namedDigest !== undefined && !knownDigests.some((d) => compareBytes(d, namedDigest) === 0)) { + return { action: "ignore", reason: "names an unrecognised relationship" }; + } + return state === "bidirectional" ? { action: "removeAndReply" } : { action: "remove" }; +} diff --git a/packages/tsp-js/src/rev2/reader.ts b/packages/tsp-js/src/rev2/reader.ts new file mode 100644 index 0000000..13a26ac --- /dev/null +++ b/packages/tsp-js/src/rev2/reader.ts @@ -0,0 +1,212 @@ +// Rev 2 — read only. +// +// This module decodes messages framed under spec Rev 2 and **cannot pack one**. +// That asymmetry is the whole design: a Rev 2 peer cannot read anything we +// send, so emitting Rev 2 would buy nothing and would leave a legacy encoder +// alive to rot. Reading one costs this file and ends a class of unreadable +// failure — a Rev 2 frame handed to the Rev 3 codec dies at the ciphertext +// selector with "missing F ciphertext field", which points at the crypto layer +// for a problem that is nothing of the sort. +// +// It is deliberately frozen. Nothing here should ever need to change, because +// Rev 2 is finished: when the last Rev 2 peer is gone this file and its arm of +// the dispatcher are deleted together, and nothing else moves. +// +// -E envelope (= HPKE info): YTSP · B sender · B receiver · X 00 00 +// ciphertext = ct ‖ tag(16) ‖ enc(32) +// -C22 -K22 sig(64) Ed25519 over envelope ‖ ciphertext +// +// The encrypted plaintext is itself a CESR payload frame: +// -Z [B sender-VID] XSCS body +// +// Differences from Rev 3, each of which is why this cannot be a flag on the +// Rev 3 codec: HPKE-**Auth** (so the sender's X25519 *public* key is needed to +// open at all), the envelope frame as HPKE `info` with empty AAD, `enc` at the +// tail rather than the head, the `G` ciphertext code, the `X 00 00` marker, an +// `-E` count that covers only the header, a non-indexed signature code, a `-J` +// count that counts VIDs rather than bytes, an inner message wrapped in a `B` +// field, and the long count code spelled `-0X`. + +import { sha256 } from "@noble/hashes/sha2.js"; + +import * as wire from "../cesr/wire.js"; +import * as hpke from "../crypto/hpke.js"; +import * as sign from "../crypto/sign.js"; + +const ENC_LEN = 32; +const TAG_LEN = 16; +const SIG_LEN = 64; +const SIG_QUADLETS = 22; +const EMPTY = new Uint8Array(0); + +const fromUtf8 = new TextDecoder("utf-8", { fatal: true }); + +/** Rev 2's long count code spelling, from a superseded draft of the CESR v2 + * tables. Every `decodeCount` in this file passes it. */ +const LONG = wire.LONG_COUNT_REV2; + +/** Keys needed to open a Rev 2 message. Note the third: HPKE-Auth puts the + * sender's static key in the KEM, so without the sender's X25519 **public** + * key a Rev 2 message cannot be opened at all — not merely left unverified. */ +export interface Rev2UnpackKeys { + receiverDecryptionKey: Uint8Array; + senderEncryptionKey: Uint8Array; + senderSigningKey: Uint8Array; +} + +export interface Rev2UnpackedMessage { + payload: Uint8Array; + sender: string; + receiver: string; + messageType: "direct" | "nested" | "routed"; + hops: string[]; + threadDigest: Uint8Array; +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +/** Decode the Rev 2 envelope, reporting the frame length — which is the HPKE + * `info` byte length, because Rev 2 bound the ciphertext by passing the whole + * envelope frame as `info`. */ +export function decodeRev2Envelope( + data: Uint8Array, +): { sender: string; receiver: string; headerLen: number } { + const cur: wire.Cursor = { pos: 0 }; + + if (wire.decodeCount(wire.TSP_ETS_WRAPPER, data, cur, LONG) === undefined) { + throw new Error("tsp(rev2): missing -E envelope wrapper"); + } + const version = wire.readVersion(data, cur); + if (version === undefined) throw new Error("tsp(rev2): missing or malformed version marker"); + + const senderBytes = wire.decodeVariableData(wire.TSP_VID, data, cur); + if (senderBytes === undefined) throw new Error("tsp(rev2): missing sender VID"); + const receiverBytes = wire.decodeVariableData(wire.TSP_VID, data, cur); + if (receiverBytes === undefined) throw new Error("tsp(rev2): missing receiver VID"); + + let sender: string; + let receiver: string; + try { + sender = fromUtf8.decode(senderBytes); + receiver = fromUtf8.decode(receiverBytes); + } catch { + throw new Error("tsp(rev2): invalid VID encoding"); + } + + // The 2-byte TMP marker, emitted unconditionally for encrypted messages. + wire.decodeFixedData(wire.TSP_TMP, 2, data, cur); + + return { sender, receiver, headerLen: cur.pos }; +} + +/** Decode a Rev 2 hop list: a `-J` group whose count is the number of VIDs. */ +function decodeHops(stream: Uint8Array, cur: wire.Cursor): Uint8Array[] { + const count = wire.decodeCount(wire.TSP_HOP_LIST, stream, cur, LONG); + if (count === undefined) throw new Error("tsp(rev2): malformed hop list"); + if (count > wire.MAX_HOPS) throw new Error("tsp(rev2): too many hops"); + const hops: Uint8Array[] = []; + for (let i = 0; i < count; i++) { + const hop = wire.decodeVariableData(wire.TSP_VID, stream, cur); + if (hop === undefined) throw new Error("tsp(rev2): malformed hop VID"); + hops.push(hop); + } + return hops; +} + +function decodePayloadFrame(frame: Uint8Array): { + kind: "direct" | "nested" | "routed"; + hops: string[]; + body: Uint8Array; +} { + const cur: wire.Cursor = { pos: 0 }; + if (wire.decodeCount(wire.TSP_PAYLOAD, frame, cur, LONG) === undefined) { + throw new Error("tsp(rev2): missing -Z payload frame"); + } + // Optional ESSR sender-VID: the reference omits it for HPKE-Auth, where the + // KEM already authenticates the sender. A non-VID marker will not match a `B` + // var-data field, so this is a tolerant skip — which is exactly what Rev 3 + // replaced with a required field and a mandatory cross-check. + wire.decodeVariableData(wire.TSP_VID, frame, cur); + + const marker = frame.slice(cur.pos, cur.pos + 3); + if (bytesEqual(marker, wire.XSCS)) { + cur.pos += 3; + const body = wire.decodeVariableData(wire.TSP_PLAINTEXT, frame, cur); + if (body === undefined) throw new Error("tsp(rev2): missing payload plaintext"); + return { kind: "direct", hops: [], body }; + } + if (bytesEqual(marker, wire.XHOP)) { + cur.pos += 3; + const hopBytes = decodeHops(frame, cur); + let hops: string[]; + try { + hops = hopBytes.map((h) => fromUtf8.decode(h)); + } catch { + throw new Error("tsp(rev2): hop VID not UTF-8"); + } + const body = wire.decodeVariableData(wire.TSP_PLAINTEXT, frame, cur); + if (body === undefined) throw new Error("tsp(rev2): missing payload plaintext"); + return { kind: hops.length === 0 ? "nested" : "routed", hops, body }; + } + throw new Error("tsp(rev2): unsupported payload type marker"); +} + +/** Unpack a Rev 2 message: verify the Ed25519 signature over + * envelope ‖ ciphertext, split `enc` off the **tail**, and HPKE-Auth open with + * the envelope as `info` and empty AAD. */ +export async function unpack( + wireBytes: Uint8Array, + keys: Rev2UnpackKeys, +): Promise { + if (wireBytes.length < 48) throw new Error("tsp(rev2): message too short"); + + const { sender, receiver, headerLen } = decodeRev2Envelope(wireBytes); + const envelopeBytes = wireBytes.slice(0, headerLen); + + const cur: wire.Cursor = { pos: headerLen }; + const ctRange = wire.decodeVariableDataRange(wire.TSP_HPKEAUTH_CIPHERTEXT, wireBytes, cur); + if (ctRange === undefined) throw new Error("tsp(rev2): missing G ciphertext frame"); + const signedEnd = cur.pos; // the signature covers envelope ‖ ciphertext + + const gLen = ctRange.end - ctRange.begin; + if (gLen > wire.MAX_FIELD_SIZE) throw new Error("tsp(rev2): ciphertext too large"); + if (gLen < ENC_LEN + TAG_LEN) throw new Error("tsp(rev2): ciphertext truncated"); + + const attach = wire.decodeCount(wire.TSP_ATTACH_GRP, wireBytes, cur, LONG); + const group = wire.decodeCount(wire.TSP_INDEX_SIG_GRP, wireBytes, cur, LONG); + if (attach !== SIG_QUADLETS || group !== SIG_QUADLETS) { + throw new Error("tsp(rev2): unexpected signature group size"); + } + const signature = wire.decodeFixedData(wire.ED25519_SIGNATURE, SIG_LEN, wireBytes, cur); + if (signature === undefined) throw new Error("tsp(rev2): missing Ed25519 signature"); + if (cur.pos !== wireBytes.length) throw new Error("tsp(rev2): trailing bytes after signature"); + if (!sign.verify(wireBytes.slice(0, signedEnd), signature, keys.senderSigningKey)) { + throw new Error("tsp(rev2): signature verification failed"); + } + + const gPayload = wireBytes.slice(ctRange.begin, ctRange.end); + const encStart = gPayload.length - ENC_LEN; + const payloadFrame = await hpke.open( + gPayload.slice(0, encStart), + EMPTY, + gPayload.slice(encStart), + keys.receiverDecryptionKey, + keys.senderEncryptionKey, + envelopeBytes, + ); + + const threadDigest = sha256(payloadFrame); + const frame = decodePayloadFrame(payloadFrame); + return { + payload: frame.body, + sender, + receiver, + messageType: frame.kind, + hops: frame.hops, + threadDigest, + }; +} diff --git a/packages/tsp-js/src/rev3/control.ts b/packages/tsp-js/src/rev3/control.ts new file mode 100644 index 0000000..42e22fd --- /dev/null +++ b/packages/tsp-js/src/rev3/control.ts @@ -0,0 +1,419 @@ +// Relationship-forming control messages — `XRFI`, `XRFA`, `XRFD` (§7.2, §9.3). +// +// These are what makes TSP's relationship lifecycle explicit where DIDComm's is +// implicit, and Rev 3 made them load-bearing: §7.2.2 says an endpoint SHOULD +// drop an application message from a VID it holds no relationship with. So a +// wallet that cannot send an invite cannot send anything. +// +// On the wire they are not a serialized body inside a generic payload. Each is +// its own CESR payload-frame variant, and like every Rev 3 layout each begins +// with the ESSR sender-VID field and ends with the padding field: +// +// Invite XRFI sndr Digest Nonce Reply_Path Referral pad +// Accept XRFA sndr Digest Reply_Digest pad +// Cancel XRFD sndr Digest pad +// +// ── The digest is the whole point ── +// +// Correlation rides on `TSP_Digest` (§7.2.1), which Rev 3 turned from a +// convention into a wire field. It is *self-addressing*: computed over the +// message's own envelope and payload with its own slot filled by 33 dummy +// `0x23` bytes, carried in the message, and recomputed by the receiver, which +// refuses the message on a mismatch. Rev 2 correlated on a hash of the +// encrypted payload that was never transmitted, so a receiver could not check +// it at all. +// +// The padding field is excluded from the derivation, which is what lets §7.5 +// fill it without changing what was signed. +// +// ── Naming, deliberately not the reference's ── +// +// An accept carries two digests and which is which is easy to get backwards. +// The wire order is `Digest` then `Reply_Digest`, and — counter to how those +// names read — the *first* is the invite's digest echoed verbatim and the +// *second* is the accept's own self-addressing digest. affinidi-tsp keeps the +// spec's field names and warns in a comment that they "read backwards against +// the spec"; we name them for what they hold instead: `digest` is always this +// message's own, `inReplyTo` is always the earlier message being named. A +// comment warning about a trap is weaker than a name that cannot spring it. + +import { sha256 } from "@noble/hashes/sha2.js"; + +import * as wire from "../cesr/wire.js"; +import { + bareVidBytes, + concatBytes, + decodeDigest, + decodeNonce, + decodePadding, + decodeVidList, + DIGEST_LEN, + emptyVidListBytes, + encodeDigest, + encodeEmptyPadding, + encodeNonce, + encodeSenderField, + encodeVidList, + ENCODED_DIGEST_LEN, + NONCE_LEN, + senderFieldBytes, + SIG_LEN, + vidListBytes, +} from "./fields.js"; + +/** Which relationship-forming message this is. */ +export type ControlType = "invite" | "accept" | "cancel"; + +/** A VID introduced over an existing relationship (§7.2.5). + * + * We decode one and expose it; we do not compose one. Composing needs the + * introduced VID's *signing key* at pack time — the signature covers the + * invite's digest, so it cannot be made beforehand — and a wallet holding + * another VID's private key is not a shape this package should invite. */ +export interface Referral { + /** The VID being introduced. */ + newVid: string; + /** `Signature_new`, made by `newVid`'s key. + * + * **Unverified.** Checking it needs `newVid`'s public key, and `newVid` is + * precisely the identifier the invite exists to introduce — so it has to be + * resolved first, which a key-in-hand codec cannot do. Until a caller + * resolves and calls {@link referralSignedData}, this is a claim that the + * sender *wishes* to introduce the VID and says nothing about whether + * whoever controls it agreed. That is the entire purpose of the signature, + * so acting on a referral without checking it has skipped the check. */ + signature: Uint8Array; +} + +/** A decoded or to-be-encoded control message. */ +export interface ControlMessage { + controlType: ControlType; + /** This message's own self-addressing digest — the thread id of the exchange + * it opens. Present on an invite and an accept; on a cancel it mirrors + * {@link inReplyTo}, since a cancel has no digest of its own to derive. + * + * Set by the packing code, which is the only place that can compute it: the + * derivation covers the envelope, which a caller does not yet have. */ + digest?: Uint8Array; + /** The earlier message this one names: for an accept, the invite it answers; + * for a cancel, the relationship-forming message it ends. Echoed verbatim, + * never recomputed. */ + inReplyTo?: Uint8Array; + /** 128-bit nonce. Invite only. */ + nonce?: Uint8Array; + /** `Reply_Path` (§7.2.4) — a route the invite asks for its accept over. + * Empty for a direct reply. Invite only. */ + route: string[]; + /** `Referral_Field` (§7.2.5). Invite only, and decode-only here. */ + referral?: Referral; +} + +/** The byte the SAID derivation fills its own slot with. */ +const SAID_DUMMY = 0x23; + +const MARKERS: Record = { + invite: wire.XRFI, + accept: wire.XRFA, + cancel: wire.XRFD, +}; + +/** + * Derive a self-addressing `TSP_Digest` (§7.2.1). + * + * Covers the message's own envelope fields and its payload fields, with the + * digest's own slot filled by {@link SAID_DUMMY} over its full encoded width. + * The `-E` and `-Z` framing tags and the padding field are excluded; the + * payload type code is included. + * + * `before` and `after` are the encoded payload fields either side of the digest + * slot, padding excluded. Verification reverses it: rebuild the same input from + * the received bytes and compare. + */ +export function deriveSaid( + envelopeFields: Uint8Array, + typeCode: Uint8Array, + before: Uint8Array, + after: Uint8Array, +): Uint8Array { + return sha256( + concatBytes( + envelopeFields, + typeCode, + before, + new Uint8Array(ENCODED_DIGEST_LEN).fill(SAID_DUMMY), + after, + ), + ); +} + +/** The derivation input that follows an invite's digest slot. + * + * Rebuilt rather than sliced out of the frame, because the referral + * contributes a **bare** `VID_new` here and the `-J` group it occupies on the + * wire there — §9.3 excludes the referral field's own code and count. The + * bytes are deliberately different in the two places. */ +function inviteDigestAfter(nonce: Uint8Array, route: string[], referral?: Referral): Uint8Array { + const nonceOut: number[] = []; + encodeNonce(nonce, nonceOut); + return concatBytes( + new Uint8Array(nonceOut), + vidListBytes(route), + referral ? bareVidBytes(referral.newVid) : emptyVidListBytes(), + ); +} + +/** + * The bytes `Signature_new` is made over (§9.3): + * `{XRFI, VID_sndr | 4BAA, Digest, Nonce, Reply_Path, VID_new}`. + * + * Exported so a caller that has resolved `VID_new` can verify a referral it was + * sent — see {@link Referral.signature} for why this cannot happen during + * `unpack`. + */ +export function referralSignedData( + senderVid: string, + digest: Uint8Array, + nonce: Uint8Array, + route: string[], + newVid: string, +): Uint8Array { + const digestOut: number[] = []; + encodeDigest(digest, digestOut); + const nonceOut: number[] = []; + encodeNonce(nonce, nonceOut); + return concatBytes( + wire.XRFI, + senderFieldBytes(senderVid), + new Uint8Array(digestOut), + new Uint8Array(nonceOut), + vidListBytes(route), + bareVidBytes(newVid), + ); +} + +/** Encode a referral field: a `-J` group holding `VID_new` and its signature + * attachment, or `-JAA` when the invite introduces nothing. */ +function encodeReferral(referral: Referral | undefined, out: number[]): void { + if (!referral) { + wire.encodeCount(wire.TSP_HOP_LIST, 0, out); + return; + } + const body: number[] = []; + wire.encodeVariableData(wire.TSP_VID, new TextEncoder().encode(referral.newVid), body); + encodeSignatureAttachment(referral.signature, body); + if (body.length % 3 !== 0) throw new Error("tsp: referral group not a multiple of 3 bytes"); + wire.encodeCount(wire.TSP_HOP_LIST, body.length / 3, out); + for (const b of body) out.push(b); +} + +/** Decode a referral field; `undefined` for the empty `-JAA` form. */ +function decodeReferral(frame: Uint8Array, cur: wire.Cursor): Referral | undefined { + const quadlets = wire.decodeCount(wire.TSP_HOP_LIST, frame, cur); + if (quadlets === undefined) throw new Error("tsp: missing referral field"); + if (quadlets === 0) return undefined; + const groupEnd = cur.pos + quadlets * 3; + if (groupEnd > frame.length) throw new Error("tsp: referral field overruns the payload"); + + const vidBytes = wire.decodeVariableData(wire.TSP_VID, frame, cur); + if (vidBytes === undefined) throw new Error("tsp: malformed VID in referral field"); + let newVid: string; + try { + newVid = new TextDecoder("utf-8", { fatal: true }).decode(vidBytes); + } catch { + throw new Error("tsp: referral VID is not UTF-8"); + } + if (newVid.length === 0) throw new Error("tsp: referral field names the NULL VID"); + + const signature = decodeSignatureAttachment(frame, cur); + if (cur.pos !== groupEnd) throw new Error("tsp: referral field does not fill its own count"); + return { newVid, signature }; +} + +// The referral's signature uses the same attachment encoding as a message +// signature. These mirror `direct.ts`'s pair; they are not shared because +// `direct.ts` imports this module and a cycle is the price of that reuse — and +// because the message signature's is checked against the sender's VID while +// this one is not checked here at all. +const SIG_GROUP_QUADLETS = 22; +const ATTACH_GROUP_QUADLETS = SIG_GROUP_QUADLETS + 1; +const SIG_INDEX = 0; + +function encodeSignatureAttachment(signature: Uint8Array, out: number[]): void { + if (signature.length !== SIG_LEN) throw new Error(`tsp: signature must be ${SIG_LEN} bytes`); + wire.encodeCount(wire.TSP_ATTACH_GRP, ATTACH_GROUP_QUADLETS, out); + wire.encodeCount(wire.TSP_INDEX_SIG_GRP, SIG_GROUP_QUADLETS, out); + wire.encodeIndexedEd25519Signature(SIG_INDEX, signature, out); +} + +function decodeSignatureAttachment(data: Uint8Array, cur: wire.Cursor): Uint8Array { + const attach = wire.decodeCount(wire.TSP_ATTACH_GRP, data, cur); + const group = wire.decodeCount(wire.TSP_INDEX_SIG_GRP, data, cur); + if (attach !== ATTACH_GROUP_QUADLETS || group !== SIG_GROUP_QUADLETS) { + throw new Error("tsp: unexpected referral signature group size"); + } + if (cur.pos + 2 + SIG_LEN > data.length) throw new Error("tsp: truncated referral signature"); + const word = ((data[cur.pos]! << 16) | (data[cur.pos + 1]! << 8)) >>> 0; + if (word >>> 18 !== wire.ED25519_SIGNATURE) { + throw new Error("tsp: referral signature is not the indexed Ed25519 code"); + } + cur.pos += 2; + const sig = data.slice(cur.pos, cur.pos + SIG_LEN); + cur.pos += SIG_LEN; + return sig; +} + +/** + * Encode a control payload frame body (everything inside the `-Z` count), and + * return it with the message's thread digest. + * + * `envelopeFields` is needed because the SAID covers the envelope — which is + * why a control message cannot be composed independently of the message that + * carries it. + */ +export function encodeControlBody( + control: ControlMessage, + senderVid: string, + envelopeFields: Uint8Array, +): { body: number[]; threadDigest: Uint8Array } { + const senderField = senderFieldBytes(senderVid); + const marker = MARKERS[control.controlType]; + const out: number[] = []; + + if (control.controlType === "invite") { + const nonce = control.nonce; + if (nonce === undefined || nonce.length !== NONCE_LEN) { + throw new Error("tsp: an invite must carry a 128-bit nonce"); + } + const digest = deriveSaid( + envelopeFields, + marker, + senderField, + inviteDigestAfter(nonce, control.route, control.referral), + ); + for (const b of marker) out.push(b); + for (const b of senderField) out.push(b); + encodeDigest(digest, out); + encodeNonce(nonce, out); + encodeVidList(control.route, out); + encodeReferral(control.referral, out); + encodeEmptyPadding(out); + return { body: out, threadDigest: digest }; + } + + if (control.controlType === "accept") { + const echoed = control.inReplyTo; + if (echoed === undefined || echoed.length !== DIGEST_LEN) { + throw new Error("tsp: an accept must name the invite it answers"); + } + // The echoed invite digest sits *before* the slot, so it is derivation + // input; the accept's own digest is what the slot dummies out. + const beforeOut: number[] = Array.from(senderField); + encodeDigest(echoed, beforeOut); + const before = new Uint8Array(beforeOut); + + const digest = deriveSaid(envelopeFields, marker, before, new Uint8Array(0)); + for (const b of marker) out.push(b); + for (const b of before) out.push(b); + encodeDigest(digest, out); + encodeEmptyPadding(out); + return { body: out, threadDigest: digest }; + } + + // Cancel. Its digest names the relationship-forming message it ends — a + // reference, not a digest of this message — so it is echoed, not derived. + const reference = control.inReplyTo; + if (reference === undefined || reference.length !== DIGEST_LEN) { + throw new Error("tsp: a cancel must name the relationship it ends"); + } + for (const b of marker) out.push(b); + for (const b of senderField) out.push(b); + encodeDigest(reference, out); + encodeEmptyPadding(out); + return { body: out, threadDigest: reference }; +} + +/** + * Decode a control payload frame body, positioned just past the ESSR sender + * field, and verify the self-addressing digest. + * + * A mismatch is a verification failure, not a parse one: the message is not the + * message its digest claims it is. + */ +export function decodeControlBody( + controlType: ControlType, + frame: Uint8Array, + cur: wire.Cursor, + senderField: Uint8Array, + envelopeFields: Uint8Array, +): { control: ControlMessage; threadDigest: Uint8Array } { + const marker = MARKERS[controlType]; + + // The first digest field means different things per type: an invite's is its + // own, an accept's and a cancel's is a reference to an earlier message. + const firstDigest = decodeDigest(frame, cur); + + if (controlType === "invite") { + const nonce = decodeNonce(frame, cur); + const route = decodeVidList(frame, cur); + const referral = decodeReferral(frame, cur); + decodePadding(frame, cur); + + const recomputed = deriveSaid( + envelopeFields, + marker, + senderField, + inviteDigestAfter(nonce, route, referral), + ); + requireDigestMatch(recomputed, firstDigest); + return { + control: { + controlType, + digest: firstDigest, + nonce, + route, + ...(referral ? { referral } : {}), + }, + threadDigest: firstDigest, + }; + } + + if (controlType === "accept") { + // The second digest is the accept's own; the first, already read, is the + // invite it answers. + const own = decodeDigest(frame, cur); + decodePadding(frame, cur); + + const beforeOut: number[] = Array.from(senderField); + encodeDigest(firstDigest, beforeOut); + const recomputed = deriveSaid( + envelopeFields, + marker, + new Uint8Array(beforeOut), + new Uint8Array(0), + ); + requireDigestMatch(recomputed, own); + return { + control: { controlType, digest: own, inReplyTo: firstDigest, route: [] }, + threadDigest: own, + }; + } + + // A cancel's only digest references another message, so there is nothing + // self-addressing to recompute. + decodePadding(frame, cur); + return { + control: { controlType, digest: firstDigest, inReplyTo: firstDigest, route: [] }, + threadDigest: firstDigest, + }; +} + +function requireDigestMatch(recomputed: Uint8Array, carried: Uint8Array): void { + if (recomputed.length !== carried.length) { + throw new Error("tsp: TSP_Digest does not match the message it identifies"); + } + let diff = 0; + for (let i = 0; i < recomputed.length; i++) diff |= recomputed[i]! ^ carried[i]!; + if (diff !== 0) { + throw new Error("tsp: TSP_Digest does not match the message it identifies"); + } +} diff --git a/packages/tsp-js/src/rev3/direct.ts b/packages/tsp-js/src/rev3/direct.ts new file mode 100644 index 0000000..2d3229a --- /dev/null +++ b/packages/tsp-js/src/rev3/direct.ts @@ -0,0 +1,369 @@ +// Rev 3 pack and unpack — the revision this package speaks. +// +// -E one frame; the count covers everything below +// YTSP `YTSP-AAC` +// sender-VID +// receiver-VID `4BAA` when absent +// enc ‖ ct HPKE-Base ciphertext, AEAD tag inside ct +// -C23 -K22 B0 sig(64) indexed Ed25519 signature over the above +// +// ── What moved from Rev 2 ── +// +// * **HPKE-Base, not HPKE-Auth.** The sender's KEM key no longer participates, +// so packing and unpacking each take one fewer key than they did. Sender +// authenticity comes from the ESSR signature and the AAD instead — which is +// where Rev 3 puts it, and why `PackKeys` and `UnpackKeys` lost +// `senderEncryptionKey` rather than keeping it as an ignored field. +// * **The crypto binding moved.** Rev 2 passed the whole envelope frame as HPKE +// `info` with empty AAD. Rev 3 passes `TSP_Version ‖ VID_sndr ‖ VID_rcvr` as +// real AAD and the fixed code `YTSP-` as `info`. +// * **`enc` leads the ciphertext field**; Rev 2 put it at the end. +// * **The `-E` count covers the ciphertext**, so the frame is finalized after +// sealing rather than built before it, and the count is authoritative for +// where the signable content ends. +// * **The signature is indexed** (`B#`), under length-based counts. + +import * as wire from "../cesr/wire.js"; +import * as hpke from "../crypto/hpke.js"; +import * as noble from "../crypto/hpke-noble.js"; +import * as sign from "../crypto/sign.js"; +import { decodeEnvelope, encodeFields, finalizeFrame } from "./envelope.js"; +import { + decodePayloadFrame, + encodeControlFrame, + encodePayloadFrame, + type ApplicationKind, + type ControlMessage, + type ControlType, + type MessageType, +} from "./payload.js"; +import { generateNonce } from "./fields.js"; + +const ENC_LEN = 32; +const TAG_LEN = 16; +const SIG_LEN = 64; +/** The `-K` group's content: the 2-byte indexed code plus 64 bytes of + * signature, 66 bytes = 22 quadlets. */ +const SIG_GROUP_QUADLETS = 22; +/** The `-C` group holds the `-K` header plus its content — §9.5 made these + * counts length-based, where Rev 2 repeated the same number twice. */ +const ATTACH_GROUP_QUADLETS = SIG_GROUP_QUADLETS + 1; +/** Only index 0 can be verified: a VID names one signing key here. */ +const SIG_INDEX = 0; + +export type { ApplicationKind, ControlMessage, ControlType, MessageType }; + +/** Raw key material needed to pack. All keys are raw 32-byte. */ +export interface PackKeys { + /** Sender's Ed25519 private key (signing). */ + senderSigningKey: Uint8Array; + /** Receiver's X25519 public key (HPKE-Base recipient). */ + receiverEncryptionKey: Uint8Array; +} + +/** Raw key material needed to unpack. All keys are raw 32-byte. */ +export interface UnpackKeys { + /** Receiver's X25519 private key (HPKE-Base recipient). */ + receiverDecryptionKey: Uint8Array; + /** Sender's Ed25519 public key (outer signature verification). */ + senderSigningKey: Uint8Array; +} + +export interface PackedMessage { + bytes: Uint8Array; + /** SHA-256 of the payload frame — the TSP thread digest. */ + threadDigest: Uint8Array; +} + +export interface UnpackedMessage { + payload: Uint8Array; + sender: string; + receiver: string; + messageType: MessageType; + /** The recovered relationship-forming message, when there is one. Its + * self-addressing digest has already been verified against the frame, so a + * `control` that is present is one that identified itself correctly. */ + control?: ControlMessage; + hops: string[]; + threadDigest: Uint8Array; +} + +/** + * Test-only knobs that make a pack byte-reproducible. **Never use outside + * tests.** + * + * Internal to the package: reached only through the `./unsafe-testing` subpath, + * and the public wrappers in `message/` deliberately do not forward it — the + * same arrangement as the `__unsafeFixedEphemeralSk` hook in `hpke-noble.ts`. + * + * Fixing the HPKE ephemeral key makes every message packed with it to the same + * recipient share one (key, base_nonce) pair. Under ChaCha20Poly1305 that leaks + * the XOR of the plaintexts and the Poly1305 one-time key: confidentiality and + * integrity both go. It exists so that the specification's Appendix A vectors, + * which publish their ephemeral as `ikmE`, can be reproduced byte for byte. + */ +export interface UnsafeDeterministicPack { + /** RFC 9180 §7.1.3 `DeriveKeyPair` input for the HPKE-Base ephemeral — the + * `ikmE` Appendix A prints. At least 32 bytes. */ + __unsafeIkmE: Uint8Array; + /** Write the NULL VID `4BAA` in the ESSR sender field instead of the sender's + * VID. §9.2 permits it under HPKE-Base, and the published vectors use it; + * this package's own stance is to always write the VID (see `payload.ts`), + * so it is offered only here, for reproducing those vectors. */ + nullPayloadSender?: boolean; +} + +/** The ESSR sender field's content for this pack. */ +const payloadSender = (senderVid: string, unsafe?: UnsafeDeterministicPack): string => + unsafe?.nullPayloadSender === true ? "" : senderVid; + +/** Encode the signature attachment: `-C23 -K22 B0 sig(64)`. */ +function encodeSignatureFrame(signature: Uint8Array, out: number[]): void { + wire.encodeCount(wire.TSP_ATTACH_GRP, ATTACH_GROUP_QUADLETS, out); + wire.encodeCount(wire.TSP_INDEX_SIG_GRP, SIG_GROUP_QUADLETS, out); + wire.encodeIndexedEd25519Signature(SIG_INDEX, signature, out); +} + +/** Decode the signature attachment; returns the 64-byte Ed25519 signature. + * + * An attachment that will not parse is a rejection, never "this message is + * unsigned" — the two must not be reachable from the same code path. */ +function decodeSignatureFrame(data: Uint8Array, cur: wire.Cursor): Uint8Array { + const attach = wire.decodeCount(wire.TSP_ATTACH_GRP, data, cur); + const group = wire.decodeCount(wire.TSP_INDEX_SIG_GRP, data, cur); + if (attach !== ATTACH_GROUP_QUADLETS || group !== SIG_GROUP_QUADLETS) { + throw new Error("tsp: unexpected signature group size"); + } + if (cur.pos + 2 + SIG_LEN > data.length) throw new Error("tsp: truncated signature attachment"); + const word = ((data[cur.pos]! << 16) | (data[cur.pos + 1]! << 8)) >>> 0; + if (word >>> 18 !== wire.ED25519_SIGNATURE) { + throw new Error("tsp: signature is not the indexed Ed25519 code"); + } + const index = (word >>> 12) & 0x3f; + if (index !== SIG_INDEX) { + throw new Error(`tsp: signature names key index ${index}, but only index ${SIG_INDEX} can be verified`); + } + cur.pos += 2; + const sig = data.slice(cur.pos, cur.pos + SIG_LEN); + cur.pos += SIG_LEN; + return sig; +} + +/** Seal a payload frame into a complete, signed `-E` message. + * + * The one place the envelope, the ciphertext and the signature come together, + * shared by the application and control paths — a control message differs only + * in the frame it hands over, and nothing below this line should know which it + * was given. */ +async function sealFrame( + fields: Uint8Array, + frame: Uint8Array, + keys: PackKeys, + unsafe?: UnsafeDeterministicPack, +): Promise { + // `aad` binds the ciphertext to the version and both VIDs; `info` is the + // fixed protocol code. + const sealed = + unsafe === undefined + ? await hpke.sealBase(frame, fields, keys.receiverEncryptionKey, wire.TSP_INFO) + : await noble.sealBase(frame, fields, keys.receiverEncryptionKey, wire.TSP_INFO, { + __unsafeFixedEphemeralSk: noble.deriveKeyPair(unsafe.__unsafeIkmE).sk, + }); + + // Ciphertext field: `enc ‖ ct`, with the AEAD tag inside `ct`. + const ciphertext = new Uint8Array(sealed.enc.length + sealed.ciphertext.length); + ciphertext.set(sealed.enc, 0); + ciphertext.set(sealed.ciphertext, sealed.enc.length); + + const field: number[] = []; + wire.encodeVariableData(wire.TSP_HPKE_BASE_CIPHERTEXT, ciphertext, field); + + // Close the `-E` frame over the fields and the body, then sign it. + const wireBytes = finalizeFrame(fields, new Uint8Array(field)); + const signature = sign.sign(wireBytes, keys.senderSigningKey); + const out = Array.from(wireBytes); + encodeSignatureFrame(signature, out); + return new Uint8Array(out); +} + +/** Pack a Rev 3 application message of any kind. */ +export async function packWithHops( + body: Uint8Array, + kind: ApplicationKind, + hops: string[], + senderVid: string, + receiverVid: string, + keys: PackKeys, + unsafe?: UnsafeDeterministicPack, +): Promise { + const fields = encodeFields(senderVid, receiverVid); + const { frame, threadDigest } = encodePayloadFrame(body, kind, hops, payloadSender(senderVid, unsafe)); + return { bytes: await sealFrame(fields, frame, keys, unsafe), threadDigest }; +} + +/** + * Pack a relationship-forming control message (§7.2, §9.3). + * + * The digest is computed here rather than by the caller, and cannot be + * otherwise: it is self-addressing over the envelope, which does not exist + * until this function builds it. That is why `threadDigest` comes back on the + * result — an inviter needs it to recognise the accept that answers it, and + * cannot know it in advance. + */ +export async function packControl( + control: ControlMessage, + senderVid: string, + receiverVid: string, + keys: PackKeys, + unsafe?: UnsafeDeterministicPack, +): Promise { + const fields = encodeFields(senderVid, receiverVid); + const { frame, threadDigest } = encodeControlFrame(control, payloadSender(senderVid, unsafe), fields); + return { bytes: await sealFrame(fields, frame, keys, unsafe), threadDigest }; +} + +/** + * Pack a relationship-forming invite (`XRFI`). + * + * `route` is the §7.2.4 `Reply_Path` — a route to send the accept back over, + * empty for a direct reply. The nonce is generated here unless one is supplied; + * supplying one is for tests and for replaying a known invite, not for reuse. + */ +export function packInvite( + senderVid: string, + receiverVid: string, + keys: PackKeys, + opts: { route?: string[]; nonce?: Uint8Array } = {}, + unsafe?: UnsafeDeterministicPack, +): Promise { + return packControl( + { + controlType: "invite", + nonce: opts.nonce ?? generateNonce(), + route: opts.route ?? [], + }, + senderVid, + receiverVid, + keys, + unsafe, + ); +} + +/** + * Pack a relationship-forming accept (`XRFA`) answering `inviteDigest`. + * + * The digest echoed here is the invite's, verbatim — it is what tells the + * inviter which exchange is being accepted, and an accept that echoes the wrong + * one is indistinguishable from an accept to a message we never sent. + */ +export function packAccept( + inviteDigest: Uint8Array, + senderVid: string, + receiverVid: string, + keys: PackKeys, + unsafe?: UnsafeDeterministicPack, +): Promise { + return packControl( + { controlType: "accept", inReplyTo: inviteDigest, route: [] }, + senderVid, + receiverVid, + keys, + unsafe, + ); +} + +/** + * Pack a relationship cancellation (`XRFD`) naming `relationshipDigest` — the + * digest of either half of the relationship being ended (§7.2.1). + */ +export function packCancel( + relationshipDigest: Uint8Array, + senderVid: string, + receiverVid: string, + keys: PackKeys, + unsafe?: UnsafeDeterministicPack, +): Promise { + return packControl( + { controlType: "cancel", inReplyTo: relationshipDigest, route: [] }, + senderVid, + receiverVid, + keys, + unsafe, + ); +} + +/** Pack a Rev 3 direct message. */ +export function pack( + body: Uint8Array, + senderVid: string, + receiverVid: string, + keys: PackKeys, + unsafe?: UnsafeDeterministicPack, +): Promise { + return packWithHops(body, "direct", [], senderVid, receiverVid, keys, unsafe); +} + +/** Unpack a Rev 3 message. */ +export async function unpack( + wireBytes: Uint8Array, + keys: UnpackKeys, +): Promise { + if (wireBytes.length < 48) throw new Error("tsp: message too short"); + + // 1. Envelope. + const decoded = decodeEnvelope(wireBytes); + const aad = wireBytes.slice(decoded.aad.begin, decoded.aad.end); + + // 2. The body. What follows the VIDs says whether the message is sealed and, + // if so, under which scheme: `F` for HPKE-Base, `C` for the libsodium + // sealed box. The code is the *only* signal — nothing is negotiated and no + // field names it — so a `C` is recognised and refused by name rather than + // dying at the `F` selector with "missing ciphertext". + const cur: wire.Cursor = { pos: decoded.headerLen }; + const ctRange = wire.decodeVariableDataRange(wire.TSP_HPKE_BASE_CIPHERTEXT, wireBytes, cur); + if (ctRange === undefined) { + const probe: wire.Cursor = { pos: decoded.headerLen }; + if (wire.decodeVariableDataRange(wire.TSP_SEALED_BOX_CIPHERTEXT, wireBytes, probe)) { + throw new Error("tsp: message is sealed with the libsodium sealed box (§8.3), which this implementation does not support"); + } + throw new Error("tsp: missing F ciphertext field"); + } + + const ctLen = ctRange.end - ctRange.begin; + if (ctLen > wire.MAX_FIELD_SIZE) throw new Error("tsp: ciphertext too large"); + if (ctLen < ENC_LEN + TAG_LEN) throw new Error("tsp: ciphertext truncated"); + + // The `-E` count is authoritative for where the signable content ends; the + // body must fill it exactly. + if (cur.pos !== decoded.contentEnd) { + throw new Error("tsp: message body does not fill the -E frame"); + } + + // 3. Signature over the whole `-E` frame. + const signature = decodeSignatureFrame(wireBytes, cur); + if (cur.pos !== wireBytes.length) throw new Error("tsp: trailing bytes after signature"); + if (!sign.verify(wireBytes.slice(0, decoded.contentEnd), signature, keys.senderSigningKey)) { + throw new Error("tsp: signature verification failed"); + } + + // 4. Open. + const ciphertext = wireBytes.slice(ctRange.begin, ctRange.end); + const payloadFrame = await hpke.openBase( + ciphertext.slice(ENC_LEN), + aad, + ciphertext.slice(0, ENC_LEN), + keys.receiverDecryptionKey, + wire.TSP_INFO, + ); + + const frame = decodePayloadFrame(payloadFrame, decoded.envelope.sender, aad); + return { + payload: frame.body, + sender: decoded.envelope.sender, + receiver: decoded.envelope.receiver, + messageType: frame.kind, + ...(frame.control ? { control: frame.control } : {}), + hops: frame.hops, + threadDigest: frame.threadDigest, + }; +} diff --git a/packages/tsp-js/src/rev3/envelope.ts b/packages/tsp-js/src/rev3/envelope.ts new file mode 100644 index 0000000..d621fcb --- /dev/null +++ b/packages/tsp-js/src/rev3/envelope.ts @@ -0,0 +1,123 @@ +// The Rev 3 `-E` envelope frame (spec Rev 3 §9.1). +// +// The envelope is the cleartext outer frame: TSP version, sender VID, receiver +// VID. Two things changed from Rev 2 and both reach right through the packing +// code: +// +// 1. **One frame for everything.** The `-E` count now covers *all* signable +// content — version, VIDs and the ciphertext — where Rev 2's covered only +// the header fields. It therefore cannot be written until the ciphertext +// size is known, which is why encoding splits into `encodeFields` (what +// exists before sealing) and `finalizeFrame` (what exists after). +// +// 2. **The trailing `X 00 00` marker is deleted.** The receiver-VID field is +// always present instead, with the NULL VID `4BAA` meaning "absent". +// +// The encoded *fields* — version ‖ VID_sndr ‖ VID_rcvr, without the `-E` count +// code — are the HPKE-Base associated data (§8: +// `aad = CONCAT(TSP_Version, VID_sndr, VID_rcvr)`). That is exactly why the +// split falls where it does: the count code is not part of the AAD. + +import * as wire from "../cesr/wire.js"; + +const utf8 = new TextEncoder(); +const fromUtf8 = new TextDecoder("utf-8", { fatal: true }); + +export interface Envelope { + sender: string; + /** Empty string is the NULL VID `4BAA` — "no receiver named". Not a valid + * VID, so the empty string is unambiguous as its representation. */ + receiver: string; +} + +export interface DecodedEnvelope { + envelope: Envelope; + /** Byte range of the encoded envelope fields — the HPKE-Base AAD. */ + aad: { begin: number; end: number }; + /** Offset just past the envelope fields: where the ciphertext field begins. */ + headerLen: number; + /** Offset just past the signable content the `-E` count declares, i.e. where + * the signature attachment begins. */ + contentEnd: number; + /** MINOR as carried. Never gates processing. */ + minor: number; +} + +/** Encode the envelope *fields* — version, sender VID, receiver VID — without + * the enclosing `-E` count code. These bytes are the HPKE-Base AAD. */ +export function encodeFields(sender: string, receiver: string): Uint8Array { + const body: number[] = []; + wire.encodeVersion(body); + wire.encodeVariableData(wire.TSP_VID, utf8.encode(sender), body); + wire.encodeVariableData(wire.TSP_VID, utf8.encode(receiver), body); + + if (body.length % 3 !== 0) { + throw new Error("tsp: envelope fields not a multiple of 3 bytes"); + } + return new Uint8Array(body); +} + +/** Prepend the `-E` count code to `fields ‖ body`, producing the complete + * envelope frame. The count covers both and excludes the signature + * attachment the caller appends afterwards. */ +export function finalizeFrame(fields: Uint8Array, body: Uint8Array): Uint8Array { + const contentLen = fields.length + body.length; + if (contentLen % 3 !== 0) { + throw new Error("tsp: envelope content not a multiple of 3 bytes"); + } + const out: number[] = []; + wire.encodeCount(wire.TSP_ETS_WRAPPER, contentLen / 3, out); + for (const b of fields) out.push(b); + for (const b of body) out.push(b); + return new Uint8Array(out); +} + +/** Decode a Rev 3 envelope and report the offsets needed to open and verify + * the message. Throws on a malformed frame. */ +export function decodeEnvelope(data: Uint8Array): DecodedEnvelope { + const cur: wire.Cursor = { pos: 0 }; + + // The `-E` count is validated against the message length: §9.1 requires the + // declared signable length to be checked on receive, so a frame claiming more + // content than the message holds is rejected here rather than surfacing later + // as something that reads like a crypto failure. + const quadlets = wire.decodeCount(wire.TSP_ETS_WRAPPER, data, cur); + if (quadlets === undefined) throw new Error("tsp: missing -E envelope frame"); + const contentBegin = cur.pos; + const contentEnd = contentBegin + quadlets * 3; + if (contentEnd > data.length) { + throw new Error("tsp: -E frame declares more content than the message"); + } + + const version = wire.readVersion(data, cur); + if (version === undefined) throw new Error("tsp: missing or malformed version marker"); + + const senderBytes = wire.decodeVariableData(wire.TSP_VID, data, cur); + if (senderBytes === undefined) throw new Error("tsp: missing sender VID"); + const receiverBytes = wire.decodeVariableData(wire.TSP_VID, data, cur); + if (receiverBytes === undefined) throw new Error("tsp: missing receiver VID field"); + + let sender: string; + let receiver: string; + try { + sender = fromUtf8.decode(senderBytes); + receiver = fromUtf8.decode(receiverBytes); + } catch { + throw new Error("tsp: invalid VID encoding"); + } + if (sender.length === 0) { + throw new Error("tsp: sender VID is the NULL VID; every TSP message names its sender"); + } + + if (cur.pos > contentEnd) { + throw new Error("tsp: envelope fields overrun the -E frame count"); + } + + return { + envelope: { sender, receiver }, + aad: { begin: contentBegin, end: cur.pos }, + headerLen: cur.pos, + contentEnd, + minor: version.minor, + }; +} diff --git a/packages/tsp-js/src/rev3/fields.ts b/packages/tsp-js/src/rev3/fields.ts new file mode 100644 index 0000000..d8fdeea --- /dev/null +++ b/packages/tsp-js/src/rev3/fields.ts @@ -0,0 +1,164 @@ +// Rev 3 payload *field* codecs — the pieces every payload layout is built from. +// +// These live apart from both `payload.ts` (which dispatches on the type code) +// and `control.ts` (which composes the relationship-forming layouts) because +// both need them and neither owns them. Splitting them out is also what keeps +// the two from importing each other in a cycle. +// +// §9.2 gives every Rev 3 layout the same skeleton — type code, ESSR sender VID, +// type-specific fields, padding — so most of what differs between a generic +// message and an invite is which of these appear and in what order. + +import * as wire from "../cesr/wire.js"; + +const utf8 = new TextEncoder(); +const fromUtf8 = new TextDecoder("utf-8", { fatal: true }); + +/** SHA-256 digest length, and the width of a `TSP_Digest` field's payload. */ +export const DIGEST_LEN = 32; +/** A digest field on the wire: the one-byte `I` code plus 32 bytes. This is the + * width the SAID derivation dummies out, so it is a constant rather than a + * computation at each use. */ +export const ENCODED_DIGEST_LEN = 33; +/** §9.2 (D9) fixes the relationship nonce at 128 bits; Rev 2 used 256. The CESR + * code follows from the length, so this is the only place it is stated. */ +export const NONCE_LEN = 16; +/** 64-byte Ed25519 signature, as carried in a referral. */ +export const SIG_LEN = 64; + +/** Generate a cryptographically random 128-bit nonce. */ +export function generateNonce(): Uint8Array { + const nonce = new Uint8Array(NONCE_LEN); + crypto.getRandomValues(nonce); + return nonce; +} + +/** The ESSR sender-VID field. Always written; the NULL VID is the empty string. */ +export function encodeSenderField(senderVid: string, out: number[]): void { + wire.encodeVariableData(wire.TSP_VID, utf8.encode(senderVid), out); +} + +/** Bytes of the ESSR sender field, for the SAID derivation input. */ +export function senderFieldBytes(senderVid: string): Uint8Array { + const out: number[] = []; + encodeSenderField(senderVid, out); + return new Uint8Array(out); +} + +/** The padding field — always empty here. §7.5 makes it fillable and excludes + * it from the digest derivation; we write it and leave it at zero width. */ +export function encodeEmptyPadding(out: number[]): void { + wire.encodeVariableData(wire.TSP_PLAINTEXT, new Uint8Array(0), out); +} + +/** Read and discard the padding field, which every layout carries. */ +export function decodePadding(frame: Uint8Array, cur: wire.Cursor): void { + if (wire.decodeVariableData(wire.TSP_PLAINTEXT, frame, cur) === undefined) { + throw new Error("tsp: missing padding field"); + } +} + +/** A `TSP_Digest` field, under the `I` (SHA-256) code. + * + * The code is a property of the PKAE scheme, not the message: §8.3 pairs the + * sealed box with Blake2b-256 under `F`. We implement HPKE-Base only, so `I` + * is the only code that can appear — and a `F`-coded digest is refused here + * rather than at the comparison further on, where a wrong-scheme message would + * read as a tampered one. */ +export function encodeDigest(digest: Uint8Array, out: number[]): void { + if (digest.length !== DIGEST_LEN) throw new Error(`tsp: digest must be ${DIGEST_LEN} bytes`); + wire.encodeFixedData(wire.TSP_SHA256, digest, out); +} + +export function decodeDigest(frame: Uint8Array, cur: wire.Cursor): Uint8Array { + const digest = wire.decodeFixedData(wire.TSP_SHA256, DIGEST_LEN, frame, cur); + if (digest === undefined) { + throw new Error("tsp: missing digest field, or one not coded for HPKE-Base"); + } + return digest; +} + +/** The relationship nonce field. */ +export function encodeNonce(nonce: Uint8Array, out: number[]): void { + if (nonce.length !== NONCE_LEN) throw new Error(`tsp: nonce must be ${NONCE_LEN} bytes`); + wire.encodeFixedData(wire.TSP_NONCE, nonce, out); +} + +export function decodeNonce(frame: Uint8Array, cur: wire.Cursor): Uint8Array { + const nonce = wire.decodeFixedData(wire.TSP_NONCE, NONCE_LEN, frame, cur); + if (nonce === undefined) throw new Error("tsp: missing or malformed nonce"); + return nonce; +} + +/** Encode a `-J` VID list — a hop list, a reply path, or a referral group. + * + * §9.2 changed the count to the group's **byte length** in quadlets, where + * Rev 2 counted VIDs. An empty list is `-JAA`, which is how an absent reply + * path, an absent referral and a non-routed nesting are all spelled. */ +export function encodeVidList(vids: string[], out: number[]): void { + const body: number[] = []; + for (const vid of vids) wire.encodeVariableData(wire.TSP_VID, utf8.encode(vid), body); + if (body.length % 3 !== 0) throw new Error("tsp: -J VID list not a multiple of 3 bytes"); + wire.encodeCount(wire.TSP_HOP_LIST, body.length / 3, out); + for (const b of body) out.push(b); +} + +/** Bytes of a `-J` VID list, for a derivation input. */ +export function vidListBytes(vids: string[]): Uint8Array { + const out: number[] = []; + encodeVidList(vids, out); + return new Uint8Array(out); +} + +/** Decode a `-J` VID list. The declared byte length is authoritative: VIDs are + * read until it is exactly consumed, and a list whose fields overrun or + * underrun it is rejected rather than truncated. */ +export function decodeVidList(stream: Uint8Array, cur: wire.Cursor): string[] { + const quadlets = wire.decodeCount(wire.TSP_HOP_LIST, stream, cur); + if (quadlets === undefined) throw new Error("tsp: missing -J VID list"); + const groupLen = quadlets * 3; + if (groupLen > wire.MAX_FIELD_SIZE) throw new Error("tsp: -J VID list too long"); + const groupEnd = cur.pos + groupLen; + if (groupEnd > stream.length) throw new Error("tsp: -J VID list overruns message"); + + const vids: string[] = []; + while (cur.pos < groupEnd) { + if (vids.length >= wire.MAX_HOPS) throw new Error("tsp: too many hops"); + const vid = wire.decodeVariableData(wire.TSP_VID, stream, cur); + if (vid === undefined) throw new Error("tsp: malformed VID in -J list"); + try { + vids.push(fromUtf8.decode(vid)); + } catch { + throw new Error("tsp: VID in -J list is not UTF-8"); + } + } + if (cur.pos !== groupEnd) throw new Error("tsp: -J VID list does not fill its declared length"); + return vids; +} + +/** A bare `VID_new` field, as the SAID and referral-signature derivations + * carry it — without the `-J` group it sits inside on the wire. §9.3 is + * explicit that the referral field's "own code and count are not covered". */ +export function bareVidBytes(vid: string): Uint8Array { + const out: number[] = []; + wire.encodeVariableData(wire.TSP_VID, utf8.encode(vid), out); + return new Uint8Array(out); +} + +/** An empty `-J` group, which is what an absent referral contributes. */ +export function emptyVidListBytes(): Uint8Array { + const out: number[] = []; + wire.encodeCount(wire.TSP_HOP_LIST, 0, out); + return new Uint8Array(out); +} + +export function concatBytes(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let at = 0; + for (const p of parts) { + out.set(p, at); + at += p.length; + } + return out; +} diff --git a/packages/tsp-js/src/rev3/payload.ts b/packages/tsp-js/src/rev3/payload.ts new file mode 100644 index 0000000..0569d95 --- /dev/null +++ b/packages/tsp-js/src/rev3/payload.ts @@ -0,0 +1,271 @@ +// The Rev 3 CESR payload frame — the plaintext that gets sealed (§9.2/§9.3). +// +// Every Rev 3 layout has the same shape: type code, ESSR sender VID, +// type-specific fields, padding. Rev 2 had none of the first two and none of +// the last. +// +// Direct -Z XSCS sndr pad -A +// Nested -Z XHOP sndr -JAA pad +// Routed -Z XHOP sndr -J hops pad +// Invite -Z XRFI sndr Digest Nonce Reply_Path Referral pad +// Accept -Z XRFA sndr Digest Reply_Digest pad +// Cancel -Z XRFD sndr Digest pad +// +// This module owns the type-code dispatch and the three application layouts; +// `control.ts` owns the three relationship-forming ones, because their digest +// derivation is a body of protocol in its own right. +// +// ── The ESSR sender field ── +// +// Rev 3 moved sender authenticity out of the KEM: HPKE-Base does not +// authenticate a sender, so the binding is the AAD plus this field plus the +// outer signature. Under HPKE-Base the field MAY be the NULL VID; when it is +// not, it MUST equal the envelope sender, and §3.7 step 7 has the receiver +// check exactly that. We always write it and always check it — the spec's own +// security considerations note the two bindings are then independent, which is +// the argument for not resting sender authenticity on one mechanism. +// +// ── Padding ── +// +// Every layout ends its fixed part with a padding field, and an absent padding +// is the empty field `4BAA` — present, not omitted. §7.5 makes it fillable and +// excludes it from the digest derivation so that filling it cannot change what +// was signed; we always write it empty, which is conformant and leaves the +// traffic-analysis defence unimplemented rather than half-implemented. + +import { sha256 } from "@noble/hashes/sha2.js"; + +import * as wire from "../cesr/wire.js"; +import { + decodeControlBody, + encodeControlBody, + type ControlMessage, + type ControlType, +} from "./control.js"; +import { + decodePadding, + decodeVidList, + encodeEmptyPadding, + encodeSenderField, + encodeVidList, + senderFieldBytes, +} from "./fields.js"; + +const fromUtf8 = new TextDecoder("utf-8", { fatal: true }); + +/** What kind of message a payload frame carries. */ +export type MessageType = "direct" | "nested" | "routed" | "control" | "padding"; + +export type { ControlMessage, ControlType }; + +/** The application layouts this module composes. A control frame is built from + * a {@link ControlMessage} instead, which is why it is not in this union. */ +export type ApplicationKind = "direct" | "nested" | "routed"; + +export interface DecodedFrame { + kind: MessageType; + /** The recovered control message, when `kind` is `"control"`. Its digest has + * already been verified against the frame. */ + control?: ControlMessage; + /** Remaining route (Routed only). */ + hops: string[]; + /** The plaintext body: the upper-layer payload for Direct, the raw inner + * message for Nested/Routed, empty for a control or padding frame. */ + body: Uint8Array; + /** The ESSR sender VID as carried, or `""` for the NULL VID. */ + senderVid: string; + /** The thread digest: SHA-256 over the whole `-Z` frame for an application + * message, and the carried `TSP_Digest` for a control one. */ + threadDigest: Uint8Array; +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +/** Wrap a frame body in its `-Z` count code. */ +function frameFromBody(frameBody: number[]): Uint8Array { + if (frameBody.length % 3 !== 0) { + throw new Error("tsp: payload frame not a multiple of 3 bytes"); + } + const out: number[] = []; + wire.encodeCount(wire.TSP_PAYLOAD, frameBody.length / 3, out); + for (const b of frameBody) out.push(b); + return new Uint8Array(out); +} + +/** Build an application payload frame, and the thread digest over it. */ +export function encodePayloadFrame( + body: Uint8Array, + kind: ApplicationKind, + hops: string[], + senderVid: string, +): { frame: Uint8Array; threadDigest: Uint8Array } { + const frameBody: number[] = []; + + if (kind === "direct") { + for (const b of wire.XSCS) frameBody.push(b); + encodeSenderField(senderVid, frameBody); + encodeEmptyPadding(frameBody); + // §9.2.3: the upper-layer payload is a generic CESR stream holding a Bytes + // primitive. We carry the caller's bytes opaquely and deliberately do NOT + // wrap them in the non-native message group `-H##`: that group is required + // for a JSON, CBOR or MsgPak serialization, and that requirement binds the + // upper layer. A caller handing us opaque bytes has not told us it is + // sending JSON, and guessing would be wrong in both directions. + const stream: number[] = []; + wire.encodeVariableData(wire.TSP_PLAINTEXT, body, stream); + wire.encodeCount(wire.TSP_GENERIC_STREAM, stream.length / 3, frameBody); + for (const b of stream) frameBody.push(b); + } else { + for (const b of wire.XHOP) frameBody.push(b); + encodeSenderField(senderVid, frameBody); + encodeVidList(kind === "nested" ? [] : hops, frameBody); + encodeEmptyPadding(frameBody); + // The inner message is self-framing and carried raw — Rev 3 drops Rev 2's + // enclosing `B` var-data field. Every TSP message is quadlet-aligned, so + // this keeps the frame aligned; a body that is not is a caller error worth + // naming here rather than a frame the far side rejects. + if (body.length % 3 !== 0) { + throw new Error("tsp: nested inner message is not quadlet-aligned"); + } + for (const b of body) frameBody.push(b); + } + + const frame = frameFromBody(frameBody); + return { frame, threadDigest: sha256(frame) }; +} + +/** Build a control payload frame, and the `TSP_Digest` it carries. + * + * `envelopeFields` is part of the digest derivation, which is why a control + * message cannot be composed independently of the message carrying it. */ +export function encodeControlFrame( + control: ControlMessage, + senderVid: string, + envelopeFields: Uint8Array, +): { frame: Uint8Array; threadDigest: Uint8Array } { + const { body, threadDigest } = encodeControlBody(control, senderVid, envelopeFields); + return { frame: frameFromBody(body), threadDigest }; +} + +/** + * Decode a payload frame. + * + * `envelopeSender` is checked against the ESSR sender field: a non-NULL field + * that disagrees with the envelope is a message claiming two senders, which is + * a verification failure and not a parse one. `envelopeFields` is needed to + * recompute a control message's self-addressing digest. + */ +export function decodePayloadFrame( + frame: Uint8Array, + envelopeSender: string, + envelopeFields: Uint8Array, +): DecodedFrame { + const cur: wire.Cursor = { pos: 0 }; + const quadlets = wire.decodeCount(wire.TSP_PAYLOAD, frame, cur); + if (quadlets === undefined) throw new Error("tsp: missing -Z payload frame"); + const frameEnd = cur.pos + quadlets * 3; + if (frameEnd > frame.length) { + throw new Error("tsp: -Z frame declares more content than the payload"); + } + const frameDigest = sha256(frame.slice(0, frameEnd)); + + if (cur.pos + 3 > frame.length) throw new Error("tsp: truncated payload type code"); + const typeCode = frame.slice(cur.pos, cur.pos + 3); + cur.pos += 3; + + // Every Rev 3 layout carries the ESSR sender field next. + const senderFieldBegin = cur.pos; + const senderBytes = wire.decodeVariableData(wire.TSP_VID, frame, cur); + if (senderBytes === undefined) throw new Error("tsp: missing ESSR sender VID field"); + const senderField = frame.slice(senderFieldBegin, cur.pos); + let senderVid: string; + try { + senderVid = fromUtf8.decode(senderBytes); + } catch { + throw new Error("tsp: ESSR sender VID is not UTF-8"); + } + if (senderVid.length > 0 && senderVid !== envelopeSender) { + throw new Error("tsp: ESSR sender VID does not match the envelope sender"); + } + + if (bytesEqual(typeCode, wire.XSCS) || bytesEqual(typeCode, wire.XCTL)) { + decodePadding(frame, cur); + const streamQuadlets = wire.decodeCount(wire.TSP_GENERIC_STREAM, frame, cur); + if (streamQuadlets === undefined) throw new Error("tsp: missing -A payload stream"); + // The body is an `-A##` stream that ends the frame and holds exactly one + // Bytes primitive — the form the spec's vectors and the ToIP reference + // use. Anything else is refused, never truncated to its first primitive + // (trustoverip/tswg-tsp-specification#77). + const streamEnd = cur.pos + streamQuadlets * 3; + if (streamEnd !== frameEnd) throw new Error("tsp: -A stream does not end the payload frame"); + const body = wire.decodeVariableData(wire.TSP_PLAINTEXT, frame, cur); + if (body === undefined) throw new Error("tsp: missing payload body"); + if (cur.pos !== streamEnd) { + throw new Error("tsp: -A stream must hold exactly one Bytes primitive"); + } + // `XCTL` carries an upper-layer control payload — opaque to TSP, exactly + // like `XSCS`. It is not a relationship-forming message and shares nothing + // with one but the word "control". + return { + kind: bytesEqual(typeCode, wire.XSCS) ? "direct" : "control", + hops: [], + body, + senderVid, + threadDigest: frameDigest, + }; + } + + if (bytesEqual(typeCode, wire.XHOP)) { + const hops = decodeVidList(frame, cur); + decodePadding(frame, cur); + // The inner message runs raw to the end of the declared frame. + const body = frame.slice(cur.pos, frameEnd); + return { + kind: hops.length === 0 ? "nested" : "routed", + hops, + body, + senderVid, + threadDigest: frameDigest, + }; + } + + const controlType = relationshipType(typeCode); + if (controlType) { + const { control, threadDigest } = decodeControlBody( + controlType, + frame, + cur, + senderField, + envelopeFields, + ); + return { kind: "control", control, hops: [], body: new Uint8Array(0), senderVid, threadDigest }; + } + + if (bytesEqual(typeCode, wire.XPAD)) { + // A padding-only message carries a nonce so two of them between the same + // pair are not identical on the wire — which would make them recognisable + // as padding, the opposite of the point. + return { + kind: "padding", + hops: [], + body: new Uint8Array(0), + senderVid, + threadDigest: frameDigest, + }; + } + + throw new Error("tsp: unsupported payload type marker"); +} + +function relationshipType(typeCode: Uint8Array): ControlType | undefined { + if (bytesEqual(typeCode, wire.XRFI)) return "invite"; + if (bytesEqual(typeCode, wire.XRFA)) return "accept"; + if (bytesEqual(typeCode, wire.XRFD)) return "cancel"; + return undefined; +} + +export { senderFieldBytes }; diff --git a/packages/tsp-js/src/revision.ts b/packages/tsp-js/src/revision.ts new file mode 100644 index 0000000..663a218 --- /dev/null +++ b/packages/tsp-js/src/revision.ts @@ -0,0 +1,141 @@ +// Which revision of the TSP specification framed this message. +// +// The whole dual-revision design rests on one property: **the revision is +// readable without any keys, at a fixed offset, before anything else is +// parsed.** A TSP message opens with the `-E` count code and then the version +// marker: +// +// f8 40 13 -E count (short form, 3 bytes) ← or `fb …` long form, 6 bytes +// 61 34 8f YTSP genus marker +// f8 00 01 version count code: MAJOR=0, MINOR=1 → Rev 2 +// f8 00 02 version count code: MAJOR=0, MINOR=2 → Rev 3 +// +// So `peekRevision` reads at most nine bytes and never touches a key. That is +// what makes "receive both, pack one" honest rather than a guess: an inbound +// message *says* what it is, and we dispatch on what it says. +// +// ── Why the two MINOR values are not symmetric ── +// +// Rev 2 is the only MINOR we match exactly. Everything else at MAJOR 0 is read +// as Rev 3, because §9.1 makes MAJOR the field that gates processability and +// MINOR one that no implementation may refuse a message on — the ToIP reference +// discards MINOR entirely. Pre-merge drafts of Rev 3 shipped `YTSP-ABA`, which +// is MINOR 64 under this (MAJOR.MINOR) reading and MINOR 1 / PATCH 0 under the +// three-component one; the merged specification's Appendix A vectors, like +// affinidi-tsp and this package, carry `AAC` = 2. Both must parse as Rev 3, +// and so must whatever the resolution of that argument turns out to be, so +// enumerating known-good MINORs would be the wrong shape. See +// `KNOWN_MINORS` for what the list is actually for. +// +// ── What this is not ── +// +// This is not negotiation. We pack Rev 3 unconditionally; a Rev 2 peer cannot +// read what we send, and nothing here pretends otherwise. `peekRevision` exists +// so that a Rev 2 message we are *given* is read correctly and reported as +// such, rather than dying at the ciphertext selector with "missing F +// ciphertext field" — a crypto-layer error for a problem that is nothing of the +// sort. + +import * as wire from "./cesr/wire.js"; + +/** A revision of the TSP specification, as carried by a message's version + * marker. */ +export type Revision = "rev2" | "rev3"; + +/** The MAJOR version this package implements. MAJOR is the only component that + * gates processability (§9.1). */ +export const SUPPORTED_MAJOR = 0; + +/** MINOR values we can name, for diagnostics only — never for admission. + * + * A MINOR absent from here still parses (as Rev 3); what the list buys is the + * ability to say "this frame declared an unrecognised MINOR" when a parse then + * fails, instead of blaming the ciphertext. */ +export const KNOWN_MINORS: Readonly> = Object.freeze({ + 1: "Rev 2 (YTSP-AAB)", + 2: "Rev 3 (YTSP-AAC)", + 64: "Rev 3 as published upstream (YTSP-ABA)", +}); + +/** A frame whose revision could not be established, or is not one we speak. + * + * Carries a machine-readable `code` because the caller's decision — drop, + * report, or ask the peer to upgrade — must not be made by matching on a + * message string (stack guide R3.7). */ +export class TspRevisionError extends Error { + /** Stable discriminator. */ + readonly code = "E_TSP_REVISION" as const; + /** MAJOR the frame declared, when it was readable. */ + readonly major: number | undefined; + /** MINOR the frame declared, when it was readable. */ + readonly minor: number | undefined; + + constructor(message: string, major?: number, minor?: number) { + super(message); + this.name = "TspRevisionError"; + this.major = major; + this.minor = minor; + } +} + +/** Structural test for {@link TspRevisionError}, on the code rather than on + * `instanceof` — the class can arrive from a different copy of this package + * (a bundled build beside a linked one), and the code cannot. */ +export function isRevisionError(err: unknown): err is TspRevisionError { + return typeof err === "object" && err !== null && (err as { code?: unknown }).code === "E_TSP_REVISION"; +} + +/** What {@link peekRevision} found. */ +export interface PeekedRevision { + /** Which codec should parse this frame. */ + revision: Revision; + /** MAJOR as carried. Always {@link SUPPORTED_MAJOR} — a mismatch throws. */ + major: number; + /** MINOR as carried, unjudged. */ + minor: number; + /** Whether {@link KNOWN_MINORS} names this MINOR. A `false` here is not an + * error; it is the note a later parse failure should cite. */ + recognised: boolean; +} + +/** + * Read a message's revision from its version marker, without keys. + * + * Throws {@link TspRevisionError} if the bytes are not a TSP frame at all, if + * the version marker is malformed, or if MAJOR is one we do not implement. + */ +export function peekRevision(bytes: Uint8Array): PeekedRevision { + if (!wire.isTsp(bytes)) { + throw new TspRevisionError("tsp: not a TSP frame (no -E count code)"); + } + + // The version marker sits immediately after the `-E` count code, whose width + // is the one thing the leading byte tells us: short is 3 bytes, long is 6. + // Both revisions' long spellings lead with 0xFB, so this is revision-agnostic + // — which it has to be, since it runs before the revision is known. + const cur: wire.Cursor = { pos: bytes[0] === wire.TSP_MAGIC_BYTE_LONG ? 6 : 3 }; + + const version = wire.readVersion(bytes, cur); + if (version === undefined) { + throw new TspRevisionError("tsp: missing or malformed YTSP version marker"); + } + if (version.major !== SUPPORTED_MAJOR) { + throw new TspRevisionError( + `tsp: message declares MAJOR ${version.major}; this implementation speaks MAJOR ${SUPPORTED_MAJOR}`, + version.major, + version.minor, + ); + } + + return { + revision: version.minor === wire.REV2_MINOR ? "rev2" : "rev3", + major: version.major, + minor: version.minor, + recognised: Object.hasOwn(KNOWN_MINORS, version.minor), + }; +} + +/** Human-readable name for a peeked revision, for error text and diagnostics. */ +export function describeRevision(peeked: PeekedRevision): string { + return KNOWN_MINORS[peeked.minor] ?? `MAJOR ${peeked.major}, MINOR ${peeked.minor} (unrecognised)`; +} diff --git a/packages/tsp-js/src/unsafe-testing.ts b/packages/tsp-js/src/unsafe-testing.ts new file mode 100644 index 0000000..3bed461 --- /dev/null +++ b/packages/tsp-js/src/unsafe-testing.ts @@ -0,0 +1,122 @@ +// @openvtc/vti-tsp-js/unsafe-testing — deterministic packing, for reproducing +// published test vectors. **NOT FOR PRODUCTION USE.** +// +// Every function here packs exactly what its namesake in the main entry point +// packs, except that the HPKE-Base ephemeral key is derived from a caller-fixed +// `ikmE` (RFC 9180 §7.1.3 DeriveKeyPair) instead of drawn at random, and the +// ESSR sender field may be written as the NULL VID. Ed25519 signatures are +// already deterministic, so with those two fixed — and the invite nonce, which +// `packInvite` already takes — the whole message is reproducible byte for byte. +// +// ── Why this is unsafe ── +// +// A fixed ephemeral key repeats the HPKE (key, base_nonce) pair for every +// message sealed with it to the same recipient. Under ChaCha20Poly1305 that +// leaks the XOR of the plaintexts and the Poly1305 one-time key, so both +// confidentiality and integrity are gone. Anyone who learns `ikmE` can also +// derive the ephemeral secret and open the message outright. The only safe +// `ikmE` is one already published next to a test vector. +// +// It is a separate subpath, absent from the main entry point and from the +// documented API, so that it cannot be reached by accident: importing it is a +// statement that the caller is a test. Every export carries the `__unsafe` +// prefix the package already uses for its fixed-ephemeral HPKE hook. + +import { MAX_HOPS } from "./cesr/wire.js"; +import { + pack, + packAccept, + packCancel, + packInvite, + packWithHops, + type PackKeys, + type PackedMessage, + type UnsafeDeterministicPack, +} from "./rev3/direct.js"; + +export type { PackKeys, PackedMessage, UnsafeDeterministicPack }; + +/** `pack` (direct, `XSCS`) with a fixed ephemeral. Test vectors only. */ +export async function __unsafeDeterministicPack( + body: Uint8Array, + senderVid: string, + receiverVid: string, + keys: PackKeys, + unsafe: UnsafeDeterministicPack, +): Promise { + return pack(body, senderVid, receiverVid, keys, requireIkm(unsafe)); +} + +/** `packInvite` (`XRFI`) with a fixed ephemeral and a caller nonce. Test vectors + * only. The nonce is required here: a random one would defeat the point. */ +export async function __unsafeDeterministicPackInvite( + senderVid: string, + receiverVid: string, + keys: PackKeys, + opts: { route?: string[]; nonce: Uint8Array }, + unsafe: UnsafeDeterministicPack, +): Promise { + return packInvite(senderVid, receiverVid, keys, opts, requireIkm(unsafe)); +} + +/** `packAccept` (`XRFA`) with a fixed ephemeral. Test vectors only. */ +export async function __unsafeDeterministicPackAccept( + inviteDigest: Uint8Array, + senderVid: string, + receiverVid: string, + keys: PackKeys, + unsafe: UnsafeDeterministicPack, +): Promise { + return packAccept(inviteDigest, senderVid, receiverVid, keys, requireIkm(unsafe)); +} + +/** `packCancel` (`XRFD`) with a fixed ephemeral. Test vectors only. */ +export async function __unsafeDeterministicPackCancel( + relationshipDigest: Uint8Array, + senderVid: string, + receiverVid: string, + keys: PackKeys, + unsafe: UnsafeDeterministicPack, +): Promise { + return packCancel(relationshipDigest, senderVid, receiverVid, keys, requireIkm(unsafe)); +} + +/** `packNested` (`XHOP`, empty hop list) with a fixed ephemeral. Test vectors + * only. */ +export async function __unsafeDeterministicPackNested( + innerBytes: Uint8Array, + senderVid: string, + intermediaryVid: string, + keys: PackKeys, + unsafe: UnsafeDeterministicPack, +): Promise { + return packWithHops(innerBytes, "nested", [], senderVid, intermediaryVid, keys, requireIkm(unsafe)); +} + +/** `packRouted` (`XHOP`) with a fixed ephemeral. Test vectors only. Same route + * bounds as `packRouted`. */ +export async function __unsafeDeterministicPackRouted( + inner: Uint8Array, + remainingRoute: string[], + senderVid: string, + firstHopVid: string, + keys: PackKeys, + unsafe: UnsafeDeterministicPack, +): Promise { + if (remainingRoute.length === 0) { + throw new Error("tsp: a routed message requires at least one onward hop"); + } + if (remainingRoute.length > MAX_HOPS) { + throw new Error(`tsp: route has ${remainingRoute.length} hops, exceeds max ${MAX_HOPS}`); + } + return packWithHops(inner, "routed", remainingRoute, senderVid, firstHopVid, keys, requireIkm(unsafe)); +} + +/** An absent `ikmE` would silently fall back to a random ephemeral and produce + * bytes that match nothing — refuse it by name instead. */ +function requireIkm(unsafe: UnsafeDeterministicPack): UnsafeDeterministicPack { + if (!(unsafe?.__unsafeIkmE instanceof Uint8Array)) { + throw new Error("tsp: unsafe-testing packers need __unsafeIkmE (a Uint8Array)"); + } + return unsafe; +} diff --git a/packages/tsp-js/tests/cesr.wire.mjs b/packages/tsp-js/tests/cesr.wire.mjs index 5e03008..c33690a 100644 --- a/packages/tsp-js/tests/cesr.wire.mjs +++ b/packages/tsp-js/tests/cesr.wire.mjs @@ -18,8 +18,11 @@ const write = (fn) => { test("code identifiers match the reference", () => { assert.equal(w.TSP_VID, 1); - assert.equal(w.TSP_HPKEAUTH_CIPHERTEXT, 6); - assert.equal(w.TSP_TMP, 23); + assert.equal(w.TSP_HPKE_BASE_CIPHERTEXT, 5); // Rev 3 `F` + assert.equal(w.TSP_SEALED_BOX_CIPHERTEXT, 2); // Rev 3 `C` (§8.3, recognised only) + assert.equal(w.TSP_GENERIC_STREAM, 0); // Rev 3 `-A` + assert.equal(w.TSP_HPKEAUTH_CIPHERTEXT, 6); // Rev 2 `G`, struck from the Rev 3 table + assert.equal(w.TSP_TMP, 23); // Rev 2 only assert.equal(w.TSP_ETS_WRAPPER, 4); assert.equal(w.TSP_PAYLOAD, 25); assert.equal(w.TSP_ATTACH_GRP, 2); @@ -41,11 +44,23 @@ test("encodeCount(-E, 19) = f8 40 13 and round-trips", () => { assert.equal(cur.pos, 3); }); -test("encodeVersion = 61 34 8f f8 00 01 and round-trips", () => { +test("encodeVersion = 61 34 8f f8 00 02 — YTSP-AAC, the Rev 3 marker", () => { + // Rev 2 was `f8 00 01` (`YTSP-AAB`). One byte, and it is the byte the whole + // dual-revision dispatch turns on, so it is pinned rather than round-tripped. const buf = write((out) => w.encodeVersion(out)); - assert.equal(hex(buf), "61348ff80001"); + assert.equal(hex(buf), "61348ff80002"); const cur = { pos: 0 }; - assert.equal(w.decodeVersion(buf, cur), true); + assert.deepEqual(w.readVersion(buf, cur), { major: 0, minor: 2 }); + assert.equal(cur.pos, 6); +}); + +test("readVersion reads a Rev 2 marker without judging it", () => { + // The discriminator has to parse a revision it does not implement the codec + // for — that is the entire job. MINOR comes back unjudged; `peekRevision` + // decides, and `readVersion` does not get an opinion. + const rev2 = bytes([0x61, 0x34, 0x8f, 0xf8, 0x00, 0x01]); + const cur = { pos: 0 }; + assert.deepEqual(w.readVersion(rev2, cur), { major: 0, minor: 1 }); assert.equal(cur.pos, 6); }); @@ -85,41 +100,66 @@ test("fixed data — 64-byte Ed25519 signature header = d0 10", () => { assert.deepEqual(w.decodeFixedData(w.ED25519_SIGNATURE, 64, buf, cur), sig); }); -test("hops — empty list round-trips to just the -J0 header", () => { - const buf = write((out) => w.encodeHops([], out)); - const cur = { pos: 0 }; - assert.deepEqual(w.decodeHops(buf, cur), []); - assert.equal(cur.pos, buf.length); -}); - -test("hops — non-empty list round-trips", () => { - const hops = [enc.encode("did:web:hop1"), enc.encode("did:web:exit")]; - const buf = write((out) => w.encodeHops(hops, out)); - const cur = { pos: 0 }; - const got = w.decodeHops(buf, cur); - assert.deepEqual(got, hops); - assert.equal(cur.pos, buf.length); -}); - -test("count long-form (≥ 4096) — byte-identical to the reference + 6-byte advance", () => { - // The reference (affinidi-tsp / tsp-sdk) encodes a long-form count as a - // 6-byte header. Its decode_count folds the identifier bits into the returned - // *value* for a nonzero id — a reference quirk we match byte-for-byte. It's - // benign: TSP frames by cursor position and discards this value (only the - // 3-vs-6-byte advance matters), so large payloads still decode correctly. +// Hop lists are no longer here: Rev 3 §9.2 made the `-J` count the group's byte +// length where Rev 2 counted VIDs, so the same bytes mean two different things +// and neither codec can share one implementation. Each revision owns its own — +// `rev3/payload.ts` and `rev2/reader.ts` — and they are exercised through +// `message.routed.mjs` and the published `routed` vector respectively. + +test("count long-form (≥ 4096) — Rev 3 spells it --X, and the count is exact", () => { + // Two things changed here and both were invisible to a round trip. + // + // The spelling: Rev 2 emitted `-0X#####` (second selector `0`), from a + // superseded draft of the CESR v2 tables; Rev 3 pins the master table for + // genus `-_AAACAA`, which carries only `--X#####`. Encoder and decoder agree + // either way, so only pinned bytes catch it. const buf = write((out) => w.encodeCount(w.TSP_PAYLOAD, 5000, out)); - assert.equal(hex(buf), "fb4640001388"); // exact reference bytes + assert.equal(hex(buf), "fbe640001388"); + assert.equal(hex(buf).slice(0, 4), "fbe6", "second selector is DASH, not '0'"); + + // The value: this test used to assert only that *a* count came back, and + // called the wrong one "a reference quirk ... benign, TSP frames by cursor + // position and discards this value". It was neither a quirk nor benign — + // affinidi-tsp fixed the same bug on its side ("every long-framed message + // decoded to a wrong length"), and Rev 3 is where it bites: the `-E` and `-Z` + // counts are now load-bearing lengths, not skippable headers. const cur = { pos: 0 }; - assert.notEqual(w.decodeCount(w.TSP_PAYLOAD, buf, cur), undefined); // present - assert.equal(cur.pos, 6); // advanced past the 6-byte long-form header + assert.equal(w.decodeCount(w.TSP_PAYLOAD, buf, cur), 5000); + assert.equal(cur.pos, 6); - // With id = 0 the id-folding degenerates and the value round-trips exactly. const buf0 = write((out) => w.encodeCount(0, 5000, out)); const cur0 = { pos: 0 }; assert.equal(w.decodeCount(0, buf0, cur0), 5000); assert.equal(cur0.pos, buf0.length); }); +test("count long-form — a Rev 2 header decodes only under the Rev 2 form", () => { + // `-0Z` + 5000. Hand-built, because nothing in this package emits one any + // more: the Rev 2 reader is the only caller that passes LONG_COUNT_REV2, and + // the Rev 3 decoder must *not* accept these bytes — a decoder loose enough to + // take either spelling would read a Rev 2 frame as Rev 3 and then fail + // somewhere that says nothing about why. + const rev2 = bytes([0xfb, 0x46, 0x40, 0x00, 0x13, 0x88]); + const asRev2 = { pos: 0 }; + assert.equal(w.decodeCount(w.TSP_PAYLOAD, rev2, asRev2, w.LONG_COUNT_REV2), 5000); + assert.equal(asRev2.pos, 6); + + const asRev3 = { pos: 0 }; + assert.equal(w.decodeCount(w.TSP_PAYLOAD, rev2, asRev3), undefined); + assert.equal(asRev3.pos, 0, "a refused decode advances nothing"); +}); + +test("isTsp accepts both framings, and nothing else", () => { + // `0xFB` is the byte a Rev 3 message leads with past ~12 KB, because the `-E` + // count now covers the ciphertext. An ingress classifier that knows only + // `0xF8` starts dropping large messages the day Rev 3 is switched on. + assert.equal(w.isTsp(bytes([0xf8, 0x40, 0x13])), true); + assert.equal(w.isTsp(bytes([0xfb, 0xe6, 0x40])), true); + assert.equal(w.isTsp(enc.encode('{"protected":"..."}')), false); // DIDComm JSON + assert.equal(w.isTsp(enc.encode("eyJhbGciOiJ")), false); // compact JWS + assert.equal(w.isTsp(bytes([])), false); +}); + test("variable data round-trips across all lead-byte alignments", () => { for (let len = 0; len <= 9; len++) { const payload = new Uint8Array(len).map((_, i) => (i * 7 + 1) & 0xff); diff --git a/packages/tsp-js/tests/control.spec-vectors.mjs b/packages/tsp-js/tests/control.spec-vectors.mjs new file mode 100644 index 0000000..a25ed3f --- /dev/null +++ b/packages/tsp-js/tests/control.spec-vectors.mjs @@ -0,0 +1,211 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +import { unpack, packInvite, packAccept, packCancel } from "../dist/index.js"; +import { decodeEnvelope as decodeRev3Envelope } from "../dist/rev3/envelope.js"; +import { deriveSaid } from "../dist/rev3/control.js"; +import { senderFieldBytes } from "../dist/rev3/fields.js"; +import * as wire from "../dist/cesr/wire.js"; +import { ed25519, x25519 } from "@noble/curves/ed25519.js"; + +// The specification's own relationship-forming vectors. These check the one +// thing in the control path that a round trip structurally cannot: the §7.2.1 +// self-addressing digest. Our decoder recomputes it and refuses the message on +// a mismatch, so a vector that unpacks at all is a derivation that agrees 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 33 dummy bytes. +// +// Nothing weaker would do. Encoder and decoder built from the same wrong +// reading agree perfectly, and so do two implementations that share it. +const VECTORS = JSON.parse( + readFileSync(new URL("./fixtures/spec-rev3-vectors.json", import.meta.url), "utf8"), +); + +const b64u = (s) => new Uint8Array(Buffer.from(s, "base64url")); +const hex = (u8) => Buffer.from(u8).toString("hex"); +const id = (name) => VECTORS.identifiers[name]; + +const openVector = (name) => { + const v = VECTORS.vectors[name]; + return unpack(b64u(v.message), { + receiverDecryptionKey: b64u(id(v.receiver).skE), + senderSigningKey: b64u(id(v.sender).pkS), + }); +}; + +test("control-rfi-direct — an invite decodes, and its digest verifies", async () => { + const out = await openVector("control-rfi-direct"); + assert.equal(out.messageType, "control"); + assert.equal(out.control.controlType, "invite"); + assert.equal(out.sender, id("alice").id); + assert.equal(out.receiver, id("bob").id); + + // §9.2 (D9): 128 bits, where Rev 2 used 256. + assert.equal(out.control.nonce.length, 16); + assert.equal(hex(out.control.nonce), "11".repeat(16)); + assert.deepEqual(out.control.route, [], "no Reply_Path in this vector"); + assert.equal(out.control.referral, undefined); + + // The digest is the thread id of the exchange the invite opens. + assert.deepEqual(out.threadDigest, out.control.digest); + assert.equal(out.control.inReplyTo, undefined, "an invite answers nothing"); +}); + +test("the invite's digest is derived exactly as the reference derives it", async () => { + // Stated directly rather than left implicit in `unpack`'s internal check, so + // that a derivation regression names the derivation instead of surfacing as + // "TSP_Digest does not match" from three layers down. + const v = VECTORS.vectors["control-rfi-direct"]; + const wireBytes = b64u(v.message); + const decoded = decodeRev3Envelope(wireBytes); + const envelopeFields = wireBytes.slice(decoded.aad.begin, decoded.aad.end); + + // The vector carries the NULL VID in its ESSR sender field, which §9.2 permits + // under HPKE-Base. We write the real VID; both are conformant, and the + // derivation has to accept whichever the message actually carries. + const nullSenderField = senderFieldBytes(""); + + const nonceOut = []; + wire.encodeFixedData(wire.TSP_NONCE, new Uint8Array(16).fill(0x11), nonceOut); + const emptyList = []; + wire.encodeCount(wire.TSP_HOP_LIST, 0, emptyList); + + const after = new Uint8Array([...nonceOut, ...emptyList, ...emptyList]); // nonce, Reply_Path, Referral + const said = deriveSaid(envelopeFields, wire.XRFI, nullSenderField, after); + + const out = await openVector("control-rfi-direct"); + assert.deepEqual(said, out.control.digest, "recomputed SAID matches the one on the wire"); +}); + +test("control-rfa-direct — an accept carries two digests, and they are not interchangeable", async () => { + const invite = await openVector("control-rfi-direct"); + const accept = await openVector("control-rfa-direct"); + + assert.equal(accept.control.controlType, "accept"); + // The accept travels the other way: bob answers alice. + assert.equal(accept.sender, id("bob").id); + assert.equal(accept.receiver, id("alice").id); + + // The wire order is `Digest` then `Reply_Digest`, and — counter to how those + // names read — the first is the *invite's* digest echoed and the second is + // the accept's own. Getting this backwards is the documented trap, so it is + // asserted against the invite vector rather than against itself. + assert.deepEqual( + accept.control.inReplyTo, + invite.control.digest, + "the accept echoes the invite's digest", + ); + assert.notDeepEqual( + accept.control.digest, + invite.control.digest, + "the accept's own digest is its own, not a copy of the invite's", + ); + assert.deepEqual(accept.threadDigest, accept.control.digest); + assert.equal(accept.control.nonce, undefined, "an accept carries no nonce"); +}); + +test("control-rfd — a cancel names the message it ends and carries no nonce", async () => { + const invite = await openVector("control-rfi-direct"); + const cancel = await openVector("control-rfd"); + + assert.equal(cancel.control.controlType, "cancel"); + assert.deepEqual( + cancel.control.inReplyTo, + invite.control.digest, + "the cancel names the relationship-forming message", + ); + // A cancel's only digest is a reference, so there is nothing self-addressing + // to recompute — `digest` mirrors the reference rather than being derived. + assert.deepEqual(cancel.control.digest, cancel.control.inReplyTo); + assert.equal(cancel.control.nonce, undefined); +}); + +test("a tampered control digest is refused as verification, not parsed as valid", async () => { + // The whole value of a self-addressing digest is that a receiver checks it. + // Flip one byte of the invite's carried digest and the recomputation must + // disagree — if this passes, the digest is decorative. + const v = VECTORS.vectors["control-rfi-direct"]; + const wireBytes = b64u(v.message); + + // The digest sits inside the ciphertext, so it cannot be flipped from out + // here. Flip the envelope's sender VID instead, which the derivation also + // covers — same property, reachable without the key. + const tampered = Uint8Array.from(wireBytes); + const marker = Buffer.from(tampered).indexOf(Buffer.from("did:peer:4zQmUL", "utf8")); + assert.ok(marker > 0, "found the sender VID in the cleartext envelope"); + tampered[marker + 14] ^= 0x01; + + await assert.rejects( + () => + unpack(tampered, { + receiverDecryptionKey: b64u(id("bob").skE), + senderSigningKey: b64u(id("alice").pkS), + }), + // The envelope is signed, so this dies at the signature before the digest + // is ever recomputed — which is the correct order and worth pinning. + /signature verification failed/, + ); +}); + +// ── Our own control messages ── + +const party = (vid) => { + const sk = ed25519.utils.randomSecretKey(); + const xsk = ed25519.utils.toMontgomerySecret(sk); + return { vid, sk, pk: ed25519.getPublicKey(sk), xsk, xpk: x25519.getPublicKey(xsk) }; +}; +const keysFrom = (from, to) => ({ senderSigningKey: from.sk, receiverEncryptionKey: to.xpk }); +const unpackKeys = (me, from) => ({ receiverDecryptionKey: me.xsk, senderSigningKey: from.pk }); + +test("a full invite → accept exchange round-trips, digests threading correctly", async () => { + const alice = party("did:web:alice.example"); + const bob = party("did:web:bob.example"); + + const invite = await packInvite(alice.vid, bob.vid, keysFrom(alice, bob)); + const atBob = await unpack(invite.bytes, unpackKeys(bob, alice)); + assert.equal(atBob.control.controlType, "invite"); + assert.deepEqual(atBob.control.digest, invite.threadDigest, "the inviter knows its own thread id"); + + const accept = await packAccept(atBob.control.digest, bob.vid, alice.vid, keysFrom(bob, alice)); + const atAlice = await unpack(accept.bytes, unpackKeys(alice, bob)); + assert.equal(atAlice.control.controlType, "accept"); + assert.deepEqual( + atAlice.control.inReplyTo, + invite.threadDigest, + "alice can match the accept to the invite she sent", + ); + + // And a cancellation may name either half (§7.2.1). + const cancel = await packCancel(accept.threadDigest, alice.vid, bob.vid, keysFrom(alice, bob)); + const cancelAtBob = await unpack(cancel.bytes, unpackKeys(bob, alice)); + assert.deepEqual(cancelAtBob.control.inReplyTo, accept.threadDigest); +}); + +test("an invite's nonce is fresh per message", async () => { + // Two invites between the same pair must not be identical on the wire. + const alice = party("did:web:alice.example"); + const bob = party("did:web:bob.example"); + const a = await unpack( + (await packInvite(alice.vid, bob.vid, keysFrom(alice, bob))).bytes, + unpackKeys(bob, alice), + ); + const b = await unpack( + (await packInvite(alice.vid, bob.vid, keysFrom(alice, bob))).bytes, + unpackKeys(bob, alice), + ); + assert.notDeepEqual(a.control.nonce, b.control.nonce); + assert.notDeepEqual(a.control.digest, b.control.digest, "different nonce, different thread id"); +}); + +test("an invite can carry a Reply_Path, and it survives the round trip", async () => { + const alice = party("did:web:alice.example"); + const bob = party("did:web:bob.example"); + const route = ["did:web:relay.example"]; + const invite = await packInvite(alice.vid, bob.vid, keysFrom(alice, bob), { route }); + const atBob = await unpack(invite.bytes, unpackKeys(bob, alice)); + assert.deepEqual(atBob.control.route, route, "§7.2.4 Reply_Path"); + // The route is inside the digest derivation, so a route that did not survive + // would fail the digest check rather than arrive empty. + assert.deepEqual(atBob.control.digest, invite.threadDigest); +}); diff --git a/packages/tsp-js/tests/fixtures/spec-rev3-vectors.json b/packages/tsp-js/tests/fixtures/spec-rev3-vectors.json new file mode 100644 index 0000000..ec73718 --- /dev/null +++ b/packages/tsp-js/tests/fixtures/spec-rev3-vectors.json @@ -0,0 +1,170 @@ +{ + "_source": { + "spec": "trustoverip/tswg-tsp-specification", + "commit": "f5b8668952aabe8e541b535fcbdf589484ffc4f4", + "section": "Appendix A: Test Vectors", + "note": "Appendix A as merged, which moved the TSP version to YTSP-AAC. Every message changed with it, and so did the control vectors' SAID digests, which cover the version; keys, ikmE and the direct vectors' printed payloads are unchanged. Supersedes the pre-merge YTSP-ABA vectors of PR #63 (commit 66a158002eb2cc6acf5ac62f9aa23224d433e6cc). Extracted mechanically from the specification (tsp-conformance fixtures/spec-vectors.json, tools/extract_spec_vectors.py); nested-direct and routed additionally carry innerPayload, the plaintext payload frame of the inner message. Values are qb64 (CESR text domain), which is a direct base64url transcode of the qb2 binary domain this package works in. The private keys are published in the spec and must never be used for anything but checking these vectors." + }, + "identifiers": { + "alice": { + "id": "did:peer:4zQmUL61Nc1F7ioiKxHNqwnJXX4srhFsKKPo6TrCmhM3dfpq", + "longForm": "did:peer:4zQmUL61Nc1F7ioiKxHNqwnJXX4srhFsKKPo6TrCmhM3dfpq:z25NRJMKpQKwnUm6k9FbTSp1eqANorDtFimHW9nGdLSgAbN1UhsQANFAnhij8VXrrwnDDG5Rb7HGrYpBGZRy3EbgmxirqJi3WuNfiajavps6kAfnvk9ykxGqcxH4u7P2TJohfbvigGMTMGxAhqkU4JDkWYi1sBp266n8tzTzD3DfRUAMspuGDnE6KM7r55U1tmXkDtRRWy97BFdh9HKfA3CPhA3gFdwRRV45CP1kGiaYU2rfwYAncrd1vfsAsZS8CoqbDWBgm7wGQNBXkjE8SuhKA8zv64cwpvbjJZioW7tx6Jo986sMDshig678dSR8whhc1zq4ZeaeDzGB6SbUZrpXXpd4aHy5AQQF7fwmStceXXEsNa4zL8xAhTszp2yFhLF399Q83hTdka8gUWyyQ26LUFDXCUivSfPxFRyUZZEQEK54sZZFP44MQoC4dDRrDqd2tyKMjTkdxVTg1Tvk1k9CNRUoHzdrboeESKmmdN39Ms3xRCVTN7cU6qGFW7Uz9xqhCinaKpzgRLp6ZSvmX4NcCTbruQeb3vMvHvJLCkMghQp", + "sigKeyType": "Ed25519", + "pkS": "b5DCBaChqWVVGJOsGWcKuKti0SKfm53pTfOwuj-hm9g", + "skS": "MqiUK5zsViOi9QxQlyMXvPdraQghVOASyFiAvlFXp4U", + "encKeyType": "X25519", + "pkE": "4ZKJH0k2hjrBnFa83B8ZWx2dQYjQmbnc2nJW6urQSWg", + "skE": "0G2OObWQl0Nz9fR8LyBvzVGVLezU3wu5Y7payTJY2BE" + }, + "bob": { + "id": "did:peer:4zQmZmCAsG7j1ewTjXjtddwujik33CE2cMbYSPagpMiYnt1A", + "longForm": "did:peer:4zQmZmCAsG7j1ewTjXjtddwujik33CE2cMbYSPagpMiYnt1A:z25NRJMKpQKwnUm6k9FbTSp1eqANorDtFimHW9nGdLSgAbN1UhsQANFAnhij8VXrrwnDDG5Rb7HGrYpBGZRy3EbgmxirqJi3WuNfiajavps6kAfnvk9ykxGqcxH4u7P2TJohfbvigGMTMGxAhqkU4JDkWYi1sBp266n8tzTzD3DfRUDuzmWt547vK52UYFjqvYuqeZke48ht1Tn7veKyU9an3DBDEK5iNnMZuTwssYn9WboW5tdbdA3dyKiqwbBwrTUQsaAbpAaMzKCsFouHetYm3r6ft4Ewh5F6sQeWtVzScGmrWr2M9rcyLyRC4nC711Sfh3q6tNeb393SqeLs1uMVD3AJ7mLMWGmaYSuMBewWRcaJZdEiDe4NjZa12WSuZ9hZ9UYigEMByhwiT8WVsgiNEjFwcQLdofowERUKhYYNv1sgDfZ4K7MGRtdkJw3eZRbP1T9NPwyDsiBycnKipqn4MfcnDMWVMSsiQCg1xprkVvtgtgjhYkG1A7w9UGk5497ytKuFMpzwJbu8cAxLqTFZ1phe8cft61Euqj1eoCQ1X5E", + "sigKeyType": "Ed25519", + "pkS": "-ezxE2yQV7bti34Ofz1Cgy_Ykf3_t6ob2zc4evBKZS4", + "skS": "8sDXUkHnFe7exy1sGdCZl9kQQb-Xz-wuMBcGKXkVetw", + "encKeyType": "X25519", + "pkE": "c2fBIIJWZgJFNKZ0SS7k63Q_73kW1nnwaAf2vIb-mGA", + "skE": "j952DlpES4WEaSrY72PZwOi1j035m9iFxsZh_OKfcOE" + }, + "nested_alice": { + "id": "did:peer:4zQmehsjMuPkPjzg7WF2tH5X1SGuFMxacJB9RETLFwjgMDqX", + "longForm": "did:peer:4zQmehsjMuPkPjzg7WF2tH5X1SGuFMxacJB9RETLFwjgMDqX:z25NRJMKpQKwnUm6k9FbTSp1eqANorDtFimHW9nGdLSgAbN1UhsQANFAnhij8VXrrwnDDG5Rb7HGrYpBGZRy3EbgmxirqJi3WuNfiajavps6kAfnvk9ykxGqcxH4u7P2TJohfbvigGMTMGxAhqkU4JDkWYi1sBp266n8tzTzD3DfRUDxc1EGZZ8oysc2HpdT4rEuGHZoobGpKmo4XJSoDPKF6CfUWCr2UyzMv8dbCQWeugCqsUqQuASukLHjNPpzRx8cuSRSA7kDNG9civ4UoFrWhgvdSrPgBjeQYQFREA9ctDXCG7QkoYfNrpLhHXZ3LK3C1Jm7hQtmX926LQ75V82SSRPmwXBYXGx153XZuCm98znoU8cWADwCtJ6GhVrUSZPkkaPgrZqE1pFukaCvZf4gstRoZEwGrtDhyDdk5L6SpoLxEYCYfgFGmYpo7a1PojKqFFqp1NLzgfimW8XEuq9sL3BXraAz6kxRXCnbeEmSNHxzgJxe9S17WFsTn2jtFHLQFHgRxzpjhdXeysKxYJSDng8Cjjk8WdZeN5YkmKsve5z", + "sigKeyType": "Ed25519", + "pkS": "_-E1m_BLThHwhUuYhV0YovargFtQUkox6LnwhHx6Nnc", + "skS": "gOpqJhU4I8_MNADm8rODdfaMP4dv61uP8VwY8e0mjNw", + "encKeyType": "X25519", + "pkE": "NJwm8WK_zTmq60S-bjrbr1OjURO0FBfEjaTlZhmDm0Y", + "skE": "e2EIWSYsqFudoECVTglwKwX_i_t36CGjjDCCRF7uQ7A" + }, + "nested_bob": { + "id": "did:peer:4zQmSMw413keKhgpTpYm1q8jzmpqNYwddVGPubkHNgp3qQei", + "longForm": "did:peer:4zQmSMw413keKhgpTpYm1q8jzmpqNYwddVGPubkHNgp3qQei:z25NRJMKpQKwnUm6k9FbTSp1eqANorDtFimHW9nGdLSgAbN1UhsQANFAnhij8VXrrwnDDG5Rb7HGrYpBGZRy3EbgmxirqJi3WuNfiajavps6kAfnvk9ykxGqcxH4u7P2TJohfbvigGMTMGxAhqkU4JDkWYi1sBp266n8tzTzD3DfRUAgR84nfSphZZkXtF4LsDaKz5V52HNNgWgz1k8aFkjSCzTgyVcS7f2RwomT1HfyLVrW3UqCszF7iJ9vpfMviins6mHyfBkcA3bJbpeKoVTqemLZ9XgkhhGxnszeDAr8HAGrwTysiGmZeJUoweCk2RyP5f1QyWBqcNcRyDRjkShHQgpcVPy2iKceZYQbZsMRVTJveWDuzbAMzAeX2XsXDGE8y6r2dEKLikSRqFFYwGTrNEBjmkfQ9DnkRbQAK3ZoKKT94khAhbw8tZkDK3w48Vjb6XZkEcpWbpwsrAM4RDPcS7UJq3fH3WCoeSDQWoXyxncPoYmNaBK6PtwQe3PhaMoUBwUUFtEsDCuVCrAz6vpvMyZaeMv4EbmBGYwtMunWhxG", + "sigKeyType": "Ed25519", + "pkS": "eGi5FU5ZXN-kavLZRr_ozjAXFzUPsv14y8CyPCOuR84", + "skS": "6X6bCFv20GqJXHcnIBba3cILwFv9eawgwOL2HgOp6ig", + "encKeyType": "X25519", + "pkE": "5tBNodlJELEWNo9d-rgDxcYf1ldc9fruci9PtWqUgAo", + "skE": "0xi9nim9P3GuGznEHJJFL2JGpuCPqtCYFJ0EvBGj9Qo" + }, + "p": { + "id": "did:peer:4zQmXuYx5quNpAYvu1syaoHpJWxHceBM8PnAS1J84mjEo118", + "longForm": "did:peer:4zQmXuYx5quNpAYvu1syaoHpJWxHceBM8PnAS1J84mjEo118:z25NRJMKpQKwnUm6k9FbTSp1eqANorDtFimHW9nGdLSgAbN1UhsQANFAnhij8VXrrwnDDG5Rb7HGrYpBGZRy3EbgmxirqJi3WuNfiajavps6kAfnvk9ykxGqcxH4u7P2TJohfbvigGMTMGxAhqkU4JDkWYi1sBp266n8tzTzD3DfRUDEQsmpmjBHzq8cijG6sryWW5nVPht6pYsbuY4ccUdfcph7owJRDqYg99MemT1i7k3HvTxvnop9E89WNwWZiDLmJk5tssb7rQSatDkq2kvRhgW8eVyFnsoTdg2h87tsGpE8p6tQNyZpzAnTNVUe886osyyavxqQccEcm8idNnFEXEqtLL1oENMQvaWr8CsYUPy1rUpmv9xFPBay42n54ssn99hEMCahvW1sLFjHwcxGGco6PSMZT5DCw9yhUVSAeYpJ6eC4a2tjXB3tjcFeWifhxkp1u2yLJ1bcdxmJDvbKPghPVeCYYEm7g7RjrdFKKX1534YVEvwgeTi6H6ujmSvvs6xLwDjkmRwYpgPAucS9TMgVGJgDpEXD1brQZUP3PBJ", + "sigKeyType": "Ed25519", + "pkS": "4aeb8EZ5rJciXQvEjl1LDcZaST67KdYnqvZ2BYWU5Y8", + "skS": "WW-aIOkC8AcowW-6H-MmjFoUA92mzz1XYmp5DsLpxE0", + "encKeyType": "X25519", + "pkE": "ajg137oz_TtbqqxihWdMYGblF9NLtFpDg8mv06leNVQ", + "skE": "ivpbTN8fMgb3oj1S3tQ5SC5UAjDQ5oLuOlmrkbJKy38" + }, + "q": { + "id": "did:peer:4zQmTKaSRexnzX2ecu1eYRusFNeWbrT5jbjr3YjtfgciZc1Q", + "longForm": "did:peer:4zQmTKaSRexnzX2ecu1eYRusFNeWbrT5jbjr3YjtfgciZc1Q:z25NRJMKpQKwnUm6k9FbTSp1eqANorDtFimHW9nGdLSgAbN1UhsQANFAnhij8VXrrwnDDG5Rb7HGrYpBGZRy3EbgmxirqJi3WuNfiajavps6kAfnvk9ykxGqcxH4u7P2TJohfbvigGMTMGxAhqkU4JDkWYi1sBp266n8tzTzD3DfRUBoRT26HWqurFNeBrp2Z5RXkhxkhyxAnpoB6ZS1CthQZKhEgZz8ubr2Q29a9C2ZYyuCNNuWCCUhfm8AASng7btZiV16P3qyFpbMk1dpwabuguNC2hBAycwQ1pniBp3yDGdTrSDg7Mvd62zkzDJfCXhxTQJQ5SQvAu1bb5ZGonmjMfu3fU877FfvbYL1gqYDKcH7hZ7NnifTQP345XsY2tTav6vR2vUAzDiWwvpVJ9gApPWJExs3A8EBw3wke2m2C9uQpdu4XRaxVzWgtGd1NL3LFwcf34gEksj2VsURD4VNhdjQ4MkKSFy7uxgDSyqTXUdr1GEGeQGAtcFTq5anuPnN9kDHm48WihypUQ48jo3zM5JXE1fhL1Xypi5rWn3tkip", + "sigKeyType": "Ed25519", + "pkS": "qUfbbaVzKhhddujMaJNSl9C8eGWCOBjA3WT4UGzXNOU", + "skS": "GolUele6xQTKisWaDZT9Q6dUNGg93_y__h-J3X_m5aU", + "encKeyType": "X25519", + "pkE": "M5Qk6RSrDUckHiQlmduCMbu9HW6AZbJCfkQEQqWShSI", + "skE": "GHZ-LhpY_K1f_PvDW6loXWLvFIcc6majVAR8_jWgL4c" + }, + "pq_alice": { + "id": "did:peer:4zQmbLqu3weZtsnNesBzecCZm5FA6uaGkgCWYR5Wndr6Gzzd", + "longForm": "did:peer:4zQmbLqu3weZtsnNesBzecCZm5FA6uaGkgCWYR5Wndr6Gzzd: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", + "sigKeyType": "MlDsa65", + "pkS": "-XNPEJoCD0SXNM9Z1Ek_Ah3JXU94GgMiqsGZ9VlhtD-ibb_VSt7Kft0622kvaA7nYI8A1STW6uYfs4EQwElWrjLKUAxyIRGj490atsxFZdlpBJhDzY5GugJfhnT_MTHlM2R1OJHRNVpL7rqAQCFhFMv_s3n_nu6ERT07HE0v4-IV7BzADacxQvizGxUiBMD5_xIBOGFJRsHXyWnxFZkZUlYlMiiA7Czj_ja6qPeNhFQtQJgkCp1hrIteqAXGyBK9ngctYOHypXkwMAG8rO74UW53CHyrCU7FuL13nDmiXxOaEDzZ4e1fbLm_wcmgl7KTCWFsoFyRyZiEHY1IPYXlTlCZjP5s2FzCpoTXO_XvQK-iOcTMesC1-_xkXYJPjjhhyq4aR6Y_gQUBTHG0EROH7NzKRAOCJ0905kxpP5zKv0EYWkMSJcigfgXhREC7Iq6JDyRw0VLpu3czeF4xG0VdojBUJKQChDzkDWC9PzgZKLvktmN9W7-yFw573qgeWY4oWnl4ZPrKoqgcGGcAMMRGG12iEdjvy0kqxg7SUUIho2dUl_lN9FivvNOBGrVfDoL2HBCuEawJA9cVhgNtMxD1OV1QRRrm0MuKzSVOd7M8DH-cRLBb4n_5wSApn53h_5OM6T_uCdrfRjl9FWgiazeXwGb9ivlJR0OjRKQS9aKiU4oP4QK4tzm9IVf7fbmOH66sUh4Cgy2JCjt8Sqhvhp4sySpA_WIUzvhdm52hKFoRx-_FJc35nGMia7tvsgPpc-GlHCdMQXB873Lfrn2yZgIXz1Ji1UIAlqtKFRqVBCRiBlwhAXT9xjrPmpD7PcLXUWe_8pTNI-1t8QqcOl_S9GbQTAnyhkoSaApimo3EzxRyjxoksn0DnX9FKEeguxGEZ0dhXuUT9Et-9FylvkKgd0ZTU_RzMWgdWfS0JEhqUE2uMgJrKYaCzZO5C84o2gSbIyXcyvKukoQ2YVA3qF6qBoj5fiL5atJ2VDxqgg2D2Q2RWL2QUeAbpw50odvjKLQUbW2YLAHuPMnxZCn9MyeXwVjoasyELSemUurKHsxWwnonMAmQ46kxjgv4s5asGxMLrP_-fKtwMEt1epJ3Anicdt4FpD0Rf2EbyCW5KH7jPPz8mq3Tr7Dx5er8jqfT46Zi3Wlx0fIwryB5ON8yQrMTtBRvSMSQndty2JHKoKhylYc4KoVszqx8d8Ia4IkjwNNrvQAQomPUM2s8Wf9HuQwHwwGjhuGQefHiUBnOqbn057HXKuWrFYY-enzKBLaSnwVD0zatyIrkPfocI0PxoCSQIlUrriyAUbOs53drxBdF7bh7urVZjPL2KrPyaC-evLUrFpTl8F1BCUlREpmDSx55LjbcbHQyf9WZY2835gMT096InMv3JrLbuwHyz8hd6oy_FqdJpjrYQK9REajLxmZNVCTPhqxdoUn0cJvnq8uOgn8qVa9AFkxUWT3eLe-IwTo3EnuLxoyvl657a0JsBofp1roFkt0DPlAiNK12ffdclWql6c6S1-W__6um0L_ZcnIMznII7demQ0RQVKBd1ycs9wlJ66GLgxgS40OIZL8OuKp1Ht9vkl5DScgkPt_zKSEdbnEnfXlo70eKAb4fkJaJyTYT4r-wPskiBqUZW8NmJrh8ioK7FyImJYxHXOBgkOD7bReyZJr5kkWD2WWrB-jPr70W_Glk7b7PdwS9Hf3leB8MfZCfOUFbRksbPaz89qOsHcBOM3_F5UthxqUboxREizv41xkN88IHQhmpAyvS3ZXqApr3x2mjkzp7kCUdJFVY2Gk4BX3h7o732Um6UW_TXE1HPvUnqQo2OxfLzw-9xcM0AQbJSrTeC-hD3ZHpJyEHue-4srYvloWjoZqbwcXJUcMiPdNFDfSDiysqwAuN9VUFSCMAzGlyL-sytuTrr1rYEY3KshQ3vjFLxiiiCa6ZBn5Je-s6IHBP-OFTAIylZVLtgq4WDKo9Q1zoulzk6SGHZJ1QF0Q-AxGpiA9We2kq2B040q1jLewPkFLq2dVH45UAzTlvUiUXWg1_sKPhHQHy5gf8S-z6jg5DnDhCik9W2lDMicBYJe1b4_ykBDbPD9Nzabt6L5JcDH-Y9NaYEzHfcx6DqkHy1XDQ6mgxaBLDueY1hAY4YPOuNDoyxhkliEpyc7_Jlogu4RXYWoDft822kyto0vlnyPgEE12Cc2jrpZiixy6_hb8euWXtpznMmHHN44qBCTIpcbmq1_uPbC_oxOSp_Tel90TQev9fyMqFVCBYMgb4Ar9YWKnz2FT1hV7RrGoqFGH6lL62wUelyIa6kQFdBUw31gEXfs3TLyQKL_ZTSh7bXNZS79sfdrh5FekYQS9UpXpnMRq4Gfe_GMdk6Qn7aVRddFrVJviHnTE0cSyEZb8aU3MfTPVVmj5aIDN7TuNG8lI7OzAbZXU-rJpHygn7_6upjNkWJzO88Y2eNuidcrrwtnLL7aGOQsSHIcjIGF3JgB2XJciVmSfxnTSvTtV-ZZRoXwp3mYZsqY3JrfUn-URIo7QTrLO1vlIBCMgxoJeIeDGG4_0wq-YG4202WKVB45US_fgGGhLDcfpsdHm5eZU_pNKYl-uCN89pb1vDrEA", + "skS": "-XNPEJoCD0SXNM9Z1Ek_Ah3JXU94GgMiqsGZ9VlhtD_kAdF67qjgAqtaOSUMDFnsYqIrfi6KACV4hnd82xS-9eniQETgp6ws5_-nsj1FHN8IWQMhm1VBcY1xBkejH4s02jxt8376jgka4Uqf0q_QlX_Skzpg9TfQT_FZD1FSGF0QMYGBgYOHU3UWU3NycoN4SIaDKBBCd2QxVAZWggV4coc1RCESNXhEFGF3JWcUURdYMkchhGc4RniHA2IUJmB3YGMINgRIcxgzgYAmYoQTB4MgNzcGAAJVF4BIAFhXVwUTR4YEYXIQVleGdFNDJndVeFdzZDcBZQAWZ4NGJAIAdgiEQGKEEjdQV4OHhCSEEhBndDNFNzeBhSQVMTUGUycFh2QHdwEUIhRlABB1M4UXIgEYeGUhVRVCZlR0ISiIMBNhERIHECYkVydYMIYoYgdWRnaIJGUYJTdlWBIIAggjR3SGhXZyAFZTJgiGVWgCNIhjAmgGBSByUihIUhhidEdQAYgDVoUYgmBgJIcHh1eFcQZ1cEQVEEAQVwBUA0hkYRNVRgUTU3hDYIUoFCdhYBIBQ3NlFUdih2Z2EVZjUTNXU0hQc3BQMlOCY4hwAEczR3ZwOFBXhgQXNINXg2EYgURHIkgCWAZxRGWDESYjV1QRImMxU2QDZSSFZ3NkEVUTBXNCg1gGZRQFdjI2QhUVZTQTVlh2ZDM2g4MIVCCIGFgkAnBkV1FjhYAkV3ATdDYGElSDYRR3NYUBCBdhMWaBJoJydSJDZSMWMIhDVnMhN3IWFlKDaEMnMoEUYHJ3dEM4BnY2c3UBMGiER3AWJVUIZ3IARmEYcXERAYQFg3B0FyI2c0RxMoU3MlWBNDZWIwBnQYYxcCV4RQVoFYBAKGhQIyYWFoZYIyAXJUhwg4N4JAVFaHgnRzVzNXNTEIZWJBU4JIBIZBYDMzd1FkOCMQAAYIiAMDdhBmR4dBGEI0RUGDE0KAcAQTZxUAhwcDFlIggnIBFRQyAVcjYQc4EUZ2RkMnJAJ3hjCHQIY3dRVoODB1MYQGMYJYMHdUY3REZYhHE4MoFUgDNESIclhVg4dFdQJDZjJjAAIWMnOCA3AWRhQShAhkUXMiZGIUJQJhRjI4VxQIUGdhEGZxYAYhYRhkRBEYQVF2dmMQNVJHUYZGgDQ2EkKGJEI3ZQI4M3YFJHKFJ0hHYwhlcoZ1goM4JXcAFIFCRFhwRWRUMkcVZjYXeDNASIIxRIeDchgXQ2MBdEcSYIU1VodCdGRgcRdxOCOIBoQSGAZIh0RQdgNyZUIkAGF2AmY3QTdxKDJIQxKBEHQ0dFZ3UkEkIiZmZRh1gIRXIYAhdVFzEjICECQmgxN1hlUgQGBBVYARBYAXIxZnE2gocTJBYoB3I4BRUzVFMDVSMRNiNIVwOHWFBBdwYEIGh4hzZiFnVHNYhyJIUSQFgDUkhXBVhBIDYQFSNyc3h0CHiAc1dGNmZxCGV4EQYTCHYUWEAiJDAhAXcVM4USEEIYQ1AFYkIVaIUmFUJzdDMzBRN4NRiHgjc4JjYgYjIRFWQnQyg2czZAYIZoBYJSiAODMwJxECRxYTVoVINyYFdYIkBDdCBkNCYgZTcUcxFoJFhwVHU4MjYIY0VlYlE3VocREodUYUgTYHYmdDVYI0ZxNHEjOIYiQiIyZmFSZDMnNiIVeEIhZ2ZVgQQhhVKIcUImUjKIhRQEdUcCMYcwRGJ3AhAzZUVidQZUaHFDAgWCIHYoFkY1gkUVFkcCQXFXiFUkQVMBMzIVWHNxBIUmATUmJgFRgDZoJgdoRWUVgREIMoBhMVEoEociAlaEg3iDFRMwV3A2NQWBASR4hWQxQGEEhBRkdSVURUNCN4ZBQUeHcBBBYxUWQHUwcFJDFIEWcoByhIMlIWM4FYACFGd3VRIYdINgAQRFZEIDUCQTCGhnIxRXBkcVZIESZgRWYjZUVRRYeFN4BEdDVgh3B4YTMkIwF4AHSGIiJTA0VRQQNoU3gBhCRxgXRxFRAHJ1RIEAZRNlBDIXYHFTJlZnZQQIQTAgdzQRNwYXdgEa7MwV9I0hxYbZm1dij9JjeuVueMp9v3iXXJLFSeeNeOEgtx__f5jXsq1bW7PIMuvslJmvvQtJLgB434F_Gmdx7z3zee30OzRQWa257gETLsDtgFwux8KNV43FcDVudsf7jh9C9102AlmOCRmbqE3NR92yGY-PkpHb1zpsK_CKpThiStJtpqGHfrxuF9WWrL_HO7QQhOHIawt-u8_KFDX-x1uTR8PH75PARzDAfvGp8UZOw8pdocNXXYdPrVan4AzRfdKTUMW-XjkKd-TrEhkq1QGkIBosojve_BhJDdHymDtoT7HQPRdzd-pKPNSl6lP4TwQYqdOqvmcoELhKVtAjxAAQIhuhL4AzU8fEarsOvkQq1oC4ewNI81NbSSZ0A5Z7iJGo-pPn0LsS_clkxjAQBD5ht3_XRc3yMT7I1h-AEGYxa_UfzYnlqDaWh-GQMd0hSRTkVf2SGSIsSp4USPHPZR2SURtpBHp659F6Zm5L5j-ROemWvk0PkhArmY71acQFpFAYK6ddoyxZhIWEJm9LDj68cuStJV2p2RTS7MDrLXN6iVSFmV4cyqoOZXLxGeNfrZ5QGRgTJqpRbaxhBC9LrVCLjT-0aodjRoqONUCfj4ADgz8qjrQo9Nkx_UjX7HWU_fDfh0eS2UjUqnGJHY0ATaeQ-7IE_WtU5ayqdK0_wlh6ZABAkFR-ShaY76KKNmTcdrq2cjp9A45cfUhv0fOkJqOBQBD8jDxS-cD_L6SGGt11zcELxObDADc89r_4Deeq9HsLbT36vHvZrZ5wMeZUPsJUVjkGvVbmUTi5544X6WZu2lX1JVuIdfrunzlYc1hWh7M8MNwSSlbqt38ImXtel1c0X993uMnLcHTISrM6FBsEcfFZ3P7t_GT-iImrLKTNBwahPJMFw0XXyz8CUuJjuVIdl1UBNBqs4NO0gp-qxU0A_anAk-ia6jlk440a2EsnB9T99pYFu1yu5ucYtb_171nK1NErw_KKRQqRl9RLbJN32GTR81lSWAyVmhVwP0_3-fhT9HfSALGvYbBu2ZMX7-PmM9_LossI8c-mfH7OngGhO5M55FfMAYnceKZeDUZxgWbijMmH3O-Bw7VUraYPniJQvBpjaqg2TOD1tph41P1SiHsvJ2q-OY_-r8kf8c8wRCWsgUemf2rbLwuWnrKWufcrgQm_AnQG2peAbyCHXXLi4X6hZRy_DogdTcXIdivgvE_0WwDcdVYEy5S9q2WvsY2uKTHvqiabK-h8B_f_v21HszG5TZDFt38zT0GhBt9eeoywR1TOViKxgh_M7cLg6P0UUUehw4jyt8TqC2u6Q-0l4IA8wO4l8Fe3LqKZBAwvkEY1q01ZCOf9cqLLf0Z3KyuudyFs4zco2AFQw9EnWgI5l17V2kcm5ji3AWpieqiXZj5f9YjR5XDX1NbOYcBJ9Zl7XpvS-Ktb4rlgppHy3kCfX3GkvfxYeatevZNvDtvnsPW9Up3zwVcf8N7KzcrAcUNmePUnP2jlaWBXTx2KytqOeqTb5gE6KTi9hXI2YM2JTT7I3oO1hL5QMDdaALseviir5-Vv0UHmWagUmAQZ6azbvDMhXzmRb4AqIX2Si6dZCrHbwZB0qYVIcQ9piXInOamryzIkOnx7A4w2YKD2cCpmdJuL0p-VrbuaMIOg1HFfD6fYugFV2X4l7-vdcC2JDCp6UWWMdbSJdgQW6IcmETG1lYjB8rbvh_5W45lqHwTxbftQNadIU4ep-uyMgu98-ZSsvI2n36yqrObPHTTw2_micQqATm5ejI_W3dOeTt6xbFgAAn-9i3yTeEFGU1N3BDbo7ndnf5KcxeiZGep6vkX2WSKmaITO0TkQmvMXx3FN7u6ATxOO6go-B79O5p9UMI9cG2Tw21A4sp2HQ8ejUyIm6xp1oMQncOv74VlTwmvgmgQHOqC6EUvnZ5E4VKva8ewNDoSVvcSoCiOD6FF5xd7CKKukkgFkPO53y9l_aXvbtgQ4EL76V5FPziJiAVclvDdc0Xh_pmg8JkssVR2IS-fQqhAtG688UwTOWXqeZjS85uGAabOQsvjzy1_Wx4dC7bV6NWHko8VN8-CeTlpdc0iLyHwQ_V2lgTlysCtenAvDQGGAZ2kZ2LWeWVJfg1zFCkKnZ0El5ismIzlloVoPXs_u0q5krVw1tmr3TUdu9kVj69wwvggHe28m9ceQMfoZW42ltQYRDf7Io7pHufW27mCpJdGHmpFUmj3OFm6eJVt2ybHxNWBIybAEQsoDCT853y-Jd-Z2qq-uA1iJ95jtweT38FeUbRq2kLFXN1iKwLoPqSuuUKfudWhzs-2Spb1gYJHlxQ_HqkNfM8I0qtRj3gJY5lOKLZB3pFFgDDWgHH0ALPxgiEI6FbcIMvPcaEJzMBJww1eZ5tssn130LcN0ImDOpG9SHY9WFXocuLYhcCwdpSE91uibnwoVEbJ4oyZNRhn7E5yAPZ3UyA-aSqj4ZhN2MdDdDPFMTIwBxzn6-8FJNYZPo-zD0hL0dcFEBaOTVQpKi_Bm2VQpaNoLva5BVL1g40M5Xkj_R6PL1JMAAjIn5y6gdXu104uWriMmv0x9EUHOb_LlOrXXHe-MPyjoe3jOIZSBKQD7zAtF_qBQFSdpmz63BiIBuG2PK1kjZ117tRSPzIZrgxiY4l-1fqtY1jzBIZzG_kykNC0eLPMB-NuMdQSaUPLvMA0k4PpYWMAzqCVU2JwPha7TTVHGbn77qZSfx4KnOdVQFdj9rQ9U2idFmZY7jMAQnVYHefGJTDghCyTJBRrXyOQS-IVkDbr8af1ZtHKGf2gWYQ3lZS-IBLc3YSb6qlTc-nYOjB6j1k7xj_1BMgv8_P1ZXQKkQg4GyEi-FTG_DfTlo0V0kASIcztDp84tG_iswh9ov_OmJ9MWGq7JH2NPiLZMHPWlKNRuoXcwZJq-ftydxxOZTWO4R41YMlI5n__osufkfEgqwJwZkCGuiTti0qRhPw-eQ_WkOwEVZ5FaK1w1vWmwGnuHw79JA7aCUIY-e4WUsp3oEjvl5jIgmsGfE_TzeawF_OeGZw4P5Ek0NXfVBYDQHKMPoYH58SZ3Jyj0UXNP1ApMPUZFgbxENfpneAwzqnlWXUFYu4dfFs4FLX9zyBDRsdS73qAZZpIe8_vtMdnHyRf0NhFxHXZrWInReuoDbp490SeAPI2lwD19AZSNLEHGfkQ90IJBtHMQYaTw3XBaZjfGvNH8nB8BOGWqnNgeIkjj84oiQGZmfXYOLx8VtNz3-_XrOWwHz47IP67FRHwCPKgPcnty9UL2B6eLpnV_9Dn487pwESVXJW1pUZSOk0C47xuc", + "encKeyType": "MLKEM768-X25519", + "pkE": "LDlJhENyVToDWclSyYcaamqm-AMGyZfM04FVB7Gk4UISujKxtGTNJ9IL0EiEy7iGMjai_hFlTfZR-WkJZ5Fyc3SSlndE-MJK5CIpM9sT8aeKetJ60nKzpoMI_xIYSQC_eWlwCgW9qzs019oOUebC6KOJMNhitrRY8QFFOds_TlouG2NxKgLPK1Y5XnaIrZGC9SWEVJW-7WMj-0iWrSEGibxn0RYRR_o8crt0mDMZvZAgGgGaQuRfUueUDtB4HNfBkMgoN3tPhvxwytRXqIQlB-opY5dgk9lgVLpEGXdN5UGkZZJHN-nOU2eD1nMqFHIW5hd6gEUyUwTDRdiX9ppPuwFdCBFbVJE6Gip7qWpK2MZVVJIXt0iRyfB00apPFsQ3ffdE74FQnCIDILhR-OI0zmbA2fNOEviXugeJZdTEyRViJqh8CtsFnjEwASxLxTQQ2kROE0AHwMAbK3Rq-aGciRmjkmFjcIFInAyAPSJxs4B924qV3QEjAeCvPdg7XiWcelC4MymZTsDPoJYMSAFS9vMdL6ksIeGaNUM_07rDh1Bu7FRiCYCO6ncP1Rc1jsYdTftq55uor0OFdoXH0DIuwOdVg5BnixWU4ctSbgM7hORDH5M2AERFFPe4exgcwgKi3BFUicySVrsRrjrAL-JP5GY-n-Km9tByCQRH0xCDDLIVa0pjzYGCi9cdnLkwjJIdy_RhUieIDraW78nAC1kcliWqEPJxVfx3LhyX8ILDocXCTKERjCUAtZFa0sclFvwB__p0Sdu9qZNWyCUj_9g5efxRG4aHO-EVHap608m6J4SZuQLPbBmzDhx_ixdvrIZjXSWNjDVqpPl_J6EdxCKZUWcBfAxOPNqvSHAnsxa5fuFbMLgbcIKXgmPNDRgb5yJJNpBC9ElxPANAQzgSxFJ7mFO6X3cYqPqaAqMDWWgrx9FBdxZjzhKkHKVIRthZM7mUprZsLLCrXeim0Uh-MMwhc9QafKg5I_m7YWYiEZrBFSxyErFg43NpKvQE0CQzBpG3ATYD-eAXfKKmj2wG_9SU3GQrXMTISNRRXDSHFUmOivV-A8QbYFkLZlGZe3EX2EOU4xl_u3WrB2PIn8gIlcCkhSJ7t_d0YUFJQRxw8vWDsLqD39usXJdhLCoI-CYi1Ay3HaKwFahJRuoybPa6tnKx_spngaHGIwOayalmyalRIssG3ll3k3m5NZhQ6nwsSKKj-EnCEAklUnltTPBqSZlmZYU93XKClQdlWxOxFPUetpdgxftsGxxv2QnA3YhkyseyBOBaY3rJagFLooA2a4i4-hNt5KRZveDG4jGqiSQxvjNDDIHBHWMftDonTnFTgbQDIzHMYfVvTWnDDna7pOEJWzK8FJSgBaW88tU8X5sGlYNOxPSD9ZeGZGJzxRAlwjfCjVQ64-aCPjUJWnhFtOKxmscQKqISe1ui_CpTnlauL0Gvh0pHlNzFPxPF6jHLVZE3DPU78FNnRXwVi_COqVM9_ruemamWHZshksI7owuMVYolTmud7uaHENqLAbodwGAvSuQdbRAN18ZMSFKCAwtBcV6TArgCjxMta2YeSPbOuy7Q8p1TRYfghQ3pfur2LLUQb8yYRQDSIsP_hb-ON7AMWQ", + "skE": "U18eGAUfzboX6GC7iBsTD4kOqA9pmCX0DH36kQBZYAA" + }, + "pq_bob": { + "id": "did:peer:4zQmZsKmffnwYVbGLb6ZmZ8gkgyTs8D76YQRBt5YnFNK4Sno", + "longForm": "did:peer:4zQmZsKmffnwYVbGLb6ZmZ8gkgyTs8D76YQRBt5YnFNK4Sno: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", + "sigKeyType": "MlDsa65", + "pkS": "E3fHwPPFvz4VSA_VgU5wZEsUlBUk8TgMt8NVYFf0MyDTsbWUhMbwZgals8GrGYdy-WRRRc3IUudHYAiZonL7XjueeMzM-WV52zZOLVsCEk-G2ZsPGXSfNBGa1B6tVKV-J_ZvgMq2H7_gBcDSeWEKHNbi8B0Pqwq6eVI_plXTkkb2lnyhzhzt839E5_dUBNvCXlvQvqjgdRlaRV8ttcy8n9XrAWiTk87-79lahLCSP7rrTGulnMbv2V2jffRbwyDbAJb5lb1fnh7J9FFKL-LvbmMUN3TTsQvdcSj5UZsnqb7O5FCCKZDvOpBdG6yk9DVgc0Br3E43PNAIkNHafecpvG3jTphxAO2Vwe0W13Vwq-VcAxdY1Gs1aJJSCROY6j8LeJs26t_gBcgYndNQ7-2ExZlK2nUdUAtHex-73NOvWOf9CS5diU9Zp6xDSN4idQyle5LxgPPp0zzFvckKI1pE5r54S4oUf41sxzzayKkUebhLs4gQuCKDnwI3hg9EFaM4z1t3hXg57lbrk9ZlDeYCEEH-597a-BsnOfxzMpiNuj10FMD0u1XLHA7vHm2n1R8QpLfEc4Vsl0gWTUg4zg747Yk3oyO86-sjh6s4lwJWj-OXIxk9kF7Es_k4Sz9Az8BiPPP7MYo4XKaSchqEDhMUxf3TsuCzLHPLJ1Lmk3AjPphJRVa19T6n8BLLbzimeqTD2Q_1Df0j8LUrL364if98TJuXed7Oxr1ZBFayHJ5P9ci0CXe_tcgDag8ZTVmBhldxMIsY92OGvANEyxqad89tco5-11O69TRIJn1I1jSA1TWfCBYPkQh_uBQWe6llkSDIg1wI_2vd1COJCHkYSO7TdgmASEai_ATmp4ZJ0bG9QafDzphcyjRDuTNA1EOFGQVzN0_ffLaRlZRXmvPoDTvuLzpK2hxqkikzg9r8-GunMiXIlB9RIfuABJu-7iEu2LqXjyDOMXz53ghysFjo4ntpZNlliphGA6Z0-RZBW115ZDUMqJ7n328PWm_hjCmiImHYBc3nOR0MrnVHGTKig1d1DonX5-Qkd81aRpN7_mvvJGs7EWijpsXPjrrpPbarOCJxLbVB7idTUFZ8YeJI-p4uZRGQhnOb18XRcs2i6nzHGAkGlt1w0rbf6wLDXlHUiscE1PVXpzeaG95oTX_fEqTB6SASxd3LFmXhAjio1TS82IJmZtiu9XGGaNpxf6EptCY1H0PCriZ0jXMMXcw7c1dytTmzZzmBzdfse8kqZLOEcOjWn8LYYMCqD_S8u9eLlPOIBEjEkm5KCgatx8l03o-qCiAe18YwlW1HUwaVQHlbDOffLjuuOmfwNEEVZT498PejsmiJ-I3WolWYmELmj1gIOBe1dsFYbTnF5WsB8G6Yeu9ICagcBbRbJNPEmSweL6LIEq-W-dazqGrSF_P8YYdCIM1iB3k7VC_oP70_Zet_tdMeOKzNb-C4fcly3WUMVd_DqiyzzANd4JqO_378znOYZF1XWBdh2-VIA1_NTa_I9CBFgJDZepHJnlOPbskq8qc61ZG_WXz7MmdJe1QK8jBFjarJp7q28h4937gjoppnWSbvQNSwnNK3U3QnyT_HGCCepTfLgHU9mNEQ-Y0lBwtLprrOR7x98LfxQrtAQ8XhsKqrHk-k4o5T6QcmAtWcSmGXwRDIGDKT9hjAWI1JtDer_HDw3_GJDpbxUZuhbVniBaDOjSBH6jHUUUJlEnbujSzOdS__CLwehEkbJ8o8CAUcfE1b2D9netchY7w77Pfdg_DcuI22gWWqqcSVshGhG9aJ_RWG87RaxX0OCkKek1JxSbSH38KdrbxqotlLoxJbRgDxAxViEZYUBesBJgomnFgYTpdroOU_lwWv97s9FDoVa-dU1oTDjfrEDdkeZNvZm5ZRF-5gxx4zBytSdh6favFK8xJPCGGrlQcB9HGMDyl5uIgo8BesLnyj-ITsK39RAbDPUP5GGGCxcKNSalO4kwXDtjvPVegOuOdbu8BIyVi43ye-YcOm6b3fuXD3AQ6w-GuVwlJifBtAInauwP6MKXg3jV_Lr8QCb6oIKNpVCL93dJhE_ldoJoVC06NL4MLNYkkQpmLN-Zxc6DBn0z3x0rdJVrIEUQGXurHMW66c7v-uiIGLexvCd4O9lKm_8ZApB6hYet7MPkAXYBOX6SEnpDuQRghZeKuHOlpoi_RBBGlHcy6qN-w4fu_-P5RZk6A9M7XwE4qu4W1gtMdmZOpZgX9T7KY_L39g9VoRpQv1SqfOAn5uX2ZRM5zatWbtbcgr4ZwHQ97BPwIi2KF_w4JhWB4FfzQufjLkxp-WPecCWr9an98mDhwdPJr1-_QSQPjM-ZOl971L1kVT9WJPQc3ErIHQHZwXPvD7wjLt3cmHVRdaspKt9qqxAWBmyZs4zqahyEgS0Z3Ds8DZXwDT_xKQFvSwR3FVfcMtWLs_xCCmIHKkxp8myRpXS9WOBwWD7am6-isL3EVJXlmyS7IKxQlpWWttniwnMpIw80ujyAb9EZpCUkCFu2DpWzEd44B5RMfFrDNO4uZaiyFxsZJyieKv-zTtQs8BI3WR4qdtV2gTQpM7xn53bzpSbBD63emxl6Nb9mQ", + "skS": "E3fHwPPFvz4VSA_VgU5wZEsUlBUk8TgMt8NVYFf0MyCcFajzHL3w3t4tiA8DnPHO_LietFcKL1Rn8dSJriXTTOsJwQ2uNPpvZbnxfxBVfk0x7FhsZIsUCMc-M86km5LsYgBxOAkkR-QtJ7K2LOoiCSPFYTgZzwQD45C7gUlwg7NIhlBiJDFhAVOCVzIXMjZyQnhSYAUBZ3iFOBNSUAMRImYxcHY0RSiERAdCEHgCEBUHg4SEUiAXRmGCSHcDUjYTdWMDcRNDiEVgMohyA3B1hmMgIhV1dgA2YiN0JlRkRWNRBEE1SHMkVBBWQlcRdSRVERdVQRV0SBN1eAJkQYckUYRDEFFYNIdlgBEzFQNlMVGAVgYSOGgmNTIIiCZ3MDVEhncVQjQ2ODVIdoaBF1VoVxIDJCR2EhETBkKHQzZmR4MSFhIhYBIyQVeHNzhWdoIzNkdUModxKFiCMGBUFSZCEhIjMyVlMjMGWIUBQQd1FCiFhFAEdYdHI0gSJBJ1Zoh2ByRBczJFZkg3hDgXMFSAJICHMEgCOFd1V4JEWHdhgBQ3MlJmQkdwRAUkADiCIRImRGV0FUGFcheIIFVTgnFQZmc1NAI0aHU4gDchc3MXeFEUNniGB4FBgzUAZ2EThjNSIRRERSFHcoVHJiBIASiDd0hmgndnWGV1EYdBcAgEM2F4MBgYZCFoUYQVZgBlc2dnVkFRARYDdRFVABUnYSMTRzFRh0MDA3USUIYCNQhwRWVxUkVXF3UUYjAgZzU0dmAQEIOHR4hhOEgxBgMWBDcXUkd1CHMUAHFAVXSFFYF1VmA4hDF3V1B3cgR1FYSAdyhFgWRXSEECETdyNFJFc1WDSEJREBUgMEdEQAGGJDEQVQiBVCJoUAZkMEIXcogVeFEQN4NHNjIneCYEASI3GFIyBXYSBVRVIUZUeIczESVFAiEgUihoUhE0FGBYRVAzhjFngmMDcTM1QlBDZjFkQoKGcIhIR3IQRIMDeFSAGBGIdRJxJXB1UYc4c4ISVFJlBXYQZQOBR1AhEkCAeFJScCQzEmIyNTJHMDAxOGRoI3QkcWYYExNBASGCAEA2VFAxGHJgKDN3gVdiQgJzKBRneEgTFIFXI4VUVjNQJjCHcIdkKHQFdXQBKAYlc2eANGUlBHVoB0QScRJ0YAc3VTBxZ1EEEiWFGAQwNmNUFSUQdiYTJXeGJSQBQWZCCFeFYSgBVGZQUhEXNAN1ExYkdHRjAHUYF2IiMGViBjQTRDF3NQdGU3BDWBd3RSRyYFSDE3F2c4Y0YDgEYXMDJjeHYCURYmgmIQNnSDQwFyZFdgA0AhRhQnIkIUIRSAAQVwUwZ1iCiBJECGRnY0IQQQIGEWAAUCcDaDEWIjZ1hkIHAUiDiDIxYHQHhgZkMyU1iDcBQoCBBVBXB2BGQTYIVmeIaCSINTNTcQFANFETMgKBhShYAoZBZ4WEUxMFJTYRAkYiECIQOCGFYwh4gwiHEjA3EBNGEmVRFTdlWIQIZWYUJARjdxIhM1AzIFQWQWNxaHQWeGFxIWaIYHglElQzNQVIAFIyQyiGaBN0goFDBGcAQIATNjB0V1E4NlQQVxBoZYBAgYgCIDQCNSNyIlYBUCADCHYFFwYzYQIwOHIzNmhVRWA4YDdihDFhZlVVZQgFJGdoiIhVMQQ4ZDNTAggkJAEEKBVwEXSAMWYlRSI1ZTZ2QDVFFkVUcyWDFRUScocxVWBhgIiHhYVwSBMhhwiHhwiIdRIFcVU3h0VRQwKFhGJ4NlGHhDJ1JQIgEiY1GFhxN2AIAEOIcDAFSIJwcmgIYIgXdyNDVXgWBwFhZhF1M2ZFcngAM0aBgReBVkEHh1AjcAiBcBMyeBJCU2JRhWd0UndmiFFwQgRXEIFoJWQXBUZEeAFiQjAjAiJhZGATIyFIJAcYAyIoIYKEQII2MQFkMDQXI0ZSRhhjAUIRRCeGgFOGBGECAmAGJngmQXYTJSN2Y4BhRYdhcjgXKAdWhCIDg4M3CGJRQIRXdBgVEQURFFVmNycCVzJmYhgWE0VSJAVoNnZyhxNWEUcGPwOZJ5PpSe0dmUdmL-i4fNu8uQiOIiTy5QagcmszKyUsQ0wlvrq8XugBfeEBAcTq8C4JxlYHw2gJfKe7xEvIDwN_-0RT9BEZ9ie-Hn4LPnNBD2xPzwGYmflfc4-q2Y1gVPQ6nW16Hyy26dY91s5Pf5v3XWVDlpSKn8pAf7iKKle5o-Vasmtsi_ySIETw6N4Iw-OsWb48vwQdQcQEL09vUUxkGoodnfoA751RtQ7aSkyZix3IOlac08Iw3uMvIyK9oKp1xpL3Z8wN0GJCAKiV-te8p1_B3HgQgMXdm452_wcGehu2ZdYkyWEkgZzql8NALm8qDsn8XElFKhdtMiCTpRLkgk_pq_oQFElY-FolMaLbQq-tEjF9vN0EbqFzg0G30-WbFrEj75kUZoHvslCyn2QVDgCT66AlIEIc8TWIm6L0V3p2pEnSGeR6k27sUT7IDkRl-EmlcHh0KQsnWpZX8cYw--ZOX5iM3RpqMvlyAONuE3A4cmBZBovL2Dh6urXfrf48Er0TTv6k80L52M4tX-s31aq88ExWDSs0BO6ZW-MwI21LcHvE4dlwiKFcWCJrjNl5M8nSe2D57xrCSYWXd-QJ6AER0kacN225v5mbZ2aodUFQnn93yB6kd-nPkceEKjMtpEzz_fz7TliJjDbfD34K9p0QixsbudU5BaKy7NKETjTIp68wdygASUJpNZHhak9immFn6mIp_d9jHZKtkKQ2CuXlDgDnG_CwejxC3iZlg_-Ba0hLFDiKi2vPoO0CueToCnCsdRi3MNpF_FKITvH_mxzZHZJ6kFJ9kzbS11R6ZrAEFkZ18OM5z98X1U5xqmkEVKT5_iJx8Pu8fgn-8o8WkbBl96goiY3RKidnEwKRh8ENFfTocH0qbJHDZGfyUVioZxUpaQ8GVNfsUEUmcr8eqW6MNrDu606y54c5Rw4DHywMEvklG6nLd1G3u30QMmmmQ0u1wJG9TY4iFRUK6NavaudfMRaYXw7zHMj_SKzNBNgV5A4wiOvAsdlQoRo5_AKsDkdTH6uKdaKg8EYbjVIzEmghoO9p3sdW6s9UgnePioFPQE_HeI1wE5XB6xYERaLrMHN41qQvLeqjbis6cIojg1gm_PEdM_W5G815b2Xg8R2psr1bAQlRyz5XJq2QkhxPlyS5WkvkGAcU5DqtUOSt_NTbgdqV1eZ79Tvp9_ZbBzSPDq24a7StrHf7FNfooY8J6_egZvP2TYGtwV4rz_QNxI1Dzrh2ltIlZjjLWyPGCulNsf9_YGacEomwK3wERDYMIvOMWyCb9K8cdi8t9KqpySnzfl2ZdMSFGZ_YW6boipY3LvIjCNUtqEdcMAo76aD-UdG-dRN4kaPpIdbtBTKp4HS9qHNyk3Wznpco2zj5gte9J7RrvOlR9OuVwDOm_cfNSuY6URpnkdYbSy3fKyMUQ1tQWujbSd5_fpyDXbsGmdY-zB_zWEhOXScL2lK7KHgJZxJj_G9jjk8Ro2FmAicUK5Vd2MeVMBlke5bIgYjtUgMX4UkobSSyGMAbid6e-ErTAahWeGxoGdC2LqcgJxKGAhEZqvMM0DeUMS4gZmdEA8TXqAGhodTI9xNFecDZoN82pC-QfNHnb7GZJulCgHXLBC-AE2zqx7PY5y0Zcfl6sPrFYBVTnixewwAXes4K0ORO3SO2UFEi0WSTVkMjj4bzZTwlhQ3VV2c1FjznmOCbNJIJ8TWaivkUmYrLCpTWf5muFQ5nMMj32AZ4WP7vmG4kJSiEShPX9QbeFX8_lc6m8XXpd1-9YTGYSRPukYoXUJg9twdx99d84oH7Sth5jSIfSYs2SDNfYQfu0OzaEkRBhn7pZSPtTR0KaGeRQy7zdXXAVp1dA4aMuk4a3OnjvzDcBaE0h9uYOA8YN0rgHzPzMe07f6zUttgxYyW4Yg5dgCknKlt0ocImI-us22r0guisgLuk7efZnPrQl-Jj0pVVituB4g869_9l7_bD4SVynhJoJynGmtBF3yITp_k5LRbvRysW9pRlYFVchWO-PX17ErA7gChnEiTw1IFPMHZiGv5z1_WLq1nk0I1O9hqCmH59t8yOMMbpDMA8eCvDMY-mdg2f_rk5RRhrUCWSps0L4S2OCLS3Cy5ZS4732itmBmvnRWCIOk9rYQKGjN67xLBqAfl7Q1y0jx_7c_kHMV5xbLE6TEXux6--LyZHWKEF3OCH81cthgcyt0yG1UaTp3NEI_v5aWuwUH4l4UsXCcJAwiL9Ed577GNI1YbC6pwPOPcn_8hPO-BdwMW7sAdPTgeB7lKUTT4unvJT_Ie76CmqhathL67pDD-rFQBUMYjcGISyorczAdb2dKN7-MyvGuDpb9ZfMM283gRNpcPEgYGzTtarIEEVyv9diHaIFKjd5nD4ixAqJKBYv8CUOUpB2tCpXyM_kss_HdO8ovToK3QPuGilavKSOgZH1gT4IQAgECb7dMDJ0AwtHVlmme9Orvs4Ksf8tgmT3R9mKWaGFRqglAuTSyOVELXaPVCpGPBn3mmpjSbZLMWFwS8Q5K3imihRMwgUtoqvurrUy6FwnbKOwaPszKo6hmH_9OhY7HXD57Iefzlpd9nlc8seIcrPBY9mBlFOmqIYZj31hJU3Essq6RtzU4qaTREaZVHMTP53ZJzG-bVpPMjpv4XrBPgxfaWyX7mmhJXZlLTseZAm5bXyJ_SntA4sLzxXWIzExaaef8lN0aqsoLd-lA4mw9W4g911Qin1oWmuXIjr34HoOvGUek53jf6unxmkwc7epNWdxxBhjWmKZzHDglaJjB960hECqDXlKUHGXnhcAdV8d7AF-hg6afLXc_hfi1n5ZLDArmTK29Q6VEz3Jtsq6U6GdGDlPVvG_aZqCUVbFuP9Tj-dROMEXe54Iz3oF5OfYQYoicWTynEPXHvmv48EJq-vtY3tMFutsKNKKSGkv8POR63-zFUlmbNueDjRxhRuZ5VYpK5QCRKWi27D3J0iPP5hD1KznMs5UAm7OzqssAB_x_VDzhaWpDhsVL4xVuNi4Sr0am_deFivikUpoKUZMoKtVH9QuzZ88a4BGneW2Xe3HonCIo-Gjdebo6W6vbKafRVAO9OGKziRhDki4XZhEpcYksCOQQgl7K6dHus-_tsfPVqn0asVptnnE-Jz0_WC23H5rpMfJHFuLbbNEn9-bcWdR3nhgRlIF4RrwLVaf4qzR37Z0GFPX0AJrkr4LPS_B8G9u0Ci1A97y6OfTH8BDknSom7dapD3px8_dzvS-vvJRt0fTMfZ9TauM_DtaDbJ9uO8ahZPIKoFsRte2NtzEVaF0jCPq252OcFRv-fi8HaljZkc", + "encKeyType": "MLKEM768-X25519", + "pkE": "CjuwTpSwD7NFkpXPXZdGLPQB4zwvnVYVKXGYnGljHQk0ojR4nLMQFtuS0ncgAiyXUoaHgGAVFIdCnBeLVIuVdlEf0Jy7xXzORRU6cVmq9rF49OtY4YFb1dor56oe5NJhmvWMbGIMK_cZPGGbYAZYe7A_JjKMUjB8eHkDcMql4bal8ialj8EZRNsdlkI7wPlGw1ocvDNgC9sdgaVU3_HLbqRy3cGAjtLGU5keIBFZBRZ3h3WJDiqFtIgyNMHCRFBwV9ULsNey5XiJKItjJFilF8JUCUapMfJwKZg41utgl2pemzBiHUnHcQNyR2A9ljpGc6SAjJpNVmp4LrQXAaEhoeMb1VqK6XMt4cRVlmCiMfWNsyyY5WZDArYMs_aSN5TPDQJWuxUTSHQG-wxD7asfzXSPZTXPdsR7BRIlahKfazuSZGbObICOcsu02mldPsGts5VmDTsNfvFQycjGjMGTfhcdg6OzfFNO0iaFgNxM-xkEdUaYKZOf6TVWxurKOUWExDqDkZavQigMr-imb2dWltsr-nicngYSQkt-ZGx6Z4kbuIVx4nuH9ValTBM03bvIaMEjc1U0aUODWFzGtmVN0gdqxUYwyPS6-xipzGt3LYY4FoCix8IWCTthIxNCK0ys1MtCFSF_BiPLwvbL7koFzQcbpuq79uOgm8eOJzGTiQEmIDS-CPC3izKlvepQbLLPelYoDyUPyKilHzq8NsiunORnD-N0Vpdq70kT4_OysbyKJrxE95i4valbYxoHmLmovtgdudfIebGVjyJbuYRuZWQwFpq6oHNJrOgugwl9IeKwM8U_8FrP8xeLz1dx_8FwSXhcy4VF4agzpeFLlBSCqMwr6Zmg9DIIpZoFfrPEMBkeZ0An0wKGfsWkOlK3BzCgDiBdCwKA7aJgMTpEp7k-apPNYZEnBmiTjnmkPHQuLJPBJHN17qUH15qjWTG7LWGeTKazMHkeh1JkKEC_hlqWDOVht_Bff-Wmw2IWaIBGR0wOkKoepFBJSgdtEVSo2ClCgoaAZayZneGQXDOUpCAz1fKhx6DAMgUs9zyLOfEHD4xh78WDjlGnrDE_swtkYxQQB4NNV-OW97JicwleFvPK40p3ajNKdCJdUExDRXIdjhmDgLML9KS0JOkDBWdacnFqT1dbZaO3r6eYi3UJ32IxukJSCjVnNMhn-5LAyLwjbwwdDoitSRlF9aUE8VcVE9CmGSpP8nrN7ykLpEeSEsE0b2IURvaVvgg_WTQoJ0pamDrAWcidG5ZpMTNOIidpqxawNjiJm9dJ1pF0Xqc7SdSR9RqPhewCB_yK9consyRuznlH_VAQqSEovJSZQxOHtEq4mGhhHdcOk8GZJgOwtuhMm3CPBoOfaUxTeFV3OQwPeAq3GrwSJ2AYd9gLz5IGrGY0hIp2FEg1jOYYuJmxg5A6xCuZG6VhWUKnWbya47kO7seKGdmN8UAZ3bOjw4qQyQMJE8JsgyM5nfwVqLrBF1aGZJJ_5XYok0w2ErgTNLohapmmDGha7EpRBsBiaOLMgnqwXM_m7jARCM0X6ccg8WDwuDxZndFwXZzkIc5zs0HY4usCp2THOwDodeS0VXLTpwYfo7zyF68l8XetCT-GxlxSEw", + "skE": "CDZNS-gKPeJMsblGZC33c-6rrv-JsuuVfitTnkg6wnw" + } + }, + "vectors": { + "direct-sealed-box": { + "sender": "alice", + "receiver": "bob", + "skEm": "EgVuWV1WsPbu8JDwzSWiCUkkjCeQUl0PkwIY_wtN3RA", + "pkEm": "wfprioKR4GMkMcd24cr7B7sM1_iy88nTHbvkED2YVys", + "message": "-EBYYTSP-AAC4BATZGlkOnBlZXI6NHpRbVVMNjFOYzFGN2lvaUt4SE5xd25KWFg0c3JoRnNLS1BvNlRyQ21oTTNkZnBx4BATZGlkOnBlZXI6NHpRbVptQ0FzRzdqMWV3VGpYanRkZHd1amlrMzNDRTJjTWJZU1BhZ3BNaVludDFB4CAtwfprioKR4GMkMcd24cr7B7sM1_iy88nTHbvkED2YVysWC_6GFqzGqxjBQhW05P2nlttvJtG8bu8WM2kZJiLlOeT7b2Wg0URDRYhOFeb4EtB8XpEUW6KqjqW_zwj79vMTAdrf8vq4u27MbDCQpI8qzeiGLN8B-tak3SI1vKAADx68VHVg5TQM-CAX-KAWBADatiGf0keq_Yh5pDYzDpKoicsevHubnS9RNd5rRX8Sn21puLukElsJxroPtCRXFYTcwkJqazpoWpCZAv95sW4M", + "payload": "-ZAcXSCS4BATZGlkOnBlZXI6NHpRbVVMNjFOYzFGN2lvaUt4SE5xd25KWFg0c3JoRnNLS1BvNlRyQ21oTTNkZnBx4BAA-AAF5BAEAGhlbGxvIHdvcmxk" + }, + "direct-hpke-base": { + "sender": "alice", + "receiver": "bob", + "ikmE": "zmfPZS31ZecVid-0CvG0x7YPwxkwy_8TLqggDcwUDJg", + "pkEm": "1T4oA1pSbehBiIwnoXFGA24kgHowT34VdE95wF9qjSs", + "message": "-EBFYTSP-AAC4BATZGlkOnBlZXI6NHpRbVVMNjFOYzFGN2lvaUt4SE5xd25KWFg0c3JoRnNLS1BvNlRyQ21oTTNkZnBx4BATZGlkOnBlZXI6NHpRbVptQ0FzRzdqMWV3VGpYanRkZHd1amlrMzNDRTJjTWJZU1BhZ3BNaVludDFB4FAa1T4oA1pSbehBiIwnoXFGA24kgHowT34VdE95wF9qjStBsok4fIkbu8IKODF2nsZMUAmS5BqxDbbYrvl_TNFj6rHuLymWkvlrkt54-cOq-CAX-KAWBADw48mquyWI0O40nsbcI7jRugSJko0JMZR-4S07YFJq3Mzy6jvAkEsDGAecW9jCUVDdwZScckWUaAzSSGbNunMN", + "payload": "-ZAJXSCS4BAA4BAA-AAF5BAEAGhlbGxvIHdvcmxk" + }, + "direct-signed-only": { + "sender": "alice", + "receiver": "bob", + "message": "-EA3YTSP-AAC4BATZGlkOnBlZXI6NHpRbVVMNjFOYzFGN2lvaUt4SE5xd25KWFg0c3JoRnNLS1BvNlRyQ21oTTNkZnBx4BATZGlkOnBlZXI6NHpRbVptQ0FzRzdqMWV3VGpYanRkZHd1amlrMzNDRTJjTWJZU1BhZ3BNaVludDFB-ZAMXSCS4BAA4BAA-AAI5BAHAHB1YmxpYyBhbm5vdW5jZW1lbnQh-CAX-KAWBACLj6MsdBES31rSLHtwvsl399JiRr8guUU--hauBWZ9zftvcY7LWzfK5JyEOcqb2l1F5c32MNtpDsQjmQgn1m8F", + "payload": "-ZAMXSCS4BAA4BAA-AAI5BAHAHB1YmxpYyBhbm5vdW5jZW1lbnQh" + }, + "control-rfi-direct": { + "sender": "alice", + "receiver": "bob", + "ikmE": "ew2j30RF7AyIl6ompEoE-0acQb8yg_Iyk303CTx8HJ8", + "pkEm": "U9I24IIKCz-mPGP1IZcfMZohp_eBlv-BBlfLm7vbUWk", + "message": "-EBSYTSP-AAC4BATZGlkOnBlZXI6NHpRbVVMNjFOYzFGN2lvaUt4SE5xd25KWFg0c3JoRnNLS1BvNlRyQ21oTTNkZnBx4BATZGlkOnBlZXI6NHpRbVptQ0FzRzdqMWV3VGpYanRkZHd1amlrMzNDRTJjTWJZU1BhZ3BNaVludDFB4FAnU9I24IIKCz-mPGP1IZcfMZohp_eBlv-BBlfLm7vbUWlcL29m6RzNgGaA6llzgwarcJ_sziA26tvIdjNKso7y8wD8jzERbXsSQ_PA0oxKauyhQSKfn8OxuivWKEaP0_r0j9Jfxnh2oanEUeLFG7wJ97xLQfnY-CAX-KAWBACNFhexhFZuxtMYcR0SnzKbkofePpeJJlPsgTyHPUT2YcG2GR5ELoNtquUeu5ltysJtqfDBwXgi2UvPhsSOJrkJ", + "payload": "-ZAWXRFI4BAAIG6HKhYGieW7r7cADGj6gJ0aMB0rNFf6IyDgK_u9jFE60AARERERERERERERERERERER-JAA-JAA4BAA" + }, + "control-rfa-direct": { + "sender": "bob", + "receiver": "alice", + "ikmE": "vU7UpVjzd8nDZDHJ1NKaDdwEzUDHI7PRmK1Udx160J4", + "pkEm": "qVZSTmoBq-jrBX8d1pa40Yk5J28FT14vIHOh8-GN6VU", + "message": "-EBVYTSP-AAC4BATZGlkOnBlZXI6NHpRbVptQ0FzRzdqMWV3VGpYanRkZHd1amlrMzNDRTJjTWJZU1BhZ3BNaVludDFB4BATZGlkOnBlZXI6NHpRbVVMNjFOYzFGN2lvaUt4SE5xd25KWFg0c3JoRnNLS1BvNlRyQ21oTTNkZnBx4FAqqVZSTmoBq-jrBX8d1pa40Yk5J28FT14vIHOh8-GN6VXzL1VMQNnLb62gdEZb0jn-MrOyxuuFV3ljbqcDOSS01nS9XMYTzOI8JqthJqS9-0Y7fa3YLQvlZmF2VPPfUxxkzmGMFsguP9GFj-Wy2_jbXRvbLGRyd9n5Iu0x6fZY-CAX-KAWBADOiTPO66uSeX7v1x8LVcVGkiJvoRaLemx6g7torYTWAC2fXuVzBxTpBBWUxqHq-feLHLlPF04nQtVvKjwBY88D", + "payload": "-ZAZXRFA4BAAIG6HKhYGieW7r7cADGj6gJ0aMB0rNFf6IyDgK_u9jFE6IFVD0MQtgrqunFx5ALtyRt4RXR8R4umLVKETH2iu5Z4h4BAA" + }, + "control-rfd": { + "sender": "alice", + "receiver": "bob", + "ikmE": "16WX82PmX4bBjabLDE16md_SNXRJfNa0xPn5_fp6GVE", + "pkEm": "OLoHxJkegv3Q68EgUa1HPL_ELnoSkq5XE30aiOOC604", + "message": "-EBKYTSP-AAC4BATZGlkOnBlZXI6NHpRbVVMNjFOYzFGN2lvaUt4SE5xd25KWFg0c3JoRnNLS1BvNlRyQ21oTTNkZnBx4BATZGlkOnBlZXI6NHpRbVptQ0FzRzdqMWV3VGpYanRkZHd1amlrMzNDRTJjTWJZU1BhZ3BNaVludDFB4FAfOLoHxJkegv3Q68EgUa1HPL_ELnoSkq5XE30aiOOC606B3B1YEcvuD4SDhkXCqlpn1_EM92pZdHHBbSC9yCTGuTFQpXadBZ21K0ReTbkUqjyX4lDkzJyHnFRHC20C-CAX-KAWBAAP0xHZu9aQ3ipUAO_IlZ25t-YZycYCnzDoep-n707EY-MQNqkRqA00M3KvMwBAV_iV9_jzDJ8TXrKiirrrahwK", + "payload": "-ZAOXRFD4BAAIG6HKhYGieW7r7cADGj6gJ0aMB0rNFf6IyDgK_u9jFE64BAA" + }, + "control-rfi-sealed-box": { + "sender": "alice", + "receiver": "bob", + "skEm": "Z4ALsIFbWmoNYPFkP6mR5CYbOlwk3Oa07reQ9j9BbYo", + "pkEm": "DvRIPYQKw_paPzePKdSbkGiRcRNmjYHhx0gYr0S543I", + "message": "-EBlYTSP-AAC4BATZGlkOnBlZXI6NHpRbVVMNjFOYzFGN2lvaUt4SE5xd25KWFg0c3JoRnNLS1BvNlRyQ21oTTNkZnBx4BATZGlkOnBlZXI6NHpRbVptQ0FzRzdqMWV3VGpYanRkZHd1amlrMzNDRTJjTWJZU1BhZ3BNaVludDFB4CA6DvRIPYQKw_paPzePKdSbkGiRcRNmjYHhx0gYr0S543LPuthiS9TykYKBa5GgjpzVaYNucbfQLrI9Q_TfieiYffgOvSAPhO5cAisip55K2b2AbuPDV4Zk5EnAMy7-470OWR2mI3ouzQl4meT26JPSyg_rg5HmXmMJWnalpAWn06Gal8YMCDRbZ0255gZ9Oo_cZ8lsjAt2L_c9YW-tI0x9gtg3b53hlA0RJj1GWRop-CAX-KAWBACZ_SWKSrGs0IJizGHSPHgDFXfwoB4xQFHecjUVbYA8d8jPfVge-5Lj3FW2E9gY1xx4to73eQgj7fc_jiCVuNAA", + "payload": "-ZApXRFI4BATZGlkOnBlZXI6NHpRbVVMNjFOYzFGN2lvaUt4SE5xd25KWFg0c3JoRnNLS1BvNlRyQ21oTTNkZnBxFCAm2-rAs9Ae4dJYRGoAzEhQMvYDNDqdqfjZW7CD8cjB0AARERERERERERERERERERER-JAA-JAA4BAA" + }, + "nested-direct": { + "sender": "alice", + "receiver": "bob", + "ikmE": "wTSnRd6U5rWz-3yO0ngUXXg_6V5WgUjGbGLk0LrLj0g", + "pkEm": "2lwtszl6nTuyUHQPcfgHqTOLcllbgMpL5_tD3myO5ko", + "message": "-ECeYTSP-AAC4BATZGlkOnBlZXI6NHpRbVVMNjFOYzFGN2lvaUt4SE5xd25KWFg0c3JoRnNLS1BvNlRyQ21oTTNkZnBx4BATZGlkOnBlZXI6NHpRbVptQ0FzRzdqMWV3VGpYanRkZHd1amlrMzNDRTJjTWJZU1BhZ3BNaVludDFB4FBz2lwtszl6nTuyUHQPcfgHqTOLcllbgMpL5_tD3myO5kqpyGOhlgwX9CKraR5Zqsd5gQo7pmzcqpslHmtpSe0VUGRKNk7lkfZ20A6iBtCP1VqpCenjGyAJ53mdytJaBU4yQlfk6_h_qkKkr8jSQoN13rtqJFnFKtSekDzdd8MtUk82fdLHrNnmk8228jNhzbL2mewQhT_V3x1-FikjdZELf5LksMYsRVdiTbIqnVk0Co_EAAO_fNEtKS-g8X2RFjJ4RHAqC-HmTC9rQg-YRasfnB2d9HuK9nLecMZspuUHPEV7Tw6UDpjSHAqwyxLkQ7LxxmW98egYKdSiaRxh6Js6BBoX_MBnjRKhY6-kPFnkWJgUA2NhQyzZ4QpYOZwiRT6qkzqB1SWU_hgQ_WLLxr1bMoaVuflEN9Dv2tKRmXJx1LMtWUkeP6Z_e5y0Cnl3d0m10M-GJnvVBBtp-CAX-KAWBAAf8fKbSjXyszjAzBQMtY580wZ_F-rhMqGMYbEGBs4te68JmWebtqW0r4cO_jyf4EQxMSV2wDmc00e0XoUo1N4M", + "payload": "-ZBiXHOP4BAA-JAA4BAA-EBFYTSP-AAC4BATZGlkOnBlZXI6NHpRbWVoc2pNdVBrUGp6ZzdXRjJ0SDVYMVNHdUZNeGFjSkI5UkVUTEZ3amdNRHFY4BATZGlkOnBlZXI6NHpRbVNNdzQxM2tlS2hncFRwWW0xcThqem1wcU5Zd2RkVkdQdWJrSE5ncDNxUWVp4FAa18xMvIf8fW5FF3cOHHKPlmJY7CNOU4gMdUw06e03UW_1PVt1O92_IkwQWqsgqb--yVFkjJd6aYMUklWFZRfxcW8kYJv_yPb6pVojLsUg-CAX-KAWBACNI0H7OMKnaJi-UyGHZNAp6gG7SlaA8JKfZxONWL5MWABhfZ2Jh9BUUC2Twe8ziNrziMbT23NJ4WeK45mWqqgJ", + "innerPayload": "-ZAJXSCS4BAA4BAA-AAF5BAEAGhlbGxvIHdvcmxk" + }, + "routed": { + "sender": "alice", + "receiver": "p", + "ikmE": "H_LnY626_NNxiT6uU0Cd6029AGyYDUMOKrr3BlRyk4g", + "pkEm": "u3jQxhX4ryavA7_XLCdatfNVJssymR8KjHMqFhCjl1c", + "message": "-EDGYTSP-AAC4BATZGlkOnBlZXI6NHpRbVVMNjFOYzFGN2lvaUt4SE5xd25KWFg0c3JoRnNLS1BvNlRyQ21oTTNkZnBx4BATZGlkOnBlZXI6NHpRbVh1WXg1cXVOcEFZdnUxc3lhb0hwSld4SGNlQk04UG5BUzFKODRtakVvMTE44FCbu3jQxhX4ryavA7_XLCdatfNVJssymR8KjHMqFhCjl1fHhCX_UWAqrxNVo7bN-T0MGRTxtZbXHohFBpJBtj56fuw7UmON9OGL5WewM8NS0pCOIVlwvkg3zFasindp0QF9RtKf60Y5ZewPVLKJE6zrYGM2B6fCupmO_qBmBJjXuHWh01-rGzvpFO_-Lldld7PmcFCcTrJDwLb39GhO_MQ8anZkjGz3AOAArmiwDhfVo9HNWj-7OnZ5fsKE5hBbf_2ZGN059sGqofoBGzM3KV9eCcZccBL0gmUCp5pqy_VTzsB19HhzED39WmMiFK67ZwFMmME9yBfMk9hRjyiq7UTulmS6E4X-TJSOhoMq7a3jrDKLzLeFyh6v_Jbu5_aYnz_dohJstsSkhwqCSuQxudCHC9pumoAaO4rE9pg8N1_yMg3bHL7i_SwHELoRSb9zMJy_BDGNeNPqYE7UdwnDanzHVqJ3UHzfkt9mwF1MM_QZdKOvgvfBxE9dxEHPR_J5qlizbf5nGw-4-Nbqt0y1YspyqoHUM7RS8CNS49ihzipCXyaTJQKe8iAamp_Ifkpya_Ufh2-9B0rfUcgRguVt6plFzt_cfp7d8p7yHAIaRGlVeVEi-CAX-KAWBADXtOMgFILitn6czr6D3zzczLT8uAfUBn8x0pBiFu92B-gyI5V8KaYiOvtCqiKVplPFx--C4YAVoNb7mnR2PR4P", + "payload": "-ZCKXHOP4BAA-JAo4BATZGlkOnBlZXI6NHpRbVRLYVNSZXhuelgyZWN1MWVZUnVzRk5lV2JyVDVqYmpyM1lqdGZnY2laYzFR4BATZGlkOnBlZXI6NHpRbVNNdzQxM2tlS2hncFRwWW0xcThqem1wcU5Zd2RkVkdQdWJrSE5ncDNxUWVp4BAA-EBFYTSP-AAC4BATZGlkOnBlZXI6NHpRbWVoc2pNdVBrUGp6ZzdXRjJ0SDVYMVNHdUZNeGFjSkI5UkVUTEZ3amdNRHFY4BATZGlkOnBlZXI6NHpRbVNNdzQxM2tlS2hncFRwWW0xcThqem1wcU5Zd2RkVkdQdWJrSE5ncDNxUWVp4FAa_Gblg2mbPRmyh5lhWb0A-hzL-Neyd0LKd1gGhh-ILyOsKH7KHRVUqhRWKUcoR8U9j2EogB7xFwucVDU959QzKK2G4rFZMNrehW6FfO0x-CAX-KAWBABWD3cFkPXspFtG2yV_hfnwBEx7lavatfw4gv5PitdP_HI2KG60AU1lnhuVcTcxVeThYwBJX0fzY60YST347tMJ", + "innerPayload": "-ZAJXSCS4BAA4BAA-AAF5BAEAGhlbGxvIHdvcmxk" + }, + "direct-hpke-base-pq": { + "sender": "pq_alice", + "receiver": "pq_bob", + "message": "-EGwYTSP-AAC4BATZGlkOnBlZXI6NHpRbWJMcXUzd2VadHNuTmVzQnplY0NabTVGQTZ1YUdrZ0NXWVI1V25kcjZHenpk4BATZGlkOnBlZXI6NHpRbVpzS21mZm53WVZiR0xiNlptWjhna2d5VHM4RDc2WVFSQnQ1WW5GTks0U25v5FGFAG_gn35RNoXWKshZoo7uJpjoK4rcKk2jfCWOYB5delcb_W29HoRlEL0dSVeU4hgMITPH3cryDD1lyXErM9gmApU_t3X2VMWTbHMN8i8YqhdzaUnPWWDqRNtFjChapB_euEuUWFXJYvlz7tyudeTDJxbbXFn5s_fzwvfKwPMsvzF0asVGhxKC77xe3cMht126-gHc0xx908WrHrMVUtfLXWQiQmIsnXcbHqPwbHnxDWamgaWPkbwjebDDnJGHwkAg1jBz9_Wo6RYymhGpBGqc2L9IsGfYSzy9PaSb22sg5BtTLO1vf3o_d3KoAks7uHG7RYDQgarMl4pWZUaICod3MK7yZlTR9LOsnXqkn2UXMyNg5Tr0bX6iEIw4bM77HXu2Ic84eF-2DfgXbK2I6DdH-KcH2b2EMmv95kC4WED-jPGoQA2Wd7kBNYlbYouQU4nDmmdVjOVvo2AeD0_aDu3cN2nQyINx34ryTcGjG6sfg6H6FYWT6yET7vpfPpZlx_cs_qq9QC9d5yjZOSzyEmgQE4a0QVGtxdk8a_Bd2O8gGutWbM5fFNLoPEwreECPHIuhuMBXMmIqRS1igha8yN_dvapSle1WrHVy9EADQww4KzR8DHvPVuqbCEKtm5_zA0jYiLtiiCGkfUJnBI5k07acUjSSmWz3idGItf-SmY4latHXAeWF6i18cJZkJjV2MA3s1ufE0xB2QgRMs_zwpDAB94ICwBUcMBuce40rVhamBx4_RPvr-FsX_IUMBOCRf-p_Av3RKqE2KQa83IgWUbgUnvXHsmNOtArVpTD5wtIDB-Pcwq_jvOgyKYupgddkTwo9jY50q4R3u2Nvm47NjurQKJ1jvmVsv-ouSfIYGeUrqWOkbwjfkgenLJHv7ZnXE2bPI-KkkM4n5f2OjCfHuirtQnstKEd4gCbhUSx5BSpjoNLT77emBWb_NjiKdCZ8dugvuu48R9uaAc3PtBQyf1xkyHpUoge7FLHRDC_Kc4mITunie4eC4SjaBpyVwL2PwW-pPr8J1RHOX0KNqxFq_7ZocLPdzAtgaYkLO7kaIBRKH4gGPRsoO4eyk0DrUzIaZBqp4VJRE7EcE48FjtneHHvjPc0eA4v35CWpdPtvEHgeAwsXgfF64Bn_PIMgyMt29Ja-s7Ob4j4TGOBXxxdMUhf67z9nnUARnLej12ARzaz9Wa00fQjfvJQVaxbjeZ9kFDM69vjW997615Oxute1YenKcZqACx9EoghOqj_xwJphXfBIQcy27pYcOPDm5Q1vjLryDv5kyyp0bIni5sIqUQnrJ9InUy2VBtSB0wE_drnoiwQu2_rMb_M0jgnuNZ-N3Y9haW4AMX7a-5uttqK-eyQO0OogmCBr8pLN0ZC7aQz2KbPl_TDxQQgrCn2ra_1VsYn8hl0x_D1FQUeB4y6bA3LnfUuVkYYYoUFQQGy5oCdT-AjqCSqfOoGRjRRBfsOpm67DykdVa1BXIEgfXNPCzfHzPHvAkYdH1aar1fOZINtK9jKJZjz-5RtiE2yvMWqj629LAtbbXg63OW_A6SJ7Y_4N-CRR-KRQ1AAQB4RkRy_co2t-SiuKfXPHlU5es2uIAfVB_WJ8artzk1efysbPS2uf5bdHL7krPJXl1ZV31V-rdxoXv1ESppt27vrwNAd2NnxWkjH9Dnif3SyV8ZY2fxe0X-_hDFVfdst75PK69kAbhCDPaMjczGLVKCFh34fGQVScKIlfsswAuzYPQaTNmt6Zg98lGlWG7oZJ1dXR89NBTYEHk2vJBWS_-qDu28hGUiGpgjK60GNxo0m9JAHvGC_SkURCtRlIydqr1o1sI_ZOIgU43EFYhYrafPrWtx6by6wRSdlRKjKSIDHkuobUsVYruanR6aPdbpjFf31Ez3sFBmn3c1VjqBOl4vBUOBapAVbbj3lnwd6eJC1psRYLgcgqPkLe2sq2i6FhlxDeBX9h9T7qI1hiWmTAwdt3W9CeR00IXIQNE_0OH_THREAv2RE-pBFtg447EwfVpvK1T-3oiPsqrgRq-zoOc5jWqfzY-CLl9lOLE5pO_bAbzzglRNs8oDbfoxSKbhl09GTFbLr5mQ3PGfReVlzL9s-wO4DTYX7zmuVPCEQY5NldKhXHXsPtNrrgedWnpsdIeHsMJ-M_z6gOmhmzctMcf1c8fPluVYmYa6-h5pEIuvmWpkWOVLVZoI7aADx5v1sDXNPokBSo6TY4g8SB2_gcxERbmSp8_BXumw-9RAtwNx0fWkqmJAzZJOLgX-cYOwQyVQvuZv_HhMFtUhhSFEzg6cHQJmbhaTLqVa7iiiJMXU-vPmpro0kf5eKw2nkbrEZ6lnH2-eniGRpycY1PsyRThIkosGwv-9SlMvieR1qMsHxr_p3SfAAvtRupWrFIUZfCECTYx5l9IuV7PEQPxvUiYjAE41NKJ2SX5d_AiX62TYfZDmuRRG5cg7cA6IhD8Uf0sm3_eLErf3dify4uEy7ftvqqQqUHdm-rVaHPp9UdK_7knXvSqXBnfch1ZORlo9e5_nG9KT0zF2Gp6AhcoDYjtXVLVjF_3mmc7SmY6gCNQuqhyrLZLzXOtMmNXM-g_Wg-Wy9KISyfHtOp86HA_mjzGNxOO470A5iQuI-nF_L4kLmhQv1PsVrusUDRNx7dyqBqhIBTetL9uU8a-2dXuBIn-5Qe01XxcnZcmDazq1BPQoeUSgYD30sBAaqEgOZ89nOB7u9nmyB5iirWH9-ejdnx9wkR2wIoB4y9ohwEG3-Gs776uNrTUI73vEHTYrcZNBod432Fnr-iibMsTMa8B_G-EZ4PXIqxzyi7j-SkGK4uVRfsPxhLsC8EvcbtHzyhRczCEhVUFHlikoFUdEoNMze09wq_d8st6OsEobm0WZr6nPlqiJ-gPJWsY0WA7Q_w1Ql5W1M-x0ThkeGxw_RMo_IScHchZ36OodPGm_n0Zjbdh-S_pnlKr8l8YUb6AtIEkOtyhl0ZBHdHTIeJZbSCNNtSaFjKmHwvDdK79CaR4vgN7y2X7aZkVlMwrCmMTydhK_8mvryyhkr6eSQb2A4faz-yzhac09e0abg6_-dXZKr7QWxD3f_3-V42y1AcmemYwKti_RFgE3koDZGY0ghZu851hvRgNzJx0iI6Yz4sdxlzD2GQ_xGKdoZjZLPXeO_Z4PKd6Gb77i-ekutyKDzy3l9g2gaKB8L5T2O9NPWPju3IqFaVzAiwH0qSzxtO6wTk99jVfX6qOBNkgcoJYp1DgQXheDGj5AS7AYWFIrI29uA7PYCEAL_DWT1-nEQFJGZ3vUlsMWRevEg4BnB_64Xw1SvKDpC9dlrot2AWe9Y6K1bcpU2ZObp2kUD7S1hgRGjpjz31Lon4iSdCdhEhXmFU9FQ9zf3K7X_oV8NFYmaUgUk8rFP2jBFG3OmCuMY2lgWN39kh5Vp6KSvvZ4GAcgds5FzUIO3sVfmEWECxnzFPvnJtWaloj2rarXLF50F6FWIJeaJsPks-4D9SB5RiLRE9OT7P-jg9F1asn7D3oE0VfOa1mW1erVL6eHbVV4l_IQCZlRMXzg7V8naaSXXE4mcRC7IA3aq7nHGmTqr2RdzJNpHXebDqL37k7myswdHyX1bHUb--Ku45s9WZwECCXfSfFzKmnGRl_2tOSix5ypPLtJZj4e11usS391NVr7lgUgQBfRAUqrBkx0yhkDc4_ChvfXGdyacRkzmu7kkDZx7PnHGQsvrtSGimxYx9Fa13dRZcW4tH4sFBhc0oUM10yGsjEXhfK0o4zAz94N_Glw5kH-kFtGPkFLTwUEECISWD7xpHVIiNSfM4YtJsNkWXbBtj0UYM57MuGDzbPqDkCnrhHBl2AhHJ5eKz4byR8ouf6i5xQQIG8NU-4cGyiGErUze1QUYQx-10xi1Q8p9Ibz3ejHpSIXmJxUyUMToqTgOA-eQqn073VyLZrc9_AYmSdT9BT1UE56jl7tITcEq0ix_Th0A5zdSoVw5lp_iXrv5V1cy-2Z_rvbjoc6Bw6NikjsKQrX5we9n8hQ-R8ugYW1eIUP-B91inNBWLp8UtY98LE5c8-CulvVJavFjQZXdpC1_Gy8s-GB1QdYfZdoLlbjrojhN-p_HG2L67eakv4hNrgNHJjMs2VvDKuGNZTOF_AFg8QBUWnD5onGwDKt1LhkzCpSgH-qjM79a5AhZPlNrk99jum_TcdKxsBZgjoU3kbs5Nvgb-MHx0tyr35A1r6O18jyBHBH61-KsVu1b3CH6sUVlZ3Vpbl5rGHi02Kv3gf2Z76Oe7rjDmgEtCwp9otovvpBrJxadkdoIOexK45Nw9ZjlArqDxfBAuIeY0VQySHyUQvRJiLC3_1SF8a4d8qnHNlmBEdYnH2bXtnvlBiaF0ZknjCy6IRynUWGYN3kEnE9MjwLWsx1DHdstewkMhq_U5qtxvh6RUmrANXB97GK17vj9E6uCfndQfq_AzSB3WEmWxXHwLDwII7cyN90FwthhxbRM-HfL0Mzj61ljk1fZXtbB9-uRw1S4KqqxJqMxe5giSzZ8A4yhL91ILt5Z6_gvxwCbZEP_BAJut2tCdSu6X6rcCiO0eIyM8MsvXGlBOsY0M_7jvbHJVA86NF8dV6k0uxsN-r1B-0Mg1AOEGt_8gC2YzhN0a6-4PDycJFHsaWFbgoSJZbM_Uy4OEg6QTG4WYaLcjbXES-UzTF1BHsjSO8BEKDHexDlJN_wY2EXJaAoUiPu1JPVjWemHBSNy-HvZGIpdXZ7XSOnBYtCbCVf3khiNNoY6NlhoZtDx5Y92xJlDcVupuq8CBsVZY8ruHd5XVlgFD8QfDKWjzyVqjKQ2yitWOtX6FY443axjZOykFWS_fBwehTV7NVX_yvYiVLFjeJ0XG_9DZqofn8cVg71FBC4AuY1_CdjPnHPlUPPUeEY9NdSyUMDqGDiLTHSWNvhFUbi4La5loNjugGEyvvHmlx6_YFKSJ747WH64AdLSnvNsQ7jgPQtD5rlxUDfdGS_0rE10jfvZGJo_sABL7DIAzi79ecyVCyG0PG3fCfNWHa7iTT6rk8KYRBC4mFhrLmdM3mIe57xu5rMdg1axkXsPOn8heZ-QZyxlSQXXTFEf70S9bgfFT86xs83Cp1k2KaEzlCYnoPxOJOUNdthWv5Skp-OszV5cwUMq5u4Wlei5V1SRzN1ydS27r60I2_JdWg5ymLRlvj5wL1eeaeB7LZF4ROd6uxDmbErX1zPPuSaHMopqEBJOWc-wjFfKF2Hdz4riu6_tYvmbYdrbQVh8ciGOlY4oQX_7EwrCD1fBzDXVtAk59DeyVL6uiFwWRuXTlmYb0yHk4YAnv3pN4BZGX0d6hacYQIbVZFyfRHKE3BmGWamgiLH1-q1OIcpAVfALQcKEjrR666EstrZN_wk2Ydwruu_-Lgj9TPE2eRc-AVYVb-2p29KE1rBkpIkLTHtUqjvIt3YeMv-N2gfVWsPVhr7VtR7vUhzkIASUDBQ3udRiWEQRI2TsFQjgBoNyDCmtBSEJY8eEHd9lA1Jyh2ZLXMVpLNxskklrAz7Xse51jjcbtlHWlHw26pmPHpXfpYxxEQE2JXTbBAvTsa5ei3A7ObX9Cg22ghgv6eMq2zX3-eHurDyZhsSuqd6WSdETenn8HmuEVWybbize-m9AfKQaH5YZN7jUL-nUgbBiOz217AbduaOY9dcnn6-sQPGoik6RxWfHN6r94ox62zRXHxLPwn8A9ycZVAX31Yvi42j435NB5gtwVK3PvykKS0iht2tWkBacQP9R5sFPGgy-mDQ2y8QeRa6rA3Ezyr36wk6OJec9oVd7AqBfXvnI9IiQ3HyeDswRGzukXMncJ0zao4l6hvXP2uE_n6Yhbc1wr_hM2OdQodTirJPaqeS8igmjIeMGhw2RqO1lxI7YII0RswgIDC2SYpsvq-JvP4voJDkFJcXd6kbjd_CcqQFiLEyCS2wAAAAAAAAAAAAAAAAAAAAAABQ4SHSIm", + "payload": "-ZAJXSCS4BAA4BAA-AAF5BAEAGhlbGxvIHdvcmxk" + } + } +} diff --git a/packages/tsp-js/tests/interop.rust-vector.mjs b/packages/tsp-js/tests/interop.rust-vector.mjs index 36e371f..d7cd251 100644 --- a/packages/tsp-js/tests/interop.rust-vector.mjs +++ b/packages/tsp-js/tests/interop.rust-vector.mjs @@ -74,3 +74,57 @@ test("JS-packed message uses the same fixed keys and self-unpacks", async () => }); assert.equal(new TextDecoder().decode(unpacked.payload), "hello from js tsp"); }); + +// ── Rev 3, the other direction ── +// +// The vector above and the published Appendix A vectors in +// `interop.spec-vectors.mjs` both check our *decoder*. Nothing in a JS-only +// suite can check the encoder: a round trip passes whenever pack and unpack +// share a misreading, which is precisely the failure mode the one-character +// changes in Rev 3 produce. +// +// So the encoder was checked by running affinidi-tsp against it. A message +// packed here with the fixed keys below was handed to `direct::unpack` on the +// crate's `tsp-rev3` branch (worktree at fb4e23a), which recovered the sender, +// the receiver, the plaintext and a byte-identical thread digest. +// +// What is pinned here is what that run cannot: the deterministic parts of what +// we emit. The sealed message itself is not reproducible — HPKE draws a fresh +// ephemeral key every time — but the payload frame and the envelope fields are, +// and between them they carry every layout decision Rev 3 changed. If either +// moves, the cross-implementation run above is stale and has to be redone. +test("Rev 3 — the envelope fields we emit are byte-exact", async () => { + const { encodeFields } = await import("../dist/rev3/envelope.js"); + const fields = encodeFields("did:web:alice.example", "did:web:bob.example"); + assert.equal( + toHex(fields), + "61348f" + // YTSP + "f80002" + // YTSP-AAC + "e010076469643a7765623a616c6963652e6578616d706c65" + // alice, 0 lead + "e81007" + "0000" + "6469643a7765623a626f622e6578616d706c65", // bob, 2 lead + ); +}); + +test("Rev 3 — the payload frame we emit is byte-exact", async () => { + const { encodePayloadFrame } = await import("../dist/rev3/payload.js"); + const { frame, threadDigest } = encodePayloadFrame( + new TextEncoder().encode("hello from js tsp rev3"), + "direct", + [], + "did:web:alice.example", + ); + assert.equal( + toHex(frame), + "f99014" + // -Z, 20 quadlets + "5d2092" + // XSCS + "e010076469643a7765623a616c6963652e6578616d706c65" + // ESSR sender VID + "e01000" + // padding: empty field — present, not omitted + "f80009" + // -A generic stream, 9 quadlets + "e81008" + "0000" + "68656c6c6f2066726f6d206a73207473702072657633", // B: the body + ); + // The digest affinidi-tsp independently recomputed from these bytes. + assert.equal( + toHex(threadDigest), + "625026c6e4a2f5061e2403777cbb2bc3d1595f4c8593d5d88a61f89c9a9b4232", + ); +}); diff --git a/packages/tsp-js/tests/interop.spec-vectors-repack.mjs b/packages/tsp-js/tests/interop.spec-vectors-repack.mjs new file mode 100644 index 0000000..4981b78 --- /dev/null +++ b/packages/tsp-js/tests/interop.spec-vectors-repack.mjs @@ -0,0 +1,246 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +import { decodeEnvelope, unpack } from "../dist/index.js"; +import * as wire from "../dist/cesr/wire.js"; +import { deriveKeyPair } from "../dist/crypto/hpke-noble.js"; +import { decodeDigest, decodeNonce, decodeVidList } from "../dist/rev3/fields.js"; +import { + __unsafeDeterministicPack, + __unsafeDeterministicPackAccept, + __unsafeDeterministicPackCancel, + __unsafeDeterministicPackInvite, + __unsafeDeterministicPackNested, + __unsafeDeterministicPackRouted, +} from "../dist/unsafe-testing.js"; + +// ── Re-packing the specification's Appendix A vectors byte for byte ── +// +// `interop.spec-vectors.mjs` proves we can *read* the published messages. This +// proves we *write* them: given the vector's keys, its `ikmE` and the fields its +// printed payload spells out, the packer emits exactly the published bytes. It +// is the stronger claim — a reader tolerates whatever it tolerates, whereas an +// exact writer has had to agree with the reference on every code, count, field +// order, AAD boundary, signature input and HPKE-Base derivation. +// +// Ed25519 is deterministic, so the only randomness is the HPKE ephemeral and the +// invite nonce. Both come from the vector here, through the test-only +// `./unsafe-testing` subpath, whose fixed ephemeral must never be used outside a +// test (see that module). +// +// Every input is derived from the vector, never hard-coded: the NULL-vs-present +// ESSR sender field and the padding from the printed `payload`, the nonce and +// digests and the inner message of a nesting from that same payload. +// +// Of the ten vectors, six are HPKE-Base and all six re-pack. The other four +// cannot, for reasons that are about what this package packs, not about the +// vectors — named below rather than silently skipped. + +const VECTORS = JSON.parse( + readFileSync(new URL("./fixtures/spec-rev3-vectors.json", import.meta.url), "utf8"), +); + +const b64u = (s) => new Uint8Array(Buffer.from(s, "base64url")); +const qb64 = (u8) => Buffer.from(u8).toString("base64url"); +const id = (name) => VECTORS.identifiers[name]; +const eq = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]); + +/** + * Read the printed payload frame far enough to recover what a packer has to be + * told: the type code, whether the ESSR sender field is the NULL VID, the + * type-specific fields, and the padding. Parsed with the wire primitives rather + * than `decodePayloadFrame`, so a mis-reading there cannot hide one here. + */ +function readPrintedPayload(printed) { + const frame = b64u(printed); + const cur = { pos: 0 }; + const quadlets = wire.decodeCount(wire.TSP_PAYLOAD, frame, cur); + assert.equal(cur.pos + quadlets * 3, frame.length, "the -Z count covers the printed payload"); + const typeCode = frame.slice(cur.pos, cur.pos + 3); + cur.pos += 3; + const sender = new TextDecoder().decode(wire.decodeVariableData(wire.TSP_VID, frame, cur)); + const out = { sender }; + const padding = () => wire.decodeVariableData(wire.TSP_PLAINTEXT, frame, cur); + + if (eq(typeCode, wire.XSCS)) { + out.type = "scs"; + out.padding = padding(); + const streamQuadlets = wire.decodeCount(wire.TSP_GENERIC_STREAM, frame, cur); + assert.equal(cur.pos + streamQuadlets * 3, frame.length); + out.body = wire.decodeVariableData(wire.TSP_PLAINTEXT, frame, cur); + } else if (eq(typeCode, wire.XHOP)) { + out.type = "hop"; + out.hops = decodeVidList(frame, cur); + out.padding = padding(); + out.inner = frame.slice(cur.pos); + } else if (eq(typeCode, wire.XRFI)) { + out.type = "rfi"; + out.digest = decodeDigest(frame, cur); + out.nonce = decodeNonce(frame, cur); + out.replyPath = decodeVidList(frame, cur); + out.referralQuadlets = wire.decodeCount(wire.TSP_HOP_LIST, frame, cur); + cur.pos += out.referralQuadlets * 3; + out.padding = padding(); + } else if (eq(typeCode, wire.XRFA)) { + out.type = "rfa"; + out.digest = decodeDigest(frame, cur); + out.replyDigest = decodeDigest(frame, cur); + out.padding = padding(); + } else if (eq(typeCode, wire.XRFD)) { + out.type = "rfd"; + out.digest = decodeDigest(frame, cur); + out.padding = padding(); + } else { + throw new Error("unexpected type code in a printed payload"); + } + if (out.type !== "scs" && out.type !== "hop") { + assert.equal(cur.pos, frame.length, "nothing follows the padding field"); + } + return out; +} + +/** Re-pack one vector from its own material; returns the qb2 bytes. */ +async function repack(name) { + const v = VECTORS.vectors[name]; + const sender = id(v.sender); + const receiver = id(v.receiver); + const p = readPrintedPayload(v.payload); + + // The two fields this packer has fixed behaviour for. Neither is a guess: if + // a vector used either differently, re-packing it would be a different claim + // and this says so instead of producing bytes that merely fail to match. + assert.equal(p.padding.length, 0, `${name}: the packer writes an empty padding field`); + assert.ok(p.sender === "" || p.sender === sender.id, `${name}: ESSR sender is NULL or the sender`); + + const keys = { senderSigningKey: b64u(sender.skS), receiverEncryptionKey: b64u(receiver.pkE) }; + const unsafe = { __unsafeIkmE: b64u(v.ikmE), nullPayloadSender: p.sender === "" }; + + switch (p.type) { + case "scs": + return (await __unsafeDeterministicPack(p.body, sender.id, receiver.id, keys, unsafe)).bytes; + case "rfi": { + assert.equal(p.referralQuadlets, 0, `${name}: referrals are decode-only here`); + const packed = await __unsafeDeterministicPackInvite( + sender.id, + receiver.id, + keys, + { route: p.replyPath, nonce: p.nonce }, + unsafe, + ); + assert.deepEqual(packed.threadDigest, p.digest, `${name}: the SAID we derive is the printed one`); + return packed.bytes; + } + case "rfa": { + const packed = await __unsafeDeterministicPackAccept(p.digest, sender.id, receiver.id, keys, unsafe); + assert.deepEqual(packed.threadDigest, p.replyDigest, `${name}: the Reply_Digest we derive is the printed one`); + return packed.bytes; + } + case "rfd": + return (await __unsafeDeterministicPackCancel(p.digest, sender.id, receiver.id, keys, unsafe)).bytes; + case "hop": + return p.hops.length === 0 + ? (await __unsafeDeterministicPackNested(p.inner, sender.id, receiver.id, keys, unsafe)).bytes + : (await __unsafeDeterministicPackRouted(p.inner, p.hops, sender.id, receiver.id, keys, unsafe)).bytes; + default: + throw new Error(`no packer for ${p.type}`); + } +} + +const REPACKED = [ + "direct-hpke-base", + "control-rfi-direct", + "control-rfa-direct", + "control-rfd", + "nested-direct", + "routed", +]; + +for (const name of REPACKED) { + test(`${name} — re-packs byte for byte from its published ikmE`, async () => { + const v = VECTORS.vectors[name]; + const got = await repack(name); + // Compare in the text domain the specification prints, so a failure names + // the qb64 offset a reader can find in Appendix A; then in qb2, the bytes on + // the wire. + assert.equal(qb64(got), v.message); + assert.deepEqual(got, b64u(v.message)); + }); +} + +test("each vector's ikmE derives the pkEm it publishes, and that key is in the message", () => { + for (const name of REPACKED) { + const v = VECTORS.vectors[name]; + const { pk } = deriveKeyPair(b64u(v.ikmE)); + assert.equal(qb64(pk), v.pkEm, `${name}: DeriveKeyPair(ikmE)`); + assert.ok(Buffer.from(b64u(v.message)).includes(Buffer.from(pk)), `${name}: enc leads the F field`); + } +}); + +test("DeriveKeyPair reproduces the CFRG RFC 9180 vector's skEm and pkEm", () => { + const cfrg = JSON.parse( + readFileSync(new URL("./fixtures/cfrg-auth-x25519-chacha.json", import.meta.url), "utf8"), + ).vectors[0]; + const hex = (u8) => Buffer.from(u8).toString("hex"); + const { sk, pk } = deriveKeyPair(new Uint8Array(Buffer.from(cfrg.ikmE, "hex"))); + assert.equal(hex(sk), cfrg.skEm); + assert.equal(hex(pk), cfrg.pkEm); +}); + +test("the re-packed nested and routed vectors carry the published inner message, which also opens", async () => { + // The inner message is opaque to the outer packer, so re-packing it proves + // nothing about it. Opening it closes that gap: it is the vectors' own + // direct message between the nested identities, and it must verify too. + for (const name of ["nested-direct", "routed"]) { + const v = VECTORS.vectors[name]; + const outer = await unpack(b64u(v.message), { + receiverDecryptionKey: b64u(id(v.receiver).skE), + senderSigningKey: b64u(id(v.sender).pkS), + }); + assert.deepEqual(outer.payload, readPrintedPayload(v.payload).inner, `${name}: inner as printed`); + + const { envelope } = decodeEnvelope(outer.payload); + const party = (vid) => Object.values(VECTORS.identifiers).find((i) => i.id === vid); + const inner = await unpack(outer.payload, { + receiverDecryptionKey: b64u(party(envelope.receiver).skE), + senderSigningKey: b64u(party(envelope.sender).pkS), + }); + assert.deepEqual(inner.payload, readPrintedPayload(v.innerPayload).body, `${name}: inner body`); + } +}); + +test("a pack without the unsafe material is not deterministic — the hook is the only way in", async () => { + const { pack } = await import("../dist/index.js"); + const v = VECTORS.vectors["direct-hpke-base"]; + const keys = { senderSigningKey: b64u(id("alice").skS), receiverEncryptionKey: b64u(id("bob").pkE) }; + const body = new TextEncoder().encode("hello world"); + const a = await pack(body, id("alice").id, id("bob").id, keys); + const b = await pack(body, id("alice").id, id("bob").id, keys); + assert.notDeepEqual(a.bytes, b.bytes, "a fresh ephemeral per message"); + assert.notEqual(qb64(a.bytes), v.message); + await assert.rejects( + () => __unsafeDeterministicPack(body, id("alice").id, id("bob").id, keys, {}), + /__unsafeIkmE/, + "an unsafe packer without its ikmE refuses rather than falling back to random", + ); +}); + +test("vectors that cannot be re-packed here are named, with the reason", () => { + const notRepacked = { + // The packer has no sealed box (§8.3) — it cannot even open one. + "direct-sealed-box": "libsodium sealed box (§8.3): not implemented", + "control-rfi-sealed-box": "libsodium sealed box (§8.3): not implemented", + // Deterministic anyway (no encryption), but there is no packer that emits + // an unencrypted `-E` frame; every pack here is HPKE-Base. + "direct-signed-only": "no signed-only packer", + // The hybrid KEM draws its own encapsulation randomness and the vector + // publishes no ephemeral material; nor does this package pack ML-KEM/ML-DSA. + "direct-hpke-base-pq": "ML-KEM-768/X25519 + ML-DSA-65: not implemented, and no published ephemeral", + }; + assert.deepEqual( + new Set([...REPACKED, ...Object.keys(notRepacked)]), + new Set(Object.keys(VECTORS.vectors)), + "every published vector is either re-packed or named", + ); + for (const name of Object.keys(notRepacked)) assert.equal(VECTORS.vectors[name].ikmE, undefined, name); +}); diff --git a/packages/tsp-js/tests/interop.spec-vectors.mjs b/packages/tsp-js/tests/interop.spec-vectors.mjs new file mode 100644 index 0000000..5d20b78 --- /dev/null +++ b/packages/tsp-js/tests/interop.spec-vectors.mjs @@ -0,0 +1,147 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +import { unpack, peekRevision } from "../dist/index.js"; + +// ── The specification's own Appendix A test vectors ── +// +// Fixture extracted from Appendix A of the merged specification +// (trustoverip/tswg-tsp-specification f5b8668), which packs `YTSP-AAC`. It +// replaced the pre-merge `YTSP-ABA` vectors from spec commit 66a1580: the +// version is inside the signed envelope, the AAD and every SAID, so every +// message changed with it. +// +// These check something neither a round trip nor a two-implementation interop +// run can. A round trip passes whenever the encoder and decoder share a +// misreading, and an interop harness that packs with one implementation and +// unpacks with the other passes whenever *both* share one. These bytes are +// fixed, external, and were produced by neither of us. +// +// Three of the ten are exercised here and the three HPKE-Base control vectors in +// `control.spec-vectors.mjs`. `direct-sealed-box` and `control-rfi-sealed-box` +// need the libsodium sealed box (§8.3), which we deliberately do not implement; +// `direct-signed-only` is an unencrypted `-E` frame we never receive; +// `direct-hpke-base-pq` needs ML-KEM and ML-DSA. Each is skipped by +// name below rather than silently absent, so the list says what is missing +// instead of looking complete. +const VECTORS = JSON.parse( + readFileSync(new URL("./fixtures/spec-rev3-vectors.json", import.meta.url), "utf8"), +); + +const b64u = (s) => new Uint8Array(Buffer.from(s, "base64url")); +const id = (name) => VECTORS.identifiers[name]; + +/** Unpack a named vector as its declared receiver. */ +const openVector = (name) => { + const v = VECTORS.vectors[name]; + const sender = id(v.sender); + const receiver = id(v.receiver); + return unpack(b64u(v.message), { + receiverDecryptionKey: b64u(receiver.skE), + senderSigningKey: b64u(sender.pkS), + }); +}; + +test("the published vectors declare Rev 3 as we pack it: YTSP-AAC", () => { + // The merged Appendix A carries `YTSP-AAC` — MINOR 2 under the MAJOR.MINOR + // reading this package and affinidi-tsp both use, the same marker our own + // packing emits. Pre-merge drafts carried `ABA` (MINOR 64), which still reads + // as Rev 3 (see `revision.dispatch.mjs`); `peekRevision` matches Rev 2 exactly + // and treats everything else at MAJOR 0 as current. + for (const [name, v] of Object.entries(VECTORS.vectors)) { + const peeked = peekRevision(b64u(v.message)); + assert.equal(peeked.revision, "rev3", name); + assert.equal(peeked.major, 0, name); + assert.equal(peeked.minor, 2, name); + assert.equal(peeked.recognised, true, name); + assert.equal(v.message.slice(4, 12), "YTSP-AAC", `${name} carries YTSP-AAC in qb64`); + } +}); + +test("direct-hpke-base — opens, verifies, and carries the published payload", async () => { + const unpacked = await openVector("direct-hpke-base"); + assert.equal(unpacked.revision, "rev3"); + assert.equal(unpacked.messageType, "direct"); + assert.equal(unpacked.sender, id("alice").id); + assert.equal(unpacked.receiver, id("bob").id); + assert.equal(Buffer.from(unpacked.payload).toString("utf8"), "hello world"); + assert.deepEqual(unpacked.hops, []); +}); + +test("direct-hpke-base — the ESSR sender field may be the NULL VID", async () => { + // This vector's payload frame is `-ZAJ XSCS 4BAA 4BAA -AAF 5BAE `: + // the sender field is `4BAA`, the NULL VID. §9.2 permits it under HPKE-Base, + // and affinidi-tsp writes the real VID instead — so a decoder that required + // either spelling would reject half the conformant messages in existence. + // qb64 `-ZAJ XSCS 4BAA 4BAA -AAF 5BAE `, which in the binary + // domain this package works in is the layout below. The third field — the + // ESSR sender — is `e01000`, an empty `B` var-data field: the NULL VID. + const frame = Buffer.from(VECTORS.vectors["direct-hpke-base"].payload, "base64url"); + assert.equal(frame.subarray(0, 3).toString("hex"), "f99009", "-Z count, 9 quadlets"); + assert.equal(frame.subarray(3, 6).toString("hex"), "5d2092", "XSCS type code"); + assert.equal(frame.subarray(6, 9).toString("hex"), "e01000", "ESSR sender: NULL VID"); + assert.equal(frame.subarray(9, 12).toString("hex"), "e01000", "padding: empty field"); + assert.equal(frame.subarray(12, 15).toString("hex"), "f80005", "-A generic stream, 5 quadlets"); + await assert.doesNotReject(() => openVector("direct-hpke-base")); +}); + +test("nested-direct — the inner message is carried raw, not in a B field", async () => { + const unpacked = await openVector("nested-direct"); + assert.equal(unpacked.messageType, "nested"); + assert.deepEqual(unpacked.hops, []); + // Rev 2 wrapped the inner message in an enclosing `B` var-data field; Rev 3 + // carries it raw. Reading the payload as a whole TSP message is what says we + // stripped nothing and added nothing: it must still lead with a `-E` frame. + const inner = peekRevision(unpacked.payload); + assert.equal(inner.revision, "rev3"); +}); + +test("routed — the hop list survives the -J byte-length count", async () => { + const unpacked = await openVector("routed"); + assert.equal(unpacked.messageType, "routed"); + // Rev 3 §9.2 made the `-J` count the group's byte length rather than the + // number of VIDs. A decoder still reading it as a VID count reads this + // vector's hop list as ~13 hops and runs off the end of the frame. + assert.ok(unpacked.hops.length >= 1, "a routed message names at least one onward hop"); + for (const hop of unpacked.hops) assert.match(hop, /^did:/); +}); + +test("a wrong verifying key is a signature failure, not a decrypt failure", async () => { + const v = VECTORS.vectors["direct-hpke-base"]; + await assert.rejects( + () => + unpack(b64u(v.message), { + receiverDecryptionKey: b64u(id("bob").skE), + senderSigningKey: b64u(id("bob").pkS), // Bob did not send this + }), + /signature verification failed/, + ); +}); + +test("vectors this implementation does not cover are named, not omitted", () => { + const uncovered = { + "direct-sealed-box": "libsodium sealed box (§8.3) — deliberately not implemented", + "control-rfi-sealed-box": "libsodium sealed box (§8.3)", + "direct-signed-only": "unencrypted -E frame; we neither send nor expect one", + "direct-hpke-base-pq": "ML-KEM-768/X25519 + ML-DSA-65 (§8.1/§8.2.1)", + }; + for (const name of Object.keys(uncovered)) { + assert.ok(VECTORS.vectors[name], `vector ${name} is in the fixture`); + } + // The HPKE-Base control vectors are opened, and their digests re-derived, in + // `control.spec-vectors.mjs`. + const covered = [ + "direct-hpke-base", + "nested-direct", + "routed", + "control-rfi-direct", + "control-rfa-direct", + "control-rfd", + ]; + assert.deepEqual( + new Set([...covered, ...Object.keys(uncovered)]), + new Set(Object.keys(VECTORS.vectors)), + "every published vector is either exercised or listed as uncovered", + ); +}); diff --git a/packages/tsp-js/tests/message.envelope.mjs b/packages/tsp-js/tests/message.envelope.mjs index 306c9b4..71cee86 100644 --- a/packages/tsp-js/tests/message.envelope.mjs +++ b/packages/tsp-js/tests/message.envelope.mjs @@ -1,46 +1,114 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; -import { encodeEnvelope, decodeEnvelope } from "../dist/index.js"; +import { decodeEnvelope } from "../dist/index.js"; +import { encodeFields, finalizeFrame, decodeEnvelope as decodeRev3 } from "../dist/rev3/envelope.js"; const hex = (u8) => Buffer.from(u8).toString("hex"); +const b64u = (s) => new Uint8Array(Buffer.from(s, "base64url")); -// Golden vector from affinidi-tsp src/message/envelope.rs -// `envelope_matches_reference_header` (reference tsp-sdk seal Bob->Alice). -test("envelope encodes byte-for-byte to the reference header (60 bytes)", () => { - const encoded = encodeEnvelope("did:web:bob.example", "did:web:alice.example"); - const prefix = [ - 0xf8, 0x40, 0x13, // -E count 19 - 0x61, 0x34, 0x8f, // YTSP - 0xf8, 0x00, 0x01, // version count - 0xe8, 0x10, 0x07, 0x00, 0x00, // sender var-data header + 2 lead - ]; - assert.equal(hex(encoded.slice(0, prefix.length)), hex(new Uint8Array(prefix))); - assert.equal(encoded.length, 60); // 19 quadlets * 3 + 3 count = 60 - assert.equal(encoded[0], 0xf8); -}); - -test("envelope round-trips and reports the AAD/info length", () => { - const encoded = encodeEnvelope("did:web:alice.example", "did:web:bob.example"); - const { envelope, headerLen } = decodeEnvelope(encoded); - assert.equal(envelope.sender, "did:web:alice.example"); - assert.equal(envelope.receiver, "did:web:bob.example"); - assert.equal(headerLen, encoded.length); -}); - -test("envelope round-trips for varied VID lengths", () => { +const VECTORS = JSON.parse( + readFileSync(new URL("./fixtures/spec-rev3-vectors.json", import.meta.url), "utf8"), +); + +test("Rev 3 envelope fields are byte-exact, and three bytes shorter than Rev 2", () => { + const fields = encodeFields("did:web:bob.example", "did:web:alice.example"); + assert.equal( + hex(fields.slice(0, 9)), + "61348f" + // YTSP + "f80002" + // version count: MAJOR 0, MINOR 2 — `YTSP-AAC` + "e81007", // sender var-data header (19 bytes → 2 lead, D6 selector) + ); + // 6 (version) + 24 (bob, 2 lead) + 24 (alice, 0 lead) = 54. Rev 2 was 57: + // it ended with a 2-byte `X 00 00` TMP marker that Rev 3 deletes outright. + assert.equal(fields.length, 54); +}); + +test("the -E count covers the body, which is why it is written last", () => { + const fields = encodeFields("did:web:bob.example", "did:web:alice.example"); + const body = new Uint8Array(9).fill(0xaa); + + const frame = finalizeFrame(fields, body); + // Rev 2's count covered only the header and so could be written first. Rev 3's + // covers fields ‖ body — 63 bytes, 21 quadlets — and cannot exist until the + // ciphertext does. + assert.equal(hex(frame.slice(0, 3)), "f84015"); // -E, 21 quadlets + assert.equal(frame.length, 3 + 54 + 9); + + const decoded = decodeRev3(frame); + assert.equal(decoded.contentEnd, frame.length, "the count fixes where signable content ends"); + assert.equal(decoded.headerLen, 3 + 54, "the ciphertext field begins after the fields"); +}); + +test("the AAD is the fields without the count code", () => { + // §8: `aad = CONCAT(TSP_Version, VID_sndr, VID_rcvr)`. The `-E` count code is + // deliberately outside it, which is exactly why encoding splits in two. + const fields = encodeFields("did:web:alice.example", "did:web:bob.example"); + const frame = finalizeFrame(fields, new Uint8Array(0)); + const decoded = decodeRev3(frame); + assert.deepEqual(frame.slice(decoded.aad.begin, decoded.aad.end), fields); + assert.equal(decoded.aad.begin, 3, "the AAD starts after the count code, not at 0"); +}); + +test("Rev 3 envelope round-trips for varied VID lengths", () => { for (const [s, r] of [ ["a", "b"], ["did:web:x", "did:web:y"], ["did:key:z6Mkexample", "did:web:host.example:path"], ]) { - const { envelope } = decodeEnvelope(encodeEnvelope(s, r)); + const frame = finalizeFrame(encodeFields(s, r), new Uint8Array(0)); + const { envelope } = decodeRev3(frame); assert.equal(envelope.sender, s); assert.equal(envelope.receiver, r); } }); -test("truncated envelope throws", () => { +test("an empty receiver is the NULL VID, and an empty sender is refused", () => { + // §9.1 always writes the receiver field; `4BAA` means "no receiver named". + // A sender has no such spelling — every TSP message names who sent it — so + // the two empty strings are not symmetric and must not be handled as if they + // were. + const frame = finalizeFrame(encodeFields("did:web:alice", ""), new Uint8Array(0)); + assert.equal(decodeRev3(frame).envelope.receiver, ""); + + const headless = finalizeFrame(encodeFields("", "did:web:bob"), new Uint8Array(0)); + assert.throws(() => decodeRev3(headless), /NULL VID/); +}); + +test("a frame claiming more content than it holds is refused at the count", () => { + const fields = encodeFields("did:web:alice", "did:web:bob"); + const honest = finalizeFrame(fields, new Uint8Array(0)); + // Overstate the count by one quadlet. §9.1 asks a receiver to check the + // declared signable length, so this dies here rather than as a confusing + // failure three layers down. + const lying = Uint8Array.from(honest); + lying[2] += 1; + assert.throws(() => decodeRev3(lying), /declares more content/); +}); + +test("the public decodeEnvelope dispatches, and reports which revision it read", () => { + // Rev 3, from the published vectors. + const rev3 = decodeEnvelope(b64u(VECTORS.vectors["direct-hpke-base"].message)); + assert.equal(rev3.revision, "rev3"); + assert.equal(rev3.minor, 2); // the merged vectors carry `AAC` + assert.equal(rev3.envelope.sender, VECTORS.identifiers.alice.id); + assert.equal(rev3.envelope.receiver, VECTORS.identifiers.bob.id); + + // Rev 2, from the pinned Rust interop vector in `interop.rust-vector.mjs`. + const rev2Wire = Buffer.from( + "f8401361348ff80001e010076469643a7765623a616c6963652e6578616d706c65e8100700006469643a7765623a626f622e6578616d706c655c0000e0601a5795132915e698a115677334d13dd7154f717eda8791473ccbb360671313f40544e2ae9153559a01d6aa33b93261dd0ab610231bad47e059d0eaa46038cf872ba82a282a431fd391e10f4d3c0603f82016f8a016d0100d308cdcf413984d884ff81ac2308da9d3afc9a0601e9393f664d54f9c37892897e996a0c8949ca8afa643ed39f888312094f6c34c55a1f4c3c0032f969cb707", + "hex", + ); + const rev2 = decodeEnvelope(new Uint8Array(rev2Wire)); + assert.equal(rev2.revision, "rev2"); + assert.equal(rev2.minor, 1); + assert.equal(rev2.envelope.sender, "did:web:alice.example"); + assert.equal(rev2.envelope.receiver, "did:web:bob.example"); +}); + +test("truncated and non-TSP input throws", () => { assert.throws(() => decodeEnvelope(new Uint8Array([0xf8, 0x40]))); assert.throws(() => decodeEnvelope(new Uint8Array([1, 0]))); + assert.throws(() => decodeEnvelope(new Uint8Array(0))); }); diff --git a/packages/tsp-js/tests/message.routed.mjs b/packages/tsp-js/tests/message.routed.mjs index 016a119..71eed4f 100644 --- a/packages/tsp-js/tests/message.routed.mjs +++ b/packages/tsp-js/tests/message.routed.mjs @@ -48,6 +48,17 @@ test("packRouted rejects empty and over-long routes", async () => { await assert.rejects(packRouted(enc.encode("x"), tooMany, alice.vid, hop1.vid, packKeys(alice, hop1))); }); +test("a route at MAX_HOPS packs and opens with its hops intact", async () => { + // 12 hops was refused when the limit was 10. + const alice = party("did:web:alice"); + const hop1 = party("did:web:hop1"); + const route = Array.from({ length: MAX_HOPS }, (_, i) => `did:web:h${i}`); + const packed = await packRouted(enc.encode("abc"), route, alice.vid, hop1.vid, packKeys(alice, hop1)); + const opened = await unpack(packed.bytes, unpackKeys(hop1, alice)); + assert.equal(opened.messageType, "routed"); + assert.deepEqual(opened.hops, route); +}); + test("routed multi-hop round-trip: alice → hop1 → hop2 → final (inner opaque)", async () => { const alice = party("did:web:alice"); const hop1 = party("did:web:hop1"); @@ -97,10 +108,28 @@ test("an intermediary cannot open a layer addressed to a different hop", async ( const alice = party("did:web:alice"); const hop1 = party("did:web:hop1"); const hop2 = party("did:web:hop2"); - const layer = await packRouted(enc.encode("inner"), ["did:web:hop2"], alice.vid, hop1.vid, packKeys(alice, hop1)); + const exit = party("did:web:exit"); + // The inner is a real packed message, not arbitrary bytes: Rev 3 carries it + // raw rather than inside a `B` var-data field, so it must be quadlet-aligned + // — and a TSP message always is. + const inner = await pack(enc.encode("inner"), alice.vid, exit.vid, packKeys(alice, exit)); + const layer = await packRouted(inner.bytes, ["did:web:hop2"], alice.vid, hop1.vid, packKeys(alice, hop1)); await assert.rejects(unpack(layer.bytes, unpackKeys(hop2, alice))); }); +test("a raw inner that is not quadlet-aligned is refused when packed", async () => { + // Rev 2 wrapped the inner message in a `B` field, which padded anything to + // alignment. Rev 3 drops the wrapper, so alignment stops being the framing's + // problem and becomes the caller's — and a misaligned frame would otherwise + // go out and desynchronise the far side's parse rather than fail here. + const alice = party("did:web:alice"); + const hop1 = party("did:web:hop1"); + await assert.rejects( + () => packRouted(enc.encode("inner"), ["did:web:hop2"], alice.vid, hop1.vid, packKeys(alice, hop1)), + /quadlet-aligned/, + ); +}); + test("nested wrapper: mediator forwards an opaque inner it can't read", async () => { const alice = party("did:web:alice"); const mediator = party("did:web:mediator"); diff --git a/packages/tsp-js/tests/payload.app-stream.mjs b/packages/tsp-js/tests/payload.app-stream.mjs new file mode 100644 index 0000000..0fa167d --- /dev/null +++ b/packages/tsp-js/tests/payload.app-stream.mjs @@ -0,0 +1,43 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import * as wire from "../dist/cesr/wire.js"; +import { decodePayloadFrame } from "../dist/rev3/payload.js"; + +// An XSCS body is exactly one Bytes primitive (tswg-tsp-specification#77): +// an -H## group, a second primitive, or data after the stream is refused, +// never truncated to the first primitive. +const enc = new TextEncoder(); +const prim = (s) => { + const out = []; + wire.encodeVariableData(wire.TSP_PLAINTEXT, enc.encode(s), out); + return out; +}; +const frameOf = (stream, trailing = []) => { + const body = [...wire.XSCS, ...prim(""), ...prim("")]; // NULL sender, no padding + wire.encodeCount(wire.TSP_GENERIC_STREAM, stream.length / 3, body); + body.push(...stream, ...trailing); + const frame = []; + wire.encodeCount(wire.TSP_PAYLOAD, body.length / 3, frame); + return Uint8Array.from([...frame, ...body]); +}; +const open = (f) => decodePayloadFrame(f, "did:example:alice", new Uint8Array()); + +test("a single Bytes primitive is the application body", () => { + assert.equal(new TextDecoder().decode(open(frameOf(prim("hello world"))).body), "hello world"); +}); + +test("an -H## group is refused", () => { + const json = prim('{"hello":"world"}'); + const group = []; + wire.encodeCount(wire.cesrInt("H"), json.length / 3, group); + assert.throws(() => open(frameOf([...group, ...json]))); +}); + +test("a second primitive is refused, not dropped", () => { + assert.throws(() => open(frameOf([...prim("one"), ...prim("two")])), /exactly one Bytes primitive/); +}); + +test("data after the stream is refused", () => { + assert.throws(() => open(frameOf(prim("one"), prim("x"))), /does not end the payload frame/); +}); diff --git a/packages/tsp-js/tests/relationship.test.mjs b/packages/tsp-js/tests/relationship.test.mjs new file mode 100644 index 0000000..488073c --- /dev/null +++ b/packages/tsp-js/tests/relationship.test.mjs @@ -0,0 +1,133 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + transition, + canSend, + admitsApplicationMessage, + resolveInviteRace, + resolveAccept, + resolveCancel, + compareBytes, + InvalidTransitionError, +} from "../dist/index.js"; + +const digest = (...bytes) => Uint8Array.from(bytes.concat(Array(32 - bytes.length).fill(0))); + +test("the outbound flow: invite, accept, established", () => { + let state = "none"; + state = transition(state, "sendInvite"); + assert.equal(state, "pending"); + assert.equal(canSend(state), false, "an unanswered invite does not license application messages"); + state = transition(state, "receiveAccept"); + assert.equal(state, "bidirectional"); + assert.equal(canSend(state), true); +}); + +test("the inbound flow: invite received, accepted, established", () => { + let state = transition("none", "receiveInvite"); + assert.equal(state, "inviteReceived"); + assert.equal(canSend(state), false); + state = transition(state, "sendAccept"); + assert.equal(state, "bidirectional"); +}); + +test("gating admits any recorded relationship, not only a completed one", () => { + // §3.6 lets a sender pack user data alongside its invite, so gating on + // `bidirectional` would drop messages the specification expects to arrive. + assert.equal(admitsApplicationMessage("none"), false); + assert.equal(admitsApplicationMessage("pending"), true); + assert.equal(admitsApplicationMessage("inviteReceived"), true); + assert.equal(admitsApplicationMessage("bidirectional"), true); +}); + +test("send is strict where receive is lenient, and that asymmetry is deliberate", () => { + // If both were strict the two sides deadlock, each waiting for the other to + // go first. Pinned because it looks like an inconsistency and is not. + for (const state of ["pending", "inviteReceived"]) { + assert.equal(canSend(state), false); + assert.equal(admitsApplicationMessage(state), true); + } +}); + +test("invalid transitions are refused with both halves named", () => { + assert.throws( + () => transition("pending", "sendInvite"), + (err) => err instanceof InvalidTransitionError && err.state === "pending" && err.event === "sendInvite", + ); + assert.throws(() => transition("none", "sendAccept"), InvalidTransitionError); + assert.throws(() => transition("none", "receiveAccept"), InvalidTransitionError); + assert.throws(() => transition("bidirectional", "sendInvite"), InvalidTransitionError); +}); + +test("a cancellation resets from any live state", () => { + for (const state of ["pending", "inviteReceived", "bidirectional"]) { + assert.equal(transition(state, "receiveCancel"), "none"); + assert.equal(transition(state, "sendCancel"), "none"); + } +}); + +// ── §7.2.3, the invite race ── + +test("both sides keep the lexicographically lower invite digest", () => { + const low = digest(0x01, 0x00); + const high = digest(0x02, 0x00); + + // The rule only converges because both endpoints compute it identically on + // the same two values. Asserting both perspectives is what checks that: run + // from either side, the *same* invite survives. + assert.equal(resolveInviteRace(low, high).keep, "ours"); + assert.equal(resolveInviteRace(high, low).keep, "theirs"); +}); + +test("the race is decided on bytes, not on who asked first", () => { + // No timestamp, no "ours wins", no tie-break on VID — any of which would let + // the two sides disagree and form two half-relationships. + const a = digest(0x00, 0xff); + const b = digest(0x01, 0x00); + assert.equal(resolveInviteRace(a, b).keep, "ours", "0x00ff < 0x0100 byte by byte"); + assert.equal(compareBytes(a, b) < 0, true); +}); + +test("compareBytes orders like a byte string, including on length", () => { + assert.equal(compareBytes(new Uint8Array([1, 2]), new Uint8Array([1, 2])), 0); + assert.equal(compareBytes(new Uint8Array([1]), new Uint8Array([1, 0])) < 0, true); + assert.equal(compareBytes(new Uint8Array([2]), new Uint8Array([1, 9])) > 0, true); +}); + +// ── §7.3, cancellation ── + +test("a cancellation naming a relationship we do not hold is ignored, not answered", () => { + // A privacy property, not tidiness: answering would let anyone probe which + // relationships we hold by cancelling ones they guessed at. + const held = digest(0xaa); + assert.equal(resolveCancel("none", held, [held]).action, "ignore"); + assert.equal(resolveCancel("bidirectional", digest(0xbb), [held]).action, "ignore"); +}); + +test("a cancellation on one direction removes it silently; on both, it is answered", () => { + const held = digest(0xaa); + assert.equal(resolveCancel("pending", held, [held]).action, "remove"); + assert.equal(resolveCancel("inviteReceived", held, [held]).action, "remove"); + assert.equal(resolveCancel("bidirectional", held, [held]).action, "removeAndReply"); +}); + +test("a cancellation may name either half of the relationship", () => { + // §7.2.1: the invite and the accept each have a digest, and a cancellation + // names one of them. Matching only the invite's would drop half of the + // legitimate cancellations. + const invite = digest(0xaa); + const accept = digest(0xbb); + assert.equal(resolveCancel("bidirectional", invite, [invite, accept]).action, "removeAndReply"); + assert.equal(resolveCancel("bidirectional", accept, [invite, accept]).action, "removeAndReply"); +}); + +test("an accept is adopted only when it answers our outstanding invite", () => { + const ours = digest(0xaa); + assert.equal(resolveAccept("pending", ours, ours).action, "adopt"); + assert.equal(resolveAccept("pending", digest(0xbb), ours).action, "ignore", "an invite we never sent"); + assert.equal(resolveAccept("pending", undefined, ours).action, "ignore"); + assert.equal(resolveAccept("pending", ours, undefined).action, "ignore", "no invite on record"); + assert.equal(resolveAccept("none", ours, ours).action, "ignore"); + assert.equal(resolveAccept("bidirectional", ours, ours).action, "ignore"); +}); diff --git a/packages/tsp-js/tests/revision.dispatch.mjs b/packages/tsp-js/tests/revision.dispatch.mjs new file mode 100644 index 0000000..3186484 --- /dev/null +++ b/packages/tsp-js/tests/revision.dispatch.mjs @@ -0,0 +1,182 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +import { pack, unpack, peekRevision, isRevisionError, isTsp } from "../dist/index.js"; +import { ed25519, x25519 } from "@noble/curves/ed25519.js"; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); +const b64u = (s) => new Uint8Array(Buffer.from(s, "base64url")); + +const VECTORS = JSON.parse( + readFileSync(new URL("./fixtures/spec-rev3-vectors.json", import.meta.url), "utf8"), +); + +// The Rev 2 golden message from `interop.rust-vector.mjs`: packed by +// affinidi-tsp 0.1.x with fixed keys, alice.example → bob.example. +const REV2 = { + wire: new Uint8Array( + Buffer.from( + "f8401361348ff80001e010076469643a7765623a616c6963652e6578616d706c65e8100700006469643a7765623a626f622e6578616d706c655c0000e0601a5795132915e698a115677334d13dd7154f717eda8791473ccbb360671313f40544e2ae9153559a01d6aa33b93261dd0ab610231bad47e059d0eaa46038cf872ba82a282a431fd391e10f4d3c0603f82016f8a016d0100d308cdcf413984d884ff81ac2308da9d3afc9a0601e9393f664d54f9c37892897e996a0c8949ca8afa643ed39f888312094f6c34c55a1f4c3c0032f969cb707", + "hex", + ), + ), + senderEncryptionKey: new Uint8Array( + Buffer.from("0faa684ed28867b97f4a6a2dee5df8ce974e76b7018e3f22a1c4cf2678570f20", "hex"), + ), + senderSigningKey: new Uint8Array( + Buffer.from("d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", "hex"), + ), + receiverDecryptionKey: new Uint8Array( + Buffer.from("3333333333333333333333333333333333333333333333333333333333333333", "hex"), + ), +}; + +const party = (vid) => { + const sk = ed25519.utils.randomSecretKey(); + const xsk = ed25519.utils.toMontgomerySecret(sk); + return { vid, sk, pk: ed25519.getPublicKey(sk), xsk, xpk: x25519.getPublicKey(xsk) }; +}; + +// ── peekRevision: the keyless discriminator ── + +test("peekRevision reads Rev 2 and Rev 3 from the version marker alone", () => { + assert.equal(peekRevision(REV2.wire).revision, "rev2"); + assert.equal(peekRevision(REV2.wire).minor, 1); + assert.equal(peekRevision(b64u(VECTORS.vectors["direct-hpke-base"].message)).revision, "rev3"); +}); + +test("peekRevision refuses what is not a TSP frame, with a matchable code", () => { + for (const junk of [enc.encode('{"protected":"..."}'), enc.encode("eyJhbGciOiJ"), new Uint8Array(0)]) { + assert.throws( + () => peekRevision(junk), + (err) => isRevisionError(err) && err.code === "E_TSP_REVISION", + ); + } +}); + +test("peekRevision refuses an unknown MAJOR and carries it on the error", () => { + // MAJOR is the only component that gates processability (§9.1), so this is + // the one version check that is allowed to refuse a message. + const frame = Uint8Array.from(REV2.wire); + frame[7] = 0x10; // version count code: MAJOR 0 → 1 + assert.throws( + () => peekRevision(frame), + (err) => isRevisionError(err) && err.major === 1, + ); +}); + +test("an unrecognised MINOR at MAJOR 0 still reads as Rev 3", () => { + // Rev 2 is the only MINOR matched exactly; everything else at MAJOR 0 is + // current-generation. A future `YTSP-AAD` must not be refused — §9.1 makes + // MINOR a field no implementation may reject on, and the reference discards + // it entirely. + const frame = Uint8Array.from(b64u(VECTORS.vectors["direct-hpke-base"].message)); + frame[8] = 0x0d; // MINOR 2 → 13, a value nothing has ever shipped + const peeked = peekRevision(frame); + assert.equal(peeked.revision, "rev3"); + assert.equal(peeked.recognised, false, "unrecognised, but not refused"); +}); + +// ── unpack: dispatch ── + +test("unpack routes a Rev 2 message to the Rev 2 reader and says so", async () => { + const out = await unpack(REV2.wire, { + receiverDecryptionKey: REV2.receiverDecryptionKey, + senderEncryptionKey: REV2.senderEncryptionKey, + senderSigningKey: REV2.senderSigningKey, + }); + assert.equal(out.revision, "rev2"); + assert.equal(dec.decode(out.payload), "hello from rust tsp"); + assert.equal(out.sender, "did:web:alice.example"); +}); + +test("a Rev 2 message without the sender's X25519 key is refused by name", async () => { + // HPKE-Auth puts the sender's static key in the KEM, so this is not "we could + // not verify the sender" — the message cannot be *opened* at all. Saying that + // beats an AEAD failure, which reads like tampering. + await assert.rejects( + () => + unpack(REV2.wire, { + receiverDecryptionKey: REV2.receiverDecryptionKey, + senderSigningKey: REV2.senderSigningKey, + }), + (err) => isRevisionError(err) && /Rev 2/.test(err.message), + ); +}); + +test("a pre-merge `YTSP-ABA` message still reads as Rev 3, and opens", async () => { + // Appendix A's `direct-hpke-base` as it stood before the specification merged + // (spec commit 66a1580), when the vectors carried `ABA` — MINOR 64 under the + // MAJOR.MINOR reading. The merged vectors moved to `AAC`, so this is pinned + // here: messages packed against the draft exist, and MINOR must never gate + // processing. Same published keys as the current fixture. + const aba = b64u( + "-EBFYTSP-ABA4BATZGlkOnBlZXI6NHpRbVVMNjFOYzFGN2lvaUt4SE5xd25KWFg0c3JoRnNLS1BvNlRyQ21oTTNkZnBx4BATZGlkOnBlZXI6NHpRbVptQ0FzRzdqMWV3VGpYanRkZHd1amlrMzNDRTJjTWJZU1BhZ3BNaVludDFB4FAa1T4oA1pSbehBiIwnoXFGA24kgHowT34VdE95wF9qjStBsok4fIkbu8IKODF2nsZMUAmS5BqxDbbYrvl_TNGpz2omwJgYX4bSYoTeKse4-CAX-KAWBADHuTmj_7jyUCkkalySPOFiy5pTbNtjEODiwwJZlI5DqMk5Wutx4LIOWkAAa3uee2b_0Kh5SXaFq65MedyO5VYJ", + ); + const peeked = peekRevision(aba); + assert.equal(peeked.revision, "rev3"); + assert.equal(peeked.minor, 64); + assert.equal(peeked.recognised, true); + const out = await unpack(aba, { + receiverDecryptionKey: b64u(VECTORS.identifiers.bob.skE), + senderSigningKey: b64u(VECTORS.identifiers.alice.pkS), + }); + assert.equal(out.revision, "rev3"); + assert.equal(dec.decode(out.payload), "hello world"); +}); + +test("a Rev 3 message needs no sender encryption key at all", async () => { + // The clearest statement that HPKE-Base moved sender authenticity out of the + // KEM: the key Rev 2 could not open a message without is simply not passed. + const out = await unpack(b64u(VECTORS.vectors["direct-hpke-base"].message), { + receiverDecryptionKey: b64u(VECTORS.identifiers.bob.skE), + senderSigningKey: b64u(VECTORS.identifiers.alice.pkS), + }); + assert.equal(out.revision, "rev3"); + assert.equal(dec.decode(out.payload), "hello world"); +}); + +test("what we pack is Rev 3, and it round-trips as Rev 3", async () => { + const alice = party("did:web:alice"); + const bob = party("did:web:bob"); + const packed = await pack(enc.encode("hello"), alice.vid, bob.vid, { + senderSigningKey: alice.sk, + receiverEncryptionKey: bob.xpk, + }); + assert.equal(peekRevision(packed.bytes).minor, 2, "we emit YTSP-AAC"); + const out = await unpack(packed.bytes, { + receiverDecryptionKey: bob.xsk, + senderSigningKey: alice.pk, + }); + assert.equal(out.revision, "rev3"); + assert.equal(dec.decode(out.payload), "hello"); +}); + +// ── The long-framed path ── + +test("a message past ~12 KB is long-framed, and still peeks and round-trips", async () => { + // This is the case Rev 2 could never produce: its `-E` count covered only the + // header. Rev 3's covers the ciphertext, so a large message leads with 0xFB + // and its count is a six-byte long form — the exact header whose decode used + // to fold the identifier bits into the length. + const alice = party("did:web:alice"); + const bob = party("did:web:bob"); + const body = new Uint8Array(20_000).map((_, i) => (i * 31 + 7) & 0xff); + + const packed = await pack(body, alice.vid, bob.vid, { + senderSigningKey: alice.sk, + receiverEncryptionKey: bob.xpk, + }); + + assert.equal(packed.bytes[0], 0xfb, "long -E framing"); + assert.equal(isTsp(packed.bytes), true, "an 0xF8-only classifier would drop this"); + assert.equal(peekRevision(packed.bytes).revision, "rev3", "the version is at offset 6, not 3"); + + const out = await unpack(packed.bytes, { + receiverDecryptionKey: bob.xsk, + senderSigningKey: alice.pk, + }); + assert.deepEqual(out.payload, body); +});