@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/capability.d.ts +34 -6
- package/dist/capability.js +143 -45
- package/dist/chain.d.ts +10 -4
- package/dist/chain.js +153 -29
- package/dist/control.d.ts +37 -5
- package/dist/control.js +148 -18
- package/dist/device-scan.d.ts +13 -0
- package/dist/device-scan.js +268 -0
- package/dist/device-state.d.ts +154 -0
- package/dist/device-state.js +318 -0
- package/dist/device.d.ts +231 -0
- package/dist/device.js +427 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/invite.d.ts +44 -8
- package/dist/invite.js +130 -45
- package/dist/objects.d.ts +12 -2
- package/dist/objects.js +18 -4
- package/dist/persistent-map.d.ts +42 -0
- package/dist/persistent-map.js +147 -0
- package/dist/revoked-reader.d.ts +29 -0
- package/dist/revoked-reader.js +39 -0
- package/package.json +3 -3
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
|
-
|
|
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
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* percent-encoding is checked; a fragment, if present,
|
|
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
|
-
|
|
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,
|
|
185
|
-
invalid(
|
|
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(
|
|
170
|
+
const path = beforeFragment.slice(prefix.length, question);
|
|
192
171
|
const resource = resourceId(id32(path, "the Resource ID"));
|
|
193
172
|
const endpoints = [];
|
|
194
|
-
let
|
|
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 ===
|
|
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 ===
|
|
212
|
-
if (
|
|
213
|
-
invalid(
|
|
214
|
-
|
|
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 (
|
|
225
|
-
invalid(
|
|
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
|
|
241
|
-
|
|
219
|
+
return {
|
|
220
|
+
resource,
|
|
242
221
|
endpoints: Object.freeze(endpoints),
|
|
243
|
-
|
|
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.
|
|
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.
|
|
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 =
|
|
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.
|
|
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.
|
|
51
|
-
"@openlfcp/crypto": "^0.
|
|
50
|
+
"@openlfcp/core": "^0.3.0",
|
|
51
|
+
"@openlfcp/crypto": "^0.3.0"
|
|
52
52
|
}
|
|
53
53
|
}
|