@openlfcp/wire 0.2.1 → 0.3.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/dist/device.js ADDED
@@ -0,0 +1,427 @@
1
+ import { bytesEqual, hash32, LfcpError, principalId, resourceId, secureRandom, toHex, } from "@openlfcp/core";
2
+ import { isValidEd25519PublicKey, sha256 } from "@openlfcp/crypto";
3
+ import { decodeStrict, encode } from "./cbor/index.js";
4
+ import { parseSignedObject, signObject, verifySignedObject, } from "./cose.js";
5
+ import { scanDeviceCertificate } from "./device-scan.js";
6
+ import { invalid } from "./fields.js";
7
+ import { derivePrincipalId, principalDescriptorFromCbor, principalDescriptorToCbor, } from "./principal.js";
8
+ /**
9
+ * Device Certificates and certificate chains (LFCP-WIRE-01 §7.2, §7.3,
10
+ * IDENTITY-01): the codec, the structural rules, and `verifyChain` with
11
+ * the class order of §62 (structure, dependencies, signatures, authority
12
+ * and state).
13
+ *
14
+ * Structure only decides what can be decided from the bytes: every
15
+ * failure of §7.3 rules 1–5, (c1), (c5) and (c7) is INVALID_STRUCTURE
16
+ * (MALFORMED_MESSAGE on the wire), thrown while decoding. The state
17
+ * questions of §7.3 step 4.2–4.4 (roles, bindings, revocations) are asked
18
+ * through the read-only `ChainStateChecks`; this module stores no state
19
+ * and applies no transition.
20
+ *
21
+ * Unknown mask codes (and 9, 11) are kept as received, in received order,
22
+ * and never promoted to abilities. Exact certificate bytes are kept: a
23
+ * chain re-encodes to the byte strings it was read from.
24
+ */
25
+ /** §7.3 rule 1: the domain and version label, payload element 0. */
26
+ export const DEVICE_CERT_LABEL = "LFCP-DEVICE-CERT-v1";
27
+ /** §7.3: `certificate-chain = [1*4 bstr]`. */
28
+ export const MAX_CHAIN_LENGTH = 4;
29
+ /** §7.3 (c7): a chain whose leaf lists `device/manage` has at most three certificates. */
30
+ export const MAX_MANAGING_CHAIN_LENGTH = 3;
31
+ /** §7.3 rule 4: `device-mask = [1*32 uint]`. */
32
+ export const MAX_MASK_ENTRIES = 32;
33
+ /** §7.3: `resource-scope = [*64 resource-id]`. */
34
+ export const MAX_SCOPE_ENTRIES = 64;
35
+ /** §7.3: `bstr .size 16`, the serial. */
36
+ export const SERIAL_LENGTH = 16;
37
+ /** §17.1 codes that have meaning only inside a Device Certificate mask. */
38
+ export const DEVICE_ABILITY = Object.freeze({
39
+ /** `device/manage`: may sign Device Certificates and issue device records for its root. */
40
+ DEVICE_MANAGE: 12n,
41
+ /** `owner/act`: in a Resource its root owns, holds every standard ability. */
42
+ OWNER_ACT: 13n,
43
+ });
44
+ /** §7.2 kinds: 0 personal, 1 service, 2 pairing. 3 and above are reserved. */
45
+ export const DEVICE_KIND = Object.freeze({ PERSONAL: 0, SERVICE: 1, PAIRING: 2 });
46
+ // ---------------------------------------------------------------------------
47
+ // Payload
48
+ const fail = (why) => invalid("device-certificate-payload", why);
49
+ const UINT64_MAX = 2n ** 64n - 1n;
50
+ const hasCode = (mask, code) => mask.includes(code);
51
+ /** Distinct members, compared by value (Resource IDs as bytes). */
52
+ function repeats(list, key) {
53
+ return new Set(list.map(key)).size !== list.length;
54
+ }
55
+ /**
56
+ * §7.3 rules 2–5 on a payload: a 32-byte root, a valid device descriptor
57
+ * (§7) that is not the root; a mask of 1–32
58
+ * distinct unsigned 64-bit codes, with `1` and `2` whenever it lists `12`; at most 64
59
+ * distinct scope entries; and the kind rules for scope and mask. Throws
60
+ * INVALID_STRUCTURE. Writers and readers apply the same rules.
61
+ */
62
+ export function checkDeviceCertificatePayload(p) {
63
+ if (!(p.root instanceof Uint8Array) || p.root.length !== 32)
64
+ fail("the root must be a 32-byte Principal ID");
65
+ // Rule 2: the descriptor as §7 requires (recomputed ID, canonical
66
+ // Ed25519 point of large order), so that what passes here can be signed.
67
+ const d = p.device;
68
+ try {
69
+ if (!bytesEqual(derivePrincipalId(d.ed25519PublicKey, d.x25519PublicKey), d.principalId))
70
+ fail("the device descriptor's ID is not the §7 hash of its keys (§7.3 rule 2)");
71
+ }
72
+ catch (e) {
73
+ if (e instanceof LfcpError && e.code === "INVALID_STRUCTURE")
74
+ throw e;
75
+ fail("the device descriptor's keys are not 32-byte keys (§7.3 rule 2)");
76
+ }
77
+ if (!isValidEd25519PublicKey(d.ed25519PublicKey))
78
+ fail("the device's Ed25519 key is not a canonical point of large order (§7.3 rule 2)");
79
+ if (bytesEqual(p.root, p.device.principalId))
80
+ fail("the device is the root (§7.3 rule 2)");
81
+ if (p.kind !== 0 && p.kind !== 1 && p.kind !== 2)
82
+ fail("kind must be 0, 1 or 2 (§7.3 rule 3)");
83
+ if (p.mask.length < 1 || p.mask.length > MAX_MASK_ENTRIES)
84
+ fail(`the mask must hold 1 to ${MAX_MASK_ENTRIES} codes (§7.3 rule 4)`);
85
+ if (!p.mask.every((c) => typeof c === "bigint" && c >= 0n && c <= UINT64_MAX))
86
+ fail("every mask code must be an unsigned 64-bit integer (§7.3)");
87
+ if (repeats(p.mask, (c) => c))
88
+ fail("the mask repeats a code (§7.3 rule 4)");
89
+ const manage = hasCode(p.mask, DEVICE_ABILITY.DEVICE_MANAGE);
90
+ if (manage && !(hasCode(p.mask, 1n) && hasCode(p.mask, 2n)))
91
+ fail("a mask that lists 12 must list 1 and 2 (§7.3 rule 4)");
92
+ if (p.scope.length > MAX_SCOPE_ENTRIES)
93
+ fail(`the scope holds more than ${MAX_SCOPE_ENTRIES} Resources (§7.3)`);
94
+ if (!p.scope.every((r) => r instanceof Uint8Array && r.length === 32))
95
+ fail("every scope entry must be a 32-byte Resource ID (§7.3)");
96
+ if (repeats(p.scope, toHex))
97
+ fail("the scope repeats a Resource (§7.3 rule 5)");
98
+ if (!(p.serial instanceof Uint8Array) || p.serial.length !== SERIAL_LENGTH)
99
+ fail(`the serial must be ${SERIAL_LENGTH} bytes (§7.3)`);
100
+ switch (p.kind) {
101
+ case DEVICE_KIND.PERSONAL:
102
+ if (p.scope.length !== 0)
103
+ fail("a personal device has an empty scope (§7.3 rule 5)");
104
+ return;
105
+ case DEVICE_KIND.SERVICE:
106
+ if (p.scope.length === 0)
107
+ fail("a service device lists 1 to 64 Resources (§7.3 rule 5)");
108
+ if (manage || hasCode(p.mask, DEVICE_ABILITY.OWNER_ACT))
109
+ fail("a service device never lists 12 or 13 (§7.3 rule 5)");
110
+ return;
111
+ case DEVICE_KIND.PAIRING:
112
+ if (p.scope.length !== 1)
113
+ fail("a pairing device lists exactly one Resource (§7.3 rule 5)");
114
+ if (!p.mask.every((c) => c === 1n || c === 2n))
115
+ fail("a pairing device's mask is a subset of {1, 2} (§7.3 rule 5)");
116
+ return;
117
+ }
118
+ }
119
+ function uintOf(value) {
120
+ return (typeof value === "number" || typeof value === "bigint") && value >= 0
121
+ ? BigInt(value)
122
+ : undefined;
123
+ }
124
+ /**
125
+ * Decodes `device-certificate-payload` (§7.3): a seven-element array
126
+ * labelled `LFCP-DEVICE-CERT-v1`, then rules 2–5. Array lengths are
127
+ * checked before their entries are decoded.
128
+ */
129
+ export function deviceCertificatePayloadFromCbor(value) {
130
+ if (!Array.isArray(value))
131
+ fail("not an array (§7.3 rule 1)");
132
+ const fields = value;
133
+ if (fields.length !== 7)
134
+ fail("must have seven elements (§7.3 rule 1)");
135
+ const [label, root, device, kind, mask, scope, serial] = fields;
136
+ if (label !== DEVICE_CERT_LABEL)
137
+ fail(`element 0 must be "${DEVICE_CERT_LABEL}" (§7.3 rule 1)`);
138
+ if (!(root instanceof Uint8Array) || root.length !== 32)
139
+ fail("field 1 must be a 32-byte Principal ID");
140
+ const k = uintOf(kind);
141
+ if (k === undefined || k > 2n)
142
+ fail("field 3 must be 0, 1 or 2 (§7.3 rule 3)");
143
+ if (!Array.isArray(mask))
144
+ fail("field 4 must be an array");
145
+ const maskList = mask;
146
+ if (maskList.length < 1 || maskList.length > MAX_MASK_ENTRIES)
147
+ fail(`field 4 must hold 1 to ${MAX_MASK_ENTRIES} codes (§7.3 rule 4)`);
148
+ if (!Array.isArray(scope))
149
+ fail("field 5 must be an array");
150
+ const scopeList = scope;
151
+ if (scopeList.length > MAX_SCOPE_ENTRIES)
152
+ fail(`field 5 must hold at most ${MAX_SCOPE_ENTRIES} Resource IDs (§7.3)`);
153
+ if (!(serial instanceof Uint8Array) || serial.length !== SERIAL_LENGTH)
154
+ fail(`field 6 must be ${SERIAL_LENGTH} bytes`);
155
+ let descriptor;
156
+ try {
157
+ descriptor = principalDescriptorFromCbor(device);
158
+ }
159
+ catch (e) {
160
+ return fail(`field 2 is not a valid Principal Descriptor (§7.3 rule 2): ${String(e)}`);
161
+ }
162
+ const payload = Object.freeze({
163
+ root: principalId(Uint8Array.from(root)),
164
+ device: descriptor,
165
+ kind: Number(k),
166
+ mask: Object.freeze(maskList.map((c) => uintOf(c) ?? fail("field 4 must hold unsigned integers only"))),
167
+ scope: Object.freeze(scopeList.map((r) => r instanceof Uint8Array && r.length === 32
168
+ ? resourceId(Uint8Array.from(r))
169
+ : fail("field 5 must hold 32-byte Resource IDs only"))),
170
+ serial: Uint8Array.from(serial),
171
+ });
172
+ checkDeviceCertificatePayload(payload);
173
+ return payload;
174
+ }
175
+ /** The payload as the §7.3 array. */
176
+ export function deviceCertificatePayloadToCbor(p) {
177
+ return [
178
+ DEVICE_CERT_LABEL,
179
+ p.root,
180
+ principalDescriptorToCbor(p.device),
181
+ p.kind,
182
+ [...p.mask],
183
+ [...p.scope],
184
+ p.serial,
185
+ ];
186
+ }
187
+ /** Deterministic CBOR of a payload that satisfies §7.3 rules 1–5 (INVALID_STRUCTURE otherwise). */
188
+ export function encodeDeviceCertificatePayload(p) {
189
+ checkDeviceCertificatePayload(p);
190
+ return encode(deviceCertificatePayloadToCbor(p));
191
+ }
192
+ /**
193
+ * §7.3 rule 6: the certificate payload digest, SHA-256 of the payload's
194
+ * deterministic CBOR. Computed before the certificate exists, it is what a
195
+ * `DEVICE_REVOKE` with reason 3 commits to (§17.5, invariant N9).
196
+ */
197
+ export const deviceCertificatePayloadDigest = (p) => hash32(sha256(encodeDeviceCertificatePayload(p)));
198
+ /** A fresh random serial (§7.3 field 6, §5.4). */
199
+ export const newDeviceCertificateSerial = () => secureRandom(SERIAL_LENGTH);
200
+ // ---------------------------------------------------------------------------
201
+ // Certificate
202
+ /**
203
+ * Parses a Device Certificate from its exact COSE bytes: the canonical
204
+ * COSE_Sign1 shape with the §5.2 canonical-bytes check of its protected
205
+ * header and payload (cose.ts), then the payload rules. The signature is
206
+ * not verified here; its signer is decided by chain position (§7.3 (c2)).
207
+ */
208
+ export function parseDeviceCertificate(bytes) {
209
+ // Bounds first, on the raw bytes: nothing is decoded or copied before them.
210
+ scanDeviceCertificate(bytes);
211
+ const signed = parseSignedObject(bytes);
212
+ const payload = deviceCertificatePayloadFromCbor(decodeStrict(signed.payloadBytes));
213
+ return Object.freeze({
214
+ signed,
215
+ payload,
216
+ id: signed.id,
217
+ payloadDigest: hash32(sha256(signed.payloadBytes)),
218
+ });
219
+ }
220
+ /**
221
+ * Signs a Device Certificate. `signer` is the root or a managing device of
222
+ * the root; which one is valid is decided by the chain it is placed in.
223
+ * The payload must satisfy §7.3 rules 1–5 (INVALID_STRUCTURE otherwise).
224
+ */
225
+ export function signDeviceCertificate(payload, signer) {
226
+ return parseDeviceCertificate(signObject(encodeDeviceCertificatePayload(payload), signer).bytes);
227
+ }
228
+ // ---------------------------------------------------------------------------
229
+ // Chain structure
230
+ const chainFail = (why) => invalid("certificate-chain", why);
231
+ /**
232
+ * (c1) every certificate names `root`; (c5) no device twice and none is
233
+ * the root (rule 2 already refuses device = own root); (c7) a managing
234
+ * leaf has at most three certificates. Payloads are leaf first.
235
+ */
236
+ function checkChainStructure(payloads, root) {
237
+ if (payloads.length < 1 || payloads.length > MAX_CHAIN_LENGTH)
238
+ chainFail(`must hold 1 to ${MAX_CHAIN_LENGTH} certificates (§7.3 (c1))`);
239
+ const seen = new Set();
240
+ for (const p of payloads) {
241
+ if (!bytesEqual(p.root, root))
242
+ chainFail("a certificate names another root (§7.3 (c1))");
243
+ const id = toHex(p.device.principalId);
244
+ if (seen.has(id) || bytesEqual(p.device.principalId, root))
245
+ chainFail("a device appears twice, or the root appears as a device (§7.3 (c5))");
246
+ seen.add(id);
247
+ }
248
+ const leaf = payloads[0];
249
+ if (hasCode(leaf.mask, DEVICE_ABILITY.DEVICE_MANAGE) &&
250
+ payloads.length > MAX_MANAGING_CHAIN_LENGTH)
251
+ chainFail(`a managing leaf has at most ${MAX_MANAGING_CHAIN_LENGTH} certificates (§7.3 (c7))`);
252
+ }
253
+ function chainOf(certificates, root) {
254
+ checkChainStructure(certificates.map((c) => c.payload), root);
255
+ return Object.freeze({
256
+ root: principalId(Uint8Array.from(root)),
257
+ certificates: Object.freeze([...certificates]),
258
+ leaf: certificates[0],
259
+ issuers: Object.freeze(certificates.slice(1).map((c) => c.payload.device.principalId)),
260
+ });
261
+ }
262
+ /** Builds a chain (leaf first) for `root`, applying (c1), (c5) and (c7). INVALID_STRUCTURE otherwise. */
263
+ export const certificateChain = (certificates, root) => chainOf(certificates, root);
264
+ /**
265
+ * Decodes a received `certificate-chain` for `root`: an array of one to
266
+ * four byte strings (checked before any certificate is parsed), each a
267
+ * Device Certificate, then (c1), (c5), (c7). Every certificate is parsed
268
+ * in full before any chain rule, so every structural failure of every
269
+ * certificate is reported as structure (§62 class 1).
270
+ */
271
+ export function certificateChainFromCbor(value, root) {
272
+ if (!Array.isArray(value))
273
+ chainFail("not an array");
274
+ const list = value;
275
+ if (list.length < 1 || list.length > MAX_CHAIN_LENGTH)
276
+ chainFail(`must hold 1 to ${MAX_CHAIN_LENGTH} certificates`);
277
+ if (!list.every((c) => c instanceof Uint8Array))
278
+ chainFail("must hold byte strings only");
279
+ return chainOf(list.map(parseDeviceCertificate), root);
280
+ }
281
+ /** The chain as the §7.3 array of exact certificate bytes (never re-encoded). */
282
+ export const certificateChainToCbor = (chain) => chain.certificates.map((c) => c.signed.bytes);
283
+ const WIRE_OF = {
284
+ "c1-root": "MALFORMED_MESSAGE",
285
+ structure: "MALFORMED_MESSAGE",
286
+ "root-descriptor": "MISSING_DEPENDENCY",
287
+ "c2-kid": "INVALID_SIGNATURE",
288
+ "c2-signature": "INVALID_SIGNATURE",
289
+ c3: "AUTHORIZATION_FAILED",
290
+ c4: "AUTHORIZATION_FAILED",
291
+ c6: "AUTHORIZATION_FAILED",
292
+ "root-role": "AUTHORIZATION_FAILED",
293
+ binding: "AUTHORIZATION_FAILED",
294
+ revoked: "AUTHORIZATION_FAILED",
295
+ };
296
+ const refuse = (rule, reason) => Object.freeze({ valid: false, rule, wireCode: WIRE_OF[rule], reason });
297
+ /** The empty state of a Genesis (§15: `S = empty`); nothing is bound or revoked. */
298
+ export const EMPTY_CHAIN_STATE = Object.freeze({
299
+ rootAllowed: () => true,
300
+ bindingMatches: () => true,
301
+ isRevoked: () => false,
302
+ });
303
+ /**
304
+ * (c2): the last certificate's kid must be the root, so a root descriptor
305
+ * for another Principal cannot verify it: INVALID_SIGNATURE, as in sdk-rs.
306
+ */
307
+ function rootDescriptorMismatch(root, descriptor) {
308
+ return bytesEqual(descriptor.principalId, root)
309
+ ? undefined
310
+ : refuse("c2-kid", "the root descriptor is not the chain's root (c2)");
311
+ }
312
+ function verifySignatures(certificates, rootDescriptor) {
313
+ for (const [i, c] of certificates.entries()) {
314
+ const signer = certificates[i + 1]?.payload.device ?? rootDescriptor;
315
+ const v = verifySignedObject(c.signed, signer);
316
+ if (!v.valid)
317
+ return v.reason === "KID_MISMATCH"
318
+ ? refuse("c2-kid", `certificate ${i} is not signed by the Principal (c2) requires`)
319
+ : refuse("c2-signature", `certificate ${i}'s signature does not verify (c2)`);
320
+ }
321
+ return undefined;
322
+ }
323
+ /** (c3), (c4), (c6) for every link, each rule over the whole chain before the next. */
324
+ function checkAuthority(payloads, resource) {
325
+ for (const issuer of payloads.slice(1))
326
+ if (issuer.kind !== DEVICE_KIND.PERSONAL || !hasCode(issuer.mask, DEVICE_ABILITY.DEVICE_MANAGE))
327
+ return refuse("c3", "an issuer is not a personal device with device/manage (c3)");
328
+ for (const [i, child] of payloads.entries()) {
329
+ const issuer = payloads[i + 1];
330
+ if (issuer !== undefined && !child.mask.every((c) => hasCode(issuer.mask, c)))
331
+ return refuse("c4", "a mask is not a subset of its issuer's mask (c4)");
332
+ }
333
+ const leaf = payloads[0];
334
+ const lists = leaf.scope.some((r) => bytesEqual(r, resource));
335
+ if (leaf.kind === DEVICE_KIND.PAIRING && !lists)
336
+ return refuse("c6", "a pairing leaf whose scope does not list this Resource (c6)");
337
+ return leaf.kind !== DEVICE_KIND.SERVICE || lists;
338
+ }
339
+ /**
340
+ * §7.3 step 3 only (class 4): (c2) for every certificate, leaf first.
341
+ * A validator of a record that embeds a chain resolves every dependency
342
+ * of the record (outer issuer and chain root) before any signature, so
343
+ * this is separate from the descriptor lookup (§62).
344
+ */
345
+ export function verifyChainSignatures(chain, rootDescriptor) {
346
+ return (rootDescriptorMismatch(chain.root, rootDescriptor) ??
347
+ verifySignatures(chain.certificates, rootDescriptor) ?? { valid: true });
348
+ }
349
+ /** §7.3 step 4.1 (class 5): (c3), (c4), (c6). `inScope` as in VerifiedChain. */
350
+ export function checkChainAuthority(chain, resource) {
351
+ const r = checkAuthority(chain.certificates.map((c) => c.payload), resource);
352
+ return typeof r === "boolean" ? { valid: true, inScope: r } : r;
353
+ }
354
+ /** §7.3 step 4.2–4.4 (class 5): the root's role, then every binding, then every revocation. */
355
+ export function checkChainState(chain, state) {
356
+ if (!state.rootAllowed(chain.root))
357
+ return refuse("root-role", "the root is bound as a device in this Resource (step 4.2)");
358
+ for (const c of chain.certificates)
359
+ if (!state.bindingMatches(c.payload))
360
+ return refuse("binding", "a device's role or binding conflicts with this chain (step 4.3, N8)");
361
+ for (const c of chain.certificates)
362
+ if (state.isRevoked(chain.root, c.payload.device.principalId))
363
+ return refuse("revoked", "a device of the chain is revoked for this root (step 4.4)");
364
+ return { valid: true };
365
+ }
366
+ /**
367
+ * `verifyChain(chain, R, R_descriptor, S)` (§7.3) in Resource `resource`:
368
+ * one pass of the §62 classes. Structure was checked when the chain was
369
+ * decoded; here (c1) is checked against `root`, then the root descriptor
370
+ * (MISSING_DEPENDENCY when undefined), then every signature, then (c3),
371
+ * (c4), (c6), then the state predicates. Pure; reads no clock.
372
+ */
373
+ export function verifyChain(chain, root, rootDescriptor, resource, state) {
374
+ if (!bytesEqual(chain.root, root))
375
+ return refuse("c1-root", "the chain is for another root (c1)");
376
+ if (rootDescriptor === undefined)
377
+ return refuse("root-descriptor", "the root's descriptor is not known (step 2)");
378
+ const signatures = verifyChainSignatures(chain, rootDescriptor);
379
+ if (!signatures.valid)
380
+ return signatures;
381
+ const authority = checkChainAuthority(chain, resource);
382
+ if (!authority.valid)
383
+ return authority;
384
+ const s = checkChainState(chain, state);
385
+ if (!s.valid)
386
+ return s;
387
+ return Object.freeze({
388
+ valid: true,
389
+ chain,
390
+ leaf: chain.leaf.payload,
391
+ leafId: chain.leaf.id,
392
+ issuers: chain.issuers,
393
+ inScope: authority.inScope,
394
+ bindings: Object.freeze(chain.certificates.map((c) => c.payload)),
395
+ });
396
+ }
397
+ /**
398
+ * Checks a prospective leaf before it is signed, against the issuing
399
+ * device's chain (`issuers`, leaf-first; empty when the root issues): the
400
+ * structure of the chain the leaf would form, the root descriptor, the
401
+ * issuers' signatures, then (c3), (c4), (c6). Lets a managing installation
402
+ * refuse an issuance (managing depth 4, an empty service scope) before it
403
+ * consumes a pairing session (§7.3 N9, IDENTITY-PROFILE-01 §5). Current
404
+ * state (standing, activity) is the caller's to check.
405
+ */
406
+ export function preflightIssuance(leaf, issuers, rootDescriptor, resource) {
407
+ if (issuers.length >= MAX_CHAIN_LENGTH)
408
+ return refuse("structure", `a chain holds at most ${MAX_CHAIN_LENGTH} certificates (c1)`);
409
+ const payloads = [leaf, ...issuers.map((c) => c.payload)];
410
+ try {
411
+ checkDeviceCertificatePayload(leaf);
412
+ checkChainStructure(payloads, leaf.root);
413
+ }
414
+ catch (e) {
415
+ if (e instanceof LfcpError && e.code === "INVALID_STRUCTURE")
416
+ return refuse("structure", e.message);
417
+ throw e;
418
+ }
419
+ if (rootDescriptor === undefined)
420
+ return refuse("root-descriptor", "the root's descriptor is not known (step 2)");
421
+ const signatures = rootDescriptorMismatch(leaf.root, rootDescriptor) ?? verifySignatures(issuers, rootDescriptor);
422
+ if (signatures !== undefined)
423
+ return signatures;
424
+ const authority = checkAuthority(payloads, resource);
425
+ return typeof authority === "boolean" ? { valid: true, inScope: authority } : authority;
426
+ }
427
+ //# sourceMappingURL=device.js.map
package/dist/index.d.ts CHANGED
@@ -1,20 +1,23 @@
1
1
  /** Name of this package. */
