@joe-broadhead/messaging 0.0.0-stage → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +220 -0
- package/LICENSE +21 -0
- package/NOTICE +12 -0
- package/README.md +36 -2
- package/dist/bin/messaging-mcp.js +26524 -0
- package/dist/bin/messaging-relay.js +19768 -0
- package/dist/bin/messaging.js +15465 -0
- package/dist/bin/messagingd.js +29449 -0
- package/dist/lib/core-testing.js +1 -0
- package/dist/lib/core.js +1 -0
- package/dist/lib/ipc-testing.js +1 -0
- package/dist/lib/ipc.js +1 -0
- package/dist/lib/modules/core/address.js +42 -0
- package/dist/lib/modules/core/agent/operations.js +595 -0
- package/dist/lib/modules/core/agent/present.js +244 -0
- package/dist/lib/modules/core/agent/text.js +79 -0
- package/dist/lib/modules/core/agent-tools.js +477 -0
- package/dist/lib/modules/core/b64u.js +20 -0
- package/dist/lib/modules/core/cards.js +55 -0
- package/dist/lib/modules/core/check.js +20 -0
- package/dist/lib/modules/core/consent.js +199 -0
- package/dist/lib/modules/core/contact-card.js +74 -0
- package/dist/lib/modules/core/conversation/context.js +80 -0
- package/dist/lib/modules/core/conversation/event.js +100 -0
- package/dist/lib/modules/core/conversation/ids.js +38 -0
- package/dist/lib/modules/core/conversation/link.js +35 -0
- package/dist/lib/modules/core/conversation/log.js +766 -0
- package/dist/lib/modules/core/conversation/ratchet.js +243 -0
- package/dist/lib/modules/core/conversation/seal.js +198 -0
- package/dist/lib/modules/core/delegation.js +174 -0
- package/dist/lib/modules/core/directory/device-log.js +258 -0
- package/dist/lib/modules/core/directory/epoch.js +103 -0
- package/dist/lib/modules/core/directory/handle.js +29 -0
- package/dist/lib/modules/core/directory/jelly-device.js +204 -0
- package/dist/lib/modules/core/directory/jelly-issuer.js +12 -0
- package/dist/lib/modules/core/directory/jelly-token.js +305 -0
- package/dist/lib/modules/core/directory/keys-statement.js +43 -0
- package/dist/lib/modules/core/directory/link.js +81 -0
- package/dist/lib/modules/core/directory/merkle-log.js +250 -0
- package/dist/lib/modules/core/directory/person-view.js +197 -0
- package/dist/lib/modules/core/directory/person.js +40 -0
- package/dist/lib/modules/core/directory/realm.js +59 -0
- package/dist/lib/modules/core/directory/smt.js +266 -0
- package/dist/lib/modules/core/envelope.js +320 -0
- package/dist/lib/modules/core/hash.js +5 -0
- package/dist/lib/modules/core/head.js +27 -0
- package/dist/lib/modules/core/identity.js +71 -0
- package/dist/lib/modules/core/index.js +53 -0
- package/dist/lib/modules/core/jcs.js +96 -0
- package/dist/lib/modules/core/jelly-host-double.js +35 -0
- package/dist/lib/modules/core/jelly-host.js +211 -0
- package/dist/lib/modules/core/keys.js +79 -0
- package/dist/lib/modules/core/peer-text.js +563 -0
- package/dist/lib/modules/core/realm.js +1 -0
- package/dist/lib/modules/core/roster.js +454 -0
- package/dist/lib/modules/core/schema/a2a.js +85 -0
- package/dist/lib/modules/core/schema/common.js +310 -0
- package/dist/lib/modules/core/schema/consent.js +120 -0
- package/dist/lib/modules/core/schema/conversation.js +292 -0
- package/dist/lib/modules/core/schema/delegation.js +58 -0
- package/dist/lib/modules/core/schema/directory-frames.js +270 -0
- package/dist/lib/modules/core/schema/directory.js +169 -0
- package/dist/lib/modules/core/schema/envelope.js +211 -0
- package/dist/lib/modules/core/schema/errors.js +104 -0
- package/dist/lib/modules/core/schema/index.js +16 -0
- package/dist/lib/modules/core/schema/ipc/consent.js +181 -0
- package/dist/lib/modules/core/schema/ipc/device.js +276 -0
- package/dist/lib/modules/core/schema/ipc/jelly.js +189 -0
- package/dist/lib/modules/core/schema/ipc/shapes.js +185 -0
- package/dist/lib/modules/core/schema/ipc/surfaces.js +223 -0
- package/dist/lib/modules/core/schema/ipc.js +451 -0
- package/dist/lib/modules/core/schema/jelly.js +122 -0
- package/dist/lib/modules/core/schema/realm.js +27 -0
- package/dist/lib/modules/core/schema/relay.js +112 -0
- package/dist/lib/modules/core/schema/roster.js +126 -0
- package/dist/lib/modules/core/sign.js +43 -0
- package/dist/lib/modules/core/task.js +37 -0
- package/dist/lib/modules/core/testing.js +21 -0
- package/dist/lib/modules/core/ulid.js +36 -0
- package/dist/lib/modules/core/version.js +6 -0
- package/dist/lib/modules/core/wire.js +255 -0
- package/dist/lib/modules/ipc/client.js +289 -0
- package/dist/lib/modules/ipc/framing.js +33 -0
- package/dist/lib/modules/ipc/index.js +6 -0
- package/dist/lib/modules/ipc/paths.js +102 -0
- package/dist/lib/modules/ipc/pipes.js +65 -0
- package/dist/lib/modules/ipc/rpc.js +8 -0
- package/dist/lib/modules/ipc/server.js +317 -0
- package/dist/lib/modules/ipc/testing.js +145 -0
- package/dist/lib/modules/ipc/trust.js +166 -0
- package/dist/lib/modules/ipc/windows-acl.js +275 -0
- package/dist/lib/modules/vendor/zod/index.js +7 -0
- package/dist/lib/modules/vendor/zod/v4/classic/checks.js +1 -0
- package/dist/lib/modules/vendor/zod/v4/classic/coerce.js +17 -0
- package/dist/lib/modules/vendor/zod/v4/classic/compat.js +31 -0
- package/dist/lib/modules/vendor/zod/v4/classic/deep-partial.js +13 -0
- package/dist/lib/modules/vendor/zod/v4/classic/errors.js +57 -0
- package/dist/lib/modules/vendor/zod/v4/classic/external.js +17 -0
- package/dist/lib/modules/vendor/zod/v4/classic/from-json-schema.js +881 -0
- package/dist/lib/modules/vendor/zod/v4/classic/in-out.js +36 -0
- package/dist/lib/modules/vendor/zod/v4/classic/iso.js +15 -0
- package/dist/lib/modules/vendor/zod/v4/classic/parse.js +16 -0
- package/dist/lib/modules/vendor/zod/v4/classic/schemas.js +1535 -0
- package/dist/lib/modules/vendor/zod/v4/core/api.js +1107 -0
- package/dist/lib/modules/vendor/zod/v4/core/checks.js +516 -0
- package/dist/lib/modules/vendor/zod/v4/core/compile.js +1866 -0
- package/dist/lib/modules/vendor/zod/v4/core/core.js +141 -0
- package/dist/lib/modules/vendor/zod/v4/core/doc.js +38 -0
- package/dist/lib/modules/vendor/zod/v4/core/errors.js +267 -0
- package/dist/lib/modules/vendor/zod/v4/core/index.js +19 -0
- package/dist/lib/modules/vendor/zod/v4/core/json-schema-generator.js +99 -0
- package/dist/lib/modules/vendor/zod/v4/core/json-schema-processors.js +843 -0
- package/dist/lib/modules/vendor/zod/v4/core/json-schema.js +1 -0
- package/dist/lib/modules/vendor/zod/v4/core/memoizer.js +311 -0
- package/dist/lib/modules/vendor/zod/v4/core/parse.js +175 -0
- package/dist/lib/modules/vendor/zod/v4/core/regexes.js +156 -0
- package/dist/lib/modules/vendor/zod/v4/core/registries.js +51 -0
- package/dist/lib/modules/vendor/zod/v4/core/schemas.js +2636 -0
- package/dist/lib/modules/vendor/zod/v4/core/to-json-schema.js +651 -0
- package/dist/lib/modules/vendor/zod/v4/core/util.js +873 -0
- package/dist/lib/modules/vendor/zod/v4/core/versions.js +5 -0
- package/dist/lib/modules/vendor/zod/v4/core/visit.js +169 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ar.js +111 -0
- package/dist/lib/modules/vendor/zod/v4/locales/az.js +110 -0
- package/dist/lib/modules/vendor/zod/v4/locales/be.js +168 -0
- package/dist/lib/modules/vendor/zod/v4/locales/bg.js +125 -0
- package/dist/lib/modules/vendor/zod/v4/locales/bn.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ca.js +112 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ckb.js +133 -0
- package/dist/lib/modules/vendor/zod/v4/locales/cs.js +116 -0
- package/dist/lib/modules/vendor/zod/v4/locales/da.js +120 -0
- package/dist/lib/modules/vendor/zod/v4/locales/de.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/el.js +112 -0
- package/dist/lib/modules/vendor/zod/v4/locales/en.js +125 -0
- package/dist/lib/modules/vendor/zod/v4/locales/eo.js +114 -0
- package/dist/lib/modules/vendor/zod/v4/locales/es.js +136 -0
- package/dist/lib/modules/vendor/zod/v4/locales/fa.js +119 -0
- package/dist/lib/modules/vendor/zod/v4/locales/fi.js +117 -0
- package/dist/lib/modules/vendor/zod/v4/locales/fr-CA.js +112 -0
- package/dist/lib/modules/vendor/zod/v4/locales/fr.js +129 -0
- package/dist/lib/modules/vendor/zod/v4/locales/gu.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/he.js +221 -0
- package/dist/lib/modules/vendor/zod/v4/locales/hi.js +111 -0
- package/dist/lib/modules/vendor/zod/v4/locales/hr.js +126 -0
- package/dist/lib/modules/vendor/zod/v4/locales/hu.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/hy.js +158 -0
- package/dist/lib/modules/vendor/zod/v4/locales/id.js +111 -0
- package/dist/lib/modules/vendor/zod/v4/locales/index.js +63 -0
- package/dist/lib/modules/vendor/zod/v4/locales/is.js +114 -0
- package/dist/lib/modules/vendor/zod/v4/locales/it.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ja.js +112 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ka.js +117 -0
- package/dist/lib/modules/vendor/zod/v4/locales/kh.js +5 -0
- package/dist/lib/modules/vendor/zod/v4/locales/km.js +115 -0
- package/dist/lib/modules/vendor/zod/v4/locales/kn.js +116 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ko.js +116 -0
- package/dist/lib/modules/vendor/zod/v4/locales/lt.js +207 -0
- package/dist/lib/modules/vendor/zod/v4/locales/mk.js +114 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ms.js +112 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ne.js +111 -0
- package/dist/lib/modules/vendor/zod/v4/locales/nl.js +115 -0
- package/dist/lib/modules/vendor/zod/v4/locales/nn.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/no.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ota.js +114 -0
- package/dist/lib/modules/vendor/zod/v4/locales/pl.js +114 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ps.js +119 -0
- package/dist/lib/modules/vendor/zod/v4/locales/pt-BR.js +143 -0
- package/dist/lib/modules/vendor/zod/v4/locales/pt.js +142 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ro.js +122 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ru.js +168 -0
- package/dist/lib/modules/vendor/zod/v4/locales/sk.js +116 -0
- package/dist/lib/modules/vendor/zod/v4/locales/sl.js +114 -0
- package/dist/lib/modules/vendor/zod/v4/locales/sv.js +115 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ta.js +115 -0
- package/dist/lib/modules/vendor/zod/v4/locales/tg.js +117 -0
- package/dist/lib/modules/vendor/zod/v4/locales/th.js +115 -0
- package/dist/lib/modules/vendor/zod/v4/locales/tk.js +107 -0
- package/dist/lib/modules/vendor/zod/v4/locales/tr.js +110 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ua.js +5 -0
- package/dist/lib/modules/vendor/zod/v4/locales/uk.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/ur.js +115 -0
- package/dist/lib/modules/vendor/zod/v4/locales/uz.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/vi.js +113 -0
- package/dist/lib/modules/vendor/zod/v4/locales/yo.js +112 -0
- package/dist/lib/modules/vendor/zod/v4/locales/zh-CN.js +114 -0
- package/dist/lib/modules/vendor/zod/v4/locales/zh-TW.js +112 -0
- package/dist/lib/testing-jelly-id.js +1219 -0
- package/dist/lib/zod.js +1 -0
- package/dist/spec/messaging-v1.md +3744 -0
- package/dist/third-party-notices.md +239 -0
- package/dist/third-party.json +88 -0
- package/dist/types/core/address.d.ts +16 -0
- package/dist/types/core/agent/operations.d.ts +129 -0
- package/dist/types/core/agent/present.d.ts +57 -0
- package/dist/types/core/agent/text.d.ts +62 -0
- package/dist/types/core/agent-tools.d.ts +966 -0
- package/dist/types/core/b64u.d.ts +6 -0
- package/dist/types/core/cards.d.ts +21 -0
- package/dist/types/core/check.d.ts +10 -0
- package/dist/types/core/consent.d.ts +132 -0
- package/dist/types/core/contact-card.d.ts +34 -0
- package/dist/types/core/conversation/context.d.ts +56 -0
- package/dist/types/core/conversation/event.d.ts +36 -0
- package/dist/types/core/conversation/ids.d.ts +18 -0
- package/dist/types/core/conversation/link.d.ts +16 -0
- package/dist/types/core/conversation/log.d.ts +142 -0
- package/dist/types/core/conversation/ratchet.d.ts +95 -0
- package/dist/types/core/conversation/seal.d.ts +47 -0
- package/dist/types/core/delegation.d.ts +71 -0
- package/dist/types/core/directory/device-log.d.ts +119 -0
- package/dist/types/core/directory/epoch.d.ts +53 -0
- package/dist/types/core/directory/handle.d.ts +11 -0
- package/dist/types/core/directory/jelly-device.d.ts +88 -0
- package/dist/types/core/directory/jelly-issuer.d.ts +8 -0
- package/dist/types/core/directory/jelly-token.d.ts +116 -0
- package/dist/types/core/directory/keys-statement.d.ts +22 -0
- package/dist/types/core/directory/link.d.ts +36 -0
- package/dist/types/core/directory/merkle-log.d.ts +40 -0
- package/dist/types/core/directory/person-view.d.ts +90 -0
- package/dist/types/core/directory/person.d.ts +8 -0
- package/dist/types/core/directory/realm.d.ts +23 -0
- package/dist/types/core/directory/smt.d.ts +52 -0
- package/dist/types/core/envelope.d.ts +68 -0
- package/dist/types/core/hash.d.ts +2 -0
- package/dist/types/core/head.d.ts +34 -0
- package/dist/types/core/identity.d.ts +35 -0
- package/dist/types/core/index.d.ts +45 -0
- package/dist/types/core/jcs.d.ts +12 -0
- package/dist/types/core/jelly-host-double.d.ts +21 -0
- package/dist/types/core/jelly-host.d.ts +86 -0
- package/dist/types/core/keys.d.ts +16 -0
- package/dist/types/core/peer-text.d.ts +87 -0
- package/dist/types/core/realm.d.ts +36 -0
- package/dist/types/core/roster.d.ts +146 -0
- package/dist/types/core/schema/a2a.d.ts +251 -0
- package/dist/types/core/schema/common.d.ts +328 -0
- package/dist/types/core/schema/consent.d.ts +140 -0
- package/dist/types/core/schema/conversation.d.ts +1657 -0
- package/dist/types/core/schema/delegation.d.ts +58 -0
- package/dist/types/core/schema/directory-frames.d.ts +1301 -0
- package/dist/types/core/schema/directory.d.ts +375 -0
- package/dist/types/core/schema/envelope.d.ts +756 -0
- package/dist/types/core/schema/errors.d.ts +142 -0
- package/dist/types/core/schema/index.d.ts +16 -0
- package/dist/types/core/schema/ipc/consent.d.ts +555 -0
- package/dist/types/core/schema/ipc/device.d.ts +735 -0
- package/dist/types/core/schema/ipc/jelly.d.ts +501 -0
- package/dist/types/core/schema/ipc/shapes.d.ts +414 -0
- package/dist/types/core/schema/ipc/surfaces.d.ts +680 -0
- package/dist/types/core/schema/ipc.d.ts +3146 -0
- package/dist/types/core/schema/jelly.d.ts +160 -0
- package/dist/types/core/schema/realm.d.ts +38 -0
- package/dist/types/core/schema/relay.d.ts +1134 -0
- package/dist/types/core/schema/roster.d.ts +347 -0
- package/dist/types/core/sign.d.ts +13 -0
- package/dist/types/core/task.d.ts +6 -0
- package/dist/types/core/testing.d.ts +19 -0
- package/dist/types/core/ulid.d.ts +5 -0
- package/dist/types/core/version.d.ts +6 -0
- package/dist/types/core/wire.d.ts +14 -0
- package/dist/types/ipc/client.d.ts +85 -0
- package/dist/types/ipc/framing.d.ts +12 -0
- package/dist/types/ipc/index.d.ts +6 -0
- package/dist/types/ipc/paths.d.ts +39 -0
- package/dist/types/ipc/pipes.d.ts +16 -0
- package/dist/types/ipc/rpc.d.ts +13 -0
- package/dist/types/ipc/server.d.ts +94 -0
- package/dist/types/ipc/testing.d.ts +89 -0
- package/dist/types/ipc/trust.d.ts +49 -0
- package/dist/types/ipc/windows-acl.d.ts +56 -0
- package/dist/types/testing/jelly-id.d.ts +518 -0
- package/dist/types/zod.d.ts +1 -0
- package/package.json +77 -4
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sender keys (spec §16.5). Each sender device keeps one chain per (conversation, epoch):
|
|
3
|
+
*
|
|
4
|
+
* mk_i = HKDF-SHA256(ikm = ck_i, salt = empty, info = DOMAIN.convMsg, 32 bytes)
|
|
5
|
+
* ck_{i+1} = HKDF-SHA256(ikm = ck_i, salt = empty, info = DOMAIN.convChain, 32 bytes)
|
|
6
|
+
*
|
|
7
|
+
* A rotation starts a fresh random chain key at the next index of the same epoch, so `(epoch, device, idx)` names a
|
|
8
|
+
* message's key whatever the rotations: the share with the greatest `idx` at or below a message's `idx` derives it.
|
|
9
|
+
* Pure: no clock, no I/O; times are passed in.
|
|
10
|
+
*/
|
|
11
|
+
import { hkdfSync, randomBytes } from "node:crypto";
|
|
12
|
+
import { z } from "../../vendor/zod/index.js";
|
|
13
|
+
import { b64u } from "../b64u.js";
|
|
14
|
+
import { parseWith } from "../check.js";
|
|
15
|
+
import { DOMAIN, Key32, LIMITS, MachineId as MachineIdSchema, Millis } from "../schema/common.js";
|
|
16
|
+
import { KeyShare as KeyShareSchema } from "../schema/conversation.js";
|
|
17
|
+
import { MessagingError } from "../schema/errors.js";
|
|
18
|
+
const EMPTY = new Uint8Array(0);
|
|
19
|
+
function hkdf(ck, info) {
|
|
20
|
+
return new Uint8Array(hkdfSync("sha256", ck, EMPTY, info, 32));
|
|
21
|
+
}
|
|
22
|
+
/** One ratchet step: the message key for this index and the chain key for the next. */
|
|
23
|
+
export function chainStep(ck) {
|
|
24
|
+
if (ck.length !== 32)
|
|
25
|
+
throw new MessagingError("invalid_request", "a chain key is 32 bytes");
|
|
26
|
+
return { mk: hkdf(ck, DOMAIN.convMsg), next: hkdf(ck, DOMAIN.convChain) };
|
|
27
|
+
}
|
|
28
|
+
const Index = z.number().int().min(0);
|
|
29
|
+
/** A sender chain as a device stores it. */
|
|
30
|
+
export const SenderChainState = z.strictObject({
|
|
31
|
+
epoch: Index,
|
|
32
|
+
device: MachineIdSchema,
|
|
33
|
+
start: Index,
|
|
34
|
+
origin: Key32,
|
|
35
|
+
idx: Index,
|
|
36
|
+
ck: Key32,
|
|
37
|
+
startedAt: Millis,
|
|
38
|
+
});
|
|
39
|
+
/** The sending side of one chain. `next()` hands out each message key once. */
|
|
40
|
+
export class SenderChain {
|
|
41
|
+
epoch;
|
|
42
|
+
device;
|
|
43
|
+
/** The index this chain starts at. */
|
|
44
|
+
start;
|
|
45
|
+
startedAt;
|
|
46
|
+
#origin;
|
|
47
|
+
#ck;
|
|
48
|
+
#idx;
|
|
49
|
+
constructor(state) {
|
|
50
|
+
this.epoch = state.epoch;
|
|
51
|
+
this.device = state.device;
|
|
52
|
+
this.start = state.start;
|
|
53
|
+
this.startedAt = state.startedAt;
|
|
54
|
+
this.#origin = state.origin;
|
|
55
|
+
this.#ck = state.ck;
|
|
56
|
+
this.#idx = state.idx;
|
|
57
|
+
}
|
|
58
|
+
/** A new chain at `idx` (default 0) of `epoch`, from a fresh random chain key. */
|
|
59
|
+
static start(args) {
|
|
60
|
+
return SenderChain.#from(args, randomBytes(32));
|
|
61
|
+
}
|
|
62
|
+
/** `start` with a caller-chosen chain key: for test vectors, and for a device restoring its own state. */
|
|
63
|
+
static startWith(args, ck) {
|
|
64
|
+
return SenderChain.#from(args, ck);
|
|
65
|
+
}
|
|
66
|
+
static #from(args, ck) {
|
|
67
|
+
const epoch = parseWith(Index, args.epoch, "epoch");
|
|
68
|
+
const device = parseWith(MachineIdSchema, args.device, "device");
|
|
69
|
+
const start = parseWith(Index, args.idx ?? 0, "chain index");
|
|
70
|
+
const startedAt = parseWith(Millis, args.now, "now");
|
|
71
|
+
if (ck.length !== 32)
|
|
72
|
+
throw new MessagingError("invalid_request", "a chain key is 32 bytes");
|
|
73
|
+
const key = new Uint8Array(ck);
|
|
74
|
+
return new SenderChain({ epoch, device, start, origin: key, idx: start, ck: key, startedAt });
|
|
75
|
+
}
|
|
76
|
+
static restore(raw) {
|
|
77
|
+
const s = parseWith(SenderChainState, raw, "sender chain");
|
|
78
|
+
if (s.idx < s.start)
|
|
79
|
+
throw new MessagingError("invalid_request", "a sender chain cannot be behind its start");
|
|
80
|
+
return new SenderChain({ ...s, origin: b64u.decode(s.origin), ck: b64u.decode(s.ck) });
|
|
81
|
+
}
|
|
82
|
+
export() {
|
|
83
|
+
return { epoch: this.epoch, device: this.device, start: this.start, origin: b64u.encode(this.#origin), idx: this.#idx, ck: b64u.encode(this.#ck), startedAt: this.startedAt };
|
|
84
|
+
}
|
|
85
|
+
/** The index the next message gets. */
|
|
86
|
+
get idx() {
|
|
87
|
+
return this.#idx;
|
|
88
|
+
}
|
|
89
|
+
/** The share that lets a receiver open every message of this chain: its chain key at its start. */
|
|
90
|
+
share() {
|
|
91
|
+
return { epoch: this.epoch, device: this.device, idx: this.start, ck: b64u.encode(this.#origin) };
|
|
92
|
+
}
|
|
93
|
+
/** The key for the next message, and its index. The chain moves past it. */
|
|
94
|
+
next() {
|
|
95
|
+
const { mk, next } = chainStep(this.#ck);
|
|
96
|
+
const idx = this.#idx;
|
|
97
|
+
this.#ck = next;
|
|
98
|
+
this.#idx = idx + 1;
|
|
99
|
+
return { idx, mk };
|
|
100
|
+
}
|
|
101
|
+
/** True once the chain has sent `LIMITS.chainMessages` messages or is `LIMITS.chainMaxMs` old: rotate before the next. */
|
|
102
|
+
due(now) {
|
|
103
|
+
return this.#idx - this.start >= LIMITS.chainMessages || now - this.startedAt >= LIMITS.chainMaxMs;
|
|
104
|
+
}
|
|
105
|
+
/** A fresh chain in the same epoch, starting at this chain's next index. Share it before sending on it. */
|
|
106
|
+
rotate(now) {
|
|
107
|
+
return SenderChain.start({ epoch: this.epoch, device: this.device, idx: this.#idx, now });
|
|
108
|
+
}
|
|
109
|
+
/** `rotate` with a caller-chosen chain key, for test vectors. */
|
|
110
|
+
rotateWith(now, ck) {
|
|
111
|
+
return SenderChain.startWith({ epoch: this.epoch, device: this.device, idx: this.#idx, now }, ck);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const Segment = z.strictObject({
|
|
115
|
+
epoch: Index,
|
|
116
|
+
device: MachineIdSchema,
|
|
117
|
+
start: Index,
|
|
118
|
+
origin: Key32,
|
|
119
|
+
pos: Index,
|
|
120
|
+
ck: Key32,
|
|
121
|
+
skipped: z.array(z.tuple([Index, Key32])).max(LIMITS.skippedKeys),
|
|
122
|
+
});
|
|
123
|
+
export const ReceiverChainsState = z.strictObject({ chains: z.array(Segment).max(100_000) });
|
|
124
|
+
/**
|
|
125
|
+
* The receiving side: every chain this device holds for one conversation, by (epoch, sender device). It derives
|
|
126
|
+
* forward only and hands out each message key once; a message that arrives out of order uses a key kept from an
|
|
127
|
+
* earlier derivation, at most `LIMITS.skippedKeys` per sender and epoch. The chain keys at each share's index are
|
|
128
|
+
* kept, so the device can share them with a late joiner (history `shared`).
|
|
129
|
+
*/
|
|
130
|
+
export class ReceiverChains {
|
|
131
|
+
#chains = new Map();
|
|
132
|
+
static restore(raw) {
|
|
133
|
+
const state = parseWith(ReceiverChainsState, raw, "receiver chains");
|
|
134
|
+
const chains = new ReceiverChains();
|
|
135
|
+
for (const s of state.chains) {
|
|
136
|
+
if (s.pos < s.start)
|
|
137
|
+
throw new MessagingError("invalid_request", "a receiver chain cannot be behind its start");
|
|
138
|
+
const list = chains.#list(s.epoch, s.device);
|
|
139
|
+
if (list.some((seg) => seg.start === s.start))
|
|
140
|
+
throw new MessagingError("invalid_request", "duplicate receiver chain");
|
|
141
|
+
list.push({ start: s.start, origin: b64u.decode(s.origin), pos: s.pos, ck: b64u.decode(s.ck), skipped: new Map(s.skipped.map(([i, k]) => [i, b64u.decode(k)])) });
|
|
142
|
+
list.sort((a, b) => a.start - b.start);
|
|
143
|
+
}
|
|
144
|
+
return chains;
|
|
145
|
+
}
|
|
146
|
+
export() {
|
|
147
|
+
const chains = [];
|
|
148
|
+
for (const [key, list] of [...this.#chains].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
149
|
+
const [epoch, device] = key.split(" ");
|
|
150
|
+
for (const s of list) {
|
|
151
|
+
chains.push({
|
|
152
|
+
epoch: Number(epoch),
|
|
153
|
+
device,
|
|
154
|
+
start: s.start,
|
|
155
|
+
origin: b64u.encode(s.origin),
|
|
156
|
+
pos: s.pos,
|
|
157
|
+
ck: b64u.encode(s.ck),
|
|
158
|
+
skipped: [...s.skipped].sort(([a], [b]) => a - b).map(([i, k]) => [i, b64u.encode(k)]),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { chains };
|
|
163
|
+
}
|
|
164
|
+
#list(epoch, device) {
|
|
165
|
+
const key = `${epoch} ${device}`;
|
|
166
|
+
let list = this.#chains.get(key);
|
|
167
|
+
if (!list) {
|
|
168
|
+
list = [];
|
|
169
|
+
this.#chains.set(key, list);
|
|
170
|
+
}
|
|
171
|
+
return list;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Adds a share. The first share at a given (epoch, device, idx) wins: a later one with another key changes
|
|
175
|
+
* nothing, and `add` returns false. Returns true when the share was new.
|
|
176
|
+
*/
|
|
177
|
+
add(raw) {
|
|
178
|
+
const share = parseWith(KeyShareSchema, raw, "key share");
|
|
179
|
+
const list = this.#list(share.epoch, share.device);
|
|
180
|
+
if (list.some((s) => s.start === share.idx))
|
|
181
|
+
return false;
|
|
182
|
+
const ck = b64u.decode(share.ck);
|
|
183
|
+
list.push({ start: share.idx, origin: ck, pos: share.idx, ck, skipped: new Map() });
|
|
184
|
+
list.sort((a, b) => a.start - b.start);
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
/** Every share this device holds, from each chain's start: what it shares with a late joiner. */
|
|
188
|
+
shares() {
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const s of this.export().chains)
|
|
191
|
+
out.push({ epoch: s.epoch, device: s.device, idx: s.start, ck: s.origin });
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The message key for (`epoch`, `device`, `idx`), used once. `not_found` when no share covers it, or its key was
|
|
196
|
+
* already used or dropped; `too_large` when it is more than `LIMITS.skippedKeys` past the chain's position.
|
|
197
|
+
*/
|
|
198
|
+
messageKey(epoch, device, idx) {
|
|
199
|
+
const list = this.#chains.get(`${epoch} ${device}`) ?? [];
|
|
200
|
+
let at = -1;
|
|
201
|
+
for (let i = 0; i < list.length; i++)
|
|
202
|
+
if (list[i].start <= idx)
|
|
203
|
+
at = i;
|
|
204
|
+
if (at < 0)
|
|
205
|
+
throw new MessagingError("not_found", "no key share covers this message");
|
|
206
|
+
// `at` is the last chain starting at or below `idx`, so the next one (a rotation) starts past it.
|
|
207
|
+
const seg = list[at];
|
|
208
|
+
if (idx < seg.pos) {
|
|
209
|
+
const mk = seg.skipped.get(idx);
|
|
210
|
+
if (!mk)
|
|
211
|
+
throw new MessagingError("not_found", "this message key was already used or dropped");
|
|
212
|
+
seg.skipped.delete(idx);
|
|
213
|
+
return mk;
|
|
214
|
+
}
|
|
215
|
+
if (idx - seg.pos > LIMITS.skippedKeys)
|
|
216
|
+
throw new MessagingError("too_large", "message index is too far past the chain");
|
|
217
|
+
let ck = seg.ck;
|
|
218
|
+
for (let i = seg.pos; i < idx; i++) {
|
|
219
|
+
const step = chainStep(ck);
|
|
220
|
+
seg.skipped.set(i, step.mk);
|
|
221
|
+
ck = step.next;
|
|
222
|
+
}
|
|
223
|
+
const { mk, next } = chainStep(ck);
|
|
224
|
+
seg.ck = next;
|
|
225
|
+
seg.pos = idx + 1;
|
|
226
|
+
this.#bound(list);
|
|
227
|
+
return mk;
|
|
228
|
+
}
|
|
229
|
+
/** Keeps at most `LIMITS.skippedKeys` skipped keys per (epoch, device), dropping the lowest indexes first. */
|
|
230
|
+
#bound(list) {
|
|
231
|
+
let total = list.reduce((n, s) => n + s.skipped.size, 0);
|
|
232
|
+
for (const seg of list) {
|
|
233
|
+
if (total <= LIMITS.skippedKeys)
|
|
234
|
+
return;
|
|
235
|
+
for (const i of [...seg.skipped.keys()].sort((a, b) => a - b)) {
|
|
236
|
+
if (total <= LIMITS.skippedKeys)
|
|
237
|
+
return;
|
|
238
|
+
seg.skipped.delete(i);
|
|
239
|
+
total--;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sealing in conversations (spec §16.5, §16.6):
|
|
3
|
+
*
|
|
4
|
+
* - a `msg` body: base64url(nonce ‖ AES-256-GCM(mk, nonce, JCS(MsgBody), aad = JCS(header)) ‖ tag), with a fresh
|
|
5
|
+
* 12-byte nonce, under the author's message key for (`epoch`, `idx`);
|
|
6
|
+
* - `create.sealedMeta`: the same, of JCS(ConvMeta) under the creator's epoch-0 message key 0, with the create
|
|
7
|
+
* event's header as AAD;
|
|
8
|
+
* - a `keys` share: JCS({shares}) sealed to one recipient device under the envelope seal (spec §5, "Sealing": X25519
|
|
9
|
+
* with a fresh ephemeral key, HKDF-SHA256 with salt epk ‖ recipient enc and info DOMAIN.seal, AES-256-GCM), with
|
|
10
|
+
* aad = JCS({header, to}).
|
|
11
|
+
*/
|
|
12
|
+
import { createCipheriv, createDecipheriv, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from "node:crypto";
|
|
13
|
+
import { b64u, utf8 } from "../b64u.js";
|
|
14
|
+
import { asMessagingError, parseWith } from "../check.js";
|
|
15
|
+
import { canonicalize } from "../jcs.js";
|
|
16
|
+
import { privateKeyFromRaw, publicKeyFromRaw, rawPublicKey } from "../keys.js";
|
|
17
|
+
import { DOMAIN, LIMITS } from "../schema/common.js";
|
|
18
|
+
import { ConvMeta, KeySharePayload, MsgBody, } from "../schema/conversation.js";
|
|
19
|
+
import { MessagingError } from "../schema/errors.js";
|
|
20
|
+
import { parseWire } from "../wire.js";
|
|
21
|
+
import { convEventHeader, signConvEvent } from "./event.js";
|
|
22
|
+
const NONCE_BYTES = 12;
|
|
23
|
+
const TAG_BYTES = 16;
|
|
24
|
+
function aad(header) {
|
|
25
|
+
return utf8(canonicalize(header));
|
|
26
|
+
}
|
|
27
|
+
function aesSeal(key, nonce, plaintext, additional) {
|
|
28
|
+
if (nonce.length !== NONCE_BYTES)
|
|
29
|
+
throw new MessagingError("invalid_request", "a nonce is 12 bytes");
|
|
30
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce, { authTagLength: TAG_BYTES });
|
|
31
|
+
cipher.setAAD(additional);
|
|
32
|
+
return Buffer.concat([nonce, cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
|
33
|
+
}
|
|
34
|
+
function aesOpen(key, sealed, additional, maxPlaintext) {
|
|
35
|
+
let data;
|
|
36
|
+
try {
|
|
37
|
+
data = b64u.decode(sealed);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new MessagingError("bad_signature", "sealed body is not base64url");
|
|
41
|
+
}
|
|
42
|
+
if (data.length - NONCE_BYTES - TAG_BYTES > maxPlaintext)
|
|
43
|
+
throw new MessagingError("too_large", "sealed body is too large");
|
|
44
|
+
try {
|
|
45
|
+
const decipher = createDecipheriv("aes-256-gcm", key, data.subarray(0, NONCE_BYTES), { authTagLength: TAG_BYTES });
|
|
46
|
+
decipher.setAAD(additional);
|
|
47
|
+
decipher.setAuthTag(data.subarray(data.length - TAG_BYTES));
|
|
48
|
+
return Buffer.concat([decipher.update(data.subarray(NONCE_BYTES, data.length - TAG_BYTES)), decipher.final()]);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new MessagingError("bad_signature", "sealed body failed to open");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Decodes UTF-8 strictly and parses with `parseWire`. */
|
|
55
|
+
function wireJson(bytes) {
|
|
56
|
+
let text;
|
|
57
|
+
try {
|
|
58
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new MessagingError("invalid_request", "sealed body is not UTF-8");
|
|
62
|
+
}
|
|
63
|
+
return parseWire(text);
|
|
64
|
+
}
|
|
65
|
+
function plaintextOf(body) {
|
|
66
|
+
parseWith(MsgBody, body, "message body");
|
|
67
|
+
const bytes = utf8(canonicalize(body));
|
|
68
|
+
if (bytes.length > LIMITS.convPlaintextBytes)
|
|
69
|
+
throw new MessagingError("too_large", "message body is over LIMITS.convPlaintextBytes");
|
|
70
|
+
return bytes;
|
|
71
|
+
}
|
|
72
|
+
/** Seals `body` under `mk` as the body of `unsealed` and signs the event. Throws only MessagingError. */
|
|
73
|
+
export function sealConvMsg(identity, unsealed, body, mk) {
|
|
74
|
+
return sealConvMsgWith(identity, unsealed, body, mk, randomBytes(NONCE_BYTES));
|
|
75
|
+
}
|
|
76
|
+
/** `sealConvMsg` with a caller-chosen nonce: for test vectors only (`@messaging/core/testing`). */
|
|
77
|
+
export function sealConvMsgWith(identity, unsealed, body, mk, nonce) {
|
|
78
|
+
try {
|
|
79
|
+
const header = convEventHeader({ ...unsealed, class: "msg" });
|
|
80
|
+
const sealed = b64u.encode(aesSeal(mk, nonce, plaintextOf(body), aad(header)));
|
|
81
|
+
return signConvEvent(identity, { ...header, body: sealed });
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
throw asMessagingError(error, "conversation message");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Opens a `msg` event's body with its message key: `bad_signature` when it fails to open, `too_large` over
|
|
89
|
+
* `LIMITS.convPlaintextBytes`, `invalid_request` when the plaintext is not a `MsgBody` (parsed with `parseWire`).
|
|
90
|
+
* The caller has verified the event's signature.
|
|
91
|
+
*/
|
|
92
|
+
export function openConvMsg(event, mk) {
|
|
93
|
+
try {
|
|
94
|
+
if (mk.length !== 32)
|
|
95
|
+
throw new MessagingError("invalid_request", "a message key is 32 bytes");
|
|
96
|
+
const bytes = aesOpen(mk, event.body, aad(convEventHeader(event)), LIMITS.convPlaintextBytes);
|
|
97
|
+
return parseWith(MsgBody, wireJson(bytes), "message body");
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
throw asMessagingError(error, "conversation message");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** `create.sealedMeta`: JCS(meta) sealed under the creator's epoch-0 key 0, with the create event's header as AAD. */
|
|
104
|
+
export function sealConvMeta(header, meta, mk0) {
|
|
105
|
+
return sealConvMetaWith(header, meta, mk0, randomBytes(NONCE_BYTES));
|
|
106
|
+
}
|
|
107
|
+
/** `sealConvMeta` with a caller-chosen nonce: for test vectors only. */
|
|
108
|
+
export function sealConvMetaWith(header, meta, mk0, nonce) {
|
|
109
|
+
try {
|
|
110
|
+
const value = parseWith(ConvMeta, meta, "conversation meta");
|
|
111
|
+
return b64u.encode(aesSeal(mk0, nonce, utf8(canonicalize(value)), aad(convEventHeader(header))));
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
throw asMessagingError(error, "conversation meta");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Opens a `create` event's `sealedMeta`. Errors as for `openConvMsg`; `not_found` when it has none. */
|
|
118
|
+
export function openConvMeta(event, mk0) {
|
|
119
|
+
try {
|
|
120
|
+
if (event.body.op !== "create" || event.body.sealedMeta === undefined)
|
|
121
|
+
throw new MessagingError("not_found", "the event carries no sealed meta");
|
|
122
|
+
const bytes = aesOpen(mk0, event.body.sealedMeta, aad(convEventHeader(event)), 2048);
|
|
123
|
+
return parseWith(ConvMeta, wireJson(bytes), "conversation meta");
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
throw asMessagingError(error, "conversation meta");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function shareAad(header, to) {
|
|
130
|
+
return utf8(canonicalize({ header, to }));
|
|
131
|
+
}
|
|
132
|
+
function shareKey(shared, epk, recipientEnc) {
|
|
133
|
+
return new Uint8Array(hkdfSync("sha256", shared, Buffer.concat([epk, recipientEnc]), DOMAIN.seal, 32));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Seals `shares` to each recipient device as the body of `unsealed`, and signs the event. One sealed share per
|
|
137
|
+
* device; at most `LIMITS.keySharesPerEvent` devices and `LIMITS.keyShares` chain keys. Throws only MessagingError.
|
|
138
|
+
*/
|
|
139
|
+
export function sealKeyShares(identity, unsealed, recipients, shares) {
|
|
140
|
+
return sealShares(identity, unsealed, recipients, shares, () => {
|
|
141
|
+
const { privateKey } = generateKeyPairSync("x25519");
|
|
142
|
+
return { ephemeral: privateKey, nonce: randomBytes(NONCE_BYTES) };
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/** `sealKeyShares` with fixed randomness per recipient, in order: for test vectors only. */
|
|
146
|
+
export function sealKeySharesWith(identity, unsealed, recipients, shares, randomness) {
|
|
147
|
+
let i = 0;
|
|
148
|
+
return sealShares(identity, unsealed, recipients, shares, () => {
|
|
149
|
+
const r = randomness[i++];
|
|
150
|
+
if (!r)
|
|
151
|
+
throw new MessagingError("invalid_request", "one randomness per recipient");
|
|
152
|
+
return { ephemeral: privateKeyFromRaw("x25519", r.ephemeral), nonce: r.nonce };
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function sealShares(identity, unsealed, recipients, shares, randomness) {
|
|
156
|
+
try {
|
|
157
|
+
const header = convEventHeader({ ...unsealed, class: "keys" });
|
|
158
|
+
const plaintext = utf8(canonicalize(parseWith(KeySharePayload, { shares }, "key shares")));
|
|
159
|
+
const sealed = recipients.map((recipient) => {
|
|
160
|
+
const to = { person: recipient.person, device: recipient.device };
|
|
161
|
+
const { ephemeral, nonce } = randomness();
|
|
162
|
+
const shared = diffieHellman({ privateKey: ephemeral, publicKey: publicKeyFromRaw("x25519", recipient.enc) });
|
|
163
|
+
const epk = rawPublicKey(ephemeral);
|
|
164
|
+
const key = shareKey(shared, b64u.decode(epk), b64u.decode(recipient.enc));
|
|
165
|
+
const data = aesSeal(key, nonce, plaintext, shareAad(header, to));
|
|
166
|
+
return { to, epk, nonce: b64u.encode(nonce), ct: b64u.encode(data.subarray(NONCE_BYTES)) };
|
|
167
|
+
});
|
|
168
|
+
return signConvEvent(identity, { ...header, body: { shares: sealed } });
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
throw asMessagingError(error, "key shares");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Opens the share sealed to `identity`'s device in a `keys` event: `not_found` when there is none, `bad_signature`
|
|
176
|
+
* when it fails to open, `invalid_request` when the plaintext is not a share payload. The caller has verified the
|
|
177
|
+
* event's signature and applied it to the log.
|
|
178
|
+
*/
|
|
179
|
+
export function openKeyShares(event, identity) {
|
|
180
|
+
try {
|
|
181
|
+
const share = event.body.shares.find((s) => s.to.device === identity.machine);
|
|
182
|
+
if (!share)
|
|
183
|
+
throw new MessagingError("not_found", "no share for this device");
|
|
184
|
+
let key;
|
|
185
|
+
try {
|
|
186
|
+
const shared = diffieHellman({ privateKey: identity.encKey, publicKey: publicKeyFromRaw("x25519", share.epk) });
|
|
187
|
+
key = shareKey(shared, b64u.decode(share.epk), b64u.decode(identity.enc));
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
throw new MessagingError("bad_signature", "key share failed to open");
|
|
191
|
+
}
|
|
192
|
+
const bytes = aesOpen(key, share.nonce + share.ct, shareAad(convEventHeader(event), share.to), Number.MAX_SAFE_INTEGER);
|
|
193
|
+
return parseWith(KeySharePayload, wireJson(bytes), "key shares").shares;
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
throw asMessagingError(error, "key shares");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { utf8 } from "./b64u.js";
|
|
2
|
+
import { parseAddress } from "./address.js";
|
|
3
|
+
import { asMessagingError, parseWith } from "./check.js";
|
|
4
|
+
import { sha256 } from "./hash.js";
|
|
5
|
+
import { canonicalize } from "./jcs.js";
|
|
6
|
+
import { DOMAIN, LIMITS } from "./schema/common.js";
|
|
7
|
+
import { DelegationChain, DelegationGrant } from "./schema/delegation.js";
|
|
8
|
+
import { MessagingError } from "./schema/errors.js";
|
|
9
|
+
import { sign, signingInput, verifyInput } from "./sign.js";
|
|
10
|
+
/** SHA-256 of the JCS form of the full grant, including `sig`: the next grant's `parent`. */
|
|
11
|
+
export function grantHash(grant) {
|
|
12
|
+
return sha256(canonicalize(grant));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Signs one grant with the machine key (spec §6.7): a root, or the next hop after `parent`. The new grant
|
|
16
|
+
* may narrow the parent's scope and maxDepth, never widen them, and expires no later than the parent.
|
|
17
|
+
* It does not check the chain as a whole; run `verifyChain` on the extended chain for that.
|
|
18
|
+
* Throws only MessagingError: `invalid_request` for bad arguments, `forbidden` for a widening, `expired`
|
|
19
|
+
* for a parent that has expired.
|
|
20
|
+
*/
|
|
21
|
+
export function createGrant(identity, args) {
|
|
22
|
+
try {
|
|
23
|
+
return grant(identity, args);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
throw asMessagingError(error, "delegation grant");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function grant(identity, args) {
|
|
30
|
+
const now = args.now ?? Date.now();
|
|
31
|
+
const ttl = args.ttlMs ?? LIMITS.ttlMs;
|
|
32
|
+
if (!Number.isSafeInteger(now))
|
|
33
|
+
throw new MessagingError("invalid_request", "now must be an integer");
|
|
34
|
+
if (!Number.isSafeInteger(ttl) || ttl <= 0 || ttl > LIMITS.ttlMs)
|
|
35
|
+
throw new MessagingError("invalid_request", "ttlMs out of range");
|
|
36
|
+
const raw = args.parent;
|
|
37
|
+
const parent = raw === undefined ? undefined : parseWith(DelegationGrant, raw, "parent grant");
|
|
38
|
+
if (parent !== undefined) {
|
|
39
|
+
if (parent.to !== args.from)
|
|
40
|
+
throw new MessagingError("invalid_request", "a grant extends the grant to its delegating session");
|
|
41
|
+
}
|
|
42
|
+
const maxDepth = args.maxDepth ?? parent?.maxDepth ?? LIMITS.delegationDepth;
|
|
43
|
+
const scope = args.scope ?? parent?.scope;
|
|
44
|
+
if (parent !== undefined) {
|
|
45
|
+
if (maxDepth > parent.maxDepth)
|
|
46
|
+
throw new MessagingError("forbidden", "maxDepth would widen the parent grant");
|
|
47
|
+
if (!narrows(scope, parent.scope))
|
|
48
|
+
throw new MessagingError("forbidden", "scope would widen the parent grant");
|
|
49
|
+
}
|
|
50
|
+
const exp = parent === undefined ? now + ttl : Math.min(now + ttl, parent.exp);
|
|
51
|
+
if (exp <= now)
|
|
52
|
+
throw new MessagingError("expired", "the parent grant has expired");
|
|
53
|
+
const unsigned = {
|
|
54
|
+
realm: args.realm,
|
|
55
|
+
task: args.task,
|
|
56
|
+
from: args.from,
|
|
57
|
+
to: args.to,
|
|
58
|
+
by: identity.machine,
|
|
59
|
+
ts: now,
|
|
60
|
+
exp,
|
|
61
|
+
maxDepth,
|
|
62
|
+
...(scope !== undefined ? { scope: [...scope] } : {}),
|
|
63
|
+
...(raw !== undefined ? { parent: grantHash(raw) } : {}),
|
|
64
|
+
};
|
|
65
|
+
parseWith(DelegationGrant.omit({ sig: true }), unsigned, "delegation grant");
|
|
66
|
+
return { ...unsigned, sig: sign(identity, DOMAIN.delegation, unsigned) };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Verifies a delegation chain for the hop it arrived on (spec §6.7), against the locally verified realm.
|
|
70
|
+
* Checks, in order, stopping at the first failure:
|
|
71
|
+
*
|
|
72
|
+
* 1. schema, I-JSON and 1…`LIMITS.delegationDepth` grants (`invalid_request`); JCS within
|
|
73
|
+
* `LIMITS.delegationBytes` (`too_large`);
|
|
74
|
+
* 2. for each grant, root first: this realm (`invalid_request`); the time window, as for envelopes
|
|
75
|
+
* (`expired`); `by` a current member whose person and label `from` names (`not_member`); the
|
|
76
|
+
* signature over the raw grant (`bad_signature`); linkage, no `parent` on the root and `from = previous.to`,
|
|
77
|
+
* `parent = grantHash(previous)` after it (`invalid_request`); narrowing of scope, maxDepth and exp
|
|
78
|
+
* (`forbidden`);
|
|
79
|
+
* 3. at most the smallest maxDepth grants (`forbidden`);
|
|
80
|
+
* 4. no session twice (`forbidden`);
|
|
81
|
+
* 5. the last grant is for this hop (`forbidden`).
|
|
82
|
+
*
|
|
83
|
+
* Throws only MessagingError, and never quotes the chain.
|
|
84
|
+
*/
|
|
85
|
+
export function verifyChain(raw, ctx) {
|
|
86
|
+
try {
|
|
87
|
+
return check(raw, ctx);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
throw asMessagingError(error, "delegation chain");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function check(raw, ctx) {
|
|
94
|
+
const { realm, now, hop } = ctx;
|
|
95
|
+
if (!Number.isSafeInteger(now))
|
|
96
|
+
throw new MessagingError("invalid_request", "now must be an integer");
|
|
97
|
+
parseWith(DelegationChain, raw, "delegation chain");
|
|
98
|
+
// The raw value, so unknown fields stay covered; canonicalizing it also requires I-JSON.
|
|
99
|
+
const grants = raw;
|
|
100
|
+
if (utf8(canonicalize(grants)).length > LIMITS.delegationBytes)
|
|
101
|
+
throw new MessagingError("too_large", `a delegation chain is limited to ${LIMITS.delegationBytes} bytes`);
|
|
102
|
+
let maxDepth = LIMITS.delegationDepth;
|
|
103
|
+
let previous;
|
|
104
|
+
for (const grant of grants) {
|
|
105
|
+
const { sig, ...unsigned } = grant;
|
|
106
|
+
const input = signingInput(DOMAIN.delegation, unsigned);
|
|
107
|
+
if (grant.realm !== realm.realm)
|
|
108
|
+
throw new MessagingError("invalid_request", "a grant is for another realm");
|
|
109
|
+
if (grant.ts > grant.exp)
|
|
110
|
+
throw new MessagingError("expired", "a grant's ts is after its exp");
|
|
111
|
+
if (grant.ts > now + LIMITS.clockSkewMs)
|
|
112
|
+
throw new MessagingError("expired", "a grant's timestamp is in the future");
|
|
113
|
+
if (grant.exp <= now)
|
|
114
|
+
throw new MessagingError("expired", "a grant has expired");
|
|
115
|
+
if (grant.exp - grant.ts > LIMITS.ttlMs)
|
|
116
|
+
throw new MessagingError("expired", "a grant's lifetime exceeds the maximum");
|
|
117
|
+
const signer = realm.device(grant.by);
|
|
118
|
+
if (!signer)
|
|
119
|
+
throw new MessagingError("not_member", "a grant's signer is not a current member");
|
|
120
|
+
const from = parseAddress(grant.from);
|
|
121
|
+
if (signer.person !== from.person || signer.label !== from.label) {
|
|
122
|
+
throw new MessagingError("not_member", "a grant's from does not name its signer");
|
|
123
|
+
}
|
|
124
|
+
if (!verifyInput(grant.by, input, sig))
|
|
125
|
+
throw new MessagingError("bad_signature", "a grant signature is invalid");
|
|
126
|
+
if (previous === undefined) {
|
|
127
|
+
if (grant.parent !== undefined)
|
|
128
|
+
throw new MessagingError("invalid_request", "a root grant has no parent");
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
if (grant.from !== previous.to)
|
|
132
|
+
throw new MessagingError("invalid_request", "a grant does not extend the previous grant's recipient");
|
|
133
|
+
if (grant.parent !== grantHash(previous))
|
|
134
|
+
throw new MessagingError("invalid_request", "a grant's parent is not the previous grant");
|
|
135
|
+
if (!narrows(grant.scope, previous.scope))
|
|
136
|
+
throw new MessagingError("forbidden", "a grant widens the previous grant's scope");
|
|
137
|
+
if (grant.maxDepth > previous.maxDepth)
|
|
138
|
+
throw new MessagingError("forbidden", "a grant raises the previous grant's maxDepth");
|
|
139
|
+
if (grant.exp > previous.exp)
|
|
140
|
+
throw new MessagingError("forbidden", "a grant outlives the previous grant");
|
|
141
|
+
}
|
|
142
|
+
maxDepth = Math.min(maxDepth, grant.maxDepth);
|
|
143
|
+
previous = grant;
|
|
144
|
+
}
|
|
145
|
+
if (grants.length > maxDepth)
|
|
146
|
+
throw new MessagingError("forbidden", "the chain is deeper than its maxDepth allows");
|
|
147
|
+
const root = grants[0];
|
|
148
|
+
const last = grants[grants.length - 1];
|
|
149
|
+
const addresses = [root.from, ...grants.map((grant) => grant.to)];
|
|
150
|
+
if (new Set(addresses).size !== addresses.length)
|
|
151
|
+
throw new MessagingError("forbidden", "a session appears twice in the chain");
|
|
152
|
+
if (last.by !== hop.by || last.from !== hop.from || last.to !== hop.to || last.task !== hop.task) {
|
|
153
|
+
throw new MessagingError("forbidden", "the chain does not authorize this request");
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
grants: structuredClone(grants),
|
|
157
|
+
summary: {
|
|
158
|
+
addresses,
|
|
159
|
+
depth: grants.length,
|
|
160
|
+
maxDepth,
|
|
161
|
+
...(last.scope !== undefined ? { scope: [...last.scope] } : {}),
|
|
162
|
+
exp: Math.min(...grants.map((grant) => grant.exp)),
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** Whether `child` names nothing `parent` does not. An absent scope is unrestricted. */
|
|
167
|
+
function narrows(child, parent) {
|
|
168
|
+
if (parent === undefined)
|
|
169
|
+
return true;
|
|
170
|
+
if (child === undefined)
|
|
171
|
+
return false;
|
|
172
|
+
const allowed = new Set(parent);
|
|
173
|
+
return child.every((label) => allowed.has(label));
|
|
174
|
+
}
|