diff --git a/packages/core/src/vta/tsp-relationship.ts b/packages/core/src/vta/tsp-relationship.ts index dfa1573..c562406 100644 --- a/packages/core/src/vta/tsp-relationship.ts +++ b/packages/core/src/vta/tsp-relationship.ts @@ -38,6 +38,7 @@ import { packInvite, + resolveAccept, unpack, transition, type ControlMessage, @@ -111,9 +112,6 @@ export class MemoryRelationshipStore implements RelationshipStore { const toHex = (bytes: Uint8Array): string => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); -const bytesEqual = (a: Uint8Array, b: Uint8Array): boolean => - a.length === b.length && a.every((v, i) => v === b[i]); - export interface EnsureRelationshipOpts { transport: TspTransport; holder: TspHolderIdentity; @@ -203,8 +201,9 @@ export async function ensureRelationship( declined = `a ${control.controlType}, not an accept`; return false; } - if (!control.inReplyTo || !bytesEqual(control.inReplyTo, invite.threadDigest)) { - declined = "an accept to an invite we did not send"; + const outcome = resolveAccept(pending, control.inReplyTo, invite.threadDigest); + if (outcome.action === "ignore") { + declined = `an accept that ${outcome.reason}`; return false; } return true; diff --git a/packages/core/tests/tsp.relationship.mjs b/packages/core/tests/tsp.relationship.mjs index c674cd2..6bea89e 100644 --- a/packages/core/tests/tsp.relationship.mjs +++ b/packages/core/tests/tsp.relationship.mjs @@ -30,7 +30,7 @@ function tspIdentity(vid) { * 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 } = {}) { +function relationshipVta(vta, holder, { gating = true, answerInvites = true, acceptDigest } = {}) { const sent = []; let related = false; return { @@ -53,7 +53,7 @@ function relationshipVta(vta, holder, { gating = true, answerInvites = true } = 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(req.control.digest, vta.vid, holder.vid, { + const accept = await packAccept(acceptDigest ?? req.control.digest, vta.vid, holder.vid, { senderSigningKey: vta.signSk, receiverEncryptionKey: holder.encPk, }); @@ -165,6 +165,20 @@ test("a VTA that never answers an invite still gets the application message", as 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 diff --git a/packages/tsp-js/CHANGELOG.md b/packages/tsp-js/CHANGELOG.md index 7bee63d..4e38074 100644 --- a/packages/tsp-js/CHANGELOG.md +++ b/packages/tsp-js/CHANGELOG.md @@ -8,6 +8,13 @@ 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. + ### Fixed - An XSCS/XCTL body that is not exactly one Bytes primitive is refused. It was diff --git a/packages/tsp-js/README.md b/packages/tsp-js/README.md index 24a9e34..75d386e 100644 --- a/packages/tsp-js/README.md +++ b/packages/tsp-js/README.md @@ -61,7 +61,7 @@ accept.control.inReplyTo; // equals invite.threadDigest ``` The state machine (`transition`, `canSend`, `admitsApplicationMessage`, -`resolveInviteRace`, `resolveCancel`) is **pure** — state and event in, state or +`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. diff --git a/packages/tsp-js/src/index.ts b/packages/tsp-js/src/index.ts index 94e930a..8e96e42 100644 --- a/packages/tsp-js/src/index.ts +++ b/packages/tsp-js/src/index.ts @@ -67,9 +67,11 @@ export { canSend, compareBytes, InvalidTransitionError, + resolveAccept, resolveCancel, resolveInviteRace, transition, + type AcceptOutcome, type CancelOutcome, type InviteRaceOutcome, type RelationshipEvent, diff --git a/packages/tsp-js/src/relationship.ts b/packages/tsp-js/src/relationship.ts index 74110b9..60711bb 100644 --- a/packages/tsp-js/src/relationship.ts +++ b/packages/tsp-js/src/relationship.ts @@ -146,6 +146,40 @@ export function compareBytes(a: Uint8Array, b: Uint8Array): number { 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. */ diff --git a/packages/tsp-js/tests/relationship.test.mjs b/packages/tsp-js/tests/relationship.test.mjs index 0912ec9..488073c 100644 --- a/packages/tsp-js/tests/relationship.test.mjs +++ b/packages/tsp-js/tests/relationship.test.mjs @@ -6,6 +6,7 @@ import { canSend, admitsApplicationMessage, resolveInviteRace, + resolveAccept, resolveCancel, compareBytes, InvalidTransitionError, @@ -120,3 +121,13 @@ test("a cancellation may name either half of the relationship", () => { 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"); +});