2
2
  export declare const PACKAGE = "@openlfcp/wire";
3
- export { ABILITY, ABILITY_NAMES, type Authorization, abilitiesOf, authorizeControlRecord, type CapabilityState, canDistributeKey, type Grant, hasAbility, isGrantActive, isStandardAbility, type VerifiedOwnerTransfer, verifyOwnerTransfer, } from "./capability.js";
3
+ export { ABILITY, ABILITY_NAMES, type Authorization, abilitiesOf, authorizeControlRecord, type CapabilityState, canDistributeKey, exposedReaders, type Grant, hasAbility, isExposingRevocation, isGrantActive, isStandardAbility, type VerifiedOwnerTransfer, verifyOwnerTransfer, } from "./capability.js";
4
4
  export { type ChainOptions, type ChainProblem, type ChainResult, type ControlEpoch, type ControlRoute, type ControlState, type EpochHistory, validateControlChain, } from "./chain.js";
5
5
  export { type ControlBody, type ControlBodyType, type ControlRecord, type ControlRecordHeader, controlBodyFromCbor, controlBodyToCbor, controlRecordSigner, controlTypeOf, decodeControlRecord, encodeControlRecordPayload, isMvpSupported, type OwnerTransferAcceptPayload, type OwnerTransferOfferPayload, ownerTransferAcceptPayloadFromCbor, ownerTransferOfferPayloadFromCbor, parseOwnerTransferAccept, parseOwnerTransferOffer, type SignedControlRecord, signControlRecord, verifyGenesis, } from "./control.js";
