@joe-broadhead/messaging 0.7.1 → 0.9.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 +364 -2
- package/dist/bin/messaging-mcp.js +5498 -4338
- package/dist/bin/messaging-relay.js +8626 -5540
- package/dist/bin/messaging.js +4492 -3158
- package/dist/bin/messagingd.js +19672 -12412
- package/dist/lib/modules/core/agent/operations.js +19 -327
- package/dist/lib/modules/core/agent/present.js +20 -55
- package/dist/lib/modules/core/agent-tools.js +35 -139
- package/dist/lib/modules/core/consent.js +54 -14
- package/dist/lib/modules/core/contact-card.js +3 -3
- package/dist/lib/modules/core/conversation/event.js +29 -4
- package/dist/lib/modules/core/conversation/guest-invitation.js +42 -0
- package/dist/lib/modules/core/conversation/log.js +287 -65
- 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 +90 -37
- 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 +9 -4
- package/dist/lib/modules/core/jelly-host.js +8 -5
- package/dist/lib/modules/core/schema/common.js +33 -5
- package/dist/lib/modules/core/schema/consent.js +23 -5
- package/dist/lib/modules/core/schema/contact-request.js +22 -0
- package/dist/lib/modules/core/schema/conv-frames.js +101 -0
- package/dist/lib/modules/core/schema/conversation.js +67 -33
- package/dist/lib/modules/core/schema/directory-frames.js +8 -3
- package/dist/lib/modules/core/schema/envelope.js +8 -24
- package/dist/lib/modules/core/schema/errors.js +7 -5
- package/dist/lib/modules/core/schema/guest-invitation.js +13 -0
- package/dist/lib/modules/core/schema/index.js +5 -0
- package/dist/lib/modules/core/schema/ipc/consent.js +97 -18
- package/dist/lib/modules/core/schema/ipc/conversation-views.js +87 -0
- package/dist/lib/modules/core/schema/ipc/conversations.js +132 -0
- package/dist/lib/modules/core/schema/ipc/device.js +9 -0
- package/dist/lib/modules/core/schema/ipc/jelly.js +17 -45
- package/dist/lib/modules/core/schema/ipc/shapes.js +74 -73
- 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 +68 -46
- package/dist/lib/modules/core/testing.js +1 -1
- 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 +854 -445
- 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 +295 -442
- package/dist/types/core/consent.d.ts +30 -7
- package/dist/types/core/contact-card.d.ts +3 -3
- package/dist/types/core/conversation/event.d.ts +6 -6
- package/dist/types/core/conversation/guest-invitation.d.ts +8 -0
- package/dist/types/core/conversation/log.d.ts +28 -13
- 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 +32 -9
- package/dist/types/core/conversation-agent-tools.d.ts +939 -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 +11 -6
- package/dist/types/core/jelly-host.d.ts +1 -1
- package/dist/types/core/schema/common.d.ts +33 -6
- package/dist/types/core/schema/consent.d.ts +29 -8
- package/dist/types/core/schema/contact-request.d.ts +264 -0
- package/dist/types/core/schema/conv-frames.d.ts +1204 -0
- package/dist/types/core/schema/conversation.d.ts +1410 -264
- package/dist/types/core/schema/directory-frames.d.ts +8 -3
- package/dist/types/core/schema/envelope.d.ts +85 -130
- package/dist/types/core/schema/errors.d.ts +5 -1
- package/dist/types/core/schema/guest-invitation.d.ts +51 -0
- package/dist/types/core/schema/index.d.ts +5 -0
- package/dist/types/core/schema/ipc/consent.d.ts +464 -62
- package/dist/types/core/schema/ipc/conversation-views.d.ts +1199 -0
- package/dist/types/core/schema/ipc/conversations.d.ts +4355 -0
- package/dist/types/core/schema/ipc/device.d.ts +10 -0
- package/dist/types/core/schema/ipc/jelly.d.ts +16 -111
- package/dist/types/core/schema/ipc/shapes.d.ts +211 -95
- package/dist/types/core/schema/ipc/surfaces.d.ts +26 -427
- package/dist/types/core/schema/ipc/tasks.d.ts +210 -0
- package/dist/types/core/schema/ipc.d.ts +3859 -890
- package/dist/types/core/schema/relay.d.ts +12 -4
- package/dist/types/core/testing.d.ts +1 -1
- 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 +16 -1
- package/dist/types/ipc/testing.d.ts +21 -8
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* 12-byte nonce, under the author's message key for (`epoch`, `idx`);
|
|
6
6
|
* - `create.sealedMeta`: the same, of JCS(ConvMeta) under the creator's epoch-0 message key 0, with the create
|
|
7
7
|
* event's header as AAD;
|
|
8
|
-
* - a `
|
|
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
|
|
9
11
|
* with a fresh ephemeral key, HKDF-SHA256 with salt epk ‖ recipient enc and info DOMAIN.seal, AES-256-GCM), with
|
|
10
12
|
* aad = JCS({header, to}).
|
|
11
13
|
*/
|
|
@@ -15,7 +17,7 @@ import { asMessagingError, parseWith } from "../check.js";
|
|
|
15
17
|
import { canonicalize } from "../jcs.js";
|
|
16
18
|
import { privateKeyFromRaw, publicKeyFromRaw, rawPublicKey } from "../keys.js";
|
|
17
19
|
import { DOMAIN, LIMITS } from "../schema/common.js";
|
|
18
|
-
import { ConvMeta, KeySharePayload, MsgBody, } from "../schema/conversation.js";
|
|
20
|
+
import { ConvMeta, KeySharePayload, MsgBody, SettingBody, } from "../schema/conversation.js";
|
|
19
21
|
import { MessagingError } from "../schema/errors.js";
|
|
20
22
|
import { parseWire } from "../wire.js";
|
|
21
23
|
import { convEventHeader, signConvEvent } from "./event.js";
|
|
@@ -133,54 +135,49 @@ function shareKey(shared, epk, recipientEnc) {
|
|
|
133
135
|
return new Uint8Array(hkdfSync("sha256", shared, Buffer.concat([epk, recipientEnc]), DOMAIN.seal, 32));
|
|
134
136
|
}
|
|
135
137
|
/**
|
|
136
|
-
* Seals `shares` to
|
|
137
|
-
* device
|
|
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.
|
|
138
140
|
*/
|
|
139
|
-
export function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
function sealShares(identity, unsealed, recipients, shares, randomness) {
|
|
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) {
|
|
156
155
|
try {
|
|
157
156
|
const header = convEventHeader({ ...unsealed, class: "keys" });
|
|
158
157
|
const plaintext = utf8(canonicalize(parseWith(KeySharePayload, { shares }, "key shares")));
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
});
|
|
168
|
-
return signConvEvent(identity, { ...header, body: { shares: sealed } });
|
|
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 });
|
|
169
166
|
}
|
|
170
167
|
catch (error) {
|
|
171
168
|
throw asMessagingError(error, "key shares");
|
|
172
169
|
}
|
|
173
170
|
}
|
|
174
171
|
/**
|
|
175
|
-
* Opens
|
|
176
|
-
*
|
|
177
|
-
*
|
|
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`).
|
|
178
175
|
*/
|
|
179
176
|
export function openKeyShares(event, identity) {
|
|
180
177
|
try {
|
|
181
|
-
const share = event.body
|
|
182
|
-
if (
|
|
183
|
-
throw new MessagingError("not_found", "
|
|
178
|
+
const share = event.body;
|
|
179
|
+
if (share.to.device !== identity.machine)
|
|
180
|
+
throw new MessagingError("not_found", "the share is for another device");
|
|
184
181
|
let key;
|
|
185
182
|
try {
|
|
186
183
|
const shared = diffieHellman({ privateKey: identity.encKey, publicKey: publicKeyFromRaw("x25519", share.epk) });
|
|
@@ -196,3 +193,59 @@ export function openKeyShares(event, identity) {
|
|
|
196
193
|
throw asMessagingError(error, "key shares");
|
|
197
194
|
}
|
|
198
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
|
+
}
|