Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions packages/core/src/vta/tsp-relationship.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

import {
packInvite,
resolveAccept,
unpack,
transition,
type ControlMessage,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 16 additions & 2 deletions packages/core/tests/tsp.relationship.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
});
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/tsp-js/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/tsp-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/tsp-js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,11 @@ export {
canSend,
compareBytes,
InvalidTransitionError,
resolveAccept,
resolveCancel,
resolveInviteRace,
transition,
type AcceptOutcome,
type CancelOutcome,
type InviteRaceOutcome,
type RelationshipEvent,
Expand Down
34 changes: 34 additions & 0 deletions packages/tsp-js/src/relationship.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
11 changes: 11 additions & 0 deletions packages/tsp-js/tests/relationship.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
canSend,
admitsApplicationMessage,
resolveInviteRace,
resolveAccept,
resolveCancel,
compareBytes,
InvalidTransitionError,
Expand Down Expand Up @@ -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");
});