6
6
  export { type ControlSyncPlan, type LocalControl, localControlOf, planControlSync, } from "./control-sync.js";
7
7
  export { COSE_ALG_EDDSA, objectId, parseSignedObject, type SignedBytes, type SignedObject, type Signer, signObject, sigStructureBytes, type VerifyResult, verifySignedObject, } from "./cose.js";
8
8
  export { checkDataUnit, type DataProfileCodec, type DataUnitAadFields, type DataUnitCheck, type DataUnitCheckOptions, type DataUnitEquivocation, type DataUnitHoldReason, type DataUnitQuarantined, type DataUnitRejected, type DataUnitRejectReason, dataUnitAad, encodeDataUnitPayload, InMemorySeenUnits, type ReceiveDataUnitOptions, type ReceivedDataUnit, receiveDataUnit, type SealedDataUnit, type SeenRecord, type SeenUnits, sealDataUnit, } from "./data-unit.js";
9
+ export { type CertificateChain, type ChainRefusal, type ChainStateChecks, type ChainWireCode, certificateChain, certificateChainFromCbor, certificateChainToCbor, checkChainAuthority, checkChainState, checkDeviceCertificatePayload, DEVICE_ABILITY, DEVICE_CERT_LABEL, DEVICE_KIND, type DeviceCertificate, type DeviceCertificatePayload, type DeviceChainRule, type DeviceKind, deviceCertificatePayloadDigest, deviceCertificatePayloadFromCbor, deviceCertificatePayloadToCbor, EMPTY_CHAIN_STATE, encodeDeviceCertificatePayload, MAX_CHAIN_LENGTH, MAX_MANAGING_CHAIN_LENGTH, MAX_MASK_ENTRIES, MAX_SCOPE_ENTRIES, newDeviceCertificateSerial, parseDeviceCertificate, preflightIssuance, SERIAL_LENGTH, signDeviceCertificate, type VerifiedChain, verifyChain, verifyChainSignatures, } from "./device.js";
10
+ export { activeDevice, actsFor, authorizeDeviceGrant, authorizeDeviceRevoke, type DeviceBinding, type DeviceRevocation, type DeviceRole, type DeviceState, deviceChainChecks, isBound, ownerAction, revocationOf, rootOf, rootStanding, sameRoot, } from "./device-state.js";
9
11
  export { checkReceivedUrl, checkWriterUrl, ENDPOINT_FLAGS, type Endpoint, endpointFromCbor, endpointToCbor, } from "./endpoint.js";
