@joe-broadhead/messaging 0.7.0 → 0.7.1
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/CHANGELOG.md +45 -0
- package/dist/bin/messaging-mcp.js +649 -12
- package/dist/bin/messaging-relay.js +774 -20
- package/dist/bin/messaging.js +748 -15
- package/dist/bin/messagingd.js +785 -18
- package/dist/lib/modules/core/agent-tools.js +23 -4
- package/dist/lib/modules/core/conversation/context.js +80 -0
- package/dist/lib/modules/core/conversation/event.js +100 -0
- package/dist/lib/modules/core/conversation/ids.js +38 -0
- package/dist/lib/modules/core/conversation/link.js +35 -0
- package/dist/lib/modules/core/conversation/log.js +766 -0
- package/dist/lib/modules/core/conversation/ratchet.js +243 -0
- package/dist/lib/modules/core/conversation/seal.js +198 -0
- package/dist/lib/modules/core/index.js +10 -1
- package/dist/lib/modules/core/schema/common.js +56 -0
- package/dist/lib/modules/core/schema/conversation.js +292 -0
- package/dist/lib/modules/core/schema/errors.js +39 -0
- package/dist/lib/modules/core/schema/index.js +1 -0
- package/dist/lib/modules/core/testing.js +1 -0
- package/dist/lib/modules/core/version.js +1 -1
- package/dist/spec/messaging-v1.md +324 -6
- package/dist/types/core/agent-tools.d.ts +20 -2
- package/dist/types/core/conversation/context.d.ts +56 -0
- package/dist/types/core/conversation/event.d.ts +36 -0
- package/dist/types/core/conversation/ids.d.ts +18 -0
- package/dist/types/core/conversation/link.d.ts +16 -0
- package/dist/types/core/conversation/log.d.ts +142 -0
- package/dist/types/core/conversation/ratchet.d.ts +95 -0
- package/dist/types/core/conversation/seal.d.ts +47 -0
- package/dist/types/core/index.d.ts +8 -1
- package/dist/types/core/schema/common.d.ts +56 -0
- package/dist/types/core/schema/conversation.d.ts +1657 -0
- package/dist/types/core/schema/envelope.d.ts +6 -0
- package/dist/types/core/schema/errors.d.ts +38 -0
- package/dist/types/core/schema/index.d.ts +1 -0
- package/dist/types/core/schema/ipc/device.d.ts +6 -0
- package/dist/types/core/schema/ipc/shapes.d.ts +3 -0
- package/dist/types/core/schema/ipc/surfaces.d.ts +6 -0
- package/dist/types/core/schema/ipc.d.ts +15 -0
- package/dist/types/core/schema/relay.d.ts +12 -0
- package/dist/types/core/testing.d.ts +1 -0
- package/dist/types/core/version.d.ts +1 -1
- 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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sealing in conversations (spec §16.5, §16.6):
|
|
3
|
+
*
|
|
4
|
+
* - a `msg` body: base64url(nonce ‖ AES-256-GCM(mk, nonce, JCS(MsgBody), aad = JCS(header)) ‖ tag), with a fresh
|
|
5
|
+
* 12-byte nonce, under the author's message key for (`epoch`, `idx`);
|
|
6
|
+
* - `create.sealedMeta`: the same, of JCS(ConvMeta) under the creator's epoch-0 message key 0, with the create
|
|
7
|
+
* event's header as AAD;
|
|
8
|
+
* - a `keys` share: JCS({shares}) sealed to one recipient device under the envelope seal (spec §5, "Sealing": X25519
|
|
9
|
+
* with a fresh ephemeral key, HKDF-SHA256 with salt epk ‖ recipient enc and info DOMAIN.seal, AES-256-GCM), with
|
|
10
|
+
* aad = JCS({header, to}).
|
|
11
|
+
*/
|
|
12
|
+
import { createCipheriv, createDecipheriv, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from "node:crypto";
|
|
13
|
+
import { b64u, utf8 } from "../b64u.js";
|
|
14
|
+
import { asMessagingError, parseWith } from "../check.js";
|
|
15
|
+
import { canonicalize } from "../jcs.js";
|
|
16
|
+
import { privateKeyFromRaw, publicKeyFromRaw, rawPublicKey } from "../keys.js";
|
|
17
|
+
import { DOMAIN, LIMITS } from "../schema/common.js";
|
|
18
|
+
import { ConvMeta, KeySharePayload, MsgBody, } from "../schema/conversation.js";
|
|
19
|
+
import { MessagingError } from "../schema/errors.js";
|
|
20
|
+
import { parseWire } from "../wire.js";
|
|
21
|
+
import { convEventHeader, signConvEvent } from "./event.js";
|
|
22
|
+
const NONCE_BYTES = 12;
|
|
23
|
+
const TAG_BYTES = 16;
|
|
24
|
+
function aad(header) {
|
|
25
|
+
return utf8(canonicalize(header));
|
|
26
|
+
}
|
|
27
|
+
function aesSeal(key, nonce, plaintext, additional) {
|
|
28
|
+
if (nonce.length !== NONCE_BYTES)
|
|
29
|
+
throw new MessagingError("invalid_request", "a nonce is 12 bytes");
|
|
30
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce, { authTagLength: TAG_BYTES });
|
|
31
|
+
cipher.setAAD(additional);
|
|
32
|
+
return Buffer.concat([nonce, cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
|
33
|
+
}
|
|
34
|
+
function aesOpen(key, sealed, additional, maxPlaintext) {
|
|
35
|
+
let data;
|
|
36
|
+
try {
|
|
37
|
+
data = b64u.decode(sealed);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new MessagingError("bad_signature", "sealed body is not base64url");
|
|
41
|
+
}
|
|
42
|
+
if (data.length - NONCE_BYTES - TAG_BYTES > maxPlaintext)
|
|
43
|
+
throw new MessagingError("too_large", "sealed body is too large");
|
|
44
|
+
try {
|
|
45
|
+
const decipher = createDecipheriv("aes-256-gcm", key, data.subarray(0, NONCE_BYTES), { authTagLength: TAG_BYTES });
|
|
46
|
+
decipher.setAAD(additional);
|
|
47
|
+
decipher.setAuthTag(data.subarray(data.length - TAG_BYTES));
|
|
48
|
+
return Buffer.concat([decipher.update(data.subarray(NONCE_BYTES, data.length - TAG_BYTES)), decipher.final()]);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new MessagingError("bad_signature", "sealed body failed to open");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Decodes UTF-8 strictly and parses with `parseWire`. */
|
|
55
|
+
function wireJson(bytes) {
|
|
56
|
+
let text;
|
|
57
|
+
try {
|
|
58
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new MessagingError("invalid_request", "sealed body is not UTF-8");
|
|
62
|
+
}
|
|
63
|
+
return parseWire(text);
|
|
64
|
+
}
|
|
65
|
+
function plaintextOf(body) {
|
|
66
|
+
parseWith(MsgBody, body, "message body");
|
|
67
|
+
const bytes = utf8(canonicalize(body));
|
|
68
|
+
if (bytes.length > LIMITS.convPlaintextBytes)
|
|
69
|
+
throw new MessagingError("too_large", "message body is over LIMITS.convPlaintextBytes");
|
|
70
|
+
return bytes;
|
|
71
|
+
}
|
|
72
|
+
/** Seals `body` under `mk` as the body of `unsealed` and signs the event. Throws only MessagingError. */
|
|
73
|
+
export function sealConvMsg(identity, unsealed, body, mk) {
|
|
74
|
+
return sealConvMsgWith(identity, unsealed, body, mk, randomBytes(NONCE_BYTES));
|
|
75
|
+
}
|
|
76
|
+
/** `sealConvMsg` with a caller-chosen nonce: for test vectors only (`@messaging/core/testing`). */
|
|
77
|
+
export function sealConvMsgWith(identity, unsealed, body, mk, nonce) {
|
|
78
|
+
try {
|
|
79
|
+
const header = convEventHeader({ ...unsealed, class: "msg" });
|
|
80
|
+
const sealed = b64u.encode(aesSeal(mk, nonce, plaintextOf(body), aad(header)));
|
|
81
|
+
return signConvEvent(identity, { ...header, body: sealed });
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
throw asMessagingError(error, "conversation message");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Opens a `msg` event's body with its message key: `bad_signature` when it fails to open, `too_large` over
|
|
89
|
+
* `LIMITS.convPlaintextBytes`, `invalid_request` when the plaintext is not a `MsgBody` (parsed with `parseWire`).
|
|
90
|
+
* The caller has verified the event's signature.
|
|
91
|
+
*/
|
|
92
|
+
export function openConvMsg(event, mk) {
|
|
93
|
+
try {
|
|
94
|
+
if (mk.length !== 32)
|
|
95
|
+
throw new MessagingError("invalid_request", "a message key is 32 bytes");
|
|
96
|
+
const bytes = aesOpen(mk, event.body, aad(convEventHeader(event)), LIMITS.convPlaintextBytes);
|
|
97
|
+
return parseWith(MsgBody, wireJson(bytes), "message body");
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
throw asMessagingError(error, "conversation message");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** `create.sealedMeta`: JCS(meta) sealed under the creator's epoch-0 key 0, with the create event's header as AAD. */
|
|
104
|
+
export function sealConvMeta(header, meta, mk0) {
|
|
105
|
+
return sealConvMetaWith(header, meta, mk0, randomBytes(NONCE_BYTES));
|
|
106
|
+
}
|
|
107
|
+
/** `sealConvMeta` with a caller-chosen nonce: for test vectors only. */
|
|
108
|
+
export function sealConvMetaWith(header, meta, mk0, nonce) {
|
|
109
|
+
try {
|
|
110
|
+
const value = parseWith(ConvMeta, meta, "conversation meta");
|
|
111
|
+
return b64u.encode(aesSeal(mk0, nonce, utf8(canonicalize(value)), aad(convEventHeader(header))));
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
throw asMessagingError(error, "conversation meta");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Opens a `create` event's `sealedMeta`. Errors as for `openConvMsg`; `not_found` when it has none. */
|
|
118
|
+
export function openConvMeta(event, mk0) {
|
|
119
|
+
try {
|
|
120
|
+
if (event.body.op !== "create" || event.body.sealedMeta === undefined)
|
|
121
|
+
throw new MessagingError("not_found", "the event carries no sealed meta");
|
|
122
|
+
const bytes = aesOpen(mk0, event.body.sealedMeta, aad(convEventHeader(event)), 2048);
|
|
123
|
+
return parseWith(ConvMeta, wireJson(bytes), "conversation meta");
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
throw asMessagingError(error, "conversation meta");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function shareAad(header, to) {
|
|
130
|
+
return utf8(canonicalize({ header, to }));
|
|
131
|
+
}
|
|
132
|
+
function shareKey(shared, epk, recipientEnc) {
|
|
133
|
+
return new Uint8Array(hkdfSync("sha256", shared, Buffer.concat([epk, recipientEnc]), DOMAIN.seal, 32));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Seals `shares` to each recipient device as the body of `unsealed`, and signs the event. One sealed share per
|
|
137
|
+
* device; at most `LIMITS.keySharesPerEvent` devices and `LIMITS.keyShares` chain keys. Throws only MessagingError.
|
|
138
|
+
*/
|
|
139
|
+
export function sealKeyShares(identity, unsealed, recipients, shares) {
|
|
140
|
+
return sealShares(identity, unsealed, recipients, shares, () => {
|
|
141
|
+
const { privateKey } = generateKeyPairSync("x25519");
|
|
142
|
+
return { ephemeral: privateKey, nonce: randomBytes(NONCE_BYTES) };
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/** `sealKeyShares` with fixed randomness per recipient, in order: for test vectors only. */
|
|
146
|
+
export function sealKeySharesWith(identity, unsealed, recipients, shares, randomness) {
|
|
147
|
+
let i = 0;
|
|
148
|
+
return sealShares(identity, unsealed, recipients, shares, () => {
|
|
149
|
+
const r = randomness[i++];
|
|
150
|
+
if (!r)
|
|
151
|
+
throw new MessagingError("invalid_request", "one randomness per recipient");
|
|
152
|
+
return { ephemeral: privateKeyFromRaw("x25519", r.ephemeral), nonce: r.nonce };
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function sealShares(identity, unsealed, recipients, shares, randomness) {
|
|
156
|
+
try {
|
|
157
|
+
const header = convEventHeader({ ...unsealed, class: "keys" });
|
|
158
|
+
const plaintext = utf8(canonicalize(parseWith(KeySharePayload, { shares }, "key shares")));
|
|
159
|
+
const sealed = recipients.map((recipient) => {
|
|
160
|
+
const to = { person: recipient.person, device: recipient.device };
|
|
161
|
+
const { ephemeral, nonce } = randomness();
|
|
162
|
+
const shared = diffieHellman({ privateKey: ephemeral, publicKey: publicKeyFromRaw("x25519", recipient.enc) });
|
|
163
|
+
const epk = rawPublicKey(ephemeral);
|
|
164
|
+
const key = shareKey(shared, b64u.decode(epk), b64u.decode(recipient.enc));
|
|
165
|
+
const data = aesSeal(key, nonce, plaintext, shareAad(header, to));
|
|
166
|
+
return { to, epk, nonce: b64u.encode(nonce), ct: b64u.encode(data.subarray(NONCE_BYTES)) };
|
|
167
|
+
});
|
|
168
|
+
return signConvEvent(identity, { ...header, body: { shares: sealed } });
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
throw asMessagingError(error, "key shares");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Opens the share sealed to `identity`'s device in a `keys` event: `not_found` when there is none, `bad_signature`
|
|
176
|
+
* when it fails to open, `invalid_request` when the plaintext is not a share payload. The caller has verified the
|
|
177
|
+
* event's signature and applied it to the log.
|
|
178
|
+
*/
|
|
179
|
+
export function openKeyShares(event, identity) {
|
|
180
|
+
try {
|
|
181
|
+
const share = event.body.shares.find((s) => s.to.device === identity.machine);
|
|
182
|
+
if (!share)
|
|
183
|
+
throw new MessagingError("not_found", "no share for this device");
|
|
184
|
+
let key;
|
|
185
|
+
try {
|
|
186
|
+
const shared = diffieHellman({ privateKey: identity.encKey, publicKey: publicKeyFromRaw("x25519", share.epk) });
|
|
187
|
+
key = shareKey(shared, b64u.decode(share.epk), b64u.decode(identity.enc));
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
throw new MessagingError("bad_signature", "key share failed to open");
|
|
191
|
+
}
|
|
192
|
+
const bytes = aesOpen(key, share.nonce + share.ct, shareAad(convEventHeader(event), share.to), Number.MAX_SAFE_INTEGER);
|
|
193
|
+
return parseWith(KeySharePayload, wireJson(bytes), "key shares").shares;
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
throw asMessagingError(error, "key shares");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -37,8 +37,17 @@ export { canTransition, isTerminal, isInterrupted } from "./task.js";
|
|
|
37
37
|
export { TERMINAL_STATES } from "./schema/a2a.js";
|
|
38
38
|
export { cleanPeerText, createFence, terminalSafe, displayWidth, HOST_TAGS, PEER_FENCE_TAG, PEER_DATA_NOTICE, provenanceOf } from "./peer-text.js";
|
|
39
39
|
// The agent tool contract shared by the OpenCode plugin and messaging-mcp (spec §11, "Agent tools").
|
|
40
|
-
export { AgentTool, TOOL_VERBS, TOOL_DESCRIPTIONS, ToolExpect, UNTRUSTED_SENTENCE, TOOL_REMEDIES, DONE_STATES, TaskStateName, ToolErrorBody, ToolFiles, a2aTaskState, conversationOf, decodeToolCursor, encodeToolCursor, formatToolAddress, parseToolAddress, taskStateName, toolErrorBody, toolName, } from "./agent-tools.js";
|
|
40
|
+
export { AgentTool, TOOL_VERBS, TOOL_DESCRIPTIONS, ToolExpect, UNTRUSTED_SENTENCE, TOOL_REMEDIES, STORE_REFUSED_REMEDIES, DONE_STATES, TaskStateName, ToolErrorBody, ToolFiles, a2aTaskState, conversationOf, decodeToolCursor, encodeToolCursor, formatToolAddress, parseToolAddress, taskStateName, toolErrorBody, toolName, } from "./agent-tools.js";
|
|
41
41
|
// The agent tools' operations and presenters, shared by the plugin and messaging-mcp (spec §11).
|
|
42
42
|
export { Operations, } from "./agent/operations.js";
|
|
43
43
|
export { agentCan, consentOf, conversationOfPeer, followUpRead, isoOf, presentAgent, presentConvEntry, presentInboxItem, presentLine, presentMail, presentSent, presentTask, presentTaskItem, presentTaskView, } from "./agent/present.js";
|
|
44
44
|
export { chainPath, compact, delegated, delegationPrompt, EXPECT_TEXT, isSafeId, messageAttributes, validatedAddress, } from "./agent/text.js";
|
|
45
|
+
// Conversations (spec §16): ids, events and the log's hash chain, sender keys and sealing, the log as a pure
|
|
46
|
+
// reducer, and context-rule resolution. Fixed-randomness sealing for vectors lives in `@messaging/core/testing`.
|
|
47
|
+
export { dmConvId, selfConvId, participantOf, participantPerson, participantAgent } from "./conversation/ids.js";
|
|
48
|
+
export { admitConvEvent, convEntryHash, convEventDigest, convEventHeader, convEventSigned, convGenesisHash, convLinkHash, signConvEvent, verifyConvEvent, } from "./conversation/event.js";
|
|
49
|
+
export { chainStep, ReceiverChains, ReceiverChainsState, SenderChain, SenderChainState } from "./conversation/ratchet.js";
|
|
50
|
+
export { openConvMeta, openConvMsg, openKeyShares, sealConvMeta, sealConvMsg, sealKeyShares, } from "./conversation/seal.js";
|
|
51
|
+
export { ConvLog, } from "./conversation/log.js";
|
|
52
|
+
export { checkVia, currentLinks, linkReader } from "./conversation/link.js";
|
|
53
|
+
export { CONTEXT_DEFAULTS, contextKindOf, matchContextRule, resolveContext, seatReads, } from "./conversation/context.js";
|
|
@@ -42,6 +42,18 @@ export const DOMAIN = {
|
|
|
42
42
|
epoch: "messaging/1/epoch\n",
|
|
43
43
|
/** Hosted realms (spec §15.6): an epoch-key rotation, signed by the old key and by the new over JCS(rotation without sigs). */
|
|
44
44
|
dirKey: "messaging/1/dir-key\n",
|
|
45
|
+
/** Conversations (spec §16.4): a conversation event, signed by `by.device` over JCS(event without sig). */
|
|
46
|
+
convEvent: "messaging/1/conv-event\n",
|
|
47
|
+
/** Conversations (spec §16.4): the prefix of a conversation log's genesis hash h(0), over JCS(cid). */
|
|
48
|
+
convGenesis: "messaging/1/conv-genesis\n",
|
|
49
|
+
/** Conversations (spec §16.1): the prefix of the hash that derives a dm's id, over JCS of its two sorted participants. */
|
|
50
|
+
dm: "messaging/1/dm\n",
|
|
51
|
+
/** Conversations (spec §16.1): the prefix of the hash that derives a person's `self` conversation id, over JCS(person). */
|
|
52
|
+
self: "messaging/1/self\n",
|
|
53
|
+
/** Conversations (spec §16.5): HKDF `info` for a sender chain's message key. */
|
|
54
|
+
convMsg: "messaging/1/conv-msg",
|
|
55
|
+
/** Conversations (spec §16.5): HKDF `info` for a sender chain's next chain key. */
|
|
56
|
+
convChain: "messaging/1/conv-chain",
|
|
45
57
|
};
|
|
46
58
|
/** A2A extension and binding URIs owned by Messaging. */
|
|
47
59
|
export const URI = {
|
|
@@ -232,6 +244,50 @@ export const LIMITS = {
|
|
|
232
244
|
a2hPerHour: 30,
|
|
233
245
|
/** Max lines one person's agents send to another person in a day, summed over the agents (spec §6.9, "Pacing"). */
|
|
234
246
|
a2hPerDay: 100,
|
|
247
|
+
/** Conversations (spec §16): max participants (persons and agents) of a channel. */
|
|
248
|
+
convParticipants: 1000,
|
|
249
|
+
/** Max participants (persons and agents) of a group. A group has at least 3. */
|
|
250
|
+
groupParticipants: 50,
|
|
251
|
+
/** Max participants one `add` or `remove` op names. */
|
|
252
|
+
convOpParticipants: 100,
|
|
253
|
+
/** Max UTF-8 bytes of one conversation event (JCS), signature and body included. */
|
|
254
|
+
convEventBytes: 262_144,
|
|
255
|
+
/** Max UTF-8 bytes of one message body's plaintext (JCS) before it is sealed. */
|
|
256
|
+
convPlaintextBytes: 65_536,
|
|
257
|
+
/** Max UTF-16 code units of one conversation line's text. */
|
|
258
|
+
lineChars: 40_000,
|
|
259
|
+
/** Max mentions in one line or edit. */
|
|
260
|
+
mentions: 50,
|
|
261
|
+
/** Max attachments in one line. */
|
|
262
|
+
files: 10,
|
|
263
|
+
/** Max delegation grants one line carries for the agents it mentions (spec §16.3). */
|
|
264
|
+
agentMentions: 5,
|
|
265
|
+
/** Max bytes of one attached file, encrypted. */
|
|
266
|
+
blobBytes: 26_214_400,
|
|
267
|
+
/** Max distinct reaction keys on one event. */
|
|
268
|
+
reactionsPerEvent: 50,
|
|
269
|
+
/** Max pinned events in one conversation. */
|
|
270
|
+
pins: 100,
|
|
271
|
+
/** Retention of a conversation that sets none, in days. */
|
|
272
|
+
retainDaysDefault: 90,
|
|
273
|
+
/** Max `retainDays` of a conversation, and max lookback of a context rule. */
|
|
274
|
+
retainDaysMax: 365,
|
|
275
|
+
/** Max message keys a receiver keeps for one sender chain to open messages that arrive out of order. */
|
|
276
|
+
skippedKeys: 1000,
|
|
277
|
+
/** A sender starts a new chain after this many messages on one chain... */
|
|
278
|
+
chainMessages: 500,
|
|
279
|
+
/** ...or this long after it started the chain, whichever comes first. */
|
|
280
|
+
chainMaxMs: 7 * 24 * 60 * 60 * 1000,
|
|
281
|
+
/** An epoch older than this may be ended by a `rekey` with cause `schedule`. */
|
|
282
|
+
rekeyScheduleMs: 30 * 24 * 60 * 60 * 1000,
|
|
283
|
+
/** Max chain keys in one sealed key share, and in one `keys` sync of the `self` conversation. */
|
|
284
|
+
keyShares: 512,
|
|
285
|
+
/** Max recipient devices one `keys` event seals to; a larger share is split over several events. */
|
|
286
|
+
keySharesPerEvent: 500,
|
|
287
|
+
/** Max agents one event's `wake` hint names. */
|
|
288
|
+
wakeHints: 5,
|
|
289
|
+
/** Max context rules a person holds (spec §16.8). */
|
|
290
|
+
contextRules: 500,
|
|
235
291
|
};
|
|
236
292
|
const LOOPBACK_HOST = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])$/;
|
|
237
293
|
/**
|