@tavrn/e2ee 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cleanup.ts ADDED
@@ -0,0 +1,336 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * Removal of ghost leaves (ENCRYPTION.md §4.7): leaves of deleted or unattested devices and of users who are no
4
+ * longer members. A client only commits removals the node allows (§4.5 rule 4) and that it verified itself
5
+ * (§4.6 ghost check):
6
+ *
7
+ * - any device of the same user removes that user's other ghost leaves (own-user rule);
8
+ * - any member removes leaves whose device is deleted or not attested;
9
+ * - leaves of users who are no longer members only by those who may manage the conversation; bot leaves in a
10
+ * conversation whose "no bots" flag is set count as such (the bot is not entitled there, APPLICATIONS.md §3.5).
11
+ *
12
+ * Before an own leaf goes, its device is attested again as retired when the node has no retired attestation for it
13
+ * (devices deleted before nodes kept them, ENCRYPTION.md §5.2): the leaf's signature key is the device key, and
14
+ * without it the history that device wrote could no longer be verified once the leaf is gone. Only a key the account
15
+ * attested itself is signed again (its own-devices record, `MlsManager.knownOwnDevice`): a leaf the node planted under
16
+ * the user's address goes without one, and the messages of a device the record does not know stay unverifiable.
17
+ *
18
+ * Leaves are removed by identity and signature key, so a leaf that names a genuine device with another key goes
19
+ * while the genuine leaf stays.
20
+ *
21
+ * To avoid a storm of competing commits, the lowest online device id among the devices that may do the removal
22
+ * tries first; every other device waits {@link CLEANUP_WAIT_MS} and tries only if the leaf is still there. One
23
+ * cleanup runs per conversation at a time; an epoch conflict is retried after the missing commits (at most 3).
24
+ *
25
+ * The election counts only the valid leaves (no ghosts) of devices that may commit every removal of the run, by the
26
+ * rules above: own ghost leaves only devices of the same user; departed members only devices of users who may manage
27
+ * the conversation (`managers`, when the caller knows them); deleted or unattested devices any member. Of those, only
28
+ * the devices known to be online count when the caller knows it: `online` (device identities, e.g. the voice
29
+ * participants) and `onlineUsers` (user addresses, e.g. the presence list; every device of a user who is not in it is
30
+ * offline). This device always counts. Without the lists every candidate counts, as before: a device that is offline
31
+ * or not allowed would otherwise hold every other device back for the whole wait.
32
+ *
33
+ * A message waiting for the removal (the ghost check refuses to encrypt while a departed member's leaf is in the
34
+ * group) cannot wait for the election: an `urgent` request skips the wait and cuts the wait of a running cleanup short.
35
+ * A second commit of the same removal costs nothing but an epoch conflict, after which the leaf is gone.
36
+ *
37
+ * A voice join cannot wait either, but nothing waits for the removal there (the current epoch works for the fresh
38
+ * voice key, ENCRYPTION.md §5a): a `noWait` request joins a running cleanup and cuts its wait short (as `urgent`), runs
39
+ * at once when this device is elected, and otherwise returns `postponed` without committing anything; the caller runs
40
+ * the cleanup again without `noWait` later (in the background, after connecting), where it waits for the election.
41
+ */
42
+ import { TavrnApiError } from "@tavrn/sdk";
43
+ import { MlsError, splitIdentity, type GhostViolation, type MlsManager } from "./mls";
44
+
45
+ /** Delay of the devices that were not elected to go first. */
46
+ export const CLEANUP_WAIT_MS = 10_000;
47
+ /** Attempts per leaf and session (a node that refuses keeps refusing). */
48
+ export const CLEANUP_MAX_TRIES = 3;
49
+ /**
50
+ * A cleanup looks every member's devices up again (`ghostCheck` `fresh`) unless the last fresh check of the
51
+ * conversation found no ghost in the same roster (identities and signature keys) less than this ago and the node
52
+ * lists no pending removal of a leaf in it. Device deletions reach clients as `devices.changed` (the key cache forgets
53
+ * the user) and as pending removals, so an unchanged roster needs no new lookups; a voice join then costs no key
54
+ * round trips. The epoch is not part of the key: every voice join's self update moves it without changing a leaf's
55
+ * identity or signature key.
56
+ */
57
+ export const FRESH_REUSE_MS = 10 * 60_000;
58
+
59
+ /** A pending removal as the hosting node lists it. */
60
+ export interface PendingRemovalLike {
61
+ identity: string;
62
+ reason: string;
63
+ }
64
+
65
+ export interface CleanupRequest {
66
+ /** Qualified conversation id. */
67
+ q: string;
68
+ /** Addresses entitled to the conversation; null when unknown (membership is then not judged). */
69
+ members: string[] | null;
70
+ /** The user may remove leaves of users who are no longer members (DMs: every member; channels: moderators). */
71
+ mayManage: boolean;
72
+ /** Pending removals of the hosting node (labels deleted devices as `removed`). */
73
+ pending?: PendingRemovalLike[] | null;
74
+ /** Device identities known to be online (voice participants); null = unknown. */
75
+ online?: Iterable<string> | null;
76
+ /** Addresses of users known to be online (presence); devices of other users are offline. null = unknown. */
77
+ onlineUsers?: Iterable<string> | null;
78
+ /**
79
+ * Addresses of the users who may remove leaves of departed members (DMs: every member, channels: moderators); only
80
+ * their devices are elected for such removals. null = unknown (every member counts).
81
+ */
82
+ managers?: Iterable<string> | null;
83
+ /** The conversation's effective "no bots" flag: bot leaves are ghosts (§4.6) and go like departed members. */
84
+ noBots?: boolean;
85
+ /**
86
+ * Content is waiting for the removal: no election wait, and a running cleanup of the conversation that is waiting
87
+ * for the elected device stops waiting.
88
+ */
89
+ urgent?: boolean;
90
+ /**
91
+ * The caller cannot wait for the election and nothing waits for the removal (a voice join): a running cleanup of the
92
+ * conversation stops waiting and is shared (as with `urgent`); otherwise a device that is not elected gets
93
+ * `postponed` at once and commits nothing (run the cleanup again without `noWait` later).
94
+ */
95
+ noWait?: boolean;
96
+ }
97
+
98
+ /** `postponed`: a `noWait` request of a device that was not elected; nothing was committed. */
99
+ export type CleanupOutcome = "clean" | "removed" | "deferred" | "not_allowed" | "failed" | "postponed";
100
+
101
+ export interface CleanupResult {
102
+ outcome: CleanupOutcome;
103
+ /** Leaves this device removed. */
104
+ removed: string[];
105
+ /** Ghost violations left afterwards, labelled with `removed` where the node reports a deleted device. */
106
+ ghosts: GhostViolation[];
107
+ }
108
+
109
+ export interface CleanerDeps {
110
+ mls: Pick<MlsManager, "ghostCheck" | "removeLeaves" | "roster" | "identity" | "address"> &
111
+ Partial<Pick<MlsManager, "rosterEntries" | "retiredDevice" | "verifiedDevices" | "knownOwnDevice">>;
112
+ /**
113
+ * Attests an own deleted device again as retired (`POST /users/me/devices/retired`) from its leaf key; true when
114
+ * the node has it now (or refused it for good), false to keep the leaf for a later try (e.g. offline). Called only
115
+ * for keys `mls.knownOwnDevice` vouches for.
116
+ */
117
+ reattest?(deviceId: string, key: Uint8Array): Promise<boolean>;
118
+ sleep?(ms: number): Promise<void>;
119
+ /** Clock of {@link FRESH_REUSE_MS} (default `Date.now`). */
120
+ now?(): number;
121
+ waitMs?: number;
122
+ /** Waits before the retries after an epoch conflict (default 500 ms, 1.5 s, 3 s). */
123
+ backoffMs?: number[];
124
+ }
125
+
126
+ type Why = "own" | "device" | "membership";
127
+
128
+ /** A leaf to remove: its identity and signature key (null: every leaf of the identity). */
129
+ interface Leaf {
130
+ identity: string;
131
+ key: Uint8Array | null;
132
+ }
133
+
134
+ function sameKey(a: Uint8Array, b: Uint8Array): boolean {
135
+ return a.length === b.length && a.every((x, i) => x === b[i]);
136
+ }
137
+
138
+ function deviceIdOf(identity: string): string {
139
+ return splitIdentity(identity).deviceId;
140
+ }
141
+
142
+ /** Labels unattested leaves whose device the node reports as deleted (`removed`: cleanup pending). */
143
+ export function labelGhosts(ghosts: GhostViolation[], pending: PendingRemovalLike[] | null | undefined): GhostViolation[] {
144
+ if (!pending?.length) return ghosts;
145
+ const deleted = new Set(pending.filter((p) => p.reason === "deleted").map((p) => p.identity));
146
+ return ghosts.map((g) => (g.reason === "unattested" && deleted.has(g.identity) ? { ...g, reason: "removed" } : g));
147
+ }
148
+
149
+ /**
150
+ * Whether this device goes first: the lowest device id among the devices that may commit the removal and are
151
+ * online (this device always counts as online). `online` names devices, `onlineUsers` users (all their devices);
152
+ * a list that is not given does not filter.
153
+ */
154
+ export function electedFirst(self: string, eligible: Iterable<string>, online?: Iterable<string> | null, onlineUsers?: Iterable<string> | null): boolean {
155
+ const on = online ? new Set(online) : null;
156
+ const users = onlineUsers ? new Set(onlineUsers) : null;
157
+ const candidates = [...new Set([...eligible, self])].filter((i) => i === self || ((!on || on.has(i)) && (!users || users.has(splitIdentity(i).address))));
158
+ candidates.sort((a, b) => (deviceIdOf(a) < deviceIdOf(b) ? -1 : deviceIdOf(a) > deviceIdOf(b) ? 1 : a < b ? -1 : 1));
159
+ return candidates[0] === self;
160
+ }
161
+
162
+ function isConflict(e: unknown): boolean {
163
+ return (e instanceof MlsError && e.code === "conflict") || (e instanceof TavrnApiError && e.code === "mls.epoch_conflict");
164
+ }
165
+
166
+ export class LeafCleaner {
167
+ /**
168
+ * Own leaves without a retired attestation whose key the account attested itself: re-attested first; leaves whose
169
+ * re-attestation failed stay. Any other own leaf goes without one.
170
+ */
171
+ private async keepHistory(leaves: Leaf[]): Promise<Leaf[]> {
172
+ const { mls, reattest } = this.deps;
173
+ const own = leaves.filter((l) => splitIdentity(l.identity).address === mls.address);
174
+ if (!reattest || !own.length || !mls.retiredDevice || !mls.verifiedDevices || !mls.knownOwnDevice) return leaves;
175
+ await mls.verifiedDevices(mls.address, true).catch(() => null);
176
+ const keep = new Set<Leaf>();
177
+ for (const leaf of own) {
178
+ const { deviceId } = splitIdentity(leaf.identity);
179
+ if (!leaf.key || mls.retiredDevice(mls.address, deviceId)) continue;
180
+ if (!(await mls.knownOwnDevice(deviceId, leaf.key).catch(() => false))) continue;
181
+ const ok = await reattest(deviceId, leaf.key).catch(() => false);
182
+ if (!ok) keep.add(leaf);
183
+ }
184
+ return leaves.filter((l) => !keep.has(l));
185
+ }
186
+
187
+ /**
188
+ * The leaves behind the target identities: with keys known (`rosterEntries`), every leaf of the identity except one
189
+ * carrying the device's attested key (that one is genuine); otherwise the identity alone (all its leaves).
190
+ */
191
+ private async leavesOf(q: string, targets: Map<string, Why>): Promise<Leaf[]> {
192
+ const { mls } = this.deps;
193
+ if (!mls.rosterEntries) return [...targets.keys()].map((identity) => ({ identity, key: null }));
194
+ const entries = mls.rosterEntries(q);
195
+ const out: Leaf[] = [];
196
+ for (const [identity, why] of targets) {
197
+ const { address, deviceId } = splitIdentity(identity);
198
+ const attested = mls.verifiedDevices ? (await mls.verifiedDevices(address).catch(() => null))?.get(deviceId) : undefined;
199
+ for (const e of entries) {
200
+ if (e.identity !== identity) continue;
201
+ // Departed members go with every leaf; otherwise the leaf with the attested key is the genuine one.
202
+ if (why !== "membership" && attested && sameKey(attested, e.key)) continue;
203
+ out.push({ identity, key: e.key });
204
+ }
205
+ }
206
+ return out;
207
+ }
208
+
209
+ private readonly running = new Map<string, Promise<CleanupResult>>();
210
+ /** Ends the election wait of a running cleanup, per conversation. */
211
+ private readonly waiting = new Map<string, () => void>();
212
+ private readonly tries = new Map<string, number>();
213
+ /** The roster of the last fresh ghost check that found nothing, per conversation, and when. */
214
+ private readonly checked = new Map<string, { roster: string; at: number }>();
215
+
216
+ constructor(private readonly deps: CleanerDeps) {}
217
+
218
+ private sleep(ms: number): Promise<void> {
219
+ return this.deps.sleep ? this.deps.sleep(ms) : new Promise((r) => setTimeout(r, ms));
220
+ }
221
+
222
+ /** The election wait of a cleanup; an urgent request for the same conversation ends it early. */
223
+ private electionWait(q: string): Promise<void> {
224
+ return new Promise((resolve) => {
225
+ const done = () => {
226
+ if (this.waiting.get(q) === done) this.waiting.delete(q);
227
+ resolve();
228
+ };
229
+ this.waiting.set(q, done);
230
+ void this.sleep(this.deps.waitMs ?? CLEANUP_WAIT_MS).then(done, done);
231
+ });
232
+ }
233
+
234
+ /** Whether a cleanup of the conversation is running (possibly in its election wait). */
235
+ isRunning(q: string): boolean {
236
+ return this.running.has(q);
237
+ }
238
+
239
+ /** Cleans the conversation's group; concurrent calls for one conversation share the run. */
240
+ clean(req: CleanupRequest): Promise<CleanupResult> {
241
+ const running = this.running.get(req.q);
242
+ if (running) {
243
+ if (req.urgent || req.noWait) this.waiting.get(req.q)?.();
244
+ return running;
245
+ }
246
+ const p = this.run(req).finally(() => this.running.delete(req.q));
247
+ this.running.set(req.q, p);
248
+ return p;
249
+ }
250
+
251
+ /** The roster's identities and signature keys (identities alone without `rosterEntries`). */
252
+ private fingerprint(q: string): string {
253
+ const { mls } = this.deps;
254
+ const entries = mls.rosterEntries ? mls.rosterEntries(q).map((e) => `${e.identity}#${e.key.join(".")}`) : mls.roster(q);
255
+ return entries.sort().join("|");
256
+ }
257
+
258
+ /** The ghosts this device may remove, by rule. */
259
+ private async targets(req: CleanupRequest): Promise<{ ghosts: GhostViolation[]; targets: Map<string, Why> }> {
260
+ const { mls } = this.deps;
261
+ const roster = mls.roster(req.q);
262
+ // Without a member list every leaf's user counts as a member: only attestation is judged.
263
+ const members = req.members ?? roster.map((i) => splitIdentity(i).address);
264
+ const fingerprint = this.fingerprint(req.q);
265
+ const now = this.deps.now?.() ?? Date.now();
266
+ const last = this.checked.get(req.q);
267
+ const pendingHere = !!req.pending?.some((p) => roster.includes(p.identity));
268
+ const fresh = !last || last.roster !== fingerprint || now - last.at >= FRESH_REUSE_MS || pendingHere;
269
+ const ghosts = await mls.ghostCheck(req.q, members, { fresh, noBots: req.noBots });
270
+ if (ghosts.length) this.checked.delete(req.q);
271
+ else if (fresh) this.checked.set(req.q, { roster: fingerprint, at: now });
272
+ const targets = new Map<string, Why>();
273
+ for (const g of ghosts) {
274
+ // Duplicates carry the attested key (removing one would hit the genuine leaf); this device's own leaf is never
275
+ // reported, a leaf under its name with another key is.
276
+ if (g.reason === "duplicate") continue;
277
+ if ((this.tries.get(`${req.q}|${g.identity}`) ?? 0) >= CLEANUP_MAX_TRIES) continue;
278
+ if (splitIdentity(g.identity).address === mls.address) targets.set(g.identity, "own");
279
+ else if (g.reason === "unattested") targets.set(g.identity, "device");
280
+ else if ((g.reason === "not_member" || g.reason === "bot") && req.mayManage) targets.set(g.identity, "membership");
281
+ }
282
+ return { ghosts, targets };
283
+ }
284
+
285
+ private async run(req: CleanupRequest): Promise<CleanupResult> {
286
+ const { mls } = this.deps;
287
+ let { ghosts, targets } = await this.targets(req);
288
+ const result = (outcome: CleanupOutcome, removed: string[] = []): CleanupResult => ({ outcome, removed, ghosts: labelGhosts(ghosts, req.pending) });
289
+ if (targets.size === 0) return result(ghosts.length ? "not_allowed" : "clean");
290
+
291
+ // Election among the valid leaves of devices that may commit every one of these removals.
292
+ const ghostIds = new Set(ghosts.map((g) => g.identity));
293
+ const kinds = new Set(targets.values());
294
+ const managers = req.managers ? new Set(req.managers) : null;
295
+ const eligible = mls.roster(req.q).filter((i) => {
296
+ if (ghostIds.has(i)) return false;
297
+ const { address } = splitIdentity(i);
298
+ if (address === mls.address) return true;
299
+ if (kinds.has("own")) return false;
300
+ return !kinds.has("membership") || !managers || managers.has(address);
301
+ });
302
+ if (!req.urgent && !electedFirst(mls.identity, eligible, req.online, req.onlineUsers)) {
303
+ if (req.noWait) return result("postponed");
304
+ await this.electionWait(req.q);
305
+ ({ ghosts, targets } = await this.targets(req));
306
+ if (targets.size === 0) return result(ghosts.length ? "deferred" : "clean");
307
+ }
308
+
309
+ const leaves = await this.keepHistory(await this.leavesOf(req.q, targets));
310
+ if (leaves.length === 0) return result("deferred");
311
+ const ids = [...new Set(leaves.map((l) => l.identity))];
312
+ for (const id of ids) this.tries.set(`${req.q}|${id}`, (this.tries.get(`${req.q}|${id}`) ?? 0) + 1);
313
+ const byKey = leaves.every((l) => l.key !== null);
314
+ const backoff = this.deps.backoffMs ?? [500, 1500, 3000];
315
+ for (let attempt = 0; ; attempt++) {
316
+ try {
317
+ const ok = byKey ? await mls.removeLeaves(req.q, leaves.map((l) => l.identity), leaves.map((l) => l.key!)) : await mls.removeLeaves(req.q, ids);
318
+ if (!ok) {
319
+ // The node refused (403 mls.forbidden) or the leaves are already gone.
320
+ const after = await this.targets(req);
321
+ ghosts = after.ghosts;
322
+ const entries = byKey && mls.rosterEntries ? mls.rosterEntries(req.q) : null;
323
+ const stillThere = entries ? leaves.some((l) => entries.some((e) => e.identity === l.identity && sameKey(e.key, l.key!))) : ids.some((i) => mls.roster(req.q).includes(i));
324
+ if (after.targets.size === 0 && !stillThere) return result("clean");
325
+ for (const id of ids) this.tries.set(`${req.q}|${id}`, CLEANUP_MAX_TRIES);
326
+ return result("not_allowed");
327
+ }
328
+ ghosts = await mls.ghostCheck(req.q, req.members ?? mls.roster(req.q).map((i) => splitIdentity(i).address), { noBots: req.noBots });
329
+ return result("removed", ids);
330
+ } catch (e) {
331
+ if (!isConflict(e) || attempt >= backoff.length - 1) return result("failed");
332
+ await this.sleep(backoff[attempt] ?? 1000);
333
+ }
334
+ }
335
+ }
336
+ }
@@ -0,0 +1,95 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * Command arguments in encrypted conversations (protocol/APPLICATIONS.md §5.8). The invoker's client encrypts
4
+ * a `command.args` content object for the conversation's MLS group (the bot is a member); the node sees only
5
+ * which command was invoked. The application decrypts it like a message (the content signature is checked
6
+ * against the invoker's attested device by the session manager) and then runs {@link CommandArgsVerifier}:
7
+ * the payload must name the invoked command, be fresh and carry a nonce it has not seen, so a node can neither
8
+ * replay arguments nor move them to another command.
9
+ */
10
+ import { b64urlEncode } from "@tavrn/sdk";
11
+ import { NonceWindow } from "./lru";
12
+
13
+ export const COMMAND_ARGS_TYPE = "command.args";
14
+ /** Arguments older than this are refused. */
15
+ export const ARGS_MAX_AGE_MS = 5 * 60 * 1000;
16
+ /** Nonces are remembered this long (the lifetime of an interaction). */
17
+ export const ARGS_NONCE_KEEP_MS = 15 * 60 * 1000;
18
+ /** Clock skew tolerated for arguments from the future. */
19
+ export const ARGS_MAX_SKEW_MS = 60 * 1000;
20
+
21
+ /** The content object of encrypted command arguments. */
22
+ export interface CommandArgsContent {
23
+ type: typeof COMMAND_ARGS_TYPE;
24
+ /** The invoked command's reference `<application ref>/<command id>`. */
25
+ command: string;
26
+ /** base64url of 16 random bytes. */
27
+ nonce: string;
28
+ issued_at: string;
29
+ args: Record<string, unknown>;
30
+ }
31
+
32
+ /** Builds the `command.args` content for `commandRef` with a fresh nonce. */
33
+ export function commandArgsContent(commandRef: string, args: Record<string, unknown>, now: Date = new Date()): CommandArgsContent {
34
+ const nonce = new Uint8Array(16);
35
+ crypto.getRandomValues(nonce);
36
+ return { type: COMMAND_ARGS_TYPE, command: commandRef, nonce: b64urlEncode(nonce), issued_at: now.toISOString(), args };
37
+ }
38
+
39
+ /** What encrypts a content object for a conversation (the session manager's `encrypt`). */
40
+ export interface ContentEncryptor {
41
+ encrypt(conversationId: string, content: Record<string, unknown>): Promise<{ content: string; epoch: number }>;
42
+ }
43
+
44
+ /** Encrypts the arguments of an invocation: the `args_ciphertext` and `epoch` of `POST …/interactions`. */
45
+ export async function encryptCommandArgs(
46
+ mls: ContentEncryptor,
47
+ conversationId: string,
48
+ commandRef: string,
49
+ args: Record<string, unknown>,
50
+ now?: Date,
51
+ ): Promise<{ args_ciphertext: string; epoch: number }> {
52
+ const sealed = await mls.encrypt(conversationId, { ...commandArgsContent(commandRef, args, now) });
53
+ return { args_ciphertext: sealed.content, epoch: sealed.epoch };
54
+ }
55
+
56
+ export type ArgsRejection = "invalid" | "command" | "stale" | "future" | "replay";
57
+
58
+ export type ArgsCheck = { ok: true; args: Record<string, unknown> } | { ok: false; reason: ArgsRejection };
59
+
60
+ const NONCE = /^[A-Za-z0-9_-]{22}$/;
61
+
62
+ /**
63
+ * The application's checks of decrypted arguments (§5.8): type and shape, the invoked command, age at most five
64
+ * minutes (and not from the future), a nonce not seen in the last 15 minutes.
65
+ */
66
+ export class CommandArgsVerifier {
67
+ private readonly seen: NonceWindow;
68
+ private readonly now: () => number;
69
+ private readonly maxAgeMs: number;
70
+ private readonly maxSkewMs: number;
71
+
72
+ constructor(opts: { now?: () => number; maxAgeMs?: number; keepMs?: number; maxSkewMs?: number } = {}) {
73
+ this.now = opts.now ?? (() => Date.now());
74
+ this.maxAgeMs = opts.maxAgeMs ?? ARGS_MAX_AGE_MS;
75
+ this.seen = new NonceWindow(opts.keepMs ?? ARGS_NONCE_KEEP_MS);
76
+ this.maxSkewMs = opts.maxSkewMs ?? ARGS_MAX_SKEW_MS;
77
+ }
78
+
79
+ check(content: unknown, invokedRef: string): ArgsCheck {
80
+ const now = this.now();
81
+ if (!content || typeof content !== "object" || Array.isArray(content)) return { ok: false, reason: "invalid" };
82
+ const c = content as Record<string, unknown>;
83
+ if (c.type !== COMMAND_ARGS_TYPE || typeof c.nonce !== "string" || !NONCE.test(c.nonce) || typeof c.issued_at !== "string") return { ok: false, reason: "invalid" };
84
+ const args = c.args;
85
+ if (!args || typeof args !== "object" || Array.isArray(args)) return { ok: false, reason: "invalid" };
86
+ if (c.command !== invokedRef) return { ok: false, reason: "command" };
87
+ const issued = Date.parse(c.issued_at);
88
+ if (!Number.isFinite(issued)) return { ok: false, reason: "invalid" };
89
+ if (now - issued > this.maxAgeMs) return { ok: false, reason: "stale" };
90
+ if (issued - now > this.maxSkewMs) return { ok: false, reason: "future" };
91
+ if (this.seen.has(c.nonce, now)) return { ok: false, reason: "replay" };
92
+ this.seen.remember(c.nonce, now);
93
+ return { ok: true, args: args as Record<string, unknown> };
94
+ }
95
+ }
@@ -0,0 +1,155 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * Component values and modal input in encrypted conversations (protocol/APPLICATIONS.md §5.10.2, §5.10.4). The
4
+ * invoker's client encrypts a `component.values` (a button click or a select choice) or `modal.values` (a submitted
5
+ * form) content object for the conversation's MLS group, exactly like command arguments (`./commands.ts`); the node
6
+ * only sees which component of which message was used. The application decrypts it like a message (the content
7
+ * signature is checked against the invoker's attested device by the session manager) and then runs
8
+ * {@link ComponentValuesVerifier}: the payload must name the interaction's message and component (or modal), be fresh
9
+ * and carry a nonce it has not seen, so a node can neither replay values nor move them to another component.
10
+ */
11
+ import { b64urlEncode } from "@tavrn/sdk";
12
+ import { ARGS_MAX_AGE_MS, ARGS_MAX_SKEW_MS, ARGS_NONCE_KEEP_MS, type ContentEncryptor } from "./commands";
13
+ import { NonceWindow } from "./lru";
14
+
15
+ export const COMPONENT_VALUES_TYPE = "component.values";
16
+ export const MODAL_VALUES_TYPE = "modal.values";
17
+
18
+ /** The content object of an encrypted component interaction. */
19
+ export interface ComponentValuesContent {
20
+ type: typeof COMPONENT_VALUES_TYPE;
21
+ /** The message that carries the component. */
22
+ message_id: string;
23
+ custom_id: string;
24
+ /** null for buttons, the selected option values for selects. */
25
+ values: string[] | null;
26
+ /** base64url of 16 random bytes. */
27
+ nonce: string;
28
+ issued_at: string;
29
+ }
30
+
31
+ /** The content object of an encrypted modal submit. */
32
+ export interface ModalValuesContent {
33
+ type: typeof MODAL_VALUES_TYPE;
34
+ /** The interaction that answered with the modal. */
35
+ interaction: string;
36
+ /** The modal's `custom_id`. */
37
+ custom_id: string;
38
+ /** Input `custom_id` → text. */
39
+ values: Record<string, string>;
40
+ nonce: string;
41
+ issued_at: string;
42
+ }
43
+
44
+ function nonce(): string {
45
+ const n = new Uint8Array(16);
46
+ crypto.getRandomValues(n);
47
+ return b64urlEncode(n);
48
+ }
49
+
50
+ /** Builds the `component.values` content with a fresh nonce. */
51
+ export function componentValuesContent(messageId: string, customId: string, values: string[] | null, now: Date = new Date()): ComponentValuesContent {
52
+ return { type: COMPONENT_VALUES_TYPE, message_id: messageId, custom_id: customId, values, nonce: nonce(), issued_at: now.toISOString() };
53
+ }
54
+
55
+ /** Builds the `modal.values` content with a fresh nonce. */
56
+ export function modalValuesContent(interactionId: string, customId: string, values: Record<string, string>, now: Date = new Date()): ModalValuesContent {
57
+ return { type: MODAL_VALUES_TYPE, interaction: interactionId, custom_id: customId, values, nonce: nonce(), issued_at: now.toISOString() };
58
+ }
59
+
60
+ /** Encrypts a component interaction: the `values_ciphertext` and `epoch` of `POST …/messages/{id}/interactions`. */
61
+ export async function encryptComponentValues(
62
+ mls: ContentEncryptor,
63
+ conversationId: string,
64
+ messageId: string,
65
+ customId: string,
66
+ values: string[] | null,
67
+ now?: Date,
68
+ ): Promise<{ values_ciphertext: string; epoch: number }> {
69
+ const sealed = await mls.encrypt(conversationId, { ...componentValuesContent(messageId, customId, values, now) });
70
+ return { values_ciphertext: sealed.content, epoch: sealed.epoch };
71
+ }
72
+
73
+ /** Encrypts a modal submit: the `values_ciphertext` and `epoch` of `POST /interactions/{id}/submit`. */
74
+ export async function encryptModalValues(
75
+ mls: ContentEncryptor,
76
+ conversationId: string,
77
+ interactionId: string,
78
+ customId: string,
79
+ values: Record<string, string>,
80
+ now?: Date,
81
+ ): Promise<{ values_ciphertext: string; epoch: number }> {
82
+ const sealed = await mls.encrypt(conversationId, { ...modalValuesContent(interactionId, customId, values, now) });
83
+ return { values_ciphertext: sealed.content, epoch: sealed.epoch };
84
+ }
85
+
86
+ export type ValuesRejection = "invalid" | "message" | "interaction" | "custom_id" | "stale" | "future" | "replay";
87
+
88
+ export type ComponentValuesCheck = { ok: true; values: string[] | null } | { ok: false; reason: ValuesRejection };
89
+ export type ModalValuesCheck = { ok: true; values: Record<string, string> } | { ok: false; reason: ValuesRejection };
90
+
91
+ const NONCE = /^[A-Za-z0-9_-]{22}$/;
92
+ const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
93
+
94
+ /**
95
+ * The application's checks of decrypted component values and modal input (§5.10.2, §5.10.4, like §5.8): type and
96
+ * shape, the interaction's message and component (or modal interaction and modal), age at most five minutes (and not
97
+ * from the future), a nonce not seen in the last 15 minutes. Whether the values fit the component is the
98
+ * application's own check (it knows the message it sent; `validateComponentValues` of `@tavrn/sdk`).
99
+ */
100
+ export class ComponentValuesVerifier {
101
+ private readonly seen: NonceWindow;
102
+ private readonly now: () => number;
103
+ private readonly maxAgeMs: number;
104
+ private readonly maxSkewMs: number;
105
+
106
+ constructor(opts: { now?: () => number; maxAgeMs?: number; keepMs?: number; maxSkewMs?: number } = {}) {
107
+ this.now = opts.now ?? (() => Date.now());
108
+ this.maxAgeMs = opts.maxAgeMs ?? ARGS_MAX_AGE_MS;
109
+ this.seen = new NonceWindow(opts.keepMs ?? ARGS_NONCE_KEEP_MS);
110
+ this.maxSkewMs = opts.maxSkewMs ?? ARGS_MAX_SKEW_MS;
111
+ }
112
+
113
+ /** `component.values` of the interaction on `expected.messageId` / `expected.customId`. */
114
+ checkComponent(content: unknown, expected: { messageId: string; customId: string }): ComponentValuesCheck {
115
+ const c = this.shape(content, COMPONENT_VALUES_TYPE);
116
+ if (!c) return { ok: false, reason: "invalid" };
117
+ const values = c.values;
118
+ if (values !== null && !(Array.isArray(values) && values.every((v) => typeof v === "string"))) return { ok: false, reason: "invalid" };
119
+ if (c.message_id !== expected.messageId) return { ok: false, reason: "message" };
120
+ if (c.custom_id !== expected.customId) return { ok: false, reason: "custom_id" };
121
+ const fresh = this.fresh(c);
122
+ return fresh ? { ok: false, reason: fresh } : { ok: true, values: values as string[] | null };
123
+ }
124
+
125
+ /** `modal.values` of the submit of the modal `expected.customId` that interaction `expected.interactionId` opened. */
126
+ checkModal(content: unknown, expected: { interactionId: string; customId: string }): ModalValuesCheck {
127
+ const c = this.shape(content, MODAL_VALUES_TYPE);
128
+ if (!c) return { ok: false, reason: "invalid" };
129
+ const values = c.values;
130
+ if (!isObj(values) || !Object.values(values).every((v) => typeof v === "string")) return { ok: false, reason: "invalid" };
131
+ if (c.interaction !== expected.interactionId) return { ok: false, reason: "interaction" };
132
+ if (c.custom_id !== expected.customId) return { ok: false, reason: "custom_id" };
133
+ const fresh = this.fresh(c);
134
+ return fresh ? { ok: false, reason: fresh } : { ok: true, values: values as Record<string, string> };
135
+ }
136
+
137
+ private shape(content: unknown, type: string): Record<string, unknown> | null {
138
+ if (!isObj(content)) return null;
139
+ if (content.type !== type || typeof content.nonce !== "string" || !NONCE.test(content.nonce) || typeof content.issued_at !== "string") return null;
140
+ if (!Number.isFinite(Date.parse(content.issued_at))) return null;
141
+ return content;
142
+ }
143
+
144
+ /** Age, skew and replay; the nonce is remembered only when everything else passed. */
145
+ private fresh(c: Record<string, unknown>): "stale" | "future" | "replay" | null {
146
+ const now = this.now();
147
+ const issued = Date.parse(c.issued_at as string);
148
+ if (now - issued > this.maxAgeMs) return "stale";
149
+ if (issued - now > this.maxSkewMs) return "future";
150
+ const key = c.nonce as string;
151
+ if (this.seen.has(key, now)) return "replay";
152
+ this.seen.remember(key, now);
153
+ return null;
154
+ }
155
+ }