10
12
  export { type ControlView, classifyDataUnit, type DataPutDecision, type DataUnitHeader, type EpochClassification, type EpochRotation, isSequenceWithinFrontier, KEY_EPOCH_REASON, rotateEpoch, serverAcceptsDataPut, } from "./epoch.js";
11
13
  export { type AuthenticatedSession, type AuthProofCheck, type AuthTranscriptFields, authTranscript, type ClientHandshakeConfig, type ClientSession, type ClientStep, clientReceive, decodeAuthTranscript, isResourceMessage, type RandomSource, type ReadySession, type ServerHandshakeConfig, type ServerSession, type ServerStep, selectWireProfile, serverReceive, signAuthProof, startClientHandshake, startServerSession, verifyAuthProof, WIRE_PROFILE, } from "./handshake.js";
12
14
  export { type ActorHave, type ActorRange, actorHaveFromCbor, actorHaveToCbor, addRange, addSequence, batchDataRanges, canonicalFrontierFromCbor, canonicalFrontierToCbor, checkLiveHave, type HaveVector, hasSequence, haveDifference, type LiveHaveEntry, liveHavesOf, MAX_DATA_GET_RANGES, missingAfter, missingFrom, normalizeLiveHaves, type SequenceRange, unionHaves, } from "./have.js";
13
- export { assembleInviteUri, decodeInviteSecret, encodeInviteSecret, INVITE_SECRET_VERSION, type Invitation, invitationPrincipal, parseInviteUri, verifyInvitationSecret, } from "./invite.js";
15
+ export { assembleInviteUri, assemblePairingUri, decodeInviteSecret, deviceFingerprint, encodeInviteSecret, INVITE_SECRET_VERSION, type Invitation, invitationPrincipal, type PairingLink, pairingDeviceSigner, parseInviteUri, parsePairingUri, verifyInvitationSecret, } from "./invite.js";
14
16
  export { type KeyPackageCheck, type KeyPackageRecipient, keyPackageHpkeAad, keyPackageHpkeInfo, openKeyPackage, type ReceivedKeyPackage, receiveKeyPackage, type SealedKeyPackage, sealKeyPackage, verifyKeyPackage, } from "./key-package.js";
15
17
  export { type AckBody, type AnyMessage, type AuthBody, type ChallengeBody, type ControlGetBody, type ControlHaveBody, type ControlHeadRef, type CoreMessageType, createMessage, type DataGetBody, type DataHaveBody, type DataRange, DEFAULT_MAX_MESSAGE_BYTES, type DecodedEnvelope, type DecodeOptions, decodeEnvelope, decodeFrame, decodeMessage, ERROR_CODE, type ErrorBody, type ExtensionMessage, encodeMessage, FIRST_EXTENSION_MESSAGE_TYPE, type Frame, type FrameResult, type HelloBody, type KeyPackageGetBody, type LfcpMessage, type LiveActorHave, MESSAGE_TYPE, type MessageBodies, messageErrorWireCode, newMessageId, type ObjectListBody, type PingBody, type PresenceBody, type PresenceLeaveBody, type ReadyBody, type ResourceBody, type ResourceHostBody, type ResourceHostedBody, type ResourceOpenBody, type ResourceOpenedBody, replyTo, type SnapshotBody, type SnapshotGetBody, type SnapshotSummary, type WireErrorName, } from "./message.js";
