@joe-broadhead/messaging 0.0.0-stage → 0.7.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 +175 -0
- package/LICENSE +21 -0
- package/NOTICE +12 -0
- package/README.md +36 -2
- package/dist/bin/messaging-mcp.js +25887 -0
- package/dist/bin/messaging-relay.js +19014 -0
- package/dist/bin/messaging.js +14732 -0
- package/dist/bin/messagingd.js +28682 -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 +458 -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/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 +44 -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 +254 -0
- package/dist/lib/modules/core/schema/consent.js +120 -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 +65 -0
- package/dist/lib/modules/core/schema/index.js +15 -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 +20 -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 +3426 -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 +948 -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/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 +38 -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 +272 -0
- package/dist/types/core/schema/consent.d.ts +140 -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 +750 -0
- package/dist/types/core/schema/errors.d.ts +104 -0
- package/dist/types/core/schema/index.d.ts +15 -0
- package/dist/types/core/schema/ipc/consent.d.ts +555 -0
- package/dist/types/core/schema/ipc/device.d.ts +729 -0
- package/dist/types/core/schema/ipc/jelly.d.ts +501 -0
- package/dist/types/core/schema/ipc/shapes.d.ts +411 -0
- package/dist/types/core/schema/ipc/surfaces.d.ts +674 -0
- package/dist/types/core/schema/ipc.d.ts +3131 -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 +1122 -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 +18 -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,258 @@
|
|
|
1
|
+
import { asMessagingError, parseWith } from "../check.js";
|
|
2
|
+
import { Identity } from "../identity.js";
|
|
3
|
+
import { canonicalize } from "../jcs.js";
|
|
4
|
+
import { sha256 } from "../hash.js";
|
|
5
|
+
import { rawPublicKey } from "../keys.js";
|
|
6
|
+
import { signWith, verify } from "../sign.js";
|
|
7
|
+
import { DOMAIN, LIMITS } from "../schema/common.js";
|
|
8
|
+
import { JELLY_DEVICE_SOURCE } from "../schema/jelly.js";
|
|
9
|
+
import { LogEntry } from "../schema/directory.js";
|
|
10
|
+
import { MessagingError } from "../schema/errors.js";
|
|
11
|
+
import { verifyJellyToken } from "./jelly-token.js";
|
|
12
|
+
import { verifyLinkSignature } from "./link.js";
|
|
13
|
+
import { personId } from "./person.js";
|
|
14
|
+
/** `SHA-256(JCS(entry))`, the signature included. */
|
|
15
|
+
export function logEntryHash(entry) {
|
|
16
|
+
return sha256(canonicalize(entry));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Signs an entry: `by` becomes the signer's public key, `sig` its signature over JCS(entry without sig). A
|
|
20
|
+
* device signs with its `Identity`; the directory signs the unbinds of an erased account with its epoch key.
|
|
21
|
+
* Throws `invalid_request`.
|
|
22
|
+
*/
|
|
23
|
+
export function signLogEntry(signer, entry) {
|
|
24
|
+
const key = signer instanceof Identity ? signer.signingKey : signer;
|
|
25
|
+
const unsigned = { ...entry, by: rawPublicKey(key) };
|
|
26
|
+
return parseWith(LogEntry, { ...unsigned, sig: signWith(key, DOMAIN.deviceLog, unsigned) }, "log entry");
|
|
27
|
+
}
|
|
28
|
+
const refuse = (what) => new MessagingError("binding_invalid", what);
|
|
29
|
+
/**
|
|
30
|
+
* One person's device log, verified entry by entry from `seq` 0. A failed `append` changes nothing. Errors:
|
|
31
|
+
* `invalid_request` (schema, size), `log_conflict` (not on the head), `device_limit` (an eleventh device),
|
|
32
|
+
* `expired` (with `now`), and `binding_invalid` for every other rule and signature.
|
|
33
|
+
*/
|
|
34
|
+
export class DeviceLog {
|
|
35
|
+
realm;
|
|
36
|
+
person;
|
|
37
|
+
#state = { entries: [], hashes: [], devices: new Map(), tokens: new Map(), profile: undefined };
|
|
38
|
+
constructor(realm, person) {
|
|
39
|
+
this.realm = realm;
|
|
40
|
+
this.person = person;
|
|
41
|
+
}
|
|
42
|
+
/** A copy that appends independently of this log. Entries are not re-verified. */
|
|
43
|
+
clone() {
|
|
44
|
+
const copy = new DeviceLog(this.realm, this.person);
|
|
45
|
+
copy.#state = this.#state;
|
|
46
|
+
return copy;
|
|
47
|
+
}
|
|
48
|
+
/** Replays `entries` from `seq` 0. */
|
|
49
|
+
static fromEntries(entries, context) {
|
|
50
|
+
const log = new DeviceLog(context.realm, context.person);
|
|
51
|
+
for (const entry of entries)
|
|
52
|
+
log.append(entry, context);
|
|
53
|
+
return log;
|
|
54
|
+
}
|
|
55
|
+
get length() {
|
|
56
|
+
return this.#state.entries.length;
|
|
57
|
+
}
|
|
58
|
+
/** The head `{seq, hash}`, or undefined for an empty log. */
|
|
59
|
+
head() {
|
|
60
|
+
const seq = this.#state.entries.length - 1;
|
|
61
|
+
return seq < 0 ? undefined : { seq, hash: this.#state.hashes[seq] };
|
|
62
|
+
}
|
|
63
|
+
/** The hash of entry `seq`. */
|
|
64
|
+
hashAt(seq) {
|
|
65
|
+
return this.#state.hashes[seq];
|
|
66
|
+
}
|
|
67
|
+
entries() {
|
|
68
|
+
return this.#state.entries;
|
|
69
|
+
}
|
|
70
|
+
/** Every device the log has bound, current or not. */
|
|
71
|
+
allDevices() {
|
|
72
|
+
return [...this.#state.devices.values()];
|
|
73
|
+
}
|
|
74
|
+
/** The current devices. */
|
|
75
|
+
devices() {
|
|
76
|
+
return this.allDevices().filter((device) => device.current);
|
|
77
|
+
}
|
|
78
|
+
device(device) {
|
|
79
|
+
return this.#state.devices.get(device);
|
|
80
|
+
}
|
|
81
|
+
/** The device that replaced `device` (an `unbind` with reason `replaced`), if it did and is current (spec §15.3). */
|
|
82
|
+
successor(device) {
|
|
83
|
+
const record = this.#state.devices.get(device);
|
|
84
|
+
if (record?.unbound?.reason !== "replaced")
|
|
85
|
+
return undefined;
|
|
86
|
+
return this.#state.devices.get(record.unbound.by)?.current ? record.unbound.by : undefined;
|
|
87
|
+
}
|
|
88
|
+
/** The profile from the newest Jelly token in the log. */
|
|
89
|
+
profile() {
|
|
90
|
+
return this.#state.profile;
|
|
91
|
+
}
|
|
92
|
+
/** The verified Jelly token of entry `seq`, for an entry with a `jelly` authority. */
|
|
93
|
+
token(seq) {
|
|
94
|
+
return this.#state.tokens.get(seq);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A `bind` or `attest` of this log carried a sign-in's token (`src: "device"`) with this `jti`: that token
|
|
98
|
+
* vouched for its one act and vouches for nothing else (spec §15.3), an erasure included.
|
|
99
|
+
*/
|
|
100
|
+
spentSignIn(jti) {
|
|
101
|
+
for (const [seq, token] of this.#state.tokens) {
|
|
102
|
+
if (token.claims.src === JELLY_DEVICE_SOURCE && token.claims.jti === jti && this.#state.entries[seq].op !== "unbind")
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The latest time at which entry `seq` may first appear in an epoch: its Jelly token's `exp` plus the clock
|
|
109
|
+
* skew, in ms. Undefined for an entry with no `jelly` authority.
|
|
110
|
+
*/
|
|
111
|
+
bindingDeadline(seq) {
|
|
112
|
+
const token = this.#state.tokens.get(seq);
|
|
113
|
+
return token === undefined ? undefined : token.claims.exp * 1000 + LIMITS.clockSkewMs;
|
|
114
|
+
}
|
|
115
|
+
/** Checks `entry` as the next entry and appends it. */
|
|
116
|
+
append(entry, context, options = {}) {
|
|
117
|
+
try {
|
|
118
|
+
const { parsed, next } = this.#check(entry, context, options);
|
|
119
|
+
this.#state = next;
|
|
120
|
+
return parsed;
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
throw asMessagingError(error, "log entry");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Checks `entry` as the next entry without appending it. */
|
|
127
|
+
check(entry, context, options = {}) {
|
|
128
|
+
try {
|
|
129
|
+
return this.#check(entry, context, options).parsed;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
throw asMessagingError(error, "log entry");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
#check(entry, context, options) {
|
|
136
|
+
const parsed = parseWith(LogEntry, entry, "log entry");
|
|
137
|
+
if (Buffer.byteLength(canonicalize(parsed), "utf8") > LIMITS.logEntryBytes)
|
|
138
|
+
throw new MessagingError("too_large", "log entry is over its size limit");
|
|
139
|
+
if (context.realm !== this.realm || context.person !== this.person)
|
|
140
|
+
throw new MessagingError("invalid_request", "log context is for another log");
|
|
141
|
+
const state = this.#state;
|
|
142
|
+
const head = this.head();
|
|
143
|
+
if (parsed.seq !== state.entries.length || parsed.prev !== (head?.hash ?? null))
|
|
144
|
+
throw new MessagingError("log_conflict", "log entry does not extend the head");
|
|
145
|
+
if (parsed.realm !== this.realm)
|
|
146
|
+
throw refuse("log entry is for another realm");
|
|
147
|
+
if (parsed.person !== this.person)
|
|
148
|
+
throw refuse("log entry is for another person");
|
|
149
|
+
const last = state.entries[state.entries.length - 1];
|
|
150
|
+
if (last && parsed.ts < last.ts - LIMITS.clockSkewMs)
|
|
151
|
+
throw refuse("log entry is dated before the one it follows");
|
|
152
|
+
if (options.now !== undefined && Math.abs(parsed.ts - options.now) > LIMITS.clockSkewMs)
|
|
153
|
+
throw new MessagingError("expired", "log entry ts is outside the clock skew");
|
|
154
|
+
const { sig, ...unsigned } = parsed;
|
|
155
|
+
if (!verify(parsed.by, DOMAIN.deviceLog, unsigned, sig))
|
|
156
|
+
throw refuse("log entry signature does not verify");
|
|
157
|
+
const devices = new Map(state.devices);
|
|
158
|
+
const tokens = new Map(state.tokens);
|
|
159
|
+
let profile = state.profile;
|
|
160
|
+
const jelly = (bound) => {
|
|
161
|
+
if (parsed.authority.kind !== "jelly")
|
|
162
|
+
throw refuse("this entry needs a jelly authority");
|
|
163
|
+
const token = verifyJellyToken(parsed.authority.token, { ...context.jelly, cnf: bound });
|
|
164
|
+
const { claims } = token;
|
|
165
|
+
if (personId(claims.iss, claims.sub) !== this.person)
|
|
166
|
+
throw refuse("Jelly token names another person");
|
|
167
|
+
// A sign-in through the browser vouches for one act (spec §15.3): the token that bound or attested a device
|
|
168
|
+
// vouches for nothing else, so one phished code removes no device. Unbinds may share one sign-in.
|
|
169
|
+
if (claims.src === JELLY_DEVICE_SOURCE) {
|
|
170
|
+
for (const [seq, used] of state.tokens) {
|
|
171
|
+
if (used.claims.src !== JELLY_DEVICE_SOURCE || used.claims.jti !== claims.jti)
|
|
172
|
+
continue;
|
|
173
|
+
if (parsed.op !== "unbind" || state.entries[seq].op !== "unbind")
|
|
174
|
+
throw refuse("a sign-in's token vouches for one act: this one was used");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// Freshness at the append (spec §15.3): the entry is dated within the token's life.
|
|
178
|
+
if (parsed.ts < claims.iat * 1000 - LIMITS.clockSkewMs || parsed.ts > claims.exp * 1000 + LIMITS.clockSkewMs)
|
|
179
|
+
throw refuse("log entry is dated outside its Jelly token's life");
|
|
180
|
+
tokens.set(parsed.seq, token);
|
|
181
|
+
profile = { iss: claims.iss, sub: claims.sub, name: claims.name, email: claims.email, at: parsed.ts };
|
|
182
|
+
return token;
|
|
183
|
+
};
|
|
184
|
+
const current = (device) => {
|
|
185
|
+
const record = devices.get(device);
|
|
186
|
+
return record?.current ? record : undefined;
|
|
187
|
+
};
|
|
188
|
+
if (parsed.op === "bind") {
|
|
189
|
+
if (devices.has(parsed.device))
|
|
190
|
+
throw refuse("a device is bound to a person at most once");
|
|
191
|
+
if ([...devices.values()].filter((d) => d.current).length >= LIMITS.devicesPerPerson)
|
|
192
|
+
throw new MessagingError("device_limit", "the person has the most current devices allowed");
|
|
193
|
+
const base = { device: parsed.device, label: parsed.label, kind: parsed.kind, since: parsed.ts, current: true };
|
|
194
|
+
if (parsed.authority.kind === "jelly") {
|
|
195
|
+
if (parsed.by !== parsed.device)
|
|
196
|
+
throw refuse("a Jelly bind is signed by the device it binds");
|
|
197
|
+
const token = jelly(parsed.device);
|
|
198
|
+
devices.set(parsed.device, { ...base, vouch: "jelly", host: token.claims.hst });
|
|
199
|
+
}
|
|
200
|
+
else if (parsed.authority.kind === "device") {
|
|
201
|
+
if (state.entries.length === 0)
|
|
202
|
+
throw refuse("a person's first device is vouched by Jelly ID");
|
|
203
|
+
const { approver, link } = parsed.authority;
|
|
204
|
+
if (parsed.by !== approver)
|
|
205
|
+
throw refuse("a link approval is signed by its approver");
|
|
206
|
+
if (current(approver)?.vouch !== "jelly")
|
|
207
|
+
throw refuse("only a current Jelly-vouched device approves a link");
|
|
208
|
+
if (!verifyLinkSignature(link))
|
|
209
|
+
throw refuse("link request signature does not verify");
|
|
210
|
+
if (link.device !== parsed.device || link.realm !== parsed.realm)
|
|
211
|
+
throw refuse("link request is for another device or realm");
|
|
212
|
+
if (link.label !== parsed.label || link.kind !== parsed.kind)
|
|
213
|
+
throw refuse("the approval changes the link request's label or kind");
|
|
214
|
+
if (parsed.ts - link.ts > LIMITS.linkTtlMs || link.ts - parsed.ts > LIMITS.clockSkewMs)
|
|
215
|
+
throw refuse("the approval is outside the link request's ten minutes");
|
|
216
|
+
devices.set(parsed.device, { ...base, vouch: "device", approver });
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
throw refuse("a bind is vouched by Jelly ID or by a link approval");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else if (parsed.op === "attest") {
|
|
223
|
+
const record = current(parsed.device);
|
|
224
|
+
if (!record)
|
|
225
|
+
throw refuse("only a current device attests");
|
|
226
|
+
if (parsed.by !== parsed.device)
|
|
227
|
+
throw refuse("an attest is signed by its device");
|
|
228
|
+
const token = jelly(parsed.device);
|
|
229
|
+
devices.set(parsed.device, { ...record, vouch: "jelly", label: parsed.label ?? record.label, kind: parsed.kind ?? record.kind, host: token.claims.hst });
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
const record = current(parsed.device);
|
|
233
|
+
if (!record)
|
|
234
|
+
throw refuse("only a current device is unbound");
|
|
235
|
+
const reason = parsed.reason;
|
|
236
|
+
if (reason === "self") {
|
|
237
|
+
if (parsed.authority.kind !== "self" || parsed.by !== parsed.device)
|
|
238
|
+
throw refuse("a self unbind is signed by the device, with a self authority");
|
|
239
|
+
}
|
|
240
|
+
else if (reason === "account_deleted") {
|
|
241
|
+
if (parsed.authority.kind !== "service" || !context.directoryKeys.includes(parsed.by))
|
|
242
|
+
throw refuse("only the directory unbinds an erased account");
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
if (parsed.by === parsed.device)
|
|
246
|
+
throw refuse("a device removes itself with reason self");
|
|
247
|
+
if (current(parsed.by)?.vouch !== "jelly")
|
|
248
|
+
throw refuse("only a current Jelly-vouched device removes another");
|
|
249
|
+
jelly(parsed.by);
|
|
250
|
+
}
|
|
251
|
+
devices.set(parsed.device, { ...record, current: false, unbound: { ts: parsed.ts, reason, by: parsed.by } });
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
parsed,
|
|
255
|
+
next: { entries: [...state.entries, parsed], hashes: [...state.hashes, logEntryHash(parsed)], devices, tokens, profile },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { utf8 } from "../b64u.js";
|
|
2
|
+
import { asMessagingError, parseWith } from "../check.js";
|
|
3
|
+
import { canonicalize } from "../jcs.js";
|
|
4
|
+
import { assertEd25519PublicKey, rawPublicKey } from "../keys.js";
|
|
5
|
+
import { signWith, verify } from "../sign.js";
|
|
6
|
+
import { DOMAIN, PROTOCOL } from "../schema/common.js";
|
|
7
|
+
import { EpochLeaf, EpochProof, KeyRotation, STH } from "../schema/directory.js";
|
|
8
|
+
import { MessagingError } from "../schema/errors.js";
|
|
9
|
+
import { merkleLeafHash, verifyConsistency, verifyInclusion } from "./merkle-log.js";
|
|
10
|
+
const inconsistent = (what) => new MessagingError("directory_inconsistent", what);
|
|
11
|
+
/** The RFC 9162 leaf hash of an epoch leaf: `SHA-256(0x00 ‖ UTF-8(JCS(leaf)))`. */
|
|
12
|
+
export function epochLeafHash(leaf) {
|
|
13
|
+
return merkleLeafHash(utf8(canonicalize(parseWith(EpochLeaf, leaf, "epoch leaf"))));
|
|
14
|
+
}
|
|
15
|
+
/** An STH signed with the directory's epoch key. Throws `invalid_request`. */
|
|
16
|
+
export function signSth(key, head) {
|
|
17
|
+
const unsigned = { v: PROTOCOL, realm: head.realm, size: head.size, root: head.root, ts: head.ts };
|
|
18
|
+
return parseWith(STH, { ...unsigned, sig: signWith(key, DOMAIN.epoch, unsigned) }, "STH");
|
|
19
|
+
}
|
|
20
|
+
/** True when `sth` is signed by `key`. Never throws. */
|
|
21
|
+
export function verifySthSignature(sth, key) {
|
|
22
|
+
try {
|
|
23
|
+
const { sig, ...unsigned } = sth;
|
|
24
|
+
return verify(key, DOMAIN.epoch, unsigned, sig);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** The anchor an envelope carries for this STH. */
|
|
31
|
+
export function epochAnchor(sth) {
|
|
32
|
+
return { kind: "epoch", size: sth.size, root: sth.root };
|
|
33
|
+
}
|
|
34
|
+
/** A rotation of the epoch key from `oldKey` to `newKey`, signed by both, from `fromSize` on. Throws `invalid_request`. */
|
|
35
|
+
export function signKeyRotation(oldKey, newKey, args) {
|
|
36
|
+
const unsigned = { v: PROTOCOL, realm: args.realm, old: rawPublicKey(oldKey), new: rawPublicKey(newKey), fromSize: args.fromSize };
|
|
37
|
+
return parseWith(KeyRotation, { ...unsigned, sigOld: signWith(oldKey, DOMAIN.dirKey, unsigned), sigNew: signWith(newKey, DOMAIN.dirKey, unsigned) }, "key rotation");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Checks that `proof` puts its leaf at its index in the tree of `sth` (the leaf's `epoch` is its index). Throws
|
|
41
|
+
* `invalid_request` for a malformed proof and `directory_inconsistent` when it does not prove the leaf.
|
|
42
|
+
*/
|
|
43
|
+
export function verifyEpochProof(sth, proof) {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = parseWith(EpochProof, proof, "epoch proof");
|
|
46
|
+
if (parsed.leaf.epoch !== parsed.index)
|
|
47
|
+
throw inconsistent("epoch leaf is not at its own index");
|
|
48
|
+
if (!verifyInclusion(parsed.index, sth.size, epochLeafHash(parsed.leaf), parsed.inclusion, sth.root))
|
|
49
|
+
throw inconsistent("epoch leaf is not in the tree head");
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
throw asMessagingError(error, "epoch proof");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The checks before a client adopts a newer STH (spec §15.6): the schema (`invalid_request`); the realm; the
|
|
58
|
+
* signature by the trusted key, or by a key that rotation statements reach from it; `size` and `ts` not below
|
|
59
|
+
* the adopted STH's; and the consistency proof from the adopted STH (equal sizes: equal roots). Every other
|
|
60
|
+
* failure is `directory_inconsistent`. `current` is undefined for the first STH, which only the pinned key
|
|
61
|
+
* (`pinnedKey`) may sign, or a key its rotations reach.
|
|
62
|
+
*/
|
|
63
|
+
export function adoptSth(current, pinnedKey, args) {
|
|
64
|
+
try {
|
|
65
|
+
const next = parseWith(STH, args.next, "STH");
|
|
66
|
+
if (next.realm !== args.realm)
|
|
67
|
+
throw inconsistent("STH is for another realm");
|
|
68
|
+
let key = current?.key ?? pinnedKey;
|
|
69
|
+
let floor = current?.sth.size ?? 0;
|
|
70
|
+
for (const raw of args.rotations ?? []) {
|
|
71
|
+
const rotation = parseWith(KeyRotation, raw, "key rotation");
|
|
72
|
+
const { sigOld, sigNew, ...unsigned } = rotation;
|
|
73
|
+
// A directory sends its rotation with every STH the new key signs: one already applied is skipped.
|
|
74
|
+
if (rotation.new === key && rotation.old !== key)
|
|
75
|
+
continue;
|
|
76
|
+
if (rotation.realm !== args.realm || rotation.old !== key)
|
|
77
|
+
throw inconsistent("key rotation does not start from the trusted key");
|
|
78
|
+
assertEd25519PublicKey(rotation.new, "new epoch key");
|
|
79
|
+
if (!verify(rotation.old, DOMAIN.dirKey, unsigned, sigOld) || !verify(rotation.new, DOMAIN.dirKey, unsigned, sigNew))
|
|
80
|
+
throw inconsistent("key rotation signature does not verify");
|
|
81
|
+
// The old key signed every adopted STH, so the new one may only sign past them.
|
|
82
|
+
if (rotation.fromSize <= floor || rotation.fromSize > next.size)
|
|
83
|
+
throw inconsistent("key rotation size is not consistent with the adopted STH");
|
|
84
|
+
key = rotation.new;
|
|
85
|
+
floor = rotation.fromSize;
|
|
86
|
+
}
|
|
87
|
+
if (!verifySthSignature(next, key))
|
|
88
|
+
throw inconsistent("STH signature does not verify");
|
|
89
|
+
if (current) {
|
|
90
|
+
const { sth } = current;
|
|
91
|
+
if (next.size < sth.size)
|
|
92
|
+
throw inconsistent("STH is smaller than the adopted one");
|
|
93
|
+
if (next.ts < sth.ts)
|
|
94
|
+
throw inconsistent("STH is older than the adopted one");
|
|
95
|
+
if (!verifyConsistency(sth.size, next.size, args.consistency ?? [], sth.root, next.root))
|
|
96
|
+
throw inconsistent("STH is not consistent with the adopted one");
|
|
97
|
+
}
|
|
98
|
+
return { sth: next, key };
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
throw asMessagingError(error, "STH");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A hosted person's handle (spec §15.8; design §6.2): what a person types to address someone in their org, made
|
|
3
|
+
* from the local part of the verified email. The directory answers `dir.resolve {handle}` with it, and a client
|
|
4
|
+
* shows it; both compute it the same way. And the org a verified email puts a person in (spec §15.10).
|
|
5
|
+
*/
|
|
6
|
+
import { Email } from "../schema/directory-frames.js";
|
|
7
|
+
/**
|
|
8
|
+
* Lower-case the local part, map every run of characters outside `[a-z0-9]` to `-`, trim `-`, and, when the
|
|
9
|
+
* result does not start with a letter, prefix `u-`; then cut to 32 characters and trim a trailing `-`. An empty
|
|
10
|
+
* result is `u`. The result always matches `[a-z][a-z0-9-]{0,31}`.
|
|
11
|
+
*/
|
|
12
|
+
export function hostedHandle(email) {
|
|
13
|
+
const at = email.lastIndexOf("@");
|
|
14
|
+
const local = (at < 0 ? email : email.slice(0, at)).toLowerCase();
|
|
15
|
+
let handle = local.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
16
|
+
if (!/^[a-z]/.test(handle))
|
|
17
|
+
handle = handle === "" ? "u" : `u-${handle}`;
|
|
18
|
+
return handle.slice(0, 32).replace(/-+$/, "");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The org of a verified email until Jelly contract v1.2 (spec §15.10): `domain:<d>` when its domain `d` is one of
|
|
22
|
+
* the realm's `orgDomains`, otherwise none (an individual).
|
|
23
|
+
*/
|
|
24
|
+
export function emailOrg(email, orgDomains) {
|
|
25
|
+
if (email === undefined || !Email.safeParse(email.toLowerCase()).success)
|
|
26
|
+
return undefined;
|
|
27
|
+
const domain = email.toLowerCase().slice(email.lastIndexOf("@") + 1);
|
|
28
|
+
return orgDomains.includes(domain) ? `domain:${domain}` : undefined;
|
|
29
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A device's sign-in through the browser at Jelly ID (Jelly's resource-token contract v1.2 §12, as built in
|
|
3
|
+
* hey-jelly `protocol/contracts/src/jelly-id-device-sign-in.ts`; spec §15.9, "Signing in through the browser"):
|
|
4
|
+
* reading Jelly ID's metadata for the two routes, the key fingerprint the consent page shows, and the reading of Jelly
|
|
5
|
+
* ID's answers. Pure: the daemon does the HTTP.
|
|
6
|
+
*
|
|
7
|
+
* Jelly ID never sends `verification_uri_complete` (v1.2 §12.1): a link with the code in it turns a phished code into
|
|
8
|
+
* one click, so the person types the code at `verification_uri`. An answer that carries one anyway is read without it:
|
|
9
|
+
* the field is never kept, shown or opened.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { b64u } from "../b64u.js";
|
|
13
|
+
import { assertEd25519PublicKey } from "../keys.js";
|
|
14
|
+
import { parseWire } from "../wire.js";
|
|
15
|
+
import { MessagingError } from "../schema/errors.js";
|
|
16
|
+
import { z } from "../../vendor/zod/index.js";
|
|
17
|
+
/** RFC 8628's device grant type. */
|
|
18
|
+
export const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
19
|
+
/** RFC 8628 §3.5, v1.2 §12.3: `slow_down` adds 5 s to the interval. The interval is kept within [1, 60] s. */
|
|
20
|
+
export const LOGIN_SLOW_DOWN_S = 5;
|
|
21
|
+
export const LOGIN_INTERVAL_MAX_S = 60;
|
|
22
|
+
/** The most bytes of an answer from Jelly ID that the daemon reads. */
|
|
23
|
+
export const JELLY_ANSWER_BYTES = 65_536;
|
|
24
|
+
/**
|
|
25
|
+
* The key's fingerprint as Jelly ID's consent page shows it (v1.2 §12.2, item 3): the first six bytes of the RFC 7638
|
|
26
|
+
* thumbprint of `{kty: "OKP", crv: "Ed25519", x}` (SHA-256 over its members in that RFC's order), in lower-case hex,
|
|
27
|
+
* in three groups of four.
|
|
28
|
+
*/
|
|
29
|
+
export function jellyKeyFingerprint(device) {
|
|
30
|
+
assertEd25519PublicKey(device, "device key");
|
|
31
|
+
const hex = createHash("sha256").update(`{"crv":"Ed25519","kty":"OKP","x":"${device}"}`).digest().subarray(0, 6).toString("hex");
|
|
32
|
+
return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}`;
|
|
33
|
+
}
|
|
34
|
+
const ResourceName = z.string().regex(/^[a-z][a-z0-9-]{0,31}$/);
|
|
35
|
+
const Metadata = z.looseObject({
|
|
36
|
+
issuer: z.string().max(2048),
|
|
37
|
+
jelly_resources: z.array(z.looseObject({ name: ResourceName, resource: z.string().max(2048) })).max(8).optional(),
|
|
38
|
+
jelly_resource_device_authorization_endpoint: z.string().max(2048).optional(),
|
|
39
|
+
jelly_resource_device_token_endpoint: z.string().max(2048).optional(),
|
|
40
|
+
});
|
|
41
|
+
const unsupported = (why) => new MessagingError("login_unsupported", `Jelly ID does not offer device sign-in yet: ${why}`);
|
|
42
|
+
/** An answer's JSON object, or undefined when it is not one. */
|
|
43
|
+
function object(text) {
|
|
44
|
+
try {
|
|
45
|
+
return parseWire(text, { maxBytes: JELLY_ANSWER_BYTES });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Reads device sign-in from Jelly ID's RFC 8414 metadata (`<issuer>/.well-known/oauth-authorization-server`, as
|
|
53
|
+
* text). It needs the two members of v1.2 §12 (`jelly_resource_device_authorization_endpoint`, `…_token_endpoint`), both on the issuer's own origin, and the realm's resource in
|
|
54
|
+
* `jelly_resources`. It never takes RFC 8628's `device_authorization_endpoint`: at Jelly ID that route enrols hosts.
|
|
55
|
+
* Throws `login_unsupported` (Jelly ID lacks it) or `jelly_unavailable` (an answer that is not metadata for `issuer`).
|
|
56
|
+
*/
|
|
57
|
+
export function readDeviceSignIn(text, realm) {
|
|
58
|
+
const parsed = Metadata.safeParse(object(text));
|
|
59
|
+
if (!parsed.success)
|
|
60
|
+
throw new MessagingError("jelly_unavailable", "Jelly ID's metadata could not be read");
|
|
61
|
+
const meta = parsed.data;
|
|
62
|
+
if (meta.issuer !== realm.issuer)
|
|
63
|
+
throw new MessagingError("jelly_unavailable", "Jelly ID's metadata is for another issuer");
|
|
64
|
+
const code = meta.jelly_resource_device_authorization_endpoint;
|
|
65
|
+
const token = meta.jelly_resource_device_token_endpoint;
|
|
66
|
+
if (code === undefined || token === undefined)
|
|
67
|
+
throw unsupported("its metadata names no jelly_resource_device_authorization_endpoint (Jelly's contract v1.2, device sign-in)");
|
|
68
|
+
const origin = new URL(realm.issuer).origin;
|
|
69
|
+
for (const url of [code, token]) {
|
|
70
|
+
let at;
|
|
71
|
+
try {
|
|
72
|
+
at = new URL(url);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw unsupported("its device sign-in endpoint is not a URL");
|
|
76
|
+
}
|
|
77
|
+
if (at.origin !== origin || at.username !== "" || at.password !== "" || at.hash !== "")
|
|
78
|
+
throw unsupported("its device sign-in endpoint is not on its own origin");
|
|
79
|
+
}
|
|
80
|
+
const entry = meta.jelly_resources?.find((r) => r.resource === realm.resource);
|
|
81
|
+
if (!entry)
|
|
82
|
+
throw unsupported("it lists no resource for this realm");
|
|
83
|
+
return { codeEndpoint: code, tokenEndpoint: token, clientId: entry.name };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Jelly ID's answer to the code request (RFC 8628 §3.2; v1.2 §12.1, `ResourceDeviceCodeResponseSchema`). Read loosely,
|
|
87
|
+
* so a member Jelly ID adds later is ignored: above all `verification_uri_complete`, which is never taken.
|
|
88
|
+
*/
|
|
89
|
+
const CodeAnswer = z.looseObject({
|
|
90
|
+
device_code: z.string().min(22).max(256),
|
|
91
|
+
user_code: z.string().regex(/^[A-HJ-NP-Z2-9]{5}-[A-HJ-NP-Z2-9]{5}$/),
|
|
92
|
+
verification_uri: z.string().max(2048),
|
|
93
|
+
expires_in: z.number().int().positive().max(3600),
|
|
94
|
+
interval: z.number().int().positive().max(LOGIN_INTERVAL_MAX_S),
|
|
95
|
+
});
|
|
96
|
+
const ErrorAnswer = z.looseObject({ error: z.string().max(64), detail: z.string().max(64).optional() });
|
|
97
|
+
function errorOf(text) {
|
|
98
|
+
const parsed = ErrorAnswer.safeParse(object(text));
|
|
99
|
+
return parsed.success ? parsed.data : undefined;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A page URL from Jelly ID, on the issuer's origin (https, or http to a loopback issuer, as the realm's), with no
|
|
103
|
+
* query or fragment: the page the person types the code into carries no code.
|
|
104
|
+
*/
|
|
105
|
+
function page(url, issuer) {
|
|
106
|
+
let at;
|
|
107
|
+
try {
|
|
108
|
+
at = new URL(url);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new MessagingError("jelly_unavailable", "Jelly ID's sign-in page is not a URL");
|
|
112
|
+
}
|
|
113
|
+
if (at.origin !== new URL(issuer).origin)
|
|
114
|
+
throw new MessagingError("jelly_unavailable", "Jelly ID's sign-in page is not on its own origin");
|
|
115
|
+
if (at.search !== "" || at.hash !== "" || at.username !== "" || at.password !== "")
|
|
116
|
+
throw new MessagingError("jelly_unavailable", "Jelly ID's sign-in page carries a query or credentials");
|
|
117
|
+
return at.href;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Reads Jelly ID's answer to the code request (v1.2 §12.1). Every refusal Jelly ID defines throws in the house style:
|
|
121
|
+
* - `429 slow_down` (too many codes from this address, or waiting for this client): `rate_limited`;
|
|
122
|
+
* - `400 invalid_target` with `detail: "invalid_cnf_jwk"` (Jelly would not bind this key): `binding_invalid`;
|
|
123
|
+
* - `400 invalid_target` (not this client's resource), `401 invalid_client` (not a device client, or device sign-in
|
|
124
|
+
* off for it): `login_unsupported`;
|
|
125
|
+
* - `400 invalid_request` (a field missing, repeated or malformed): `invalid_request`;
|
|
126
|
+
* - `503 temporarily_unavailable`, and anything Jelly ID does not define: `jelly_unavailable`.
|
|
127
|
+
*/
|
|
128
|
+
export function readDeviceCodeAnswer(status, text, issuer) {
|
|
129
|
+
if (status === 200) {
|
|
130
|
+
const parsed = CodeAnswer.safeParse(object(text));
|
|
131
|
+
if (!parsed.success)
|
|
132
|
+
throw new MessagingError("jelly_unavailable", "Jelly ID's sign-in code could not be read");
|
|
133
|
+
const a = parsed.data;
|
|
134
|
+
return { deviceCode: a.device_code, userCode: a.user_code, verificationUri: page(a.verification_uri, issuer), expiresInS: a.expires_in, intervalS: a.interval };
|
|
135
|
+
}
|
|
136
|
+
const answer = errorOf(text);
|
|
137
|
+
switch (answer?.error) {
|
|
138
|
+
case "slow_down":
|
|
139
|
+
throw new MessagingError("rate_limited", "Jelly ID asks this address to wait before signing in again");
|
|
140
|
+
case "invalid_target":
|
|
141
|
+
if (answer.detail === "invalid_cnf_jwk")
|
|
142
|
+
throw new MessagingError("binding_invalid", "Jelly ID would not bind this device's key");
|
|
143
|
+
throw unsupported("it offers no device sign-in for this realm's resource");
|
|
144
|
+
case "invalid_client":
|
|
145
|
+
throw unsupported("it offers no device sign-in for this realm's resource");
|
|
146
|
+
case "invalid_request":
|
|
147
|
+
throw new MessagingError("invalid_request", "Jelly ID refused the sign-in request as malformed");
|
|
148
|
+
default:
|
|
149
|
+
throw new MessagingError("jelly_unavailable", `Jelly ID answered ${status}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const TokenAnswer = z.looseObject({ access_token: z.string().min(1).max(8192), token_type: z.string().max(32) });
|
|
153
|
+
/**
|
|
154
|
+
* Reads a poll's answer (v1.2 §12.3). `authorization_pending` and `slow_down` keep polling. A final refusal throws:
|
|
155
|
+
* `login_denied` (`access_denied`: the person denied it, or the account is disabled or being deleted), `expired`
|
|
156
|
+
* (`expired_token`: the code's time passed, decided or not; or `invalid_grant`: the code was used, or Jelly ID no
|
|
157
|
+
* longer knows it), `login_unsupported` (`invalid_client`, `unsupported_grant_type`). `503 temporarily_unavailable`,
|
|
158
|
+
* and any answer Jelly ID does not define, is `retry`: the daemon polls on until the code expires.
|
|
159
|
+
*/
|
|
160
|
+
export function readDevicePoll(status, text) {
|
|
161
|
+
if (status === 200) {
|
|
162
|
+
const parsed = TokenAnswer.safeParse(object(text));
|
|
163
|
+
if (!parsed.success || parsed.data.token_type.toLowerCase() !== "bearer")
|
|
164
|
+
return { kind: "retry" };
|
|
165
|
+
return { kind: "token", token: parsed.data.access_token };
|
|
166
|
+
}
|
|
167
|
+
const answer = errorOf(text);
|
|
168
|
+
switch (answer?.error) {
|
|
169
|
+
case "authorization_pending":
|
|
170
|
+
return { kind: "pending" };
|
|
171
|
+
case "slow_down":
|
|
172
|
+
return { kind: "slow_down" };
|
|
173
|
+
case "access_denied":
|
|
174
|
+
throw new MessagingError("login_denied", "the sign-in was denied at Jelly ID, or the account is disabled or being deleted");
|
|
175
|
+
case "expired_token":
|
|
176
|
+
throw new MessagingError("expired", "the sign-in code expired before it was approved; start again");
|
|
177
|
+
case "invalid_grant":
|
|
178
|
+
throw new MessagingError("expired", "Jelly ID no longer accepts this sign-in code: it was used, or it expired; start again");
|
|
179
|
+
case "invalid_client":
|
|
180
|
+
case "unsupported_grant_type":
|
|
181
|
+
throw unsupported("it refused this device client");
|
|
182
|
+
default:
|
|
183
|
+
return { kind: "retry" };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** The form of the code request (v1.2 §12.1). */
|
|
187
|
+
export function deviceCodeForm(args) {
|
|
188
|
+
assertEd25519PublicKey(args.device, "device key");
|
|
189
|
+
return new URLSearchParams({
|
|
190
|
+
client_id: args.clientId,
|
|
191
|
+
resource: args.resource,
|
|
192
|
+
cnf_jwk: JSON.stringify({ kty: "OKP", crv: "Ed25519", x: args.device }),
|
|
193
|
+
label: args.label,
|
|
194
|
+
kind: args.kind,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/** The form of a poll (v1.2 §12.3). */
|
|
198
|
+
export function devicePollForm(args) {
|
|
199
|
+
return new URLSearchParams({ grant_type: DEVICE_GRANT_TYPE, device_code: args.deviceCode, client_id: args.clientId });
|
|
200
|
+
}
|
|
201
|
+
/** For tests of the fingerprint's preimage: the RFC 7638 thumbprint, base64url. */
|
|
202
|
+
export function jellyKeyThumbprint(device) {
|
|
203
|
+
return b64u.encode(createHash("sha256").update(`{"crv":"Ed25519","kty":"OKP","x":"${device}"}`).digest());
|
|
204
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Jelly ID issuer double, for tests and the vectors only (exported from `@messaging/core/testing`): it signs a
|
|
3
|
+
* resource token as Jelly ID does (contract v1.1 §4). A JWS signs its own base64url text, not JCS, so this is
|
|
4
|
+
* Jelly's format and not a Messaging signature.
|
|
5
|
+
*/
|
|
6
|
+
import { sign as ed25519Sign } from "node:crypto";
|
|
7
|
+
import { b64u, utf8 } from "../b64u.js";
|
|
8
|
+
/** A compact JWS over `header` and `claims` as given (members in their insertion order), signed with `key`. */
|
|
9
|
+
export function signJellyToken(key, header, claims) {
|
|
10
|
+
const input = `${b64u.encode(utf8(JSON.stringify(header)))}.${b64u.encode(utf8(JSON.stringify(claims)))}`;
|
|
11
|
+
return `${input}.${b64u.encode(ed25519Sign(null, utf8(input), key))}`;
|
|
12
|
+
}
|