@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/invite.js CHANGED
@@ -1,5 +1,5 @@
1
- import { bytesEqual, controlRecordId, fromBase64url, LfcpError, resourceId, toBase64url, toHex, } from "@openlfcp/core";
2
- import { exportSecretKeyBytes, InvitationSecret, importAgreementKey, importSigningKey, } from "@openlfcp/crypto";
1
+ import { bytesEqual, controlRecordId, fromBase64url, LfcpError, principalId, resourceId, toBase64url, toHex, } from "@openlfcp/core";
2
+ import { exportSecretKeyBytes, InvitationSecret, importAgreementKey, importSigningKey, sha256, } from "@openlfcp/crypto";
3
3
  import { ABILITY } from "./capability.js";
4
4
  import { cborMap, decodeDeterministic, encode } from "./cbor/index.js";
5
5
  import { strictUtf8Decoder, utf8Encoder } from "./cbor/text.js";
@@ -147,56 +147,35 @@ function id32(text, what) {
147
147
  * percent-encoded (G-RS4).
148
148
  */
149
149
  export function assembleInviteUri(invitation) {
150
- if (invitation.endpoints.length === 0)
151
- invalid("at least one endpoint is required (§18.2)");
152
- for (const url of invitation.endpoints) {
153
- try {
154
- checkWriterUrl(url);
155
- }
156
- catch {
157
- invalid("an endpoint is not a URL a writer may use (§16)");
158
- }
159
- }
160
- const query = [
161
- ...invitation.endpoints.map((url) => `endpoint=${percentEncode(url)}`),
162
- `grant=${toBase64url(invitation.grantId)}`,
163
- ].join("&");
164
- const fragment = invitation.secret === undefined
165
- ? ""
166
- : `#secret=${toBase64url(encodeInviteSecret(invitation.secret))}`;
167
- return `${PREFIX}${toBase64url(invitation.resourceId)}?${query}${fragment}`;
150
+ return assembleLink(PREFIX, invitation.resourceId, invitation.endpoints, "grant", invitation.grantId, invitation.secret);
168
151
  }
169
152
  /**
170
- * Parses a §18.2 invitation URI. The scheme and host compare
171
- * case-insensitively (RFC 3986 §3.1, §3.2.2); the path is one Resource ID;
172
- * the query has one or more `endpoint` parameters and exactly one `grant`;
173
- * other parameters are ignored, for forward compatibility, once their
174
- * percent-encoding is checked; a fragment, if present, is exactly
175
- * `secret=<b64url>`.
176
- * Endpoints are percent-decoded and must use ws or wss (§16). Throws
177
- * INVALID_INVITATION, never with the URI or the secret in the message.
178
- * Verify a bearer secret against the grant (verifyInvitationSecret) before
179
- * using it.
153
+ * The §18.2 parser shared by invitation and pairing links: `prefix` and
154
+ * its one required 32-byte ID parameter `param`. The scheme and host
155
+ * compare case-insensitively (RFC 3986 §3.1, §3.2.2); the path is one
156
+ * Resource ID; one or more `endpoint` parameters; other parameters are
157
+ * ignored once their percent-encoding is checked; a fragment, if present,
158
+ * is exactly `secret=<b64url>`.
180
159
  */
181
- export function parseInviteUri(uri) {
160
+ function parseLink(uri, prefix, param, label) {
182
161
  if (typeof uri !== "string")
183
162
  invalid("the URI is not a string");
184
- if (uri.slice(0, PREFIX.length).toLowerCase() !== PREFIX)
185
- invalid("the URI does not start with lfcp://join/");
163
+ if (uri.slice(0, prefix.length).toLowerCase() !== prefix)
164
+ invalid(`the URI does not start with ${prefix}`);
186
165
  const hash = uri.indexOf("#");
187
166
  const beforeFragment = hash === -1 ? uri : uri.slice(0, hash);
188
167
  const question = beforeFragment.indexOf("?");
189
168
  if (question === -1)
190
169
  invalid("the URI has no query");
191
- const path = beforeFragment.slice(PREFIX.length, question);
170
+ const path = beforeFragment.slice(prefix.length, question);
192
171
  const resource = resourceId(id32(path, "the Resource ID"));
193
172
  const endpoints = [];
194
- let grant;
173
+ let id;
195
174
  for (const parameter of beforeFragment.slice(question + 1).split("&")) {
196
175
  const eq = parameter.indexOf("=");
197
176
  const name = eq === -1 ? parameter : parameter.slice(0, eq);
198
177
  const value = eq === -1 ? "" : parameter.slice(eq + 1);
199
- if ((name === "endpoint" || name === "grant") && value === "")
178
+ if ((name === "endpoint" || name === param) && value === "")
200
179
  invalid(`the ${name} parameter has no value`);
201
180
  if (name === "endpoint") {
202
181
  const url = percentDecode(value, "an endpoint");
@@ -208,10 +187,10 @@ export function parseInviteUri(uri) {
208
187
  }
209
188
  endpoints.push(url);
210
189
  }
211
- else if (name === "grant") {
212
- if (grant !== undefined)
213
- invalid("the grant parameter appears twice");
214
- grant = controlRecordId(id32(value, "the grant ID"));
190
+ else if (name === param) {
191
+ if (id !== undefined)
192
+ invalid(`the ${param} parameter appears twice`);
193
+ id = id32(value, label);
215
194
  }
216
195
  else {
217
196
  // Not defined by §18.2: ignored, but still a well-formed query parameter.
@@ -221,8 +200,8 @@ export function parseInviteUri(uri) {
221
200
  }
222
201
  if (endpoints.length === 0)
223
202
  invalid("the URI has no endpoint parameter");
224
- if (grant === undefined)
225
- invalid("the URI has no grant parameter");
203
+ if (id === undefined)
204
+ invalid(`the URI has no ${param} parameter`);
226
205
  let secret;
227
206
  if (hash !== -1) {
228
207
  const fragment = uri.slice(hash + 1);
@@ -237,11 +216,117 @@ export function parseInviteUri(uri) {
237
216
  }
238
217
  secret = decodeInviteSecret(bytes);
239
218
  }
240
- return Object.freeze({
241
- resourceId: resource,
219
+ return {
220
+ resource,
242
221
  endpoints: Object.freeze(endpoints),
243
- grantId: grant,
222
+ id: id,
244
223
  ...(secret !== undefined ? { secret } : {}),
224
+ };
225
+ }
226
+ /** The §18.2 query and fragment of a link: endpoints, the ID parameter, the optional secret. */
227
+ function assembleLink(prefix, resource, endpoints, param, id, secret) {
228
+ if (endpoints.length === 0)
229
+ invalid("at least one endpoint is required (§18.2)");
230
+ for (const url of endpoints) {
231
+ try {
232
+ checkWriterUrl(url);
233
+ }
234
+ catch {
235
+ invalid("an endpoint is not a URL a writer may use (§16)");
236
+ }
237
+ }
238
+ const query = [
239
+ ...endpoints.map((url) => `endpoint=${percentEncode(url)}`),
240
+ `${param}=${toBase64url(id)}`,
241
+ ].join("&");
242
+ const fragment = secret === undefined ? "" : `#secret=${toBase64url(encodeInviteSecret(secret))}`;
243
+ return `${prefix}${toBase64url(resource)}?${query}${fragment}`;
244
+ }
245
+ /**
246
+ * Parses a §18.2 invitation URI: lfcp://join/<resource>, one or more
247
+ * `endpoint` parameters and exactly one `grant` (see parseLink).
248
+ * Endpoints are percent-decoded and must use ws or wss (§16). Throws
249
+ * INVALID_INVITATION, never with the URI or the secret in the message.
250
+ * Verify a bearer secret against the grant (verifyInvitationSecret) before
251
+ * using it. A pairing link (lfcp://pair/) is not an invitation.
252
+ */
253
+ export function parseInviteUri(uri) {
254
+ const link = parseLink(uri, PREFIX, "grant", "the grant ID");
255
+ return Object.freeze({
256
+ resourceId: link.resource,
257
+ endpoints: link.endpoints,
258
+ grantId: controlRecordId(link.id),
259
+ ...(link.secret !== undefined ? { secret: link.secret } : {}),
245
260
  });
246
261
  }
262
+ // ---------------------------------------------------------------------------
263
+ // Pairing links (IDENTITY-PROFILE-01 §5.2)
264
+ const PAIR_PREFIX = "lfcp://pair/";
265
+ /**
266
+ * The canonical pairing link: lfcp://pair/<I>?endpoint=…[&endpoint=…]&device=<J>#secret=<secret>.
267
+ * `pairingDevice` must be the Principal the secret determines.
268
+ */
269
+ export function assemblePairingUri(link) {
270
+ if (!bytesEqual(invitationPrincipal(link.secret).principalId, link.pairingDevice))
271
+ invalid("the secret is not the pairing device's (IDENTITY-PROFILE-01 §5.2)");
272
+ return assembleLink(PAIR_PREFIX, link.identityResourceId, link.endpoints, "device", link.pairingDevice, link.secret);
273
+ }
274
+ /**
275
+ * Parses a pairing link (IDENTITY-PROFILE-01 §5.2) with the §18.2 rules
276
+ * (percent-encoding, several `endpoint` parameters, unknown parameters
277
+ * ignored) and exactly one `device`. The `#secret=` fragment is required,
278
+ * and the Principal recomputed from it must be `device`: a receiver MUST
279
+ * check that before using the secret. An lfcp://join/ link is rejected.
280
+ * Throws INVALID_INVITATION, never with the URI or the secret in the
281
+ * message. Opening `I` as `J` and checking that `J` is a registered,
282
+ * active kind-2 device of `I`'s owner is the client's next step.
283
+ */
284
+ export function parsePairingUri(uri) {
285
+ const link = parseLink(uri, PAIR_PREFIX, "device", "the pairing device ID");
286
+ const secret = link.secret ?? invalid("a pairing link carries #secret=<b64url>");
287
+ const device = principalId(link.id);
288
+ if (!bytesEqual(invitationPrincipal(secret).principalId, device))
289
+ invalid("the secret is not the device's (IDENTITY-PROFILE-01 §5.2)");
290
+ return Object.freeze({
291
+ identityResourceId: link.resource,
292
+ endpoints: link.endpoints,
293
+ pairingDevice: device,
294
+ secret,
295
+ });
296
+ }
297
+ /** The pairing device as a signer, recomputed from the link's secret (IDENTITY-PROFILE-01 §5.2). */
298
+ export function pairingDeviceSigner(link) {
299
+ const descriptor = invitationPrincipal(link.secret);
300
+ if (!bytesEqual(descriptor.principalId, link.pairingDevice))
301
+ invalid("the secret is not the device's (IDENTITY-PROFILE-01 §5.2)");
302
+ return Object.freeze({ key: link.secret.signingKey, descriptor });
303
+ }
304
+ const FP_DOMAIN = utf8Encoder.encode("LFCP-DEVICE-FP-v1");
305
+ const BASE32 = "abcdefghijklmnopqrstuvwxyz234567";
306
+ /**
307
+ * The device fingerprint shown during pairing (IDENTITY-PROFILE-01 §5.2):
308
+ * base32-lower-nopad(SHA-256("LFCP-DEVICE-FP-v1" || principal_id)[0..10]),
309
+ * as four groups of four characters joined by "-". A display convention,
310
+ * not a wire object.
311
+ */
312
+ export function deviceFingerprint(device) {
313
+ if (!(device instanceof Uint8Array) || device.length !== ID_LENGTH)
314
+ throw new LfcpError("INVALID_LENGTH", "a Principal ID is 32 bytes");
315
+ const input = new Uint8Array(FP_DOMAIN.length + ID_LENGTH);
316
+ input.set(FP_DOMAIN, 0);
317
+ input.set(device, FP_DOMAIN.length);
318
+ const digest = sha256(input).subarray(0, 10);
319
+ let bits = 0;
320
+ let value = 0;
321
+ let out = "";
322
+ for (const byte of digest) {
323
+ value = (value << 8) | byte;
324
+ bits += 8;
325
+ while (bits >= 5) {
326
+ out += BASE32[(value >>> (bits - 5)) & 31];
327
+ bits -= 5;
328
+ }
329
+ }
330
+ return out.match(/.{4}/g).join("-");
331
+ }
247
332
  //# sourceMappingURL=invite.js.map
package/dist/objects.d.ts CHANGED
@@ -23,7 +23,7 @@ import { type ActorHave } from "./have.js";
23
23
  *
24
24
  * Errors and their wire mapping (ADR 0001):
25
25
  * - COSE_MALFORMED, CBOR_* and INVALID_STRUCTURE → MALFORMED_MESSAGE;
26
- * - UNSUPPORTED_VALUE (reserved core Control Record type) →
26
+ * - UNSUPPORTED_VALUE (reserved core Control Record type 11–31) →
27
27
  * INVALID_CONTROL_CHAIN (§14);
28
28
  * - INVALID_CONTROL_CHAIN (a Genesis not at control_seq 0 with a null
29
29
  * link, §13.1) → INVALID_CONTROL_CHAIN;
@@ -34,7 +34,7 @@ export interface Parsed<P> {
34
34
  readonly signed: SignedObject;
35
35
  readonly payload: P;
36
36
  }
37
- /** §14 Control Record types defined by LFCP-WIRE-01. 9–31 are reserved; 32 and up are extensions. */
37
+ /** §14 Control Record types defined by LFCP-WIRE-01. 11–31 are reserved; 32 and up are extensions. */
38
38
  export declare const CONTROL_TYPE: Readonly<{
39
39
  GENESIS: 0n;
40
40
  CAPABILITY_GRANT: 1n;
@@ -45,6 +45,8 @@ export declare const CONTROL_TYPE: Readonly<{
45
45
  OWNER_TRANSFER_COMMIT: 6n;
46
46
  COORDINATOR_RECOVERY: 7n;
47
47
  RESOURCE_TOMBSTONE: 8n;
48
+ DEVICE_GRANT: 9n;
49
+ DEVICE_REVOKE: 10n;
48
50
  }>;
49
51
  /**
50
52
  * `control-record-payload` (§13). The body is the decoded `any` value. It is
@@ -107,6 +109,14 @@ export interface SnapshotPayload {
107
109
  * (LFCP-020).
108
110
  */
109
111
  export declare function controlRecordPayloadFromCbor(value: CborValue): ControlRecordPayload;
112
+ /**
113
+ * controlRecordPayloadFromCbor without the Genesis placement rule (§13.1,
114
+ * class 2 of §62), for a decoder that checks the typed body (class 1)
115
+ * first. Package-internal.
116
+ */
117
+ export declare function readControlRecordPayload(value: CborValue): ControlRecordPayload;
118
+ /** §13.1: a Genesis is at control_seq 0 with a null link (INVALID_CONTROL_CHAIN). Package-internal. */
119
+ export declare function checkGenesisPlacement(p: ControlRecordPayload): ControlRecordPayload;
110
120
  /**
111
121
  * Structural rules: the §26 field set and actor sequence >= 1 (§8, N4).
112
122
  * A non-null previous unit at sequence 1 is not rejected here: §26.2 asks
package/dist/objects.js CHANGED
@@ -3,7 +3,7 @@ import { decodeDeterministic, decodeStrict } from "./cbor/index.js";
3
3
  import { parseSignedObject } from "./cose.js";
4
4
  import { Fields, invalid } from "./fields.js";
5
5
  import { canonicalFrontierFromCbor } from "./have.js";
6
- /** §14 Control Record types defined by LFCP-WIRE-01. 9–31 are reserved; 32 and up are extensions. */
6
+ /** §14 Control Record types defined by LFCP-WIRE-01. 11–31 are reserved; 32 and up are extensions. */
7
7
  export const CONTROL_TYPE = Object.freeze({
8
8
  GENESIS: 0n,
9
9
  CAPABILITY_GRANT: 1n,
@@ -14,8 +14,10 @@ export const CONTROL_TYPE = Object.freeze({
14
14
  OWNER_TRANSFER_COMMIT: 6n,
15
15
  COORDINATOR_RECOVERY: 7n,
16
16
  RESOURCE_TOMBSTONE: 8n,
17
+ DEVICE_GRANT: 9n,
18
+ DEVICE_REVOKE: 10n,
17
19
  });
18
- const FIRST_RESERVED_CONTROL_TYPE = 9n;
20
+ const FIRST_RESERVED_CONTROL_TYPE = 11n;
19
21
  const FIRST_EXTENSION_CONTROL_TYPE = 32n;
20
22
  const ALL_FIELDS = [0, 1, 2, 3, 4, 5, 6];
21
23
  /**
@@ -26,6 +28,14 @@ const ALL_FIELDS = [0, 1, 2, 3, 4, 5, 6];
26
28
  * (LFCP-020).
27
29
  */
28
30
  export function controlRecordPayloadFromCbor(value) {
31
+ return checkGenesisPlacement(readControlRecordPayload(value));
32
+ }
33
+ /**
34
+ * controlRecordPayloadFromCbor without the Genesis placement rule (§13.1,
35
+ * class 2 of §62), for a decoder that checks the typed body (class 1)
36
+ * first. Package-internal.
37
+ */
38
+ export function readControlRecordPayload(value) {
29
39
  const f = new Fields(value, "control-record-payload", ALL_FIELDS.slice(0, 6));
30
40
  const rid = resourceId(f.bytes(0, 32));
31
41
  const controlSeq = f.uint(1);
@@ -35,8 +45,6 @@ export function controlRecordPayloadFromCbor(value) {
35
45
  if (controlType >= FIRST_RESERVED_CONTROL_TYPE && controlType < FIRST_EXTENSION_CONTROL_TYPE) {
36
46
  throw new LfcpError("UNSUPPORTED_VALUE", `Control Record type ${controlType} is reserved for LFCP core (§14)`);
37
47
  }
38
- if (controlType === CONTROL_TYPE.GENESIS && (controlSeq !== 0n || prev !== null))
39
- throw new LfcpError("INVALID_CONTROL_CHAIN", "a Genesis record must have control_seq 0 and a null prev_control_id (§13.1)");
40
48
  return Object.freeze({
41
49
  kind: "control-record",
42
50
  resourceId: rid,
@@ -48,6 +56,12 @@ export function controlRecordPayloadFromCbor(value) {
48
56
  body: f.any(5),
49
57
  });
50
58
  }
59
+ /** §13.1: a Genesis is at control_seq 0 with a null link (INVALID_CONTROL_CHAIN). Package-internal. */
60
+ export function checkGenesisPlacement(p) {
61
+ if (p.controlType === CONTROL_TYPE.GENESIS && (p.controlSeq !== 0n || p.prevControlId !== null))
62
+ throw new LfcpError("INVALID_CONTROL_CHAIN", "a Genesis record must have control_seq 0 and a null prev_control_id (§13.1)");
63
+ return p;
64
+ }
51
65
  /**
52
66
  * Structural rules: the §26 field set and actor sequence >= 1 (§8, N4).
53
67
  * A non-null previous unit at sequence 1 is not rejected here: §26.2 asks
@@ -0,0 +1,42 @@
1
+ /**
2
+ * An immutable map from strings with structural sharing: a persistent AVL
3
+ * tree ordered by the full key. `set` copies only the nodes on the path
4
+ * to its key (at most about 1.44 log2 n of them) and rebalances there, so
5
+ * the states a chain keeps for every head share all untouched entries
6
+ * (review of #8, R8-1).
7
+ *
8
+ * Keys are compared whole: no hash, no bucket, so no choice of keys (an
9
+ * arbitrary device ID in a pre-emptive revocation, for example) can make
10
+ * an update copy more than one path (review of #8, R8-1b). Iteration is
11
+ * in ascending key order (UTF-16 code units).
12
+ */
13
+ export declare class PersistentMap<V> implements ReadonlyMap<string, V> {
14
+ #private;
15
+ readonly size: number;
16
+ private constructor();
17
+ static empty<V>(): PersistentMap<V>;
18
+ static from<V>(entries: Iterable<readonly [string, V]>): PersistentMap<V>;
19
+ get(key: string): V | undefined;
20
+ has(key: string): boolean;
21
+ /** A map with `key` set to `value`; this one is unchanged. */
22
+ set(key: string, value: V): PersistentMap<V>;
23
+ /** The tree's height (tests: it stays logarithmic whatever the keys). */
24
+ get height(): number;
25
+ /**
26
+ * Adds every tree node of this map to `seen` (tests: the structure the
27
+ * retained states hold together, shared nodes counted once).
28
+ */
29
+ collectNodes(seen: Set<object>): void;
30
+ entries(): ArrayIterator<[string, V]>;
31
+ keys(): ArrayIterator<string>;
32
+ values(): ArrayIterator<V>;
33
+ [Symbol.iterator](): ArrayIterator<[string, V]>;
34
+ forEach(fn: (value: V, key: string, map: ReadonlyMap<string, V>) => void, thisArg?: unknown): void;
35
+ }
36
+ /**
37
+ * `map` with `key` set to `value`, sharing structure when `map` is
38
+ * persistent; any other ReadonlyMap (a state built by a caller) is copied
39
+ * once into a persistent one.
40
+ */
41
+ export declare function put<V>(map: ReadonlyMap<string, V>, key: string, value: V): PersistentMap<V>;
42
+ //# sourceMappingURL=persistent-map.d.ts.map
@@ -0,0 +1,147 @@
1
+ /**
2
+ * An immutable map from strings with structural sharing: a persistent AVL
3
+ * tree ordered by the full key. `set` copies only the nodes on the path
4
+ * to its key (at most about 1.44 log2 n of them) and rebalances there, so
5
+ * the states a chain keeps for every head share all untouched entries
6
+ * (review of #8, R8-1).
7
+ *
8
+ * Keys are compared whole: no hash, no bucket, so no choice of keys (an
9
+ * arbitrary device ID in a pre-emptive revocation, for example) can make
10
+ * an update copy more than one path (review of #8, R8-1b). Iteration is
11
+ * in ascending key order (UTF-16 code units).
12
+ */
13
+ const heightOf = (n) => (n === undefined ? 0 : n.height);
14
+ function node(key, value, left, right) {
15
+ const height = 1 + Math.max(heightOf(left), heightOf(right));
16
+ return Object.freeze({ key, value, left, right, height });
17
+ }
18
+ /** A node for (key, value) over `left` and `right`, whose heights differ by at most two. */
19
+ function balanced(key, value, left, right) {
20
+ const hl = heightOf(left);
21
+ const hr = heightOf(right);
22
+ if (hl > hr + 1) {
23
+ const l = left;
24
+ if (heightOf(l.left) >= heightOf(l.right))
25
+ return node(l.key, l.value, l.left, node(key, value, l.right, right));
26
+ const lr = l.right;
27
+ return node(lr.key, lr.value, node(l.key, l.value, l.left, lr.left), node(key, value, lr.right, right));
28
+ }
29
+ if (hr > hl + 1) {
30
+ const r = right;
31
+ if (heightOf(r.right) >= heightOf(r.left))
32
+ return node(r.key, r.value, node(key, value, left, r.left), r.right);
33
+ const rl = r.left;
34
+ return node(rl.key, rl.value, node(key, value, left, rl.left), node(r.key, r.value, rl.right, r.right));
35
+ }
36
+ return node(key, value, left, right);
37
+ }
38
+ /** `n` with `key` set; the second element is whether the key is new. */
39
+ function insert(n, key, value) {
40
+ if (n === undefined)
41
+ return [node(key, value, undefined, undefined), true];
42
+ if (key < n.key) {
43
+ const [left, added] = insert(n.left, key, value);
44
+ return [balanced(n.key, n.value, left, n.right), added];
45
+ }
46
+ if (key > n.key) {
47
+ const [right, added] = insert(n.right, key, value);
48
+ return [balanced(n.key, n.value, n.left, right), added];
49
+ }
50
+ return [node(key, value, n.left, n.right), false];
51
+ }
52
+ function lookup(root, key) {
53
+ let n = root;
54
+ while (n !== undefined) {
55
+ if (key < n.key)
56
+ n = n.left;
57
+ else if (key > n.key)
58
+ n = n.right;
59
+ else
60
+ return n;
61
+ }
62
+ return undefined;
63
+ }
64
+ /** `pick` of every node under `n`, in key order, appended to `out`. */
65
+ function collect(n, pick, out) {
66
+ const stack = [];
67
+ let at = n;
68
+ while (at !== undefined || stack.length > 0) {
69
+ while (at !== undefined) {
70
+ stack.push(at);
71
+ at = at.left;
72
+ }
73
+ const top = stack.pop();
74
+ out.push(pick(top));
75
+ at = top.right;
76
+ }
77
+ return out;
78
+ }
79
+ const pickEntry = (n) => [n.key, n.value];
80
+ const pickKey = (n) => n.key;
81
+ const pickValue = (n) => n.value;
82
+ export class PersistentMap {
83
+ static #EMPTY = new PersistentMap(undefined, 0);
84
+ #root;
85
+ size;
86
+ constructor(root, size) {
87
+ this.#root = root;
88
+ this.size = size;
89
+ Object.freeze(this);
90
+ }
91
+ static empty() {
92
+ return PersistentMap.#EMPTY;
93
+ }
94
+ static from(entries) {
95
+ let map = PersistentMap.empty();
96
+ for (const [k, v] of entries)
97
+ map = map.set(k, v);
98
+ return map;
99
+ }
100
+ get(key) {
101
+ return lookup(this.#root, key)?.value;
102
+ }
103
+ has(key) {
104
+ return lookup(this.#root, key) !== undefined;
105
+ }
106
+ /** A map with `key` set to `value`; this one is unchanged. */
107
+ set(key, value) {
108
+ const [root, added] = insert(this.#root, key, value);
109
+ return new PersistentMap(root, added ? this.size + 1 : this.size);
110
+ }
111
+ /** The tree's height (tests: it stays logarithmic whatever the keys). */
112
+ get height() {
113
+ return heightOf(this.#root);
114
+ }
115
+ /**
116
+ * Adds every tree node of this map to `seen` (tests: the structure the
117
+ * retained states hold together, shared nodes counted once).
118
+ */
119
+ collectNodes(seen) {
120
+ collect(this.#root, (n) => seen.add(n).size, []);
121
+ }
122
+ entries() {
123
+ return collect(this.#root, pickEntry, []).values();
124
+ }
125
+ keys() {
126
+ return collect(this.#root, pickKey, []).values();
127
+ }
128
+ values() {
129
+ return collect(this.#root, pickValue, []).values();
130
+ }
131
+ [Symbol.iterator]() {
132
+ return this.entries();
133
+ }
134
+ forEach(fn, thisArg) {
135
+ for (const [k, v] of this.entries())
136
+ fn.call(thisArg, v, k, this);
137
+ }
138
+ }
139
+ /**
140
+ * `map` with `key` set to `value`, sharing structure when `map` is
141
+ * persistent; any other ReadonlyMap (a state built by a caller) is copied
142
+ * once into a persistent one.
143
+ */
144
+ export function put(map, key, value) {
145
+ return (map instanceof PersistentMap ? map : PersistentMap.from(map)).set(key, value);
146
+ }
147
+ //# sourceMappingURL=persistent-map.js.map
@@ -0,0 +1,29 @@
1
+ import { type ControlRecordId, type PrincipalId } from "@openlfcp/core";
2
+ import type { ChainResult } from "./chain.js";
3
+ type Linear = Extract<ChainResult, {
4
+ kind: "linear";
5
+ }>;
6
+ /** The end of the Control Chain prefix a revoked reader may still read (W §41). */
7
+ export interface RevokedReaderPrefix {
8
+ /** Position of the revoking record in the chain (Genesis is 0). */
9
+ readonly index: number;
10
+ /** The first record that revoked the Principal: the prefix's only Control Head. */
11
+ readonly recordId: ControlRecordId;
12
+ readonly reason: bigint;
13
+ }
14
+ /**
15
+ * W §41 "Control read for a revoked reader": a Principal `p` that no longer
16
+ * holds `data/read` at the accepted head may read the Control Chain prefix
17
+ * through the first `DEVICE_REVOKE {root, p, ...}` with `root =
18
+ * devices[p].root`, when `p` is registered and held `data/read` at the head
19
+ * immediately before that record. Undefined otherwise: an unbound,
20
+ * intermediate-only or out-of-scope device, a device whose mask never
21
+ * conferred `data/read`, a device whose root lost standing without a
22
+ * revocation, and a current reader get nothing from this rule. A consumed
23
+ * pairing device (reason 3) is covered like any revoked reader. This is
24
+ * what lets a revoked device prove its revocation (IDENTITY-PROFILE-01
25
+ * §8.6); a server serves it, a client verifies it the same way.
26
+ */
27
+ export declare function revokedReaderPrefix(chain: Linear, p: PrincipalId): RevokedReaderPrefix | undefined;
28
+ export {};
29
+ //# sourceMappingURL=revoked-reader.d.ts.map
@@ -0,0 +1,39 @@
1
+ import { bytesEqual, controlRecordId, toHex, } from "@openlfcp/core";
2
+ import { ABILITY, hasAbility } from "./capability.js";
3
+ /**
4
+ * W §41 "Control read for a revoked reader": a Principal `p` that no longer
5
+ * holds `data/read` at the accepted head may read the Control Chain prefix
6
+ * through the first `DEVICE_REVOKE {root, p, ...}` with `root =
7
+ * devices[p].root`, when `p` is registered and held `data/read` at the head
8
+ * immediately before that record. Undefined otherwise: an unbound,
9
+ * intermediate-only or out-of-scope device, a device whose mask never
10
+ * conferred `data/read`, a device whose root lost standing without a
11
+ * revocation, and a current reader get nothing from this rule. A consumed
12
+ * pairing device (reason 3) is covered like any revoked reader. This is
13
+ * what lets a revoked device prove its revocation (IDENTITY-PROFILE-01
14
+ * §8.6); a server serves it, a client verifies it the same way.
15
+ */
16
+ export function revokedReaderPrefix(chain, p) {
17
+ if (hasAbility(chain.state, p, ABILITY.DATA_READ))
18
+ return undefined;
19
+ const d = chain.state.devices.get(toHex(p));
20
+ if (d === undefined || !d.registered)
21
+ return undefined;
22
+ for (let i = 1; i < chain.records.length; i++) {
23
+ const r = chain.records[i];
24
+ if (r?.body.type !== "DEVICE_REVOKE")
25
+ continue;
26
+ if (!bytesEqual(r.body.device, p) || !bytesEqual(r.body.root, d.root))
27
+ continue;
28
+ const before = chain.stateAt(chain.records[i - 1]?.signed.id);
29
+ if (before === undefined || !hasAbility(before, p, ABILITY.DATA_READ))
30
+ return undefined;
31
+ return Object.freeze({
32
+ index: i,
33
+ recordId: controlRecordId(r.signed.id),
34
+ reason: r.body.reason,
35
+ });
36
+ }
37
+ return undefined;
38
+ }
39
+ //# sourceMappingURL=revoked-reader.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlfcp/wire",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Deterministic CBOR, COSE and LFCP Wire structures and codecs.",
5
5
  "keywords": [
6
6
  "openlfcp",
@@ -47,7 +47,7 @@
47
47
  "tag": "latest"
48
48
  },
49
49
  "dependencies": {
50
- "@openlfcp/core": "^0.2.1",
51
- "@openlfcp/crypto": "^0.2.1"
50
+ "@openlfcp/core": "^0.3.0",
51
+ "@openlfcp/crypto": "^0.3.0"
52
52
  }
53
53
  }