16
18
  export { CONTROL_TYPE, type ControlRecordPayload, controlRecordPayloadFromCbor, type DataUnitPayload, dataUnitPayloadFromCbor, decodeControlRecordPayload, decodeDataUnitPayload, decodeKeyPackagePayload, decodeSnapshotPayload, expectedSignerOf, type KeyPackagePayload, keyPackagePayloadFromCbor, type Parsed, parseControlRecord, parseDataUnit, parseKeyPackage, parseSnapshot, type SnapshotPayload, snapshotPayloadFromCbor, } from "./objects.js";
17
19
  export { decodePrincipalDescriptor, derivePrincipalId, encodePrincipalDescriptor, type PrincipalDescriptor, principalDescriptor, principalDescriptorFromCbor, principalDescriptorFromKeys, principalDescriptorToCbor, } from "./principal.js";
20
+ export { type RevokedReaderPrefix, revokedReaderPrefix } from "./revoked-reader.js";
18
21
  export { type ClientConnectionEvent, type ClientConnectionState, clientConnectionTransition, type ServerSessionEvent, type ServerSessionState, serverSessionTransition, } from "./session-state.js";
19
22
  export { beyondCutoff, checkSnapshot, encodeSnapshotPayload, type ReceivedSnapshot, type ReceiveSnapshotOptions, receiveSnapshot, type SealedSnapshot, type SnapshotAadFields, type SnapshotCheck, type SnapshotCheckOptions, type SnapshotRejected, type SnapshotRejectReason, sealSnapshot, snapshotAad, } from "./snapshot.js";
