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
192 changes: 190 additions & 2 deletions packages/core/src/rooms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RoomsKeysPresentResponsePayload>(
sender,
{ holder: params.holder, service: params.service },
KEYS_PRESENT,
KEYS_PRESENT_RESPONSE,
"rooms/keys/present/0.1",
payload,
);

const got = res.presentation as Partial<Presentation> | 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<RoomsKeysChainResponsePayload> {
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<RoomsKeysChainResponsePayload>(
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,
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -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<EpochLink[]> {
const { holder, service, ...rest } = params;
const res = await call<RoomsEpochChainResponsePayload>(
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`.
*
Expand Down
146 changes: 146 additions & 0 deletions packages/core/tests/rooms.present-and-chain.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
27 changes: 15 additions & 12 deletions packages/core/tests/task-surface.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading