@openlfcp/wire 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +27 -0
  3. package/dist/capability.d.ts +143 -0
  4. package/dist/capability.js +409 -0
  5. package/dist/cbor/decode.d.ts +23 -0
  6. package/dist/cbor/decode.js +163 -0
  7. package/dist/cbor/encode.d.ts +10 -0
  8. package/dist/cbor/encode.js +149 -0
  9. package/dist/cbor/index.d.ts +10 -0
  10. package/dist/cbor/index.js +10 -0
  11. package/dist/cbor/text.d.ts +11 -0
  12. package/dist/cbor/text.js +5 -0
  13. package/dist/cbor/value.d.ts +30 -0
  14. package/dist/cbor/value.js +23 -0
  15. package/dist/chain.d.ts +140 -0
  16. package/dist/chain.js +339 -0
  17. package/dist/control-sync.d.ts +69 -0
  18. package/dist/control-sync.js +44 -0
  19. package/dist/control.d.ts +173 -0
  20. package/dist/control.js +369 -0
  21. package/dist/cose.d.ts +81 -0
  22. package/dist/cose.js +133 -0
  23. package/dist/data-unit.d.ts +204 -0
  24. package/dist/data-unit.js +314 -0
  25. package/dist/endpoint.d.ts +51 -0
  26. package/dist/endpoint.js +116 -0
  27. package/dist/epoch.d.ts +109 -0
  28. package/dist/epoch.js +128 -0
  29. package/dist/fields.d.ts +18 -0
  30. package/dist/fields.js +78 -0
  31. package/dist/handshake.d.ts +175 -0
  32. package/dist/handshake.js +297 -0
  33. package/dist/have.d.ts +101 -0
  34. package/dist/have.js +268 -0
  35. package/dist/index.d.ts +21 -0
  36. package/dist/index.js +21 -0
  37. package/dist/invite.d.ts +80 -0
  38. package/dist/invite.js +247 -0
  39. package/dist/key-package.d.ts +92 -0
  40. package/dist/key-package.js +132 -0
  41. package/dist/message.d.ts +367 -0
  42. package/dist/message.js +690 -0
  43. package/dist/objects.d.ts +154 -0
  44. package/dist/objects.js +156 -0
  45. package/dist/principal.d.ts +43 -0
  46. package/dist/principal.js +81 -0
  47. package/dist/session-state.d.ts +47 -0
  48. package/dist/session-state.js +49 -0
  49. package/dist/snapshot.d.ts +86 -0
  50. package/dist/snapshot.js +189 -0
  51. package/dist/transition.d.ts +97 -0
  52. package/dist/transition.js +130 -0
  53. package/package.json +53 -0