20
23
  export { type ControlPutBody, controlPutBodyFromCbor, decodeControlPutBody, proposeControlPut, proposeControlTransition, type TransitionResult, } from "./transition.js";
package/dist/index.js CHANGED
@@ -1,20 +1,23 @@
1
1
  /** Name of this package. */
2
2
  export const PACKAGE = "@openlfcp/wire";
3
- export { ABILITY, ABILITY_NAMES, abilitiesOf, authorizeControlRecord, canDistributeKey, hasAbility, isGrantActive, isStandardAbility, verifyOwnerTransfer, } from "./capability.js";
3
+ export { ABILITY, ABILITY_NAMES, abilitiesOf, authorizeControlRecord, canDistributeKey, exposedReaders, hasAbility, isExposingRevocation, isGrantActive, isStandardAbility, verifyOwnerTransfer, } from "./capability.js";
4
4
  export { validateControlChain, } from "./chain.js";
5
5
  export { controlBodyFromCbor, controlBodyToCbor, controlRecordSigner, controlTypeOf, decodeControlRecord, encodeControlRecordPayload, isMvpSupported, ownerTransferAcceptPayloadFromCbor, ownerTransferOfferPayloadFromCbor, parseOwnerTransferAccept, parseOwnerTransferOffer, signControlRecord, verifyGenesis, } from "./control.js";
6
6
  export { localControlOf, planControlSync, } from "./control-sync.js";
7
7
  export { COSE_ALG_EDDSA, objectId, parseSignedObject, signObject, sigStructureBytes, verifySignedObject, } from "./cose.js";
8
8
  export { checkDataUnit, dataUnitAad, encodeDataUnitPayload, InMemorySeenUnits, receiveDataUnit, sealDataUnit, } from "./data-unit.js";
9
+ export { certificateChain, certificateChainFromCbor, certificateChainToCbor, checkChainAuthority, checkChainState, checkDeviceCertificatePayload, DEVICE_ABILITY, DEVICE_CERT_LABEL, DEVICE_KIND, deviceCertificatePayloadDigest, deviceCertificatePayloadFromCbor, deviceCertificatePayloadToCbor, EMPTY_CHAIN_STATE, encodeDeviceCertificatePayload, MAX_CHAIN_LENGTH, MAX_MANAGING_CHAIN_LENGTH, MAX_MASK_ENTRIES, MAX_SCOPE_ENTRIES, newDeviceCertificateSerial, parseDeviceCertificate, preflightIssuance, SERIAL_LENGTH, signDeviceCertificate, verifyChain, verifyChainSignatures, } from "./device.js";
10
+ export { activeDevice, actsFor, authorizeDeviceGrant, authorizeDeviceRevoke, deviceChainChecks, isBound, ownerAction, revocationOf, rootOf, rootStanding, sameRoot, } from "./device-state.js";
9
11
  export { checkReceivedUrl, checkWriterUrl, ENDPOINT_FLAGS, endpointFromCbor, endpointToCbor, } from "./endpoint.js";
10
12
  export { classifyDataUnit, isSequenceWithinFrontier, KEY_EPOCH_REASON, rotateEpoch, serverAcceptsDataPut, } from "./epoch.js";
11
13
  export { authTranscript, clientReceive, decodeAuthTranscript, isResourceMessage, selectWireProfile, serverReceive, signAuthProof, startClientHandshake, startServerSession, verifyAuthProof, WIRE_PROFILE, } from "./handshake.js";
12
14
  export { actorHaveFromCbor, actorHaveToCbor, addRange, addSequence, batchDataRanges, canonicalFrontierFromCbor, canonicalFrontierToCbor, checkLiveHave, hasSequence, haveDifference, liveHavesOf, MAX_DATA_GET_RANGES, missingAfter, missingFrom, normalizeLiveHaves, unionHaves, } from "./have.js";
13
- export { assembleInviteUri, decodeInviteSecret, encodeInviteSecret, INVITE_SECRET_VERSION, invitationPrincipal, parseInviteUri, verifyInvitationSecret, } from "./invite.js";
15
+ export { assembleInviteUri, assemblePairingUri, decodeInviteSecret, deviceFingerprint, encodeInviteSecret, INVITE_SECRET_VERSION, invitationPrincipal, pairingDeviceSigner, parseInviteUri, parsePairingUri, verifyInvitationSecret, } from "./invite.js";
14
16
  export { keyPackageHpkeAad, keyPackageHpkeInfo, openKeyPackage, receiveKeyPackage, sealKeyPackage, verifyKeyPackage, } from "./key-package.js";
