@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.
- package/CHANGELOG.md +259 -2
- package/dist/bin/messaging-mcp.js +5484 -3858
- package/dist/bin/messaging-relay.js +4728 -1136
- package/dist/bin/messaging.js +4616 -2807
- package/dist/bin/messagingd.js +16451 -9444
- package/dist/lib/modules/core/agent/operations.js +19 -327
- package/dist/lib/modules/core/agent/present.js +15 -55
- package/dist/lib/modules/core/agent-tools.js +46 -143
- package/dist/lib/modules/core/consent.js +1 -1
- package/dist/lib/modules/core/conversation/context.js +80 -0
- package/dist/lib/modules/core/conversation/event.js +125 -0
- package/dist/lib/modules/core/conversation/guest-invitation.js +42 -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 +988 -0
- package/dist/lib/modules/core/conversation/ratchet.js +243 -0
- package/dist/lib/modules/core/conversation/request-log.js +158 -0
- package/dist/lib/modules/core/conversation/request-seal.js +41 -0
- package/dist/lib/modules/core/conversation/requests.js +87 -0
- package/dist/lib/modules/core/conversation/seal.js +251 -0
- package/dist/lib/modules/core/conversation-agent-tools.js +69 -0
- package/dist/lib/modules/core/federation/directory-proof.js +83 -0
- package/dist/lib/modules/core/federation/proof-state.js +167 -0
- package/dist/lib/modules/core/federation/roster-proof.js +74 -0
- package/dist/lib/modules/core/index.js +16 -2
- package/dist/lib/modules/core/jelly-host.js +5 -5
- package/dist/lib/modules/core/schema/common.js +85 -3
- package/dist/lib/modules/core/schema/consent.js +5 -4
- package/dist/lib/modules/core/schema/contact-request.js +22 -0
- package/dist/lib/modules/core/schema/conv-frames.js +94 -0
- package/dist/lib/modules/core/schema/conversation.js +326 -0
- package/dist/lib/modules/core/schema/envelope.js +8 -24
- package/dist/lib/modules/core/schema/errors.js +42 -2
- package/dist/lib/modules/core/schema/guest-invitation.js +13 -0
- package/dist/lib/modules/core/schema/index.js +6 -0
- package/dist/lib/modules/core/schema/ipc/consent.js +13 -9
- package/dist/lib/modules/core/schema/ipc/conversation-views.js +80 -0
- package/dist/lib/modules/core/schema/ipc/conversations.js +128 -0
- package/dist/lib/modules/core/schema/ipc/device.js +2 -0
- package/dist/lib/modules/core/schema/ipc/jelly.js +3 -38
- package/dist/lib/modules/core/schema/ipc/shapes.js +14 -72
- package/dist/lib/modules/core/schema/ipc/surfaces.js +12 -195
- package/dist/lib/modules/core/schema/ipc/tasks.js +31 -0
- package/dist/lib/modules/core/schema/ipc.js +18 -44
- package/dist/lib/modules/core/testing.js +1 -0
- package/dist/lib/modules/core/version.js +1 -1
- package/dist/lib/modules/ipc/client.js +10 -2
- package/dist/lib/modules/ipc/server.js +14 -5
- package/dist/lib/modules/ipc/testing.js +13 -23
- package/dist/spec/messaging-v1.md +835 -380
- package/dist/third-party-notices.md +1 -1
- package/dist/third-party.json +1 -1
- package/dist/types/core/agent/operations.d.ts +5 -52
- package/dist/types/core/agent/present.d.ts +6 -21
- package/dist/types/core/agent-tools.d.ts +277 -438
- 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/guest-invitation.d.ts +8 -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 +157 -0
- package/dist/types/core/conversation/ratchet.d.ts +95 -0
- package/dist/types/core/conversation/request-log.d.ts +24 -0
- package/dist/types/core/conversation/request-seal.d.ts +6 -0
- package/dist/types/core/conversation/requests.d.ts +14 -0
- package/dist/types/core/conversation/seal.d.ts +70 -0
- package/dist/types/core/conversation-agent-tools.d.ts +918 -0
- package/dist/types/core/federation/directory-proof.d.ts +8 -0
- package/dist/types/core/federation/proof-state.d.ts +114 -0
- package/dist/types/core/federation/roster-proof.d.ts +7 -0
- package/dist/types/core/index.d.ts +15 -3
- package/dist/types/core/jelly-host.d.ts +1 -1
- package/dist/types/core/schema/common.d.ts +85 -4
- package/dist/types/core/schema/consent.d.ts +7 -7
- package/dist/types/core/schema/contact-request.d.ts +264 -0
- package/dist/types/core/schema/conv-frames.d.ts +1200 -0
- package/dist/types/core/schema/conversation.d.ts +2803 -0
- package/dist/types/core/schema/envelope.d.ts +89 -130
- package/dist/types/core/schema/errors.d.ts +40 -0
- package/dist/types/core/schema/guest-invitation.d.ts +51 -0
- package/dist/types/core/schema/index.d.ts +6 -0
- package/dist/types/core/schema/ipc/consent.d.ts +89 -49
- package/dist/types/core/schema/ipc/conversation-views.d.ts +1023 -0
- package/dist/types/core/schema/ipc/conversations.d.ts +3987 -0
- package/dist/types/core/schema/ipc/device.d.ts +10 -0
- package/dist/types/core/schema/ipc/jelly.d.ts +2 -105
- package/dist/types/core/schema/ipc/shapes.d.ts +16 -95
- package/dist/types/core/schema/ipc/surfaces.d.ts +28 -424
- package/dist/types/core/schema/ipc/tasks.d.ts +208 -0
- package/dist/types/core/schema/ipc.d.ts +2684 -523
- package/dist/types/core/schema/relay.d.ts +20 -4
- package/dist/types/core/testing.d.ts +1 -0
- package/dist/types/core/version.d.ts +1 -1
- package/dist/types/ipc/client.d.ts +4 -1
- package/dist/types/ipc/server.d.ts +10 -1
- package/dist/types/ipc/testing.d.ts +21 -8
- package/package.json +1 -1
|
@@ -0,0 +1,251 @@
|
|
|
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 `meta` or `setting` op's content: the same, under the author's key at the op's (`epoch`, `idx`), with
|
|
9
|
+
* aad = JCS(event without `sig`, the op without `sealed`);
|
|
10
|
+
* - a `keys` share: JCS({shares}) sealed to its one recipient device under the envelope seal (spec §5, "Sealing": X25519
|
|
11
|
+
* with a fresh ephemeral key, HKDF-SHA256 with salt epk ‖ recipient enc and info DOMAIN.seal, AES-256-GCM), with
|
|
12
|
+
* aad = JCS({header, to}).
|
|
13
|
+
*/
|
|
14
|
+
import { createCipheriv, createDecipheriv, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from "node:crypto";
|
|
15
|
+
import { b64u, utf8 } from "../b64u.js";
|
|
16
|
+
import { asMessagingError, parseWith } from "../check.js";
|
|
17
|
+
import { canonicalize } from "../jcs.js";
|
|
18
|
+
import { privateKeyFromRaw, publicKeyFromRaw, rawPublicKey } from "../keys.js";
|
|
19
|
+
import { DOMAIN, LIMITS } from "../schema/common.js";
|
|
20
|
+
import { ConvMeta, KeySharePayload, MsgBody, SettingBody, } from "../schema/conversation.js";
|
|
21
|
+
import { MessagingError } from "../schema/errors.js";
|
|
22
|
+
import { parseWire } from "../wire.js";
|
|
23
|
+
import { convEventHeader, signConvEvent } from "./event.js";
|
|
24
|
+
const NONCE_BYTES = 12;
|
|
25
|
+
const TAG_BYTES = 16;
|
|
26
|
+
function aad(header) {
|
|
27
|
+
return utf8(canonicalize(header));
|
|
28
|
+
}
|
|
29
|
+
function aesSeal(key, nonce, plaintext, additional) {
|
|
30
|
+
if (nonce.length !== NONCE_BYTES)
|
|
31
|
+
throw new MessagingError("invalid_request", "a nonce is 12 bytes");
|
|
32
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce, { authTagLength: TAG_BYTES });
|
|
33
|
+
cipher.setAAD(additional);
|
|
34
|
+
return Buffer.concat([nonce, cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
|
35
|
+
}
|
|
36
|
+
function aesOpen(key, sealed, additional, maxPlaintext) {
|
|
37
|
+
let data;
|
|
38
|
+
try {
|
|
39
|
+
data = b64u.decode(sealed);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new MessagingError("bad_signature", "sealed body is not base64url");
|
|
43
|
+
}
|
|
44
|
+
if (data.length - NONCE_BYTES - TAG_BYTES > maxPlaintext)
|
|
45
|
+
throw new MessagingError("too_large", "sealed body is too large");
|
|
46
|
+
try {
|
|
47
|
+
const decipher = createDecipheriv("aes-256-gcm", key, data.subarray(0, NONCE_BYTES), { authTagLength: TAG_BYTES });
|
|
48
|
+
decipher.setAAD(additional);
|
|
49
|
+
decipher.setAuthTag(data.subarray(data.length - TAG_BYTES));
|
|
50
|
+
return Buffer.concat([decipher.update(data.subarray(NONCE_BYTES, data.length - TAG_BYTES)), decipher.final()]);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new MessagingError("bad_signature", "sealed body failed to open");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Decodes UTF-8 strictly and parses with `parseWire`. */
|
|
57
|
+
function wireJson(bytes) {
|
|
58
|
+
let text;
|
|
59
|
+
try {
|
|
60
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new MessagingError("invalid_request", "sealed body is not UTF-8");
|
|
64
|
+
}
|
|
65
|
+
return parseWire(text);
|
|
66
|
+
}
|
|
67
|
+
function plaintextOf(body) {
|
|
68
|
+
parseWith(MsgBody, body, "message body");
|
|
69
|
+
const bytes = utf8(canonicalize(body));
|
|
70
|
+
if (bytes.length > LIMITS.convPlaintextBytes)
|
|
71
|
+
throw new MessagingError("too_large", "message body is over LIMITS.convPlaintextBytes");
|
|
72
|
+
return bytes;
|
|
73
|
+
}
|
|
74
|
+
/** Seals `body` under `mk` as the body of `unsealed` and signs the event. Throws only MessagingError. */
|
|
75
|
+
export function sealConvMsg(identity, unsealed, body, mk) {
|
|
76
|
+
return sealConvMsgWith(identity, unsealed, body, mk, randomBytes(NONCE_BYTES));
|
|
77
|
+
}
|
|
78
|
+
/** `sealConvMsg` with a caller-chosen nonce: for test vectors only (`@messaging/core/testing`). */
|
|
79
|
+
export function sealConvMsgWith(identity, unsealed, body, mk, nonce) {
|
|
80
|
+
try {
|
|
81
|
+
const header = convEventHeader({ ...unsealed, class: "msg" });
|
|
82
|
+
const sealed = b64u.encode(aesSeal(mk, nonce, plaintextOf(body), aad(header)));
|
|
83
|
+
return signConvEvent(identity, { ...header, body: sealed });
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
throw asMessagingError(error, "conversation message");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Opens a `msg` event's body with its message key: `bad_signature` when it fails to open, `too_large` over
|
|
91
|
+
* `LIMITS.convPlaintextBytes`, `invalid_request` when the plaintext is not a `MsgBody` (parsed with `parseWire`).
|
|
92
|
+
* The caller has verified the event's signature.
|
|
93
|
+
*/
|
|
94
|
+
export function openConvMsg(event, mk) {
|
|
95
|
+
try {
|
|
96
|
+
if (mk.length !== 32)
|
|
97
|
+
throw new MessagingError("invalid_request", "a message key is 32 bytes");
|
|
98
|
+
const bytes = aesOpen(mk, event.body, aad(convEventHeader(event)), LIMITS.convPlaintextBytes);
|
|
99
|
+
return parseWith(MsgBody, wireJson(bytes), "message body");
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
throw asMessagingError(error, "conversation message");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** `create.sealedMeta`: JCS(meta) sealed under the creator's epoch-0 key 0, with the create event's header as AAD. */
|
|
106
|
+
export function sealConvMeta(header, meta, mk0) {
|
|
107
|
+
return sealConvMetaWith(header, meta, mk0, randomBytes(NONCE_BYTES));
|
|
108
|
+
}
|
|
109
|
+
/** `sealConvMeta` with a caller-chosen nonce: for test vectors only. */
|
|
110
|
+
export function sealConvMetaWith(header, meta, mk0, nonce) {
|
|
111
|
+
try {
|
|
112
|
+
const value = parseWith(ConvMeta, meta, "conversation meta");
|
|
113
|
+
return b64u.encode(aesSeal(mk0, nonce, utf8(canonicalize(value)), aad(convEventHeader(header))));
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
throw asMessagingError(error, "conversation meta");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Opens a `create` event's `sealedMeta`. Errors as for `openConvMsg`; `not_found` when it has none. */
|
|
120
|
+
export function openConvMeta(event, mk0) {
|
|
121
|
+
try {
|
|
122
|
+
if (event.body.op !== "create" || event.body.sealedMeta === undefined)
|
|
123
|
+
throw new MessagingError("not_found", "the event carries no sealed meta");
|
|
124
|
+
const bytes = aesOpen(mk0, event.body.sealedMeta, aad(convEventHeader(event)), 2048);
|
|
125
|
+
return parseWith(ConvMeta, wireJson(bytes), "conversation meta");
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
throw asMessagingError(error, "conversation meta");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function shareAad(header, to) {
|
|
132
|
+
return utf8(canonicalize({ header, to }));
|
|
133
|
+
}
|
|
134
|
+
function shareKey(shared, epk, recipientEnc) {
|
|
135
|
+
return new Uint8Array(hkdfSync("sha256", shared, Buffer.concat([epk, recipientEnc]), DOMAIN.seal, 32));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Seals `shares` to one recipient device as the body of `unsealed`, and signs the event: one `keys` event per
|
|
139
|
+
* recipient device (spec §16.6), at most `LIMITS.keyShares` chain keys. Throws only MessagingError.
|
|
140
|
+
*/
|
|
141
|
+
export function sealKeyShare(identity, unsealed, recipient, shares) {
|
|
142
|
+
const { privateKey } = generateKeyPairSync("x25519");
|
|
143
|
+
return sealShare(identity, unsealed, recipient, shares, { ephemeral: privateKey, nonce: randomBytes(NONCE_BYTES) });
|
|
144
|
+
}
|
|
145
|
+
/** `sealKeyShare` with fixed randomness: for test vectors only. */
|
|
146
|
+
export function sealKeyShareWith(identity, unsealed, recipient, shares, randomness) {
|
|
147
|
+
try {
|
|
148
|
+
return sealShare(identity, unsealed, recipient, shares, { ephemeral: privateKeyFromRaw("x25519", randomness.ephemeral), nonce: randomness.nonce });
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
throw asMessagingError(error, "key shares");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function sealShare(identity, unsealed, recipient, shares, randomness) {
|
|
155
|
+
try {
|
|
156
|
+
const header = convEventHeader({ ...unsealed, class: "keys" });
|
|
157
|
+
const plaintext = utf8(canonicalize(parseWith(KeySharePayload, { shares }, "key shares")));
|
|
158
|
+
const to = { person: recipient.person, device: recipient.device };
|
|
159
|
+
const { ephemeral, nonce } = randomness;
|
|
160
|
+
const shared = diffieHellman({ privateKey: ephemeral, publicKey: publicKeyFromRaw("x25519", recipient.enc) });
|
|
161
|
+
const epk = rawPublicKey(ephemeral);
|
|
162
|
+
const key = shareKey(shared, b64u.decode(epk), b64u.decode(recipient.enc));
|
|
163
|
+
const data = aesSeal(key, nonce, plaintext, shareAad(header, to));
|
|
164
|
+
const body = { to, epk, nonce: b64u.encode(nonce), ct: b64u.encode(data.subarray(NONCE_BYTES)) };
|
|
165
|
+
return signConvEvent(identity, { ...header, body });
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
throw asMessagingError(error, "key shares");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Opens a `keys` event sealed to `identity`'s device: `not_found` when it is another device's, `bad_signature` when it
|
|
173
|
+
* fails to open, `invalid_request` when the plaintext is not a share payload. The caller has checked the event
|
|
174
|
+
* (`ConvLog.checkShare`).
|
|
175
|
+
*/
|
|
176
|
+
export function openKeyShares(event, identity) {
|
|
177
|
+
try {
|
|
178
|
+
const share = event.body;
|
|
179
|
+
if (share.to.device !== identity.machine)
|
|
180
|
+
throw new MessagingError("not_found", "the share is for another device");
|
|
181
|
+
let key;
|
|
182
|
+
try {
|
|
183
|
+
const shared = diffieHellman({ privateKey: identity.encKey, publicKey: publicKeyFromRaw("x25519", share.epk) });
|
|
184
|
+
key = shareKey(shared, b64u.decode(share.epk), b64u.decode(identity.enc));
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
throw new MessagingError("bad_signature", "key share failed to open");
|
|
188
|
+
}
|
|
189
|
+
const bytes = aesOpen(key, share.nonce + share.ct, shareAad(convEventHeader(event), share.to), Number.MAX_SAFE_INTEGER);
|
|
190
|
+
return parseWith(KeySharePayload, wireJson(bytes), "key shares").shares;
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
throw asMessagingError(error, "key shares");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** AAD of a sealed op: JCS of the event without `sig`, with the op without `sealed`. */
|
|
197
|
+
function stateAad(event) {
|
|
198
|
+
const { sealed: _sealed, ...op } = event.body;
|
|
199
|
+
return utf8(canonicalize({ ...convEventHeader(event), body: op }));
|
|
200
|
+
}
|
|
201
|
+
function statePlaintext(op, content) {
|
|
202
|
+
const value = op.op === "meta" ? parseWith(ConvMeta, content, "conversation meta") : parseWith(SettingBody, content, "setting");
|
|
203
|
+
if (op.op === "setting" && value.s !== op.kind)
|
|
204
|
+
throw new MessagingError("invalid_request", "a setting's body is of its op's kind");
|
|
205
|
+
const bytes = utf8(canonicalize(value));
|
|
206
|
+
if (bytes.length > LIMITS.convPlaintextBytes)
|
|
207
|
+
throw new MessagingError("too_large", "sealed op is over LIMITS.convPlaintextBytes");
|
|
208
|
+
return bytes;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Seals `content` (a `ConvMeta` for `meta`, a `SettingBody` of the op's kind for `setting`) under `mk`, the author's
|
|
212
|
+
* message key at the op's (`epoch`, `idx`), and signs the op event. Throws only MessagingError.
|
|
213
|
+
*/
|
|
214
|
+
export function sealConvState(identity, unsealed, content, mk) {
|
|
215
|
+
return sealConvStateWith(identity, unsealed, content, mk, randomBytes(NONCE_BYTES));
|
|
216
|
+
}
|
|
217
|
+
/** `sealConvState` with a caller-chosen nonce: for test vectors only. */
|
|
218
|
+
export function sealConvStateWith(identity, unsealed, content, mk, nonce) {
|
|
219
|
+
try {
|
|
220
|
+
const event = { ...unsealed, class: "op" };
|
|
221
|
+
const sealed = b64u.encode(aesSeal(mk, nonce, statePlaintext(unsealed.body, content), stateAad(event)));
|
|
222
|
+
return signConvEvent(identity, { ...event, body: { ...unsealed.body, sealed } });
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
throw asMessagingError(error, "sealed op");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Opens a `meta` or `setting` op with the author's message key at its (`epoch`, `idx`): a `ConvMeta`, or a
|
|
230
|
+
* `SettingBody` whose `s` is the op's `kind` (`invalid_request` otherwise). `not_found` for another op; errors as
|
|
231
|
+
* for `openConvMsg`. The caller has applied the event to the log.
|
|
232
|
+
*/
|
|
233
|
+
export function openConvState(event, mk) {
|
|
234
|
+
try {
|
|
235
|
+
const op = event.body;
|
|
236
|
+
if (op.op !== "meta" && op.op !== "setting")
|
|
237
|
+
throw new MessagingError("not_found", "the op carries no sealed content");
|
|
238
|
+
if (mk.length !== 32)
|
|
239
|
+
throw new MessagingError("invalid_request", "a message key is 32 bytes");
|
|
240
|
+
const value = wireJson(aesOpen(mk, op.sealed, stateAad(event), LIMITS.convPlaintextBytes));
|
|
241
|
+
if (op.op === "meta")
|
|
242
|
+
return parseWith(ConvMeta, value, "conversation meta");
|
|
243
|
+
const setting = parseWith(SettingBody, value, "setting");
|
|
244
|
+
if (setting.s !== op.kind)
|
|
245
|
+
throw new MessagingError("invalid_request", "a setting's body is of its op's kind");
|
|
246
|
+
return setting;
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
throw asMessagingError(error, "sealed op");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Shared conversation-model tools, including the retained task-envelope path. */
|
|
2
|
+
import { z } from "../vendor/zod/index.js";
|
|
3
|
+
import { AgentTool, TaskReadOutput, TaskSendFields, TaskSendOutput, TaskSource, ToolChainSchema, ToolConsentSchema, ToolExpect } from "./agent-tools.js";
|
|
4
|
+
import { LIMITS, Millis, Ulid } from "./schema/common.js";
|
|
5
|
+
import { ConvId, ConvKind, ParticipantId } from "./schema/conversation.js";
|
|
6
|
+
const Cursor = z.string().min(1).max(512).optional();
|
|
7
|
+
const Limit = z.number().int().min(1).max(50).optional();
|
|
8
|
+
const Fenced = z.string().describe("Untrusted peer content, fenced and with closing delimiters neutralized");
|
|
9
|
+
const Author = z.object({ person: Fenced, agent: Fenced.optional(), of: Fenced.optional(), side: z.enum(["ours", "peer"]) });
|
|
10
|
+
const Citation = z.string().regex(/^messaging:(?:[ds]_[a-z2-7]{26}|c_[0-7][0-9A-HJKMNP-TV-Z]{25})\/[0-7][0-9A-HJKMNP-TV-Z]{25}$/);
|
|
11
|
+
const ConvSource = z.strictObject({ kind: z.literal("conv"), cid: ConvId, event: Ulid });
|
|
12
|
+
export const ToolSource = z.discriminatedUnion("kind", [ConvSource, TaskSource]);
|
|
13
|
+
/** Rendered model result: peer text and names are fenced by the client, never instructions. */
|
|
14
|
+
export const ConversationToolEvent = z.object({
|
|
15
|
+
cite: Citation, cid: ConvId, id: Ulid, seq: z.number().int().positive().safe(), at: Millis,
|
|
16
|
+
conv: z.object({ kind: ConvKind, name: Fenced.optional() }), author: Author, thread: Ulid.optional(),
|
|
17
|
+
text: Fenced.max(LIMITS.toolTextChars).optional(), truncated: z.boolean().optional(), deleted: z.boolean().optional(), nextOffset: z.number().int().nonnegative().optional(),
|
|
18
|
+
}).refine((e) => e.cite === `messaging:${e.cid}/${e.id}`, "citation names this event")
|
|
19
|
+
.refine((e) => !e.deleted || e.text === undefined, "deleted events contain no text");
|
|
20
|
+
const Held = z.object({ stage: z.literal("held"), code: z.enum(["context", "audience"]), expect: z.literal("on_consent"), note: z.string().max(500) });
|
|
21
|
+
const Events = z.array(ConversationToolEvent).max(50).refine((items) => items.reduce((n, item) => n + new TextEncoder().encode(item.text ?? "").length, 0) <= 32 * 1024, "at most 32 KiB of text");
|
|
22
|
+
const EventPage = z.object({ events: Events, cursor: Cursor, notice: z.string().optional() });
|
|
23
|
+
const Item = z.object({ item: z.union([
|
|
24
|
+
z.object({ id: Ulid, source: ConvSource, event: ConversationToolEvent }).refine((p) => p.source.cid === p.event.cid && p.source.event === p.event.id, "source names this event"),
|
|
25
|
+
TaskReadOutput.shape.item,
|
|
26
|
+
]), notice: z.string().optional() });
|
|
27
|
+
const Address = z.string().min(1).max(200).describe("me, person, person/agent or #channel");
|
|
28
|
+
export const ConversationTools = {
|
|
29
|
+
contacts: AgentTool.contacts,
|
|
30
|
+
conversations: {
|
|
31
|
+
input: z.strictObject({ cid: ConvId.optional(), limit: Limit, cursor: Cursor }),
|
|
32
|
+
output: z.object({ conversations: z.array(z.object({ cid: ConvId, kind: ConvKind, name: Fenced.optional(), reason: z.enum(["seat", "allow", "ask"]), lookbackDays: z.number().int().positive().max(365), indexedFrom: Millis.optional(), complete: z.boolean() })).max(50), cursor: Cursor, notice: z.string().optional() }),
|
|
33
|
+
},
|
|
34
|
+
send: {
|
|
35
|
+
input: z.strictObject({ to: Address.optional(), cid: ConvId.optional(), text: z.string().min(1).max(40_000), thread: Ulid.optional(), answer: Ulid.optional(), ask: z.boolean().optional(), clientId: z.string().min(1).max(200).optional(),
|
|
36
|
+
...TaskSendFields })
|
|
37
|
+
.refine((p) => (p.to === undefined) !== (p.cid === undefined), "choose exactly one destination"),
|
|
38
|
+
output: z.union([z.object({ source: ConvSource, stage: z.literal("pending"), clientId: z.string().max(200), expect: ToolExpect.optional() }),
|
|
39
|
+
TaskSendOutput, Held]),
|
|
40
|
+
},
|
|
41
|
+
inbox: {
|
|
42
|
+
input: z.strictObject({ unread: z.boolean().optional(), from: ParticipantId.optional(), limit: Limit }),
|
|
43
|
+
output: z.object({ items: z.array(z.object({ id: Ulid, source: ToolSource, from: Fenced, preview: Fenced.max(2000).optional(), at: Millis, stage: z.enum(["delivered", "handed_to_agent", "read"]),
|
|
44
|
+
consent: ToolConsentSchema, chain: ToolChainSchema.optional() })).max(50), unread: z.number().int().nonnegative(), notice: z.string().optional() }),
|
|
45
|
+
},
|
|
46
|
+
read: {
|
|
47
|
+
input: z.strictObject({ cid: ConvId.optional(), source: ToolSource.optional(), id: Ulid.optional().describe("A received envelope of the named task"), offset: z.number().int().min(0).max(LIMITS.lineChars).optional(), thread: Ulid.optional(), before: z.string().min(1).max(512).optional(), after: z.string().min(1).max(512).optional(), limit: Limit, cursor: Cursor })
|
|
48
|
+
.refine((p) => (p.cid === undefined) !== (p.source === undefined), "choose exactly one of cid or source")
|
|
49
|
+
.refine((p) => [p.before, p.after, p.cursor].filter((v) => v !== undefined).length <= 1, "choose one page position")
|
|
50
|
+
.refine((p) => p.offset === undefined || p.source?.kind === "conv", "offset requires a conversation event source")
|
|
51
|
+
.refine((p) => p.id === undefined || p.source?.kind === "task", "envelope id requires its task source")
|
|
52
|
+
.refine((p) => p.source === undefined || (p.thread === undefined && p.before === undefined && p.after === undefined && p.cursor === undefined), "item reads do not page"),
|
|
53
|
+
output: z.union([EventPage, Item, Held]),
|
|
54
|
+
},
|
|
55
|
+
search: {
|
|
56
|
+
input: z.strictObject({ query: z.string().min(1).max(200), cid: ConvId.optional(), from: ParticipantId.optional(), after: Millis.optional(), before: Millis.optional(), thread: Ulid.optional(), limit: Limit, cursor: Cursor }),
|
|
57
|
+
output: z.union([EventPage, Held]),
|
|
58
|
+
},
|
|
59
|
+
context: {
|
|
60
|
+
input: z.strictObject({ cid: ConvId, event: Ulid, before: z.number().int().min(0).max(20).optional(), after: z.number().int().min(0).max(20).optional() }),
|
|
61
|
+
output: z.union([z.object({ event: ConversationToolEvent, before: Events, after: Events, root: ConversationToolEvent.optional(), notice: z.string().optional() })
|
|
62
|
+
.refine((p) => p.before.length <= 20 && p.after.length <= 20, "at most 20 neighbours on each side")
|
|
63
|
+
.refine((p) => [...p.before, ...p.after].reduce((n, e) => n + new TextEncoder().encode(e.text ?? "").length, 0) <= 32 * 1024, "at most 32 KiB of neighbour text"), Held]),
|
|
64
|
+
},
|
|
65
|
+
tasks: AgentTool.tasks,
|
|
66
|
+
task_update: AgentTool.task_update,
|
|
67
|
+
task_cancel: AgentTool.task_cancel,
|
|
68
|
+
};
|
|
69
|
+
export const CONVERSATION_TOOL_VERBS = ["contacts", "conversations", "send", "inbox", "read", "search", "context", "tasks", "task_update", "task_cancel"];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from "../../vendor/zod/index.js";
|
|
2
|
+
import { parseWith } from "../check.js";
|
|
3
|
+
import { DeviceLog } from "../directory/device-log.js";
|
|
4
|
+
import { adoptSth } from "../directory/epoch.js";
|
|
5
|
+
import { verifyPersonView } from "../directory/person-view.js";
|
|
6
|
+
import { DirectoryRealmConfig, directoryRealmId } from "../directory/realm.js";
|
|
7
|
+
import { DOMAIN, Hash, HostedPersonId } from "../schema/common.js";
|
|
8
|
+
import { KeyRotation } from "../schema/directory.js";
|
|
9
|
+
import { verify } from "../sign.js";
|
|
10
|
+
import { admitForeignProof, checkContext, FOREIGN_BUDGET, FOREIGN_LIMITS, ForeignProofError, proofDigest, proofFailure, requireProof, verifiedProof } from "./proof-state.js";
|
|
11
|
+
const Bundle = z.strictObject({ sth: z.unknown(), head: z.unknown(), view: z.unknown(), rotations: z.array(KeyRotation), consistency: z.array(Hash) });
|
|
12
|
+
/** Key resolution is trusted issuer configuration, never keys embedded in a foreign response.
|
|
13
|
+
* `options.heads` may only shrink the recent-head working window (tests exercise eviction with a
|
|
14
|
+
* few heads); it never exceeds FOREIGN_BUDGET.heads. */
|
|
15
|
+
export function verifyForeignDirectory(input, key, options = {}) {
|
|
16
|
+
const window = options.heads ?? FOREIGN_BUDGET.heads;
|
|
17
|
+
if (!Number.isSafeInteger(window) || window < 1 || window > FOREIGN_BUDGET.heads)
|
|
18
|
+
throw new RangeError("invalid-head-window");
|
|
19
|
+
try {
|
|
20
|
+
admitForeignProof(input.proof, "directory");
|
|
21
|
+
checkContext(input, "directory");
|
|
22
|
+
const person = parseWith(HostedPersonId, input.subject.person, "foreign person");
|
|
23
|
+
const pin = parseWith(DirectoryRealmConfig, input.pin.root, "directory pin");
|
|
24
|
+
requireProof(directoryRealmId(pin) === input.pin.realm, "directory-root", "root");
|
|
25
|
+
const bundle = parseWith(Bundle, input.proof, "directory proof");
|
|
26
|
+
// Require the full rotation chain from the original pin on every bundle. Strict schemas preserve
|
|
27
|
+
// signed JSON exactly; they reject extra fields rather than stripping them before verification.
|
|
28
|
+
// adoptSth intentionally skips already-applied rotations. A full foreign bundle must authenticate
|
|
29
|
+
// those too, before their keys become service-unbind authorities or enter the retained cursor.
|
|
30
|
+
let rotationKey = pin.key;
|
|
31
|
+
for (const rotation of bundle.rotations) {
|
|
32
|
+
const { sigOld, sigNew, ...unsigned } = rotation;
|
|
33
|
+
requireProof(verify(rotation.old, DOMAIN.dirKey, unsigned, sigOld) && verify(rotation.new, DOMAIN.dirKey, unsigned, sigNew), "rotation-signature");
|
|
34
|
+
requireProof(rotation.realm === input.pin.realm && rotation.old === rotationKey && rotation.new !== rotationKey, "rotation-chain");
|
|
35
|
+
rotationKey = rotation.new;
|
|
36
|
+
}
|
|
37
|
+
const anchored = adoptSth(undefined, pin.key, { realm: input.pin.realm, next: bundle.sth, rotations: bundle.rotations });
|
|
38
|
+
const previous = input.realm?.cursor;
|
|
39
|
+
const heldHeads = [...(previous?.heads ?? []), ...[input.realm?.head, input.known?.head, input.observedHead].filter((head) => head !== undefined)];
|
|
40
|
+
const comparable = heldHeads.filter((held) => held.position === anchored.sth.size);
|
|
41
|
+
for (const held of comparable)
|
|
42
|
+
requireProof(anchored.sth.root === held.hash, "directory-fork", "fork");
|
|
43
|
+
for (const held of [input.realm?.head, input.known?.head]) {
|
|
44
|
+
if (!held)
|
|
45
|
+
continue;
|
|
46
|
+
requireProof(anchored.sth.size >= held.position, "directory-rollback", "rollback", comparable.length > 0);
|
|
47
|
+
requireProof(anchored.sth.ts >= held.issuedAt, "directory-time-rollback", "rollback", comparable.length > 0);
|
|
48
|
+
}
|
|
49
|
+
requireProof(!input.known || previous !== undefined, "missing-realm-continuity");
|
|
50
|
+
const after = previous ? bundle.rotations.findIndex((rotation) => rotation.new === previous.adopted.key) + 1 : 0;
|
|
51
|
+
let adopted;
|
|
52
|
+
try {
|
|
53
|
+
adopted = adoptSth(previous?.adopted, pin.key, { realm: input.pin.realm, next: bundle.sth, rotations: bundle.rotations.slice(after), consistency: bundle.consistency });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// A stale consistency base does not prove compatibility with the current head.
|
|
57
|
+
throw new ForeignProofError("invalid", "continuity-proof");
|
|
58
|
+
}
|
|
59
|
+
const directoryKeys = [pin.key, ...bundle.rotations.map((rotation) => rotation.new)];
|
|
60
|
+
const context = { realm: input.pin.realm, person, jelly: { issuer: pin.issuer, audience: pin.resource, key }, directoryKeys };
|
|
61
|
+
const saved = input.known?.cursor;
|
|
62
|
+
const known = saved ? { person, log: DeviceLog.fromEntries(saved.log, context), keys: new Map(saved.keys.map((statement) => [statement.device, statement])), erased: saved.erased } : undefined;
|
|
63
|
+
const state = verifyPersonView(bundle.view, { ...context, sth: adopted.sth, head: bundle.head, known });
|
|
64
|
+
requireProof(state.person === person, "requested-person");
|
|
65
|
+
requireProof(!known?.erased || state.erased, "erasure-reversal", "rollback");
|
|
66
|
+
const head = { position: adopted.sth.size, hash: adopted.sth.root, issuedAt: adopted.sth.ts, expiresAt: adopted.sth.ts + FOREIGN_LIMITS.headMs };
|
|
67
|
+
// A working window, never a lifetime limit. The caller archives every authenticated
|
|
68
|
+
// observation and supplies observedHead for comparisons outside this window.
|
|
69
|
+
const heads = [...(previous?.heads ?? []).filter((held) => held.position !== head.position), head].slice(-window);
|
|
70
|
+
const devices = state.erased ? [] : state.log.devices().map((device) => {
|
|
71
|
+
const statement = state.keys.get(device.device);
|
|
72
|
+
return { signingKey: device.device, encryptionKey: statement?.enc, keyVersion: statement?.seq ?? -1, review: null };
|
|
73
|
+
});
|
|
74
|
+
const removed = state.log.allDevices().filter((device) => state.erased || !device.current).map((device) => device.device);
|
|
75
|
+
return verifiedProof({
|
|
76
|
+
realm: { realm: input.pin.realm, pinDigest: proofDigest(input.pin), head, cursor: { adopted, keys: directoryKeys, heads } },
|
|
77
|
+
identity: { subject: { ...input.subject }, head, devices, removed, erased: state.erased, proofDigest: proofDigest(input.proof), cursor: { log: [...state.log.entries()], keys: [...state.keys.values()], erased: state.erased } },
|
|
78
|
+
}, input.now);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
return proofFailure(error, input.proof);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/** Internal X3-3 boundary. No discovery response is a pin and no result is human approval. */
|
|
2
|
+
import { canonicalize } from "../jcs.js";
|
|
3
|
+
import { sha256 } from "../hash.js";
|
|
4
|
+
import { LIMITS } from "../schema/common.js";
|
|
5
|
+
export const FOREIGN_LIMITS = { fetchMs: 10 * 60_000, headMs: 20 * 60_000, futureMs: LIMITS.clockSkewMs };
|
|
6
|
+
/** Internal work budgets, independent of transport limits. `heads` bounds the recent-head
|
|
7
|
+
* working window only; the caller's durable archive retains observations beyond it. */
|
|
8
|
+
export const FOREIGN_BUDGET = { proofBytes: 1_048_576, stateBytes: 4_194_304, nodes: 32_768, depth: 32, rosterEntries: 1024, rotations: 64, consistency: 64, heads: 1024 };
|
|
9
|
+
export class ForeignInputError extends Error {
|
|
10
|
+
constructor() { super("foreign-input-limit"); }
|
|
11
|
+
}
|
|
12
|
+
/** Bound traversal before canonicalization allocates/sorts the complete value. No accessors run. */
|
|
13
|
+
export function checkForeignValue(value, maxBytes = FOREIGN_BUDGET.proofBytes) {
|
|
14
|
+
let bytes = 0, nodes = 0;
|
|
15
|
+
const maxNodes = FOREIGN_BUDGET.nodes * Math.max(1, maxBytes / FOREIGN_BUDGET.proofBytes);
|
|
16
|
+
const add = (size) => { bytes += size; if (bytes > maxBytes)
|
|
17
|
+
throw new ForeignInputError(); };
|
|
18
|
+
const text = (s) => {
|
|
19
|
+
if (s.length > maxBytes - bytes)
|
|
20
|
+
throw new ForeignInputError();
|
|
21
|
+
add(Buffer.byteLength(JSON.stringify(s), "utf8"));
|
|
22
|
+
};
|
|
23
|
+
const visit = (item, depth) => {
|
|
24
|
+
if (++nodes > maxNodes)
|
|
25
|
+
throw new ForeignInputError();
|
|
26
|
+
if (item === null) {
|
|
27
|
+
add(4);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (typeof item === "string") {
|
|
31
|
+
text(item);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (typeof item === "boolean") {
|
|
35
|
+
add(item ? 4 : 5);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (typeof item === "number" && Number.isFinite(item)) {
|
|
39
|
+
add(String(item).length);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (typeof item !== "object" || depth >= FOREIGN_BUDGET.depth)
|
|
43
|
+
throw new ForeignInputError();
|
|
44
|
+
add(2);
|
|
45
|
+
if (Array.isArray(item)) {
|
|
46
|
+
if (item.length > maxNodes - nodes)
|
|
47
|
+
throw new ForeignInputError();
|
|
48
|
+
for (let i = 0; i < item.length; i++) {
|
|
49
|
+
const field = Object.getOwnPropertyDescriptor(item, String(i));
|
|
50
|
+
if (!field || !("value" in field))
|
|
51
|
+
throw new ForeignInputError();
|
|
52
|
+
if (i)
|
|
53
|
+
add(1);
|
|
54
|
+
visit(field.value, depth + 1);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const proto = Object.getPrototypeOf(item);
|
|
59
|
+
if (proto !== Object.prototype && proto !== null && Object.getPrototypeOf(proto) !== null)
|
|
60
|
+
throw new ForeignInputError();
|
|
61
|
+
let count = 0;
|
|
62
|
+
for (const key in item) {
|
|
63
|
+
if (!Object.hasOwn(item, key))
|
|
64
|
+
continue;
|
|
65
|
+
if (++nodes > maxNodes)
|
|
66
|
+
throw new ForeignInputError();
|
|
67
|
+
const field = Object.getOwnPropertyDescriptor(item, key);
|
|
68
|
+
if (!("value" in field))
|
|
69
|
+
throw new ForeignInputError();
|
|
70
|
+
if (field.value === undefined)
|
|
71
|
+
continue;
|
|
72
|
+
if (count++)
|
|
73
|
+
add(1);
|
|
74
|
+
text(key);
|
|
75
|
+
add(1);
|
|
76
|
+
visit(field.value, depth + 1);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
visit(value, 0);
|
|
80
|
+
}
|
|
81
|
+
export function admitForeignProof(proof, kind) {
|
|
82
|
+
checkForeignValue(proof);
|
|
83
|
+
if (typeof proof !== "object" || proof === null)
|
|
84
|
+
return; // The verifier owns schema errors.
|
|
85
|
+
const bundle = proof;
|
|
86
|
+
const collection = (name, max) => {
|
|
87
|
+
const entries = bundle[name];
|
|
88
|
+
if (Array.isArray(entries) && entries.length > max)
|
|
89
|
+
throw new ForeignInputError();
|
|
90
|
+
};
|
|
91
|
+
if (kind === "roster") {
|
|
92
|
+
collection("entries", FOREIGN_BUDGET.rosterEntries);
|
|
93
|
+
if (Array.isArray(bundle.entries))
|
|
94
|
+
for (const entry of bundle.entries)
|
|
95
|
+
checkForeignValue(entry, LIMITS.rosterEntryBytes);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
collection("rotations", FOREIGN_BUDGET.rotations);
|
|
99
|
+
collection("consistency", FOREIGN_BUDGET.consistency);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export class ForeignProofError extends Error {
|
|
103
|
+
reason;
|
|
104
|
+
code;
|
|
105
|
+
compatible;
|
|
106
|
+
constructor(reason, code, compatible = false) {
|
|
107
|
+
super(code);
|
|
108
|
+
this.reason = reason;
|
|
109
|
+
this.code = code;
|
|
110
|
+
this.compatible = compatible;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function requireProof(condition, code, reason = "invalid", compatible = false) {
|
|
114
|
+
if (!condition)
|
|
115
|
+
throw new ForeignProofError(reason, code, compatible);
|
|
116
|
+
}
|
|
117
|
+
export function proofDigest(value) { checkForeignValue(value); return sha256(canonicalize(value)); }
|
|
118
|
+
export function proofFailure(error, proof) {
|
|
119
|
+
let digest;
|
|
120
|
+
if (error instanceof ForeignInputError)
|
|
121
|
+
return { ok: false, reason: "invalid", evidence: { code: "foreign-input-limit", digest: sha256("foreign-input-limit") } };
|
|
122
|
+
try {
|
|
123
|
+
digest = proofDigest(proof);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
digest = sha256("non-json-proof");
|
|
127
|
+
}
|
|
128
|
+
return { ok: false, reason: error instanceof ForeignProofError && error.reason !== "stale" ? error.reason : "invalid", evidence: { code: error instanceof ForeignProofError ? error.code : "invalid-proof", digest, ...(error instanceof ForeignProofError && error.reason === "rollback" && error.compatible ? { compatible: true } : {}) } };
|
|
129
|
+
}
|
|
130
|
+
/** Expiry changes key eligibility, never the authenticated history we must retain. */
|
|
131
|
+
export function verifiedProof(verified, now) {
|
|
132
|
+
try {
|
|
133
|
+
checkHeadTime(verified.realm.head, now);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (!(error instanceof ForeignProofError) || error.reason !== "stale")
|
|
137
|
+
throw error;
|
|
138
|
+
return { ok: false, reason: "stale", verified, evidence: { code: error.code, digest: verified.identity.proofDigest } };
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, ...verified };
|
|
141
|
+
}
|
|
142
|
+
export function checkContext(input, kind) {
|
|
143
|
+
requireProof(Number.isSafeInteger(input.now) && input.now >= 0, "clock");
|
|
144
|
+
requireProof(input.pin.kind === kind && input.subject.realm === input.pin.realm, "pinned-realm", "root");
|
|
145
|
+
if (input.realm)
|
|
146
|
+
requireProof(input.realm.realm === input.pin.realm && input.realm.pinDigest === proofDigest(input.pin), "known-root", "root");
|
|
147
|
+
if (input.known)
|
|
148
|
+
requireProof(input.known.subject.realm === input.subject.realm && input.known.subject.person === input.subject.person, "known-person");
|
|
149
|
+
}
|
|
150
|
+
export function checkHeadTime(head, now) {
|
|
151
|
+
requireProof(Number.isSafeInteger(head.issuedAt) && Number.isSafeInteger(head.expiresAt), "head-time");
|
|
152
|
+
requireProof(head.issuedAt <= now + FOREIGN_LIMITS.futureMs, "future-head");
|
|
153
|
+
requireProof(head.expiresAt > head.issuedAt && head.expiresAt - head.issuedAt <= FOREIGN_LIMITS.headMs, "head-lifetime");
|
|
154
|
+
requireProof(now - head.issuedAt <= FOREIGN_LIMITS.headMs && now < head.expiresAt, "expired-head", "stale");
|
|
155
|
+
}
|
|
156
|
+
/** Caller clock only. A future local fetch time (including clock rollback) fails closed. */
|
|
157
|
+
export function foreignFresh(head, fetchedAt, now) {
|
|
158
|
+
if (!Number.isSafeInteger(now) || !Number.isSafeInteger(fetchedAt) || fetchedAt > now || now - fetchedAt > FOREIGN_LIMITS.fetchMs)
|
|
159
|
+
return false;
|
|
160
|
+
try {
|
|
161
|
+
checkHeadTime(head, now);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from "../../vendor/zod/index.js";
|
|
2
|
+
import { parseWith } from "../check.js";
|
|
3
|
+
import { Roster, entryHash } from "../roster.js";
|
|
4
|
+
import { Hash, MachineId, Millis, PersonName } from "../schema/common.js";
|
|
5
|
+
import { verify } from "../sign.js";
|
|
6
|
+
import { admitForeignProof, checkContext, proofDigest, proofFailure, requireProof, verifiedProof } from "./proof-state.js";
|
|
7
|
+
/** Internal until X3-2/3 supplies the normative contract. */
|
|
8
|
+
export const DOMAIN = { rosterCheckpoint: "messaging/1/federation/roster-checkpoint\n" };
|
|
9
|
+
const Checkpoint = z.strictObject({ realm: Hash, seq: z.number().int().nonnegative(), hash: Hash, issuedAt: Millis, expiresAt: Millis, by: MachineId, sig: z.string() });
|
|
10
|
+
const Bundle = z.strictObject({ entries: z.array(z.unknown()).min(1), checkpoint: z.unknown() });
|
|
11
|
+
/** Full history only; the caller has separately authorized its disclosure. */
|
|
12
|
+
export function verifyForeignRoster(input) {
|
|
13
|
+
try {
|
|
14
|
+
admitForeignProof(input.proof, "roster");
|
|
15
|
+
checkContext(input, "roster");
|
|
16
|
+
parseWith(PersonName, input.subject.person, "foreign person");
|
|
17
|
+
const pin = parseWith(z.strictObject({ genesis: Hash }), input.pin.root, "roster pin");
|
|
18
|
+
requireProof(pin.genesis === input.pin.realm, "genesis-pin", "root");
|
|
19
|
+
const bundle = parseWith(Bundle, input.proof, "roster proof");
|
|
20
|
+
// Roster retains and verifies raw signed entries, including unknown signed fields.
|
|
21
|
+
const roster = Roster.fromEntries(bundle.entries);
|
|
22
|
+
requireProof(roster.realm === pin.genesis, "genesis-root", "root");
|
|
23
|
+
const raw = bundle.checkpoint;
|
|
24
|
+
requireProof(typeof raw === "object" && raw !== null, "checkpoint-shape");
|
|
25
|
+
const { sig, ...unsigned } = raw;
|
|
26
|
+
requireProof(typeof raw.by === "string" && typeof sig === "string" && verify(raw.by, DOMAIN.rosterCheckpoint, unsigned, sig), "checkpoint-signature");
|
|
27
|
+
const checkpoint = parseWith(Checkpoint, raw, "roster checkpoint");
|
|
28
|
+
const current = roster.head();
|
|
29
|
+
requireProof(checkpoint.realm === input.pin.realm, "checkpoint-realm");
|
|
30
|
+
requireProof(checkpoint.seq === current.seq && checkpoint.hash === current.hash, "checkpoint-head");
|
|
31
|
+
requireProof(roster.isAdmin(checkpoint.by), "checkpoint-admin");
|
|
32
|
+
// Compare every available common prefix before classifying an older response as rollback.
|
|
33
|
+
// The realm cursor also protects persons for whom we have never retained a separate view.
|
|
34
|
+
const hashes = roster.entries().map(entryHash);
|
|
35
|
+
for (const held of [input.realm, input.known]) {
|
|
36
|
+
if (!held)
|
|
37
|
+
continue;
|
|
38
|
+
const position = Math.min(current.seq, held.head.position);
|
|
39
|
+
const expected = position === held.head.position ? held.head.hash : held.cursor.hashes[position];
|
|
40
|
+
requireProof(hashes[position] === expected, "roster-fork", "fork");
|
|
41
|
+
}
|
|
42
|
+
for (const held of [input.realm?.head, input.known?.head]) {
|
|
43
|
+
if (!held)
|
|
44
|
+
continue;
|
|
45
|
+
requireProof(current.seq >= held.position, "roster-rollback", "rollback", true);
|
|
46
|
+
requireProof(checkpoint.issuedAt >= held.issuedAt, "checkpoint-rollback", "rollback", true);
|
|
47
|
+
}
|
|
48
|
+
const head = { position: current.seq, hash: current.hash, issuedAt: checkpoint.issuedAt, expiresAt: checkpoint.expiresAt };
|
|
49
|
+
const removed = new Set();
|
|
50
|
+
const people = new Map();
|
|
51
|
+
const reviews = new Map();
|
|
52
|
+
const versions = new Map();
|
|
53
|
+
for (const entry of roster.entries()) {
|
|
54
|
+
const member = entry.kind === "genesis" ? entry.body.founder : entry.kind === "member.add" ? entry.body : entry.kind === "invite.redeem" ? entry.body.member : undefined;
|
|
55
|
+
if (member)
|
|
56
|
+
people.set(member.machine, member.person);
|
|
57
|
+
if (entry.kind === "member.remove" && people.get(entry.body.machine) === input.subject.person)
|
|
58
|
+
removed.add(entry.body.machine);
|
|
59
|
+
if (entry.kind === "member.rekey") {
|
|
60
|
+
versions.set(entry.body.machine, entry.seq);
|
|
61
|
+
if (entry.by !== entry.body.machine)
|
|
62
|
+
reviews.set(entry.body.machine, entryHash(entry));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const devices = roster.members().filter((member) => member.person === input.subject.person).map((member) => ({ signingKey: member.machine, encryptionKey: member.enc, keyVersion: versions.get(member.machine) ?? 0, review: reviews.get(member.machine) ?? null }));
|
|
66
|
+
return verifiedProof({
|
|
67
|
+
realm: { realm: input.pin.realm, pinDigest: proofDigest(input.pin), head, cursor: { hashes } },
|
|
68
|
+
identity: { subject: { ...input.subject }, head, devices, removed: [...removed], erased: false, proofDigest: proofDigest(input.proof), cursor: { hashes } },
|
|
69
|
+
}, input.now);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return proofFailure(error, input.proof);
|
|
73
|
+
}
|
|
74
|
+
}
|