@@ -0,0 +1,409 @@
1
+ import { bytesEqual, controlRecordId, toHex, } from "@openlfcp/core";
2
+ import { parseOwnerTransferAccept, parseOwnerTransferOffer, } from "./control.js";
3
+ import { objectId, verifySignedObject } from "./cose.js";
4
+ /**
5
+ * The LFCP capability engine (LFCP-WIRE-01 §17, §18, §20, §19, §25.2).
6
+ *
7
+ * Authority comes only from the validated Control Chain: a Principal ID
8
+ * holds an ability at a Control Head H because of the owner rule or of
9
+ * grants and claims committed through H. There is no server account,
10
+ * user table or hosting identity anywhere in this module, and nothing
11
+ * here takes one.
12
+ *
13
+ * Everything is a pure function of a ControlState (the state at some head
14
+ * H). Evaluating at an older H means using the state at that H (see
15
+ * `stateAt` on a linear chain result), so a later revocation never
16
+ * changes an earlier answer.
17
+ */
18
+ /** §17.1 standard ability codes. */
19
+ export const ABILITY = Object.freeze({
20
+ DATA_READ: 1n,
21
+ DATA_WRITE: 2n,
22
+ SNAPSHOT_PUBLISH: 3n,
23
+ CAPABILITY_GRANT: 4n,
24
+ CAPABILITY_REVOKE: 5n,
25
+ KEY_DISTRIBUTE: 6n,
26
+ KEY_ROTATE: 7n,
27
+ ROUTE_UPDATE: 8n,
28
+ OWNER_TRANSFER_OFFER: 9n,
29
+ RESOURCE_TOMBSTONE: 10n,
30
+ INVITE_CLAIM: 11n,
31
+ });
32
+ /** §17.1 names of the standard codes. */
33
+ export const ABILITY_NAMES = new Map([
34
+ [1n, "data/read"],
35
+ [2n, "data/write"],
36
+ [3n, "snapshot/publish"],
37
+ [4n, "capability/grant"],
38
+ [5n, "capability/revoke"],
39
+ [6n, "key/distribute"],
40
+ [7n, "key/rotate"],
41
+ [8n, "route/update"],
42
+ [9n, "owner/transfer-offer"],
43
+ [10n, "resource/tombstone"],
44
+ [11n, "invite/claim"],
45
+ ]);
46
+ /**
47
+ * Whether `ability` is a §17.1 standard code. §17.1: "A code that is not in
48
+ * the table above is kept as received and confers nothing", so an unknown
49
+ * code is kept in its record but is never held.
50
+ */
51
+ export const isStandardAbility = (ability) => ABILITY_NAMES.has(ability);
52
+ /**
53
+ * Whether holding `ability` can mean anything: a standard code other than
54
+ * 9. §17.1: "9 | owner/transfer-offer (reserved; confers nothing in
55
+ * WIRE-01)"; §23.1: "Only the current owner creates offers."
56
+ */
57
+ const confers = (ability) => isStandardAbility(ability) && ability !== ABILITY.OWNER_TRANSFER_OFFER;
58
+ const key = (id) => toHex(id);
59
+ /**
60
+ * Whether a grant is active at this state. §17.2: "A grant is active while
61
+ * it has not been revoked and, when it has a parent grant, while that
62
+ * parent is active. Revoking a grant therefore also deactivates every grant
63
+ * delegated from it, directly or through further delegations."
64
+ */
65
+ export function isGrantActive(state, grantId) {
66
+ const seen = new Set();
67
+ let grant = state.grants.get(key(grantId));
68
+ while (grant !== undefined) {
69
+ if (grant.revokedBy !== null)
70
+ return false;
71
+ if (grant.parentGrantId === null)
72
+ return true;
73
+ if (seen.has(key(grant.id)))
74
+ return false;
75
+ seen.add(key(grant.id));
76
+ grant = state.grants.get(key(grant.parentGrantId));
77
+ }
78
+ return false;
79
+ }
80
+ const isOwner = (state, principal) => bytesEqual(state.owner.principalId, principal);
81
+ /**
82
+ * Whether an invitation grant's claims are used up. §18.1: "An Invitation
83
+ * Grant whose claims are used up confers no invite/claim; its other
84
+ * abilities stay active until it is revoked."
85
+ */
86
+ const claimsExhausted = (g) => g.claimLimit !== null && g.claimsUsed >= g.claimLimit;
87
+ /** Whether grant `g` confers `ability` at this state: it lists it, is active, and (for invite/claim) is not used up. */
88
+ function grantConfers(state, g, ability) {
89
+ if (!g.abilities.includes(ability) || !isGrantActive(state, g.id))
90
+ return false;
91
+ return ability !== ABILITY.INVITE_CLAIM || !claimsExhausted(g);
92
+ }
93
+ /**
94
+ * Whether `principal` holds `ability` at the head of `state`: the owner
95
+ * holds every standard ability implicitly (§17.1, no self-grant); anyone
96
+ * else holds it through an active grant that confers it. Unknown codes and
97
+ * the reserved ability 9 are held by no one (§17.1, §23.1).
98
+ */
99
+ export function hasAbility(state, principal, ability) {
100
+ if (!confers(ability))
101
+ return false;
102
+ if (isOwner(state, principal))
103
+ return true;
104
+ for (const g of state.grants.values())
105
+ if (bytesEqual(g.subject, principal) && grantConfers(state, g, ability))
106
+ return true;
107
+ return false;
108
+ }
109
+ /** Every standard ability `principal` holds at the head of `state`, ascending. */
110
+ export function abilitiesOf(state, principal) {
111
+ return [...ABILITY_NAMES.keys()].filter((a) => hasAbility(state, principal, a));
112
+ }
113
+ const ALLOW = Object.freeze({ allowed: true });
114
+ const deny = (reason) => Object.freeze({ allowed: false, reason });
115
+ const badSignature = (reason) => Object.freeze({ allowed: false, reason, code: "INVALID_SIGNATURE" });
116
+ const subset = (list, of) => list.every((a) => of.includes(a));
117
+ /**
118
+ * §17.3: revoke authority "covers a grant when the revoker issued it, or
119
+ * when it was delegated, directly or through further delegations, from a
120
+ * grant the revoker issued. A grant the revoker received is not covered
121
+ * unless the revoker also issued one of its ancestors."
122
+ */
123
+ function covers(state, target, revoker) {
124
+ const seen = new Set();
125
+ let grant = target;
126
+ while (grant !== undefined && !seen.has(key(grant.id))) {
127
+ if (bytesEqual(grant.issuer, revoker))
128
+ return true;
129
+ seen.add(key(grant.id));
130
+ grant = grant.parentGrantId === null ? undefined : state.grants.get(key(grant.parentGrantId));
131
+ }
132
+ return false;
133
+ }
134
+ /**
135
+ * Whether the issuer of `record` had the authority the record needs, at
136
+ * the state before it (the chain's previous head). Genesis is authorized by
137
+ * verifyGenesis. Coordinator Recovery and Resource Tombstone records never
138
+ * get here: the chain refuses them first (DV1).
139
+ */
140
+ export function authorizeControlRecord(record, state) {
141
+ const issuer = record.payload.issuer;
142
+ const body = record.body;
143
+ switch (body.type) {
144
+ case "GENESIS":
145
+ return ALLOW;
146
+ case "CAPABILITY_GRANT": {
147
+ // §17.2: "The owner MAY grant any abilities without a parent grant. A
148
+ // non-owner issuer MUST hold capability/grant and MUST reference a
149
+ // parent grant." "If parent grant id is present: the parent grant
150
+ // MUST be active; the issuer MUST be the subject of the parent grant;
151
+ // every granted ability MUST be included in the parent's delegable
152
+ // abilities; every delegable ability of the new grant MUST also be
153
+ // included in the parent's delegable abilities."
154
+ if (body.parentGrantId !== undefined) {
155
+ const parent = state.grants.get(key(body.parentGrantId));
156
+ if (parent === undefined)
157
+ return deny("the parent grant does not exist (§17.2)");
158
+ if (!isGrantActive(state, parent.id))
159
+ return deny("the parent grant is not active (§17.2)");
160
+ if (!bytesEqual(parent.subject, issuer))
161
+ return deny("the issuer is not the subject of the parent grant (§17.2)");
162
+ if (!subset(body.abilities, parent.delegable))
163
+ return deny("a granted ability is not delegable under the parent grant (§17.2)");
164
+ if (!subset(body.delegable, parent.delegable))
165
+ return deny("a delegable ability is not delegable under the parent grant (§17.2)");
166
+ }
167
+ else if (!isOwner(state, issuer)) {
168
+ return deny("a non-owner grant must prove its authority through a parent grant (§17.2)");
169
+ }
170
+ if (!isOwner(state, issuer) && !hasAbility(state, issuer, ABILITY.CAPABILITY_GRANT))
171
+ return deny("the issuer does not hold capability/grant (§17.2)");
172
+ return ALLOW;
173
+ }
174
+ case "CAPABILITY_REVOKE": {
175
+ // §17.3, in order: the target exists, the issuer's authority covers
176
+ // it, it is not already revoked. Each failure is AUTHORIZATION_FAILED.
177
+ const target = state.grants.get(key(body.grantId));
178
+ if (target === undefined)
179
+ return deny("the revoked grant does not exist (§17.3)");
180
+ // "The owner may revoke any grant." Otherwise the issuer MUST "possess
181
+ // capability/revoke authority that covers the target grant".
182
+ if (!isOwner(state, issuer)) {
183
+ if (!hasAbility(state, issuer, ABILITY.CAPABILITY_REVOKE))
184
+ return deny("the issuer does not hold capability/revoke (§17.3)");
185
+ if (!covers(state, target, issuer))
186
+ return deny("the issuer's capability/revoke authority does not cover the grant (§17.3)");
187
+ }
188
+ if (target.revokedBy !== null)
189
+ return deny("the grant is already revoked (§17.3)");
190
+ return ALLOW;
191
+ }
192
+ case "CAPABILITY_CLAIM": {
193
+ // §18.1 validation rules 1-5 (rule 6, coordinator serialization, is LFCP-022).
194
+ const invitation = state.grants.get(key(body.invitationGrantId));
195
+ if (invitation === undefined)
196
+ return deny("the invitation grant does not exist (§18.1)");
197
+ if (!isGrantActive(state, invitation.id))
198
+ return deny("the invitation grant is not active (§18.1 rule 1)");
199
+ if (!invitation.abilities.includes(ABILITY.INVITE_CLAIM))
200
+ return deny("the invitation grant does not grant invite/claim (§18.1 rule 2)");
201
+ // §18: "Only an invitation grant with a claim_limit can be claimed: one
202
+ // without claim_limit is not claimable."
203
+ if (invitation.claimLimit === null)
204
+ return deny("the invitation grant has no claim_limit and is not claimable (§18)");
205
+ if (claimsExhausted(invitation))
206
+ return Object.freeze({
207
+ allowed: false,
208
+ reason: "the invitation grant has no claims left (§18.1 rule 3)",
209
+ code: "INVITE_CLAIM_EXHAUSTED",
210
+ });
211
+ if (!bytesEqual(invitation.subject, issuer))
212
+ return deny("the claim issuer is not the Invitation Principal (§18.1 rule 5)");
213
+ // Rule 4: a subset of the invitation's abilities, excluding invite/claim
214
+ // unless the invitation explicitly delegates it. Unknown codes confer
215
+ // nothing (§17.1), so they request nothing and are not checked.
216
+ const transferable = invitation.abilities.filter((a) => a !== ABILITY.INVITE_CLAIM || invitation.delegable.includes(ABILITY.INVITE_CLAIM));
217
+ if (!subset(body.abilities.filter(isStandardAbility), transferable))
218
+ return deny("the claim requests abilities beyond the invitation grant (§18.1 rule 4)");
219
+ return ALLOW;
220
+ }
221
+ case "KEY_EPOCH":
222
+ // §19: "The issuer MUST possess key/rotate."
223
+ return hasAbility(state, issuer, ABILITY.KEY_ROTATE)
224
+ ? ALLOW
225
+ : deny("the issuer does not hold key/rotate (§19)");
226
+ case "OWNER_TRANSFER_COMMIT":
227
+ return verifyOwnerTransfer(record, state).authorization;
228
+ case "ROUTE_UPDATE":
229
+ // §20: "The issuer MUST possess route/update"; "Each Route Update MUST
230
+ // carry a route version strictly greater than the current one: 0 after
231
+ // Genesis (Section 15), otherwise the version of the last committed
232
+ // Route Update." §20 names no code: AUTHORIZATION_FAILED here.
233
+ if (!hasAbility(state, issuer, ABILITY.ROUTE_UPDATE))
234
+ return deny("the issuer does not hold route/update (§20)");
235
+ return body.routeVersion > state.routeVersion
236
+ ? ALLOW
237
+ : deny("the route version does not increase (§20)");
238
+ case "EXTENSION":
239
+ // §14: "A Control Record of an extension type (32 or above) requires
240
+ // owner authority: its issuer MUST be the Resource owner at the
241
+ // record's position in the chain, whether or not the receiver supports
242
+ // the extension." §14 names no code: AUTHORIZATION_FAILED here.
243
+ return isOwner(state, issuer)
244
+ ? ALLOW
245
+ : deny("an extension record must be issued by the owner (§14)");
246
+ case "COORDINATOR_RECOVERY":
247
+ case "RESOURCE_TOMBSTONE":
248
+ return deny(`${body.type} is refused in MVP 0.1 (scope §4, DV1)`);
249
+ }
250
+ }
251
+ /** The capability transitions of an applied record (called by applyRecord). */
252
+ export function applyCapabilities(grants, record) {
253
+ const body = record.body;
254
+ const id = controlRecordId(record.signed.id);
255
+ switch (body.type) {
256
+ case "CAPABILITY_GRANT": {
257
+ const next = new Map(grants);
258
+ next.set(key(id), Object.freeze({
259
+ id,
260
+ source: "grant",
261
+ issuer: record.payload.issuer,
262
+ subject: body.subject.principalId,
263
+ abilities: body.abilities,
264
+ delegable: body.delegable,
265
+ parentGrantId: body.parentGrantId ?? null,
266
+ claimLimit: body.claimLimit ?? null,
267
+ claimsUsed: 0n,
268
+ revokedBy: null,
269
+ }));
270
+ return next;
271
+ }
272
+ case "CAPABILITY_REVOKE": {
273
+ const target = grants.get(key(body.grantId));
274
+ if (target === undefined)
275
+ return grants;
276
+ const next = new Map(grants);
277
+ next.set(key(target.id), Object.freeze({ ...target, revokedBy: id }));
278
+ return next;
279
+ }
280
+ case "CAPABILITY_CLAIM": {
281
+ // §18.1: "A successful claim creates a new capability grant to the
282
+ // claimant [and] consumes one claim from the Invitation Grant." "The
283
+ // grant a claim creates is identified by the claim record's Control
284
+ // Record ID. Its subject is the claimant and its abilities are the
285
+ // claimed abilities; it has no parent grant and an empty delegable
286
+ // list, so revoking the Invitation Grant later does not revoke it."
287
+ const invitation = grants.get(key(body.invitationGrantId));
288
+ const next = new Map(grants);
289
+ if (invitation !== undefined)
290
+ next.set(key(invitation.id), Object.freeze({ ...invitation, claimsUsed: invitation.claimsUsed + 1n }));
291
+ next.set(key(id), Object.freeze({
292
+ id,
293
+ source: "claim",
294
+ issuer: record.payload.issuer,
295
+ subject: body.claimant.principalId,
296
+ abilities: body.abilities,
297
+ delegable: [],
298
+ parentGrantId: null,
299
+ claimLimit: null,
300
+ claimsUsed: 0n,
301
+ revokedBy: null,
302
+ }));
303
+ return next;
304
+ }
305
+ default:
306
+ return grants;
307
+ }
308
+ }
309
+ /**
310
+ * §25.2 Key Package authority at the package's referenced Control Head:
311
+ * the sender must hold key/distribute, and the recipient must hold
312
+ * data/read, "except for an Invitation Principal explicitly authorized by
313
+ * an active invite grant: a recipient that is the subject of an active
314
+ * grant that includes and still confers invite/claim". `epoch` is the
315
+ * package's Data Epoch; whether that epoch is recognized at the head is
316
+ * checked with the epoch rules (LFCP-023, LFCP-024).
317
+ */
318
+ export function canDistributeKey(state, sender, recipient, _epoch) {
319
+ if (!hasAbility(state, sender, ABILITY.KEY_DISTRIBUTE))
320
+ return deny("the sender does not hold key/distribute (§25.2)");
321
+ if (hasAbility(state, recipient, ABILITY.DATA_READ))
322
+ return ALLOW;
323
+ if (hasAbility(state, recipient, ABILITY.INVITE_CLAIM))
324
+ return ALLOW;
325
+ return deny("the recipient holds neither data/read nor an active invite grant (§25.2)");
326
+ }
327
+ /**
328
+ * The §23.3 verifier for an OWNER_TRANSFER_COMMIT, at the state before it.
329
+ * Verification only: there is no transfer UI or flow (deferred from MVP 0.1).
330
+ *
331
+ * §23.3 "A verifier MUST confirm":
332
+ * 1. the offer is signed by the current owner;
333
+ * 2. the offer references the current Control Head;
334
+ * 3. the offer names the accepting Principal;
335
+ * 4. the acceptance is signed by that Principal;
336
+ * 5. the commit itself is signed by that Principal (here: issued by it;
337
+ * the chain verifies the signature against the issuer);
338
+ * 6. the expected next Control Sequence matches the commit sequence.
339
+ * Also, from §23.1 and §23.2: the offer and accept are canonical signed
340
+ * objects of this Resource, and the accept names the exact offer ID (the
341
+ * §10.6 object ID of the offer bytes). §23.3: "A commit that fails any of
342
+ * these checks is rejected with AUTHORIZATION_FAILED, except that an offer
343
+ * or acceptance whose signature does not verify is rejected with
344
+ * INVALID_SIGNATURE" (the refusal's code).
345
+ */
346
+ export function verifyOwnerTransfer(record, state) {
347
+ const body = record.body;
348
+ if (body.type !== "OWNER_TRANSFER_COMMIT")
349
+ return { authorization: deny("not an ownership transfer commit") };
350
+ let offer;
351
+ let accept;
352
+ try {
353
+ offer = parseOwnerTransferOffer(body.offer);
354
+ accept = parseOwnerTransferAccept(body.accept);
355
+ }
356
+ catch {
357
+ return {
358
+ authorization: deny("the offer or accept is not a valid signed object (§23.1, §23.2)"),
359
+ };
360
+ }
361
+ const o = offer.payload;
362
+ const a = accept.payload;
363
+ if (!bytesEqual(o.resourceId, state.resourceId) || !bytesEqual(a.resourceId, state.resourceId))
364
+ return { authorization: deny("the offer or accept is for another Resource (§23.1, §23.2)") };
365
+ const offerSigned = verifySignedObject(offer.signed, state.owner);
366
+ if (!offerSigned.valid)
367
+ return {
368
+ authorization: badSignature(`the offer is not signed by the current owner (${offerSigned.reason}, §23.3 rule 1)`),
369
+ };
370
+ if (!bytesEqual(o.controlHead, state.head))
371
+ return {
372
+ authorization: deny("the offer does not reference the current Control Head (§23.3 rule 2)"),
373
+ };
374
+ if (!bytesEqual(a.newOwner, o.proposedOwner.principalId))
375
+ return {
376
+ authorization: deny("the accept is not by the Principal the offer names (§23.3 rule 3)"),
377
+ };
378
+ if (!bytesEqual(a.offerId, objectId(offer.signed.bytes)))
379
+ return { authorization: deny("the accept does not name this offer (§23.2)") };
380
+ const acceptSigned = verifySignedObject(accept.signed, o.proposedOwner);
381
+ if (!acceptSigned.valid)
382
+ return {
383
+ authorization: badSignature(`the accept is not signed by the new owner (${acceptSigned.reason}, §23.3 rule 4)`),
384
+ };
385
+ if (!bytesEqual(record.payload.issuer, o.proposedOwner.principalId))
386
+ return { authorization: deny("the commit is not issued by the new owner (§23.3 rule 5)") };
387
+ if (o.expectedControlSeq !== record.payload.controlSeq)
388
+ return {
389
+ authorization: deny("the offer's expected Control Sequence is not the commit's (§23.3 rule 6)"),
390
+ };
391
+ return { authorization: ALLOW, newOwner: o.proposedOwner };
392
+ }
393
+ /**
394
+ * The descriptor a transfer commit carries for its own issuer: the offer's
395
+ * proposed owner (self-certifying, §7), so a new owner never granted
396
+ * before can still be verified. Undefined for other records.
397
+ */
398
+ export function transferIssuerDescriptor(record) {
399
+ if (record.body.type !== "OWNER_TRANSFER_COMMIT")
400
+ return undefined;
401
+ try {
402
+ const proposed = parseOwnerTransferOffer(record.body.offer).payload.proposedOwner;
403
+ return bytesEqual(proposed.principalId, record.payload.issuer) ? proposed : undefined;
404
+ }
405
+ catch {
406
+ return undefined;
407
+ }
408
+ }
409
+ //# sourceMappingURL=capability.js.map
@@ -0,0 +1,23 @@
1
+ import { type CborMap, type CborValue } from "./value.js";
2
+ /**
3
+ * Decodes exactly one deterministic CBOR item (LFCP-WIRE-01 §5.2) and
4
+ * rejects anything else: indefinite lengths, non-shortest heads, unsorted
5
+ * or duplicate map keys, tags, floats and other simple values, invalid
6
+ * UTF-8, truncated input and trailing bytes.
7
+ */
8
+ export declare function decodeStrict(bytes: Uint8Array): CborValue;
9
+ /**
10
+ * The §5.2 receiver check: `bytes` decode strictly and re-encode to exactly
11
+ * the same bytes. Signature and object-ID checks still use the received
12
+ * bytes; this only decides whether they are the deterministic encoding.
13
+ */
14
+ /**
15
+ * Strict decode plus the §5.2 re-encode comparison (N7): the bytes must be
16
+ * well-formed (CBOR_* errors from `decodeStrict` otherwise) and exactly the
17
+ * deterministic encoding of their value (CBOR_NON_CANONICAL otherwise). The
18
+ * re-encoding is only compared, never returned.
19
+ */
20
+ export declare function decodeDeterministic(bytes: Uint8Array): CborValue;
21
+ export declare function isDeterministic(bytes: Uint8Array): boolean;
22
+ export type { CborMap };
23
+ //# sourceMappingURL=decode.d.ts.map
@@ -0,0 +1,163 @@
1
+ import { bytesEqual, LfcpError } from "@openlfcp/core";
2
+ import { compareEncodedKeys, encode } from "./encode.js";
3
+ import { strictUtf8Decoder } from "./text.js";
4
+ import { cborMap, MAX_DEPTH } from "./value.js";
5
+ const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
6
+ const asInteger = (v) => (v <= MAX_SAFE && v >= -MAX_SAFE ? Number(v) : v);
7
+ class Reader {
8
+ bytes;
9
+ pos = 0;
10
+ constructor(bytes) {
11
+ this.bytes = bytes;
12
+ }
13
+ need(n) {
14
+ if (BigInt(this.pos) + BigInt(n) > BigInt(this.bytes.length))
15
+ throw new LfcpError("CBOR_TRUNCATED", "input ends inside an item");
16
+ return Number(n);
17
+ }
18
+ byte() {
19
+ this.need(1);
20
+ return this.bytes[this.pos++];
21
+ }
22
+ take(n) {
23
+ this.need(n);
24
+ const out = this.bytes.slice(this.pos, this.pos + n);
25
+ this.pos += n;
26
+ return out;
27
+ }
28
+ /** Reads an argument and enforces the shortest form (§5.2 rule 1). */
29
+ argument(ai) {
30
+ if (ai < 24)
31
+ return BigInt(ai);
32
+ if (ai === 31)
33
+ throw new LfcpError("CBOR_INDEFINITE_LENGTH", "indefinite lengths are not allowed (§5.2 rule 2)");
34
+ if (ai > 27)
35
+ throw new LfcpError("CBOR_UNSUPPORTED_TYPE", "reserved additional information value");
36
+ const size = 1 << (ai - 24);
37
+ let v = 0n;
38
+ for (const b of this.take(size))
39
+ v = (v << 8n) | BigInt(b);
40
+ const floor = [24n, 0x100n, 0x10000n, 0x100000000n][ai - 24];
41
+ if (v < floor)
42
+ throw new LfcpError("CBOR_NON_CANONICAL", "integer or length not in its shortest form");
43
+ return v;
44
+ }
45
+ }
46
+ function readKey(r, depth) {
47
+ const v = readValue(r, depth);
48
+ if (typeof v === "number" ||
49
+ typeof v === "bigint" ||
50
+ typeof v === "string" ||
51
+ v instanceof Uint8Array)
52
+ return v;
53
+ throw new LfcpError("CBOR_UNSUPPORTED_TYPE", "map keys must be integers, text or byte strings");
54
+ }
55
+ function readValue(r, depth) {
56
+ if (depth > MAX_DEPTH)
57
+ throw new LfcpError("CBOR_TOO_DEEP", `nesting deeper than ${MAX_DEPTH}`);
58
+ const ib = r.byte();
59
+ const major = ib >> 5;
60
+ const ai = ib & 0x1f;
61
+ switch (major) {
62
+ case 0:
63
+ return asInteger(r.argument(ai));
64
+ case 1:
65
+ return asInteger(-1n - r.argument(ai));
66
+ case 2:
67
+ return r.take(r.need(r.argument(ai)));
68
+ case 3: {
69
+ const raw = r.take(r.need(r.argument(ai)));
70
+ try {
71
+ return strictUtf8Decoder.decode(raw);
72
+ }
73
+ catch {
74
+ throw new LfcpError("CBOR_INVALID_UTF8", "text string is not well-formed UTF-8");
75
+ }
76
+ }
77
+ case 4: {
78
+ const count = r.argument(ai);
79
+ r.need(count); // every item takes at least one byte
80
+ const items = [];
81
+ for (let i = 0n; i < count; i += 1n)
82
+ items.push(readValue(r, depth + 1));
83
+ return items;
84
+ }
85
+ case 5: {
86
+ const count = r.argument(ai);
87
+ r.need(count * 2n);
88
+ const entries = [];
89
+ const seen = new Set();
90
+ let previous = null;
91
+ for (let i = 0n; i < count; i += 1n) {
92
+ const start = r.pos;
93
+ const key = readKey(r, depth + 1);
94
+ const keyBytes = r.bytes.subarray(start, r.pos);
95
+ const id = Array.from(keyBytes, (b) => b.toString(16).padStart(2, "0")).join("");
96
+ if (seen.has(id))
97
+ throw new LfcpError("CBOR_DUPLICATE_KEY", "map contains a duplicate key");
98
+ if (previous && compareEncodedKeys(previous, keyBytes) > 0) {
99
+ throw new LfcpError("CBOR_NON_CANONICAL", "map keys are not in deterministic order (§5.2 rule 4)");
100
+ }
101
+ seen.add(id);
102
+ previous = keyBytes;
103
+ entries.push([key, readValue(r, depth + 1)]);
104
+ }
105
+ return cborMap(entries);
106
+ }
107
+ case 6:
108
+ throw new LfcpError("CBOR_UNSUPPORTED_TYPE", "CBOR tags are not used by LFCP (§5.2 rule 6)");
109
+ default:
110
+ if (ai === 20)
111
+ return false;
112
+ if (ai === 21)
113
+ return true;
114
+ if (ai === 22)
115
+ return null;
116
+ if (ai === 31)
117
+ throw new LfcpError("CBOR_INDEFINITE_LENGTH", "unexpected break code");
118
+ throw new LfcpError("CBOR_UNSUPPORTED_TYPE", "floats, undefined and other simple values are not used by LFCP");
119
+ }
120
+ }
121
+ /**
122
+ * Decodes exactly one deterministic CBOR item (LFCP-WIRE-01 §5.2) and
123
+ * rejects anything else: indefinite lengths, non-shortest heads, unsorted
124
+ * or duplicate map keys, tags, floats and other simple values, invalid
125
+ * UTF-8, truncated input and trailing bytes.
126
+ */
127
+ export function decodeStrict(bytes) {
128
+ const r = new Reader(bytes);
129
+ const value = readValue(r, 0);
130
+ if (r.pos !== bytes.length)
131
+ throw new LfcpError("CBOR_TRAILING_BYTES", "bytes remain after the top-level item");
132
+ return value;
133
+ }
134
+ /**
135
+ * The §5.2 receiver check: `bytes` decode strictly and re-encode to exactly
136
+ * the same bytes. Signature and object-ID checks still use the received
137
+ * bytes; this only decides whether they are the deterministic encoding.
138
+ */
139
+ /**
140
+ * Strict decode plus the §5.2 re-encode comparison (N7): the bytes must be
141
+ * well-formed (CBOR_* errors from `decodeStrict` otherwise) and exactly the
142
+ * deterministic encoding of their value (CBOR_NON_CANONICAL otherwise). The
143
+ * re-encoding is only compared, never returned.
144
+ */
145
+ export function decodeDeterministic(bytes) {
146
+ const value = decodeStrict(bytes);
147
+ if (!bytesEqual(encode(value), bytes))
148
+ throw new LfcpError("CBOR_NON_CANONICAL", "the bytes are not the deterministic encoding");
149
+ return value;
150
+ }
151
+ export function isDeterministic(bytes) {
152
+ let value;
153
+ try {
154
+ value = decodeStrict(bytes);
155
+ }
156
+ catch (e) {
157
+ if (e instanceof LfcpError)
158
+ return false;
159
+ throw e;
160
+ }
161
+ return bytesEqual(encode(value), bytes);
162
+ }
163
+ //# sourceMappingURL=decode.js.map
@@ -0,0 +1,10 @@
1
+ import { type CborValue } from "./value.js";
2
+ /** Byte order of encoded map keys (§5.2 rule 4): shorter encodings first, then bytewise. */
3
+ export declare function compareEncodedKeys(a: Uint8Array, b: Uint8Array): number;
4
+ /**
5
+ * Deterministic CBOR encoding (LFCP-WIRE-01 §5.2): shortest heads,
6
+ * definite lengths, map keys sorted by encoded length then bytewise,
7
+ * duplicate keys rejected, array order kept, no tags.
8
+ */
9
+ export declare function encode(value: CborValue): Uint8Array;
10
+ //# sourceMappingURL=encode.d.ts.map