diff --git a/packages/core/src/rooms/index.ts b/packages/core/src/rooms/index.ts index 34c79a1..3b82955 100644 --- a/packages/core/src/rooms/index.ts +++ b/packages/core/src/rooms/index.ts @@ -169,12 +169,157 @@ export async function roomsKeysSeal( return res.sealed; } +import { + TYPE_URI as KEYS_PRESENT, + RESPONSE_TYPE_URI as KEYS_PRESENT_RESPONSE, + type RoomsKeysPresentPayload, + type RoomsKeysPresentResponsePayload, +} from "@openvtc/trust-tasks/rooms/keys/present/0.1/payload"; +import { + TYPE_URI as KEYS_CHAIN, + RESPONSE_TYPE_URI as KEYS_CHAIN_RESPONSE, + type RoomsKeysChainPayload, + type RoomsKeysChainResponsePayload, +} from "@openvtc/trust-tasks/rooms/keys/chain/0.1/payload"; + +export interface RoomsPresentParams extends RoomsCaller { + roomId: string; + /** The action the presentation must confer, and no more. */ + action: RoomAction; + /** + * Who it is for — a host's DID. + * + * Optional on the wire and never omitted here: it is what stops a presentation + * being replayed at a different host, and a caller that has one has no reason + * to leave it out. + */ + audience: string; + /** A verifier-supplied freshness value, where one was offered. */ + nonce?: string; +} + +/** + * Ask this agent's own VTA for a presentation of the room's credentials. + * + * **Every host call needs one, and only this produces one.** The credentials + * themselves never cross — the VTA holds them and hands back a presentation + * bound to one operation, which is why `action` and `audience` are asked for + * rather than defaulted: a presentation minted for `read` should not open a + * `write`, and one minted for everything hands the caller the holder's whole + * standing. + * + * `nonce` is optional because a room host does not issue one. Freshness there is + * anchored by the request document's own proof and `issuedAt` — the host binds + * the presentation to the DID that signed the envelope, so an observed + * presentation is not replayable by anyone else. Pass a nonce when some other + * verifier supplies one; its absence here is a property of the host, not an + * omission. + * + * ## Why the answer is checked before it is returned + * + * The published schemas type the two ends of this value differently: this + * response declares `presentation` as a bare open object, while every host task + * `$ref`s `AuthorityPresentation`, whose `membership` and `authority` are + * REQUIRED. So the type that comes back does not fit the parameter it exists to + * fill, and the only ways past that are a blind cast or a check. + * + * A blind cast moves the failure: a presentation missing its chain reaches the + * host and comes back "no authority chain presented; a room operation is + * authorized by the chain" — an accusation aimed at the member, three hops from + * the agent that actually produced the empty answer. Checking here names the + * party that did. + */ +export async function roomsKeysPresent( + sender: TrustTaskSender, + params: RoomsPresentParams, +): Promise<{ presentation: Presentation; expiresAt?: string }> { + // Built as a typed literal rather than spread-and-cast: the casts elsewhere in + // this module hide a misspelled member, and this payload has three of them + // whose names are easy to guess wrong (`nonce`, not `challenge`). + const payload: RoomsKeysPresentPayload = { + roomId: params.roomId, + action: params.action, + audience: params.audience, + ...(params.nonce ? { nonce: params.nonce } : {}), + }; + const res = await call( + sender, + { holder: params.holder, service: params.service }, + KEYS_PRESENT, + KEYS_PRESENT_RESPONSE, + "rooms/keys/present/0.1", + payload, + ); + + const got = res.presentation as Partial | undefined; + if (!got || typeof got.membership !== "string" || !Array.isArray(got.authority)) { + throw new Error( + `${params.service.did} returned a presentation for room ${params.roomId} with no ` + + `membership credential or no authority chain. A host authorizes from the chain ` + + `alone, so it would refuse this — and the refusal would read as if the member ` + + `lacked authority rather than as an agent that answered incompletely.`, + ); + } + + return { + presentation: got as Presentation, + ...(res.expiresAt ? { expiresAt: res.expiresAt } : {}), + }; +} + +/** + * Hand this agent's own VTA the rungs its principal fetched from the host. + * + * The last leg of a joining member's backfill, and the repair for a room that + * reads only from the epoch its holder joined at. The VTA accrues a rung for + * every membership change it lives through; this is for the history it did not. + * + * **The response is the answer worth having.** `earliestReadableEpoch` is not a + * restatement of what was sent — a rung extends reach only if every rung above + * it is present too, so a set with a gap in it is reported here rather than at + * the first record that will not open, which reads like corruption. + */ +export async function roomsKeysChain( + sender: TrustTaskSender, + params: RoomsCaller & { roomId: string; links: EpochLink[] }, +): Promise { + const payload: RoomsKeysChainPayload = { + roomId: params.roomId, + // `links` is `[EpochLink, ...EpochLink[]]` — the schema's `minItems: 1`. A + // caller with nothing to deliver must not send an empty delivery, so the + // narrowing is here rather than left to the agent to reject. + links: params.links as RoomsKeysChainPayload["links"], + }; + return call( + sender, + { holder: params.holder, service: params.service }, + KEYS_CHAIN, + KEYS_CHAIN_RESPONSE, + "rooms/keys/chain/0.1", + payload, + ); +} + // ── The host side ──────────────────────────────────────────────────────── // // Everything below goes to the room's HOST and carries an authority // presentation — the credentials the room issued, which is the only thing a -// host consults. `presentation` is passed through opaquely: this module does -// not mint credentials, and a caller that has none cannot do these. +// host consults. Mint one with `roomsKeysPresent` above; nothing else in this +// library produces one, and a caller that has none cannot do these at all. +// +// **The browser console cannot call any of these, and that is a property of the +// extension rather than of this module.** Its bridge (`manager/carrier.ts`) +// passes exactly `{type, payload}` — so that the offscreen document mints and +// signs the envelope rather than counter-signing one composed in a page — and +// the background then addresses it to the wallet's own VTA. A `service` naming a +// host is therefore dropped, and the call lands at an agent that does not serve +// it. Nothing type-checks as wrong; it simply goes to the wrong party. +// +// A surface holding a channel to a host of its own — a server-side consumer, a +// CLI — uses these directly. A surface that only reaches its own agent asks the +// agent to make the call: `rooms/keys/backfill` and `rooms/owner/register` +// (trustoverip/dtgwg-trust-tasks-tf#402) exist for exactly that, and will appear +// above, in the VTA-terminating half, once they ship. import { TYPE_URI as ROOMS_CREATE, @@ -206,6 +351,16 @@ import { type RoomsEpochMintPayload, type RoomsEpochMintResponsePayload, } from "@openvtc/trust-tasks/rooms/epoch/mint/0.1/payload"; +import { + TYPE_URI as EPOCH_CHAIN, + RESPONSE_TYPE_URI as EPOCH_CHAIN_RESPONSE, + type RoomsEpochChainPayload, + type RoomsEpochChainResponsePayload, + type EpochLink, +} from "@openvtc/trust-tasks/rooms/epoch/chain/0.1/payload"; + +/** One rung of the epoch key chain: an epoch's storage key sealed under the next. */ +export type { EpochLink }; /** The credentials a caller presents to a host. Opaque here. */ export type Presentation = RoomsRecordsListPayload["presentation"]; @@ -290,6 +445,39 @@ export async function roomsRecordsPut( ); } +/** + * Fetch the room's epoch key chain from its host. + * + * The first of the two hops that repair a member who can only read from where + * they joined. This gets the rungs; [`roomsKeysChain`] hands them to the + * member's own VTA, which is the only party that can say how far back they + * actually reach. + * + * **Needs a `read` presentation, not a special one.** Reading the room and + * reading the parts written earlier are the same act, so they take the same + * grant — a separate one would be a grant nobody could explain. + * + * What comes back is ciphertext the host cannot read: a rung is an epoch's + * storage key sealed under the next, and no host holds either. Serving them to a + * party with no epoch key discloses only how many epochs there have been, which + * the room's epoch number already said. + */ +export async function roomsEpochChain( + sender: TrustTaskSender, + params: RoomsHostCall & { fromEpoch?: number; limit?: number }, +): Promise { + const { holder, service, ...rest } = params; + const res = await call( + sender, + { holder, service }, + EPOCH_CHAIN, + EPOCH_CHAIN_RESPONSE, + "rooms/epoch/chain/0.1", + rest as unknown as RoomsEpochChainPayload, + ); + return res.links ?? []; +} + /** * Renew the room by minting its next epoch. Needs `admin`. * diff --git a/packages/core/tests/rooms.present-and-chain.mjs b/packages/core/tests/rooms.present-and-chain.mjs new file mode 100644 index 0000000..7579393 --- /dev/null +++ b/packages/core/tests/rooms.present-and-chain.mjs @@ -0,0 +1,146 @@ +// The presentation oracle, and the delivery that repairs a member's history. +// +// `rooms/keys/present` is the load-bearing one and its absence was invisible: +// every host-served room task takes an authority presentation, and until this +// existed nothing in the library could produce one — so `records/{list,get,put}` +// and `epoch/mint` were exported, typechecked, and impossible to call. +// +// The tests below are about the two places that shape is easy to get wrong. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { roomsKeysPresent, roomsKeysChain } from "../dist/rooms/index.js"; + +const HOLDER = { did: "did:key:zMember" }; +const AGENT = { did: "did:webvh:QmAgent:agent.example" }; +const HOST = "did:webvh:QmHost:host.example"; +const ROOM = "did:webvh:QmRoom:rooms.example"; + +const PRESENTATION = { membership: "eyJhbGciOiJFZERTQSJ9.vmc", authority: ["eyJ.vac"] }; + +/** Captures the envelope instead of sending it, and replies with `reply`. */ +function recorder(reply) { + const sent = []; + return { + sent, + send(envelope, opts) { + sent.push({ envelope, opts }); + return Promise.resolve(reply); + }, + }; +} + +// ── present ───────────────────────────────────────────────────────────────── + +// The member is `nonce`, not `challenge` — the schema's *description* says +// "freshness value" and reading that instead of the member name puts an unknown +// key on a payload the agent will reject. +test("the freshness value is sent as `nonce`, and omitted when absent", async () => { + const withNonce = recorder({ presentation: PRESENTATION }); + await roomsKeysPresent(withNonce, { + holder: HOLDER, service: AGENT, roomId: ROOM, action: "read", + audience: HOST, nonce: "n-1", + }); + assert.equal(withNonce.sent[0].envelope.payload.nonce, "n-1"); + assert.ok(!("challenge" in withNonce.sent[0].envelope.payload)); + + const without = recorder({ presentation: PRESENTATION }); + await roomsKeysPresent(without, { + holder: HOLDER, service: AGENT, roomId: ROOM, action: "read", audience: HOST, + }); + assert.ok( + !("nonce" in without.sent[0].envelope.payload), + "an absent nonce must be absent, not null — the payload denies unknown shapes", + ); +}); + +// `audience` is optional on the wire and is what stops a presentation being +// replayed at a different host, so a caller holding one has no reason to omit it. +test("the presentation is scoped to one action and one audience", async () => { + const channel = recorder({ presentation: PRESENTATION }); + await roomsKeysPresent(channel, { + holder: HOLDER, service: AGENT, roomId: ROOM, action: "read", audience: HOST, + }); + const { payload } = channel.sent[0].envelope; + assert.equal(payload.action, "read"); + assert.equal(payload.audience, HOST); + assert.equal(payload.roomId, ROOM); +}); + +// It goes to the member's OWN agent — the credentials never leave it, and a +// presentation asked of the host would be asking the verifier to vouch for the +// party it is about to check. +test("present is addressed to the agent, not the host", async () => { + const channel = recorder({ presentation: PRESENTATION }); + await roomsKeysPresent(channel, { + holder: HOLDER, service: AGENT, roomId: ROOM, action: "read", audience: HOST, + }); + assert.equal(channel.sent[0].envelope.recipient, AGENT.did); + assert.equal(channel.sent[0].envelope.issuer, HOLDER.did); +}); + +// The published schemas type the two ends of this value differently — the +// response is a bare open object, every host task requires `membership` and +// `authority` — so an incomplete answer is representable. Caught here, it names +// the agent that produced it; passed on, the host refuses it in words that +// accuse the member of lacking authority. +test("an incomplete presentation is refused where it was produced", async () => { + for (const [what, bad] of [ + ["no authority chain", { membership: "eyJ.vmc" }], + ["no membership credential", { authority: ["eyJ.vac"] }], + ["an empty object", {}], + ]) { + await assert.rejects( + roomsKeysPresent(recorder({ presentation: bad }), { + holder: HOLDER, service: AGENT, roomId: ROOM, action: "read", audience: HOST, + }), + (e) => { + assert.match(e.message, new RegExp(AGENT.did), `${what}: must name the agent`); + assert.match(e.message, /membership credential or no authority chain/); + return true; + }, + `${what} should be refused`, + ); + } +}); + +// An empty `authority` array is a chain of length zero, which a host refuses for +// the same reason as a missing one — but it IS an array, so a shape check that +// only tested `Array.isArray` would pass it through. This documents which side +// of that line the client sits on: it forwards, and the host is the authority on +// depth. +test("an empty authority array is forwarded, not judged here", async () => { + const res = await roomsKeysPresent(recorder({ presentation: { membership: "m", authority: [] } }), { + holder: HOLDER, service: AGENT, roomId: ROOM, action: "read", audience: HOST, + }); + assert.deepEqual(res.presentation.authority, []); +}); + +// ── keys/chain ────────────────────────────────────────────────────────────── + +test("the rungs are delivered to the agent under the canonical type", async () => { + const links = [{ epoch: 4, wrapped: "w4", nonce: "n4" }]; + const channel = recorder({ roomId: ROOM, earliestReadableEpoch: 1, stored: 1 }); + + const res = await roomsKeysChain(channel, { + holder: HOLDER, service: AGENT, roomId: ROOM, links, + }); + + const { envelope, opts } = channel.sent[0]; + assert.equal(envelope.type, "https://trusttasks.org/spec/rooms/keys/chain/0.1"); + assert.equal(opts.expectedResponseType, "https://trusttasks.org/spec/rooms/keys/chain/0.1#response"); + assert.equal(envelope.recipient, AGENT.did); + assert.deepEqual(envelope.payload.links, links); + assert.equal(res.earliestReadableEpoch, 1); +}); + +// `stored: 0` is a success and not a no-op — it is what a retry looks like — so +// the client must pass it through rather than treating it as a missing field. +test("zero stored is reported, not swallowed", async () => { + const res = await roomsKeysChain(recorder({ roomId: ROOM, earliestReadableEpoch: 3, stored: 0 }), { + holder: HOLDER, service: AGENT, roomId: ROOM, links: [{ epoch: 3, wrapped: "w", nonce: "n" }], + }); + assert.equal(res.stored, 0); + assert.equal(res.earliestReadableEpoch, 3); +}); diff --git a/packages/core/tests/task-surface.mjs b/packages/core/tests/task-surface.mjs index 4987fee..b3df6bb 100644 --- a/packages/core/tests/task-surface.mjs +++ b/packages/core/tests/task-surface.mjs @@ -313,19 +313,22 @@ test("coverage against the agent's surface is recorded, not discovered", () => { // library gained are those minus `keys/chain`, plus `keys/open`, which was // canonical and unimplemented until the rooms pane needed to read a record. // - // **`rooms/keys/chain` is the one outstanding piece of the pane's own story.** - // The pane already tells a member their history is unreadable before the - // epoch they joined at; delivering the chain to their key holder is the - // repair, and it is a second hop after fetching the rungs from the host - // (`rooms/epoch/chain`, which is host-served and in NOT_IN_SDK). Wiring the - // two together is the next increment, not an omission to paper over. + // 194 -> 196 closes that: `rooms/keys/chain` (the delivery that repairs a + // member reading only from where they joined) and `rooms/keys/present` (the + // presentation oracle). The canonical total does not move — both were already + // in the SDK and merely unimplemented here. // - // The outstanding count is unchanged at 20 — `keys/chain` joined it and - // `keys/open` left it — but the set is not the same: the four remaining - // `rooms/keys/*` (commit, key-package, present, welcome) are MLS group - // operations a browser does not perform. They belong to whatever holds the - // group state, which is the VTA, not this library. - const expected = 194; + // **`present` was the load-bearing one, and its absence was not visible as a + // gap.** Every host-served room task takes an authority presentation, and + // nothing in this library could produce one — so `records/{list,get,put}` and + // `epoch/mint` were exported, typechecked, and impossible to call. A count + // does not catch that; the missing family was in a *different* half of the + // surface from the ones it made unreachable. + // + // The three remaining `rooms/keys/*` — commit, key-package, welcome — are MLS + // group operations a browser does not perform. They belong to whatever holds + // the group state, which is the VTA, not this library. + const expected = 196; assert.equal( implemented.size, expected,