@joe-broadhead/messaging 0.7.0 → 0.8.0

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.
Files changed (97) hide show
  1. package/CHANGELOG.md +259 -2
  2. package/dist/bin/messaging-mcp.js +5484 -3858
  3. package/dist/bin/messaging-relay.js +4728 -1136
  4. package/dist/bin/messaging.js +4616 -2807
  5. package/dist/bin/messagingd.js +16451 -9444
  6. package/dist/lib/modules/core/agent/operations.js +19 -327
  7. package/dist/lib/modules/core/agent/present.js +15 -55
  8. package/dist/lib/modules/core/agent-tools.js +46 -143
  9. package/dist/lib/modules/core/consent.js +1 -1
  10. package/dist/lib/modules/core/conversation/context.js +80 -0
  11. package/dist/lib/modules/core/conversation/event.js +125 -0
  12. package/dist/lib/modules/core/conversation/guest-invitation.js +42 -0
  13. package/dist/lib/modules/core/conversation/ids.js +38 -0
  14. package/dist/lib/modules/core/conversation/link.js +35 -0
  15. package/dist/lib/modules/core/conversation/log.js +988 -0
  16. package/dist/lib/modules/core/conversation/ratchet.js +243 -0
  17. package/dist/lib/modules/core/conversation/request-log.js +158 -0
  18. package/dist/lib/modules/core/conversation/request-seal.js +41 -0
  19. package/dist/lib/modules/core/conversation/requests.js +87 -0
  20. package/dist/lib/modules/core/conversation/seal.js +251 -0
  21. package/dist/lib/modules/core/conversation-agent-tools.js +69 -0
  22. package/dist/lib/modules/core/federation/directory-proof.js +83 -0
  23. package/dist/lib/modules/core/federation/proof-state.js +167 -0
  24. package/dist/lib/modules/core/federation/roster-proof.js +74 -0
  25. package/dist/lib/modules/core/index.js +16 -2
  26. package/dist/lib/modules/core/jelly-host.js +5 -5
  27. package/dist/lib/modules/core/schema/common.js +85 -3
  28. package/dist/lib/modules/core/schema/consent.js +5 -4
  29. package/dist/lib/modules/core/schema/contact-request.js +22 -0
  30. package/dist/lib/modules/core/schema/conv-frames.js +94 -0
  31. package/dist/lib/modules/core/schema/conversation.js +326 -0
  32. package/dist/lib/modules/core/schema/envelope.js +8 -24
  33. package/dist/lib/modules/core/schema/errors.js +42 -2
  34. package/dist/lib/modules/core/schema/guest-invitation.js +13 -0
  35. package/dist/lib/modules/core/schema/index.js +6 -0
  36. package/dist/lib/modules/core/schema/ipc/consent.js +13 -9
  37. package/dist/lib/modules/core/schema/ipc/conversation-views.js +80 -0
  38. package/dist/lib/modules/core/schema/ipc/conversations.js +128 -0
  39. package/dist/lib/modules/core/schema/ipc/device.js +2 -0
  40. package/dist/lib/modules/core/schema/ipc/jelly.js +3 -38
  41. package/dist/lib/modules/core/schema/ipc/shapes.js +14 -72
  42. package/dist/lib/modules/core/schema/ipc/surfaces.js +12 -195
  43. package/dist/lib/modules/core/schema/ipc/tasks.js +31 -0
  44. package/dist/lib/modules/core/schema/ipc.js +18 -44
  45. package/dist/lib/modules/core/testing.js +1 -0
  46. package/dist/lib/modules/core/version.js +1 -1
  47. package/dist/lib/modules/ipc/client.js +10 -2
  48. package/dist/lib/modules/ipc/server.js +14 -5
  49. package/dist/lib/modules/ipc/testing.js +13 -23
  50. package/dist/spec/messaging-v1.md +835 -380
  51. package/dist/third-party-notices.md +1 -1
  52. package/dist/third-party.json +1 -1
  53. package/dist/types/core/agent/operations.d.ts +5 -52
  54. package/dist/types/core/agent/present.d.ts +6 -21
  55. package/dist/types/core/agent-tools.d.ts +277 -438
  56. package/dist/types/core/conversation/context.d.ts +56 -0
  57. package/dist/types/core/conversation/event.d.ts +36 -0
  58. package/dist/types/core/conversation/guest-invitation.d.ts +8 -0
  59. package/dist/types/core/conversation/ids.d.ts +18 -0
  60. package/dist/types/core/conversation/link.d.ts +16 -0
  61. package/dist/types/core/conversation/log.d.ts +157 -0
  62. package/dist/types/core/conversation/ratchet.d.ts +95 -0
  63. package/dist/types/core/conversation/request-log.d.ts +24 -0
  64. package/dist/types/core/conversation/request-seal.d.ts +6 -0
  65. package/dist/types/core/conversation/requests.d.ts +14 -0
  66. package/dist/types/core/conversation/seal.d.ts +70 -0
  67. package/dist/types/core/conversation-agent-tools.d.ts +918 -0
  68. package/dist/types/core/federation/directory-proof.d.ts +8 -0
  69. package/dist/types/core/federation/proof-state.d.ts +114 -0
  70. package/dist/types/core/federation/roster-proof.d.ts +7 -0
  71. package/dist/types/core/index.d.ts +15 -3
  72. package/dist/types/core/jelly-host.d.ts +1 -1
  73. package/dist/types/core/schema/common.d.ts +85 -4
  74. package/dist/types/core/schema/consent.d.ts +7 -7
  75. package/dist/types/core/schema/contact-request.d.ts +264 -0
  76. package/dist/types/core/schema/conv-frames.d.ts +1200 -0
  77. package/dist/types/core/schema/conversation.d.ts +2803 -0
  78. package/dist/types/core/schema/envelope.d.ts +89 -130
  79. package/dist/types/core/schema/errors.d.ts +40 -0
  80. package/dist/types/core/schema/guest-invitation.d.ts +51 -0
  81. package/dist/types/core/schema/index.d.ts +6 -0
  82. package/dist/types/core/schema/ipc/consent.d.ts +89 -49
  83. package/dist/types/core/schema/ipc/conversation-views.d.ts +1023 -0
  84. package/dist/types/core/schema/ipc/conversations.d.ts +3987 -0
  85. package/dist/types/core/schema/ipc/device.d.ts +10 -0
  86. package/dist/types/core/schema/ipc/jelly.d.ts +2 -105
  87. package/dist/types/core/schema/ipc/shapes.d.ts +16 -95
  88. package/dist/types/core/schema/ipc/surfaces.d.ts +28 -424
  89. package/dist/types/core/schema/ipc/tasks.d.ts +208 -0
  90. package/dist/types/core/schema/ipc.d.ts +2684 -523
  91. package/dist/types/core/schema/relay.d.ts +20 -4
  92. package/dist/types/core/testing.d.ts +1 -0
  93. package/dist/types/core/version.d.ts +1 -1
  94. package/dist/types/ipc/client.d.ts +4 -1
  95. package/dist/types/ipc/server.d.ts +10 -1
  96. package/dist/types/ipc/testing.d.ts +21 -8
  97. package/package.json +1 -1
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Sender keys (spec §16.5). Each sender device keeps one chain per (conversation, epoch):
3
+ *
4
+ * mk_i = HKDF-SHA256(ikm = ck_i, salt = empty, info = DOMAIN.convMsg, 32 bytes)
5
+ * ck_{i+1} = HKDF-SHA256(ikm = ck_i, salt = empty, info = DOMAIN.convChain, 32 bytes)
6
+ *
7
+ * A rotation starts a fresh random chain key at the next index of the same epoch, so `(epoch, device, idx)` names a
8
+ * message's key whatever the rotations: the share with the greatest `idx` at or below a message's `idx` derives it.
9
+ * Pure: no clock, no I/O; times are passed in.
10
+ */
11
+ import { hkdfSync, randomBytes } from "node:crypto";
12
+ import { z } from "../../vendor/zod/index.js";
13
+ import { b64u } from "../b64u.js";
14
+ import { parseWith } from "../check.js";
15
+ import { DOMAIN, Key32, LIMITS, MachineId as MachineIdSchema, Millis } from "../schema/common.js";
16
+ import { KeyShare as KeyShareSchema } from "../schema/conversation.js";
17
+ import { MessagingError } from "../schema/errors.js";
18
+ const EMPTY = new Uint8Array(0);
19
+ function hkdf(ck, info) {
20
+ return new Uint8Array(hkdfSync("sha256", ck, EMPTY, info, 32));
21
+ }
22
+ /** One ratchet step: the message key for this index and the chain key for the next. */
23
+ export function chainStep(ck) {
24
+ if (ck.length !== 32)
25
+ throw new MessagingError("invalid_request", "a chain key is 32 bytes");
26
+ return { mk: hkdf(ck, DOMAIN.convMsg), next: hkdf(ck, DOMAIN.convChain) };
27
+ }
28
+ const Index = z.number().int().min(0);
29
+ /** A sender chain as a device stores it. */
30
+ export const SenderChainState = z.strictObject({
31
+ epoch: Index,
32
+ device: MachineIdSchema,
33
+ start: Index,
34
+ origin: Key32,
35
+ idx: Index,
36
+ ck: Key32,
37
+ startedAt: Millis,
38
+ });
39
+ /** The sending side of one chain. `next()` hands out each message key once. */
40
+ export class SenderChain {
41
+ epoch;
42
+ device;
43
+ /** The index this chain starts at. */
44
+ start;
45
+ startedAt;
46
+ #origin;
47
+ #ck;
48
+ #idx;
49
+ constructor(state) {
50
+ this.epoch = state.epoch;
51
+ this.device = state.device;
52
+ this.start = state.start;
53
+ this.startedAt = state.startedAt;
54
+ this.#origin = state.origin;
55
+ this.#ck = state.ck;
56
+ this.#idx = state.idx;
57
+ }
58
+ /** A new chain at `idx` (default 0) of `epoch`, from a fresh random chain key. */
59
+ static start(args) {
60
+ return SenderChain.#from(args, randomBytes(32));
61
+ }
62
+ /** `start` with a caller-chosen chain key: for test vectors, and for a device restoring its own state. */
63
+ static startWith(args, ck) {
64
+ return SenderChain.#from(args, ck);
65
+ }
66
+ static #from(args, ck) {
67
+ const epoch = parseWith(Index, args.epoch, "epoch");
68
+ const device = parseWith(MachineIdSchema, args.device, "device");
69
+ const start = parseWith(Index, args.idx ?? 0, "chain index");
70
+ const startedAt = parseWith(Millis, args.now, "now");
71
+ if (ck.length !== 32)
72
+ throw new MessagingError("invalid_request", "a chain key is 32 bytes");
73
+ const key = new Uint8Array(ck);
74
+ return new SenderChain({ epoch, device, start, origin: key, idx: start, ck: key, startedAt });
75
+ }
76
+ static restore(raw) {
77
+ const s = parseWith(SenderChainState, raw, "sender chain");
78
+ if (s.idx < s.start)
79
+ throw new MessagingError("invalid_request", "a sender chain cannot be behind its start");
80
+ return new SenderChain({ ...s, origin: b64u.decode(s.origin), ck: b64u.decode(s.ck) });
81
+ }
82
+ export() {
83
+ return { epoch: this.epoch, device: this.device, start: this.start, origin: b64u.encode(this.#origin), idx: this.#idx, ck: b64u.encode(this.#ck), startedAt: this.startedAt };
84
+ }
85
+ /** The index the next message gets. */
86
+ get idx() {
87
+ return this.#idx;
88
+ }
89
+ /** The share that lets a receiver open every message of this chain: its chain key at its start. */
90
+ share() {
91
+ return { epoch: this.epoch, device: this.device, idx: this.start, ck: b64u.encode(this.#origin) };
92
+ }
93
+ /** The key for the next message, and its index. The chain moves past it. */
94
+ next() {
95
+ const { mk, next } = chainStep(this.#ck);
96
+ const idx = this.#idx;
97
+ this.#ck = next;
98
+ this.#idx = idx + 1;
99
+ return { idx, mk };
100
+ }
101
+ /** True once the chain has sent `LIMITS.chainMessages` messages or is `LIMITS.chainMaxMs` old: rotate before the next. */
102
+ due(now) {
103
+ return this.#idx - this.start >= LIMITS.chainMessages || now - this.startedAt >= LIMITS.chainMaxMs;
104
+ }
105
+ /** A fresh chain in the same epoch, starting at this chain's next index. Share it before sending on it. */
106
+ rotate(now) {
107
+ return SenderChain.start({ epoch: this.epoch, device: this.device, idx: this.#idx, now });
108
+ }
109
+ /** `rotate` with a caller-chosen chain key, for test vectors. */
110
+ rotateWith(now, ck) {
111
+ return SenderChain.startWith({ epoch: this.epoch, device: this.device, idx: this.#idx, now }, ck);
112
+ }
113
+ }
114
+ const Segment = z.strictObject({
115
+ epoch: Index,
116
+ device: MachineIdSchema,
117
+ start: Index,
118
+ origin: Key32,
119
+ pos: Index,
120
+ ck: Key32,
121
+ skipped: z.array(z.tuple([Index, Key32])).max(LIMITS.skippedKeys),
122
+ });
123
+ export const ReceiverChainsState = z.strictObject({ chains: z.array(Segment).max(100_000) });
124
+ /**
125
+ * The receiving side: every chain this device holds for one conversation, by (epoch, sender device). It derives
126
+ * forward only and hands out each message key once; a message that arrives out of order uses a key kept from an
127
+ * earlier derivation, at most `LIMITS.skippedKeys` per sender and epoch. The chain keys at each share's index are
128
+ * kept, so the device can share them with a late joiner (history `shared`).
129
+ */
130
+ export class ReceiverChains {
131
+ #chains = new Map();
132
+ static restore(raw) {
133
+ const state = parseWith(ReceiverChainsState, raw, "receiver chains");
134
+ const chains = new ReceiverChains();
135
+ for (const s of state.chains) {
136
+ if (s.pos < s.start)
137
+ throw new MessagingError("invalid_request", "a receiver chain cannot be behind its start");
138
+ const list = chains.#list(s.epoch, s.device);
139
+ if (list.some((seg) => seg.start === s.start))
140
+ throw new MessagingError("invalid_request", "duplicate receiver chain");
141
+ list.push({ start: s.start, origin: b64u.decode(s.origin), pos: s.pos, ck: b64u.decode(s.ck), skipped: new Map(s.skipped.map(([i, k]) => [i, b64u.decode(k)])) });
142
+ list.sort((a, b) => a.start - b.start);
143
+ }
144
+ return chains;
145
+ }
146
+ export() {
147
+ const chains = [];
148
+ for (const [key, list] of [...this.#chains].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
149
+ const [epoch, device] = key.split(" ");
150
+ for (const s of list) {
151
+ chains.push({
152
+ epoch: Number(epoch),
153
+ device,
154
+ start: s.start,
155
+ origin: b64u.encode(s.origin),
156
+ pos: s.pos,
157
+ ck: b64u.encode(s.ck),
158
+ skipped: [...s.skipped].sort(([a], [b]) => a - b).map(([i, k]) => [i, b64u.encode(k)]),
159
+ });
160
+ }
161
+ }
162
+ return { chains };
163
+ }
164
+ #list(epoch, device) {
165
+ const key = `${epoch} ${device}`;
166
+ let list = this.#chains.get(key);
167
+ if (!list) {
168
+ list = [];
169
+ this.#chains.set(key, list);
170
+ }
171
+ return list;
172
+ }
173
+ /**
174
+ * Adds a share. The first share at a given (epoch, device, idx) wins: a later one with another key changes
175
+ * nothing, and `add` returns false. Returns true when the share was new.
176
+ */
177
+ add(raw) {
178
+ const share = parseWith(KeyShareSchema, raw, "key share");
179
+ const list = this.#list(share.epoch, share.device);
180
+ if (list.some((s) => s.start === share.idx))
181
+ return false;
182
+ const ck = b64u.decode(share.ck);
183
+ list.push({ start: share.idx, origin: ck, pos: share.idx, ck, skipped: new Map() });
184
+ list.sort((a, b) => a.start - b.start);
185
+ return true;
186
+ }
187
+ /** Every share this device holds, from each chain's start: what it shares with a late joiner. */
188
+ shares() {
189
+ const out = [];
190
+ for (const s of this.export().chains)
191
+ out.push({ epoch: s.epoch, device: s.device, idx: s.start, ck: s.origin });
192
+ return out;
193
+ }
194
+ /**
195
+ * The message key for (`epoch`, `device`, `idx`), used once. `not_found` when no share covers it, or its key was
196
+ * already used or dropped; `too_large` when it is more than `LIMITS.skippedKeys` past the chain's position.
197
+ */
198
+ messageKey(epoch, device, idx) {
199
+ const list = this.#chains.get(`${epoch} ${device}`) ?? [];
200
+ let at = -1;
201
+ for (let i = 0; i < list.length; i++)
202
+ if (list[i].start <= idx)
203
+ at = i;
204
+ if (at < 0)
205
+ throw new MessagingError("not_found", "no key share covers this message");
206
+ // `at` is the last chain starting at or below `idx`, so the next one (a rotation) starts past it.
207
+ const seg = list[at];
208
+ if (idx < seg.pos) {
209
+ const mk = seg.skipped.get(idx);
210
+ if (!mk)
211
+ throw new MessagingError("not_found", "this message key was already used or dropped");
212
+ seg.skipped.delete(idx);
213
+ return mk;
214
+ }
215
+ if (idx - seg.pos > LIMITS.skippedKeys)
216
+ throw new MessagingError("too_large", "message index is too far past the chain");
217
+ let ck = seg.ck;
218
+ for (let i = seg.pos; i < idx; i++) {
219
+ const step = chainStep(ck);
220
+ seg.skipped.set(i, step.mk);
221
+ ck = step.next;
222
+ }
223
+ const { mk, next } = chainStep(ck);
224
+ seg.ck = next;
225
+ seg.pos = idx + 1;
226
+ this.#bound(list);
227
+ return mk;
228
+ }
229
+ /** Keeps at most `LIMITS.skippedKeys` skipped keys per (epoch, device), dropping the lowest indexes first. */
230
+ #bound(list) {
231
+ let total = list.reduce((n, s) => n + s.skipped.size, 0);
232
+ for (const seg of list) {
233
+ if (total <= LIMITS.skippedKeys)
234
+ return;
235
+ for (const i of [...seg.skipped.keys()].sort((a, b) => a - b)) {
236
+ if (total <= LIMITS.skippedKeys)
237
+ return;
238
+ seg.skipped.delete(i);
239
+ total--;
240
+ }
241
+ }
242
+ }
243
+ }
@@ -0,0 +1,158 @@
1
+ /** Permanent request activation and import reservations, replayed alongside ConvLog (spec §16.11). */
2
+ import { b64u } from "../b64u.js";
3
+ import { canonicalize } from "../jcs.js";
4
+ import { parseWith } from "../check.js";
5
+ import { RequestImport } from "../schema/contact-request.js";
6
+ import { MessagingError } from "../schema/errors.js";
7
+ import { convEventDigest, verifyConvEvent } from "./event.js";
8
+ import { verifyContactAuthorization, verifyPortableRequest, verifyRequestActivation } from "./requests.js";
9
+ const refuse = (message) => { throw new MessagingError("request_required", message); };
10
+ const conflict = (message) => { throw new MessagingError("conv_conflict", message); };
11
+ const digest = (event) => b64u.encode(convEventDigest(event));
12
+ function proofFor(log, proof, binding) {
13
+ const state = log.state();
14
+ if (state?.kind !== "dm" || state.participants.length !== 2 || state.participants.some((p) => p.agent !== undefined))
15
+ refuse("activation is for a two-person DM");
16
+ const persons = state.participants.map((p) => p.person).sort();
17
+ const check = (p) => {
18
+ if (p.realm !== log.realm || p.cid !== log.cid || canonicalize([p.from, p.to].sort()) !== canonicalize(persons))
19
+ conflict("activation names another DM");
20
+ };
21
+ if ("contacts" in proof) {
22
+ const a = verifyContactAuthorization(proof.contacts[0], binding), b = verifyContactAuthorization(proof.contacts[1], binding);
23
+ check(a);
24
+ check(b);
25
+ if (a.from !== b.to || a.to !== b.from)
26
+ refuse("both contacts must authorize the DM");
27
+ return undefined;
28
+ }
29
+ const request = verifyRequestActivation(proof, binding);
30
+ check(request.descriptor);
31
+ return request;
32
+ }
33
+ /** Rebuilds permanent activation state even when imported messages have expired. */
34
+ export function requestLogState(log, binding) {
35
+ const entries = log.entries();
36
+ const first = entries[0];
37
+ const required = !!first && "event" in first && first.event.class === "op" && first.event.body.op === "create" && first.event.body.admission === "request";
38
+ const state = { required, active: !required, reservations: new Map() };
39
+ for (const entry of entries) {
40
+ if (!("event" in entry) || entry.event.class !== "op" || log.at(entry.seq)?.changed !== entry.seq)
41
+ continue;
42
+ const op = entry.event.body;
43
+ if (op.op === "activate") {
44
+ if (!required)
45
+ refuse("this conversation has no request admission");
46
+ const p = proofFor(log, op.proof, binding);
47
+ state.active = true;
48
+ if (p) {
49
+ const old = state.reservations.get(p.descriptor.id);
50
+ if (old && canonicalize(old.proof) !== canonicalize(p))
51
+ conflict("request activation conflicts with its reservation");
52
+ if (!old)
53
+ state.reservations.set(p.descriptor.id, { proof: p });
54
+ }
55
+ }
56
+ else if (op.op === "request.complete") {
57
+ const reservation = state.reservations.get(op.request);
58
+ if (!reservation || reservation.completed)
59
+ conflict("request completion has no pending reservation");
60
+ if (entry.event.by.person !== reservation.proof.descriptor.to || entry.event.by.agent !== undefined)
61
+ refuse("only the accepting person completes an import");
62
+ const imported = entries[op.seq - 1];
63
+ if (!imported || op.seq >= entry.seq)
64
+ conflict("completion must name an earlier import");
65
+ if ("event" in imported) {
66
+ const e = imported.event;
67
+ if (e.class !== "msg" || e.id !== op.request || e.request !== op.request || e.by.person !== reservation.proof.descriptor.to || e.by.agent !== undefined)
68
+ conflict("completion names another event");
69
+ }
70
+ if (("event" in imported ? digest(imported.event) : imported.digest) !== op.digest)
71
+ conflict("completion digest does not match the log");
72
+ reservation.completed = { seq: op.seq, digest: op.digest };
73
+ }
74
+ }
75
+ return state;
76
+ }
77
+ /** Checks before appending. The relay also checks live org admission on create. No mutation on failure. */
78
+ export function checkRequestEvent(log, raw, binding, body) {
79
+ const event = verifyConvEvent(raw);
80
+ if (event.realm !== log.realm || event.cid !== log.cid)
81
+ throw new MessagingError("invalid_request", "request event names another conversation");
82
+ if (event.class === "op" && event.body.op === "create") {
83
+ if (event.body.admission && (event.body.kind !== "dm" || event.body.participants.some((p) => p.p.includes("/"))))
84
+ refuse("request admission is for human DMs only");
85
+ return;
86
+ }
87
+ const s = requestLogState(log, binding);
88
+ if (event.class === "op" && event.body.op === "activate") {
89
+ if (!s.required || event.by.agent !== undefined)
90
+ refuse("activation requires a person's request DM");
91
+ const p = proofFor(log, event.body.proof, binding);
92
+ if (p) {
93
+ if (event.by.person !== p.descriptor.to)
94
+ refuse("the accepting person activates the request");
95
+ const old = s.reservations.get(p.descriptor.id);
96
+ if (old)
97
+ conflict("this request already has a permanent activation");
98
+ if (log.entries().some((e) => "event" in e && e.event.id === p.descriptor.id))
99
+ conflict("request id collides with an existing event");
100
+ }
101
+ return;
102
+ }
103
+ const deviceControl = event.class === "op" && event.body.op === "rekey" && event.body.cause === "device";
104
+ if (!s.active && !deviceControl)
105
+ refuse("accept the contact request before content or key sharing");
106
+ const pending = [...s.reservations].filter(([, r]) => !r.completed);
107
+ if (event.class === "keys")
108
+ return;
109
+ if (event.class === "op" && event.body.op === "request.complete") {
110
+ const op = event.body, r = s.reservations.get(op.request);
111
+ if (!r || r.completed || event.by.person !== r.proof.descriptor.to || event.by.agent !== undefined)
112
+ conflict("no pending import for this recipient");
113
+ const e = log.entries()[op.seq - 1];
114
+ if (!e || ("event" in e ? e.event.class !== "msg" || e.event.request !== op.request || e.event.id !== op.request || digest(e.event) !== op.digest : e.digest !== op.digest))
115
+ conflict("completion does not bind the imported event");
116
+ return;
117
+ }
118
+ if (event.class === "msg" && event.request !== undefined) {
119
+ const r = s.reservations.get(event.request);
120
+ if (!r || r.completed || event.id !== event.request || event.by.person !== r.proof.descriptor.to || event.by.agent !== undefined || event.wake !== undefined)
121
+ refuse("no accepted import reservation for this author");
122
+ if (body !== undefined) {
123
+ const p = parseWith(RequestImport, body, "request import");
124
+ verifyPortableRequest({ descriptor: p.descriptor, opening: p.opening }, binding);
125
+ verifyRequestActivation({ descriptor: p.descriptor, decision: p.decision }, binding);
126
+ if (canonicalize({ descriptor: p.descriptor, decision: p.decision }) !== canonicalize(r.proof))
127
+ conflict("import differs from the permanent activation");
128
+ }
129
+ return;
130
+ }
131
+ if (s.reservations.has(event.id))
132
+ conflict("the request id is reserved for its import");
133
+ if (pending.length && !deviceControl)
134
+ refuse("accepted first messages must finish importing before ordinary sends");
135
+ if (body && typeof body === "object" && "t" in body) {
136
+ const b = body;
137
+ if (b.t === "request.import")
138
+ refuse("request imports require a reserved header");
139
+ if ((b.t === "edit" || b.t === "delete") && s.reservations.has(b.target)) {
140
+ if (b.t === "edit")
141
+ throw new MessagingError("forbidden", "an imported request is immutable");
142
+ if (event.by.agent !== undefined || event.by.person !== s.reservations.get(b.target).proof.descriptor.from)
143
+ throw new MessagingError("forbidden", "only the original author deletes a request import");
144
+ }
145
+ }
146
+ }
147
+ /** Member admission seam for the cut-over engine; preserves wrapper authorship and verifies source separately. */
148
+ export function applyRequestEntry(log, entry, binding, options = {}) {
149
+ checkRequestEvent(log, entry.event, binding, options.body);
150
+ const body = options.body;
151
+ // ConvLog's line author is the wrapper author. Source attribution/deletion comes only from verified proofs.
152
+ const imported = body?.t === "request.import" || (body?.t === "delete" && requestLogState(log, binding).reservations.has(body.target));
153
+ return log.apply(entry, imported ? { ...options, body: undefined } : options);
154
+ }
155
+ /** Public author for a verified import, never the importing device. The renderer uses this only after admission. */
156
+ export function importedRequestAuthor(log, id, binding) {
157
+ return requestLogState(log, binding).reservations.get(id)?.proof.descriptor.from;
158
+ }
@@ -0,0 +1,41 @@
1
+ /** The request.import profile uses the conversation sender-key seal, with its own strict plaintext schema. */
2
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
3
+ import { b64u } from "../b64u.js";
4
+ import { asMessagingError, parseWith } from "../check.js";
5
+ import { canonicalize } from "../jcs.js";
6
+ import { RequestImport } from "../schema/contact-request.js";
7
+ import { MessagingError } from "../schema/errors.js";
8
+ import { parseWire } from "../wire.js";
9
+ import { convEventHeader, signConvEvent, verifyConvEvent } from "./event.js";
10
+ export function sealRequestImport(identity, header, body, mk) {
11
+ try {
12
+ const value = parseWith(RequestImport, body, "request import");
13
+ if (header.request !== value.descriptor.id || header.id !== header.request || header.by.person !== value.descriptor.to || header.by.agent !== undefined || header.wake !== undefined)
14
+ throw new MessagingError("invalid_request", "import header does not bind its recipient and reservation");
15
+ const nonce = randomBytes(12), cipher = createCipheriv("aes-256-gcm", mk, nonce);
16
+ cipher.setAAD(Buffer.from(canonicalize(header)));
17
+ const sealed = Buffer.concat([nonce, cipher.update(canonicalize(value), "utf8"), cipher.final(), cipher.getAuthTag()]);
18
+ return signConvEvent(identity, { ...header, body: b64u.encode(sealed) });
19
+ }
20
+ catch (error) {
21
+ throw asMessagingError(error, "request import seal");
22
+ }
23
+ }
24
+ export function openRequestImport(raw, mk) {
25
+ try {
26
+ const event = verifyConvEvent(raw);
27
+ if (event.class !== "msg" || !event.request)
28
+ throw new MessagingError("invalid_request", "not a reserved request import");
29
+ const data = b64u.decode(event.body);
30
+ if (data.length > 8192)
31
+ throw new MessagingError("too_large", "request import too large");
32
+ const decipher = createDecipheriv("aes-256-gcm", mk, data.subarray(0, 12));
33
+ decipher.setAAD(Buffer.from(canonicalize(convEventHeader(event))));
34
+ decipher.setAuthTag(data.subarray(-16));
35
+ const bytes = Buffer.concat([decipher.update(data.subarray(12, -16)), decipher.final()]);
36
+ return parseWith(RequestImport, parseWire(new TextDecoder("utf-8", { fatal: true }).decode(bytes)), "request import");
37
+ }
38
+ catch (error) {
39
+ throw asMessagingError(error, "request import open");
40
+ }
41
+ }
@@ -0,0 +1,87 @@
1
+ /** Portable request proofs. Verify raw signatures before using parsed values. No key material is transported. */
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { parseWith } from "../check.js";
4
+ import { canonicalize } from "../jcs.js";
5
+ import { assertEd25519PublicKey } from "../keys.js";
6
+ import { DOMAIN, LIMITS } from "../schema/common.js";
7
+ import { ContactAuthorization, PortableRequest, RequestActivation, RequestDecision, RequestDescriptor, RequestOpening } from "../schema/contact-request.js";
8
+ import { MessagingError } from "../schema/errors.js";
9
+ import { sign, verify } from "../sign.js";
10
+ import { dmConvId } from "./ids.js";
11
+ const fail = (message) => { throw new MessagingError("invalid_request", message); };
12
+ const digest = (domain, value) => createHash("sha256").update(domain).update(canonicalize(value)).digest("base64url");
13
+ export const requestCommitment = (opening) => digest(DOMAIN.requestContent, parseWith(RequestOpening, opening, "request opening"));
14
+ export const requestDigest = (descriptor) => digest(DOMAIN.requestDigest, descriptor);
15
+ function signed(schema, raw, domain) {
16
+ parseWith(schema, raw, "request proof");
17
+ const value = raw;
18
+ assertEd25519PublicKey(value.by, "request device");
19
+ const { sig, ...unsigned } = value;
20
+ if (!verify(value.by, domain, unsigned, sig))
21
+ throw new MessagingError("bad_signature", "invalid request proof signature");
22
+ return parseWith(schema, raw, "request proof");
23
+ }
24
+ function bound(person, device, binding) {
25
+ if (!binding(person, device))
26
+ throw new MessagingError("unknown_device", "request signer has no verified person binding");
27
+ }
28
+ function route(p) {
29
+ if (p.from === p.to || p.cid !== dmConvId(p.from, p.to))
30
+ fail("request must name its two persons' exact DM");
31
+ }
32
+ export function verifyRequestDescriptor(raw, binding) {
33
+ const d = signed(RequestDescriptor, raw, DOMAIN.request);
34
+ route(d);
35
+ bound(d.from, d.by, binding);
36
+ if (d.expiresAt <= d.issuedAt || d.expiresAt - d.issuedAt > LIMITS.ttlMs)
37
+ fail("invalid request consent window");
38
+ return d;
39
+ }
40
+ export function verifyPortableRequest(raw, binding) {
41
+ parseWith(PortableRequest, raw, "portable request");
42
+ const value = raw;
43
+ const descriptor = verifyRequestDescriptor(value.descriptor, binding);
44
+ const opening = parseWith(RequestOpening, value.opening, "request opening");
45
+ if (requestCommitment(opening) !== descriptor.commitment)
46
+ throw new MessagingError("bad_signature", "request opening does not match signed content");
47
+ return { descriptor, opening };
48
+ }
49
+ export function createPortableRequest(identity, args, text) {
50
+ const opening = parseWith(RequestOpening, { salt: randomBytes(32).toString("base64url"), text }, "request opening");
51
+ const unsigned = { ...args, commitment: requestCommitment(opening), by: identity.machine };
52
+ return verifyPortableRequest({ descriptor: { ...unsigned, sig: sign(identity, DOMAIN.request, unsigned) }, opening }, (person, by) => person === args.from && by === identity.machine);
53
+ }
54
+ export function verifyRequestDecision(raw, descriptor, binding) {
55
+ const d = verifyRequestDescriptor(descriptor, binding);
56
+ const a = signed(RequestDecision, raw, DOMAIN.requestDecision);
57
+ bound(d.to, a.by, binding);
58
+ if (a.realm !== d.realm || a.cid !== d.cid || a.id !== d.id || a.from !== d.from || a.to !== d.to || a.descriptor !== requestDigest(d))
59
+ fail("decision does not bind this request");
60
+ if (a.at < d.issuedAt || a.at >= d.expiresAt)
61
+ throw new MessagingError("expired", "decision is outside the original consent window");
62
+ return a;
63
+ }
64
+ export function signRequestDecision(identity, d, answer, at) {
65
+ const unsigned = { v: d.v, realm: d.realm, cid: d.cid, id: d.id, from: d.from, to: d.to, descriptor: requestDigest(d), answer, at, by: identity.machine };
66
+ return parseWith(RequestDecision, { ...unsigned, sig: sign(identity, DOMAIN.requestDecision, unsigned) }, "request decision");
67
+ }
68
+ export function verifyRequestActivation(raw, binding) {
69
+ parseWith(RequestActivation, raw, "activation");
70
+ const p = raw;
71
+ const descriptor = verifyRequestDescriptor(p.descriptor, binding);
72
+ const decision = verifyRequestDecision(p.decision, descriptor, binding);
73
+ if (decision.answer !== "accept")
74
+ throw new MessagingError("forbidden", "a declined request cannot activate a conversation");
75
+ return { descriptor, decision };
76
+ }
77
+ export function signContactAuthorization(identity, args) {
78
+ route(args);
79
+ const unsigned = { ...args, by: identity.machine };
80
+ return parseWith(ContactAuthorization, { ...unsigned, sig: sign(identity, DOMAIN.contactAuthorization, unsigned) }, "contact authorization");
81
+ }
82
+ export function verifyContactAuthorization(raw, binding) {
83
+ const c = signed(ContactAuthorization, raw, DOMAIN.contactAuthorization);
84
+ route(c);
85
+ bound(c.from, c.by, binding);
86
+ return c;
87
+ }