15
17
  export { createMessage, DEFAULT_MAX_MESSAGE_BYTES, decodeEnvelope, decodeFrame, decodeMessage, ERROR_CODE, encodeMessage, FIRST_EXTENSION_MESSAGE_TYPE, MESSAGE_TYPE, messageErrorWireCode, newMessageId, replyTo, } from "./message.js";
16
18
  export { CONTROL_TYPE, controlRecordPayloadFromCbor, dataUnitPayloadFromCbor, decodeControlRecordPayload, decodeDataUnitPayload, decodeKeyPackagePayload, decodeSnapshotPayload, expectedSignerOf, keyPackagePayloadFromCbor, parseControlRecord, parseDataUnit, parseKeyPackage, parseSnapshot, snapshotPayloadFromCbor, } from "./objects.js";
17
19
  export { decodePrincipalDescriptor, derivePrincipalId, encodePrincipalDescriptor, principalDescriptor, principalDescriptorFromCbor, principalDescriptorFromKeys, principalDescriptorToCbor, } from "./principal.js";
20
+ export { revokedReaderPrefix } from "./revoked-reader.js";
18
21
  export { clientConnectionTransition, serverSessionTransition, } from "./session-state.js";
19
22
  export { beyondCutoff, checkSnapshot, encodeSnapshotPayload, receiveSnapshot, sealSnapshot, snapshotAad, } from "./snapshot.js";
20
23
  export { controlPutBodyFromCbor, decodeControlPutBody, proposeControlPut, proposeControlTransition, } from "./transition.js";
package/dist/invite.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ControlRecordId, type ResourceId } from "@openlfcp/core";
1
+ import { type ControlRecordId, type PrincipalId, type ResourceId } from "@openlfcp/core";
2
2
  import { InvitationSecret } from "@openlfcp/crypto";
3
3
  import type { ControlState } from "./chain.js";
4
4
  import type { Signer } from "./cose.js";
@@ -65,16 +65,52 @@ export interface Invitation {
65
65
  */
66
66
  export declare function assembleInviteUri(invitation: Invitation): string;
67
67
  /**
68
- * Parses a §18.2 invitation URI. The scheme and host compare
69
- * case-insensitively (RFC 3986 §3.1, §3.2.2); the path is one Resource ID;
70
- * the query has one or more `endpoint` parameters and exactly one `grant`;
71
- * other parameters are ignored, for forward compatibility, once their
72
- * percent-encoding is checked; a fragment, if present, is exactly
73
- * `secret=<b64url>`.
68
+ * Parses a §18.2 invitation URI: lfcp://join/<resource>, one or more
69
+ * `endpoint` parameters and exactly one `grant` (see parseLink).
74
70
  * Endpoints are percent-decoded and must use ws or wss (§16). Throws
75
71
  * INVALID_INVITATION, never with the URI or the secret in the message.
76
72
  * Verify a bearer secret against the grant (verifyInvitationSecret) before
77
- * using it.
73
+ * using it. A pairing link (lfcp://pair/) is not an invitation.
78
74
  */
79
75
  export declare function parseInviteUri(uri: string): Invitation;
76
+ /**
77
+ * A pairing link (IDENTITY-PROFILE-01 §5.2): the keys of a pairing device
78
+ * `J` (kind 2) registered in the Identity Resource. The secret reuses the
79
+ * §18.2 invite-secret codec; `J` is not an Invitation Principal (no grant,
80
+ * no claim). The link always carries the secret: treat it like a key.
81
+ */
82
+ export interface PairingLink {
83
+ /** The Identity Resource `I`. */
84
+ readonly identityResourceId: ResourceId;
85
+ /** One or more endpoint URLs, in URI order. */
86
+ readonly endpoints: readonly string[];
87
+ /** `J`'s Principal ID. */
88
+ readonly pairingDevice: PrincipalId;
89
+ readonly secret: InvitationSecret;
90
+ }
91
+ /**
92
+ * The canonical pairing link: lfcp://pair/<I>?endpoint=…[&endpoint=…]&device=<J>#secret=<secret>.
93
+ * `pairingDevice` must be the Principal the secret determines.
94
+ */
95
+ export declare function assemblePairingUri(link: PairingLink): string;
96
+ /**
97
+ * Parses a pairing link (IDENTITY-PROFILE-01 §5.2) with the §18.2 rules
98
+ * (percent-encoding, several `endpoint` parameters, unknown parameters
99
+ * ignored) and exactly one `device`. The `#secret=` fragment is required,
100
+ * and the Principal recomputed from it must be `device`: a receiver MUST
101
+ * check that before using the secret. An lfcp://join/ link is rejected.
102
+ * Throws INVALID_INVITATION, never with the URI or the secret in the
103
+ * message. Opening `I` as `J` and checking that `J` is a registered,
104
+ * active kind-2 device of `I`'s owner is the client's next step.
105
+ */
106
+ export declare function parsePairingUri(uri: string): PairingLink;
107
+ /** The pairing device as a signer, recomputed from the link's secret (IDENTITY-PROFILE-01 §5.2). */
108
+ export declare function pairingDeviceSigner(link: PairingLink): Signer;
109
+ /**
110
+ * The device fingerprint shown during pairing (IDENTITY-PROFILE-01 §5.2):
111
+ * base32-lower-nopad(SHA-256("LFCP-DEVICE-FP-v1" || principal_id)[0..10]),
112
+ * as four groups of four characters joined by "-". A display convention,
113
+ * not a wire object.
114
+ */
115
+ export declare function deviceFingerprint(device: PrincipalId): string;
80
116
  //# sourceMappingURL=invite.d.ts.map