@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,369 @@
1
+ import { bytesEqual, controlRecordId, dataEpoch, hash32, LfcpError, principalId, resourceId, } from "@openlfcp/core";
2
+ import { cborMap, decodeStrict, encode } from "./cbor/index.js";
3
+ import { parseSignedObject, signObject, verifySignedObject, } from "./cose.js";
4
+ import { checkReceivedUrl, checkWriterUrl, endpointFromCbor, endpointToCbor, } from "./endpoint.js";
5
+ import { Fields } from "./fields.js";
6
+ import { canonicalFrontierFromCbor, canonicalFrontierToCbor } from "./have.js";
7
+ import { CONTROL_TYPE, controlRecordPayloadFromCbor, parseControlRecord, } from "./objects.js";
8
+ import { principalDescriptorFromCbor, principalDescriptorToCbor, } from "./principal.js";
9
+ const CODE = CONTROL_TYPE;
10
+ /**
11
+ * §14 core types MVP 0.1 implements; the others decode but are not
12
+ * applied. OWNER_TRANSFER_COMMIT is verified and applied
13
+ * (MVP-0.1-PROTOCOL-SCOPE §4: "ownership transfer verification is in MVP
14
+ * 0.1"). A chain containing Coordinator Recovery or Resource Tombstone is
15
+ * refused (DV1, chain.ts); extensions are kept unapplied.
16
+ */
17
+ const MVP_SUPPORTED = new Set([
18
+ "GENESIS",
19
+ "CAPABILITY_GRANT",
20
+ "CAPABILITY_REVOKE",
21
+ "CAPABILITY_CLAIM",
22
+ "KEY_EPOCH",
23
+ "ROUTE_UPDATE",
24
+ "OWNER_TRANSFER_COMMIT",
25
+ ]);
26
+ /** Whether MVP 0.1 implements a body type (false for deferred core types and extensions). */
27
+ export const isMvpSupported = (type) => MVP_SUPPORTED.has(type);
28
+ /** The §14 code of a body. */
29
+ export const controlTypeOf = (body) => body.type === "EXTENSION" ? body.code : CODE[body.type];
30
+ const MAX_TEXT_BYTES = 256;
31
+ const TEXT = new globalThis.TextEncoder();
32
+ // ---------------------------------------------------------------------------
33
+ // Decoding (receivers: structure only)
34
+ const id32 = (f, key) => controlRecordId(f.bytes(key, 32));
35
+ function endpoints(f, key) {
36
+ const list = f.array(key);
37
+ if (list.length === 0)
38
+ f.fail(key, "must list at least one endpoint");
39
+ const decoded = list.map(endpointFromCbor);
40
+ for (const e of decoded)
41
+ checkReceivedUrl(e.url);
42
+ return Object.freeze(decoded);
43
+ }
44
+ /** A Control Coordinator URL: text with a ws or wss scheme (§16). */
45
+ function coordinatorUrl(f, key) {
46
+ const url = f.text(key);
47
+ checkReceivedUrl(url);
48
+ return url;
49
+ }
50
+ /**
51
+ * The Key Epoch final frontier. §19: "Field 2 is a canonical frontier
52
+ * (Sections 28.1 and 28.2) [...] A Key Epoch Record whose final frontier is
53
+ * not canonical MUST be rejected with MALFORMED_MESSAGE."
54
+ */
55
+ function frontierList(f, key) {
56
+ return canonicalFrontierFromCbor(f.array(key));
57
+ }
58
+ /**
59
+ * An ability list (§17.1 codes). §17.1: "An ability list [...] MUST NOT
60
+ * repeat a code; a record whose list repeats a code is rejected with
61
+ * MALFORMED_MESSAGE" (INVALID_STRUCTURE here). "A code that is not in the
62
+ * table above is kept as received and confers nothing" (capability.ts).
63
+ */
64
+ function abilityList(f, key, nonEmpty) {
65
+ const list = f.uintArray(key, nonEmpty);
66
+ if (new Set(list).size !== list.length)
67
+ f.fail(key, "lists an ability twice");
68
+ return list;
69
+ }
70
+ /**
71
+ * Decodes the body of a Control Record of §14 type `type`. Core types 0-8
72
+ * get their closed-map structure; 9-31 are UNSUPPORTED_VALUE (§14:
73
+ * INVALID_CONTROL_CHAIN on the wire); 32 and up are kept as opaque
74
+ * EXTENSION bodies.
75
+ */
76
+ export function controlBodyFromCbor(type, value) {
77
+ switch (type) {
78
+ case CONTROL_TYPE.GENESIS: {
79
+ const f = new Fields(value, "genesis-body", [0, 1, 2, 3, 4]);
80
+ return Object.freeze({
81
+ type: "GENESIS",
82
+ dataProfile: f.text(0),
83
+ owner: principalDescriptorFromCbor(f.any(1)),
84
+ dekCommitment: hash32(f.bytes(2, 32)),
85
+ endpoints: endpoints(f, 3),
86
+ coordinatorUrl: coordinatorUrl(f, 4),
87
+ });
88
+ }
89
+ case CONTROL_TYPE.CAPABILITY_GRANT: {
90
+ const f = new Fields(value, "capability-grant-body", [0, 1, 2], [3, 4]);
91
+ return Object.freeze({
92
+ type: "CAPABILITY_GRANT",
93
+ subject: principalDescriptorFromCbor(f.any(0)),
94
+ abilities: abilityList(f, 1, true),
95
+ delegable: abilityList(f, 2, false),
96
+ ...(f.has(3) ? { parentGrantId: id32(f, 3) } : {}),
97
+ ...(f.has(4) ? { claimLimit: f.uint(4) } : {}),
98
+ });
99
+ }
100
+ case CONTROL_TYPE.CAPABILITY_REVOKE: {
101
+ const f = new Fields(value, "capability-revoke-body", [0]);
102
+ return Object.freeze({ type: "CAPABILITY_REVOKE", grantId: id32(f, 0) });
103
+ }
104
+ case CONTROL_TYPE.CAPABILITY_CLAIM: {
105
+ const f = new Fields(value, "capability-claim-body", [0, 1, 2]);
106
+ return Object.freeze({
107
+ type: "CAPABILITY_CLAIM",
108
+ invitationGrantId: id32(f, 0),
109
+ claimant: principalDescriptorFromCbor(f.any(1)),
110
+ abilities: abilityList(f, 2, true),
111
+ });
112
+ }
113
+ case CONTROL_TYPE.KEY_EPOCH: {
114
+ const f = new Fields(value, "key-epoch-body", [0, 1, 2, 3]);
115
+ return Object.freeze({
116
+ type: "KEY_EPOCH",
117
+ epoch: dataEpoch(f.uint(0)),
118
+ dekCommitment: hash32(f.bytes(1, 32)),
119
+ finalFrontier: frontierList(f, 2),
120
+ reason: f.uint(3),
121
+ });
122
+ }
123
+ case CONTROL_TYPE.ROUTE_UPDATE: {
124
+ const f = new Fields(value, "route-update-body", [0, 1, 2]);
125
+ return Object.freeze({
126
+ type: "ROUTE_UPDATE",
127
+ routeVersion: f.uint(0),
128
+ endpoints: endpoints(f, 1),
129
+ coordinatorUrl: coordinatorUrl(f, 2),
130
+ });
131
+ }
132
+ case CONTROL_TYPE.OWNER_TRANSFER_COMMIT: {
133
+ const f = new Fields(value, "owner-transfer-commit-body", [0, 1]);
134
+ return Object.freeze({
135
+ type: "OWNER_TRANSFER_COMMIT",
136
+ offer: f.bytes(0),
137
+ accept: f.bytes(1),
138
+ });
139
+ }
140
+ case CONTROL_TYPE.COORDINATOR_RECOVERY: {
141
+ const f = new Fields(value, "coordinator-recovery-body", [0, 1, 2, 3]);
142
+ return Object.freeze({
143
+ type: "COORDINATOR_RECOVERY",
144
+ routeVersion: f.uint(0),
145
+ endpoints: endpoints(f, 1),
146
+ coordinatorUrl: coordinatorUrl(f, 2),
147
+ reason: f.text(3),
148
+ });
149
+ }
150
+ case CONTROL_TYPE.RESOURCE_TOMBSTONE: {
151
+ const f = new Fields(value, "resource-tombstone-body", [0], [1]);
152
+ return Object.freeze({
153
+ type: "RESOURCE_TOMBSTONE",
154
+ reason: f.uint(0),
155
+ ...(f.has(1) ? { note: f.text(1) } : {}),
156
+ });
157
+ }
158
+ default:
159
+ if (type >= 32n)
160
+ return Object.freeze({ type: "EXTENSION", code: type, body: value });
161
+ throw new LfcpError("UNSUPPORTED_VALUE", `Control Record type ${type} is reserved for LFCP core (§14)`);
162
+ }
163
+ }
164
+ /**
165
+ * Parses a received Control Record: canonical COSE and the generic
166
+ * envelope (parseControlRecord), then the typed body. The record ID is
167
+ * `signed.id`, SHA-256 of the exact received bytes. The signature is not
168
+ * checked here; see controlRecordSigner and verifyGenesis.
169
+ */
170
+ export function decodeControlRecord(bytes) {
171
+ const parsed = parseControlRecord(bytes);
172
+ const body = controlBodyFromCbor(parsed.payload.controlType, parsed.payload.body);
173
+ return Object.freeze({ ...parsed, body, mvpSupported: isMvpSupported(body.type) });
174
+ }
175
+ /**
176
+ * The Principal whose key must have signed a Control Record: its issuer
177
+ * (payload field 4). §13: "the protected-header kid MUST equal field 4,
178
+ * and a record whose kid is any other Principal is rejected with
179
+ * INVALID_SIGNATURE." Resolving the issuer to a descriptor (and checking
180
+ * its authority) is LFCP-020/021.
181
+ */
182
+ export const controlRecordSigner = (record) => record.payload.issuer;
183
+ /**
184
+ * Verifies a Genesis record against the owner in its own body (§15:
185
+ * "MUST be signed by the owner Principal contained in the body"): the
186
+ * issuer and the kid must be the owner and the signature must verify with
187
+ * the owner's key. A failure surfaces as INVALID_SIGNATURE.
188
+ */
189
+ export function verifyGenesis(record) {
190
+ if (record.body.type !== "GENESIS")
191
+ return { valid: false, reason: "NOT_GENESIS" };
192
+ if (!bytesEqual(record.payload.issuer, record.body.owner.principalId))
193
+ return { valid: false, reason: "ISSUER_NOT_OWNER" };
194
+ return verifySignedObject(record.signed, record.body.owner);
195
+ }
196
+ // ---------------------------------------------------------------------------
197
+ // Encoding (writers)
198
+ function refuse(why) {
199
+ throw new LfcpError("INVALID_STRUCTURE", `refusing to write a Control Record: ${why}`);
200
+ }
201
+ function text256(what, s) {
202
+ if (typeof s !== "string")
203
+ refuse(`${what} must be text`);
204
+ if (TEXT.encode(s).length > MAX_TEXT_BYTES)
205
+ refuse(`${what} must be at most ${MAX_TEXT_BYTES} UTF-8 bytes`);
206
+ return s;
207
+ }
208
+ function writerEndpoints(list) {
209
+ if (list.length === 0)
210
+ refuse("at least one endpoint is required");
211
+ return list.map(endpointToCbor);
212
+ }
213
+ function writerUrl(url) {
214
+ checkWriterUrl(url);
215
+ return url;
216
+ }
217
+ function abilities(list, nonEmpty) {
218
+ if (nonEmpty && list.length === 0)
219
+ refuse("the ability list must not be empty");
220
+ // §17.1: an ability list MUST NOT repeat a code.
221
+ if (new Set(list).size !== list.length)
222
+ refuse("an ability is listed twice");
223
+ return [...list];
224
+ }
225
+ /** The CBOR of a typed body, applying the writer rules. */
226
+ export function controlBodyToCbor(body) {
227
+ switch (body.type) {
228
+ case "GENESIS":
229
+ return cborMap([
230
+ [0, body.dataProfile],
231
+ [1, principalDescriptorToCbor(body.owner)],
232
+ [2, hash32(body.dekCommitment)],
233
+ [3, writerEndpoints(body.endpoints)],
234
+ [4, writerUrl(body.coordinatorUrl)],
235
+ ]);
236
+ case "CAPABILITY_GRANT":
237
+ return cborMap([
238
+ [0, principalDescriptorToCbor(body.subject)],
239
+ [1, abilities(body.abilities, true)],
240
+ [2, abilities(body.delegable, false)],
241
+ ...(body.parentGrantId !== undefined
242
+ ? [[3, controlRecordId(body.parentGrantId)]]
243
+ : []),
244
+ ...(body.claimLimit !== undefined ? [[4, body.claimLimit]] : []),
245
+ ]);
246
+ case "CAPABILITY_REVOKE":
247
+ return cborMap([[0, controlRecordId(body.grantId)]]);
248
+ case "CAPABILITY_CLAIM":
249
+ return cborMap([
250
+ [0, controlRecordId(body.invitationGrantId)],
251
+ [1, principalDescriptorToCbor(body.claimant)],
252
+ [2, abilities(body.abilities, true)],
253
+ ]);
254
+ case "KEY_EPOCH":
255
+ return cborMap([
256
+ [0, dataEpoch(body.epoch)],
257
+ [1, hash32(body.dekCommitment)],
258
+ [2, canonicalFrontierToCbor(body.finalFrontier)],
259
+ [3, body.reason],
260
+ ]);
261
+ case "ROUTE_UPDATE":
262
+ return cborMap([
263
+ [0, body.routeVersion],
264
+ [1, writerEndpoints(body.endpoints)],
265
+ [2, writerUrl(body.coordinatorUrl)],
266
+ ]);
267
+ case "OWNER_TRANSFER_COMMIT":
268
+ return cborMap([
269
+ [0, Uint8Array.from(body.offer)],
270
+ [1, Uint8Array.from(body.accept)],
271
+ ]);
272
+ case "COORDINATOR_RECOVERY":
273
+ return cborMap([
274
+ [0, body.routeVersion],
275
+ [1, writerEndpoints(body.endpoints)],
276
+ [2, writerUrl(body.coordinatorUrl)],
277
+ [3, text256("the recovery reason", body.reason)],
278
+ ]);
279
+ case "RESOURCE_TOMBSTONE":
280
+ return cborMap([
281
+ [0, body.reason],
282
+ ...(body.note !== undefined
283
+ ? [[1, text256("the tombstone note", body.note)]]
284
+ : []),
285
+ ]);
286
+ case "EXTENSION":
287
+ if (body.code < 32n)
288
+ refuse("extension types start at 32 (§14)");
289
+ return body.body;
290
+ }
291
+ }
292
+ /**
293
+ * Deterministic CBOR of control-record-payload (§13) for `issuer`. Writer
294
+ * rules: Genesis is at control_seq 0 with a null link and is issued by the
295
+ * owner in its body; every other record has control_seq >= 1 and a link
296
+ * (§13.1). The result is checked with the receiver's decoder too.
297
+ */
298
+ export function encodeControlRecordPayload(header, issuer, body) {
299
+ const genesis = body.type === "GENESIS";
300
+ if (genesis) {
301
+ if (header.controlSeq !== 0n || header.prevControlId !== null)
302
+ refuse("Genesis must have control_seq 0 and a null previous record (§13.1)");
303
+ if (!bytesEqual(issuer, body.owner.principalId))
304
+ refuse("Genesis must be issued by the owner in its body (§15)");
305
+ }
306
+ else if (header.controlSeq < 1n || header.prevControlId === null) {
307
+ refuse("a non-Genesis record needs control_seq >= 1 and the previous record ID (§13.1)");
308
+ }
309
+ const payload = cborMap([
310
+ [0, header.resourceId],
311
+ [1, header.controlSeq],
312
+ [2, header.prevControlId],
313
+ [3, controlTypeOf(body)],
314
+ [4, issuer],
315
+ [5, controlBodyToCbor(body)],
316
+ ]);
317
+ const bytes = encode(payload);
318
+ const check = controlRecordPayloadFromCbor(decodeStrict(bytes));
319
+ controlBodyFromCbor(check.controlType, check.body);
320
+ return bytes;
321
+ }
322
+ /**
323
+ * Signs a Control Record: typed payload -> deterministic CBOR -> canonical
324
+ * untagged COSE_Sign1 (signObject) -> exact bytes -> SHA-256 = record ID.
325
+ * The issuer is the signer's Principal (§13: kid = issuer, see
326
+ * controlRecordSigner).
327
+ */
328
+ export function signControlRecord(header, body, signer) {
329
+ const payload = encodeControlRecordPayload(header, signer.descriptor.principalId, body);
330
+ const signed = signObject(payload, signer);
331
+ return Object.freeze({ ...signed, recordId: controlRecordId(signed.id) });
332
+ }
333
+ /** owner-transfer-offer-payload (§23.1). */
334
+ export function ownerTransferOfferPayloadFromCbor(value) {
335
+ const f = new Fields(value, "owner-transfer-offer-payload", [0, 1, 2, 3, 4]);
336
+ return Object.freeze({
337
+ resourceId: resourceId(f.bytes(0, 32)),
338
+ controlHead: id32(f, 1),
339
+ expectedControlSeq: f.uint(2),
340
+ proposedOwner: principalDescriptorFromCbor(f.any(3)),
341
+ nonce: f.bytes(4, 16),
342
+ });
343
+ }
344
+ /** owner-transfer-accept-payload (§23.2). */
345
+ export function ownerTransferAcceptPayloadFromCbor(value) {
346
+ const f = new Fields(value, "owner-transfer-accept-payload", [0, 1, 2]);
347
+ return Object.freeze({
348
+ resourceId: resourceId(f.bytes(0, 32)),
349
+ offerId: hash32(f.bytes(1, 32)),
350
+ newOwner: principalId(f.bytes(2, 32)),
351
+ });
352
+ }
353
+ /** Parses a received transfer offer (canonical COSE, typed payload). Deferred from MVP 0.1: never applied. */
354
+ export function parseOwnerTransferOffer(bytes) {
355
+ const signed = parseSignedObject(bytes);
356
+ return Object.freeze({
357
+ signed,
358
+ payload: ownerTransferOfferPayloadFromCbor(decodeStrict(signed.payloadBytes)),
359
+ });
360
+ }
361
+ /** Parses a received transfer accept (canonical COSE, typed payload). Deferred from MVP 0.1: never applied. */
362
+ export function parseOwnerTransferAccept(bytes) {
363
+ const signed = parseSignedObject(bytes);
364
+ return Object.freeze({
365
+ signed,
366
+ payload: ownerTransferAcceptPayloadFromCbor(decodeStrict(signed.payloadBytes)),
367
+ });
368
+ }
369
+ //# sourceMappingURL=control.js.map
package/dist/cose.d.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { type Hash32, type PrincipalId } from "@openlfcp/core";
2
+ import { type SigningKeyPair } from "@openlfcp/crypto";
3
+ import { type PrincipalDescriptor } from "./principal.js";
4
+ /**
5
+ * Canonical LFCP COSE_Sign1 (LFCP-WIRE-01 §10).
6
+ *
7
+ * Every persistent LFCP signed object is the untagged array
8
+ * [protected, {}, payload, signature]:
9
+ *
10
+ * - protected: a bstr holding exactly the deterministic map {1: -8, 4: kid},
11
+ * where kid is the signer's 32-byte Principal ID (§10.1);
12
+ * - unprotected: the empty map (§10.2);
13
+ * - payload: a present bstr with the deterministic CBOR of the LFCP structure (§10.3);
14
+ * - signature: 64-byte Ed25519 over Sig_structure ["Signature1", protected, h'', payload] (§10.4, §10.5).
15
+ *
16
+ * The object ID is SHA-256 of the exact object bytes (§10.6).
17
+ *
18
+ * Signing (`signObject`) and checking received bytes (`parseSignedObject`,
19
+ * `verifySignedObject`) are separate. A parsed object keeps the exact
20
+ * received bytes, and nothing here ever re-encodes them into a "fixed" object.
21
+ *
22
+ * Errors and their wire mapping (ADR 0001):
23
+ * - COSE_MALFORMED and CBOR_* (tags, shape, non-deterministic bytes) → MALFORMED_MESSAGE;
24
+ * - a failed `verifySignedObject` (wrong kid or bad signature) → INVALID_SIGNATURE.
25
+ */
26
+ /** COSE algorithm identifier for EdDSA (RFC 9053); the only one LFCP-WIRE-01 allows. */
27
+ export declare const COSE_ALG_EDDSA = -8;
28
+ /** A signer: the Ed25519 key pair and the Principal Descriptor it belongs to. */
29
+ export interface Signer {
30
+ readonly key: SigningKeyPair;
31
+ readonly descriptor: PrincipalDescriptor;
32
+ }
33
+ /** A newly signed object: its exact bytes and its object ID. */
34
+ export interface SignedBytes {
35
+ readonly bytes: Uint8Array;
36
+ readonly id: Hash32;
37
+ }
38
+ /** A received signed object. `bytes` are exactly the received bytes; `id` is their SHA-256. */
39
+ export interface SignedObject {
40
+ readonly bytes: Uint8Array;
41
+ readonly protectedBytes: Uint8Array;
42
+ readonly payloadBytes: Uint8Array;
43
+ readonly signature: Uint8Array;
44
+ readonly kid: PrincipalId;
45
+ readonly alg: typeof COSE_ALG_EDDSA;
46
+ readonly id: Hash32;
47
+ }
48
+ export type VerifyResult = {
49
+ readonly valid: true;
50
+ } | {
51
+ readonly valid: false;
52
+ readonly reason: "KID_MISMATCH" | "BAD_SIGNATURE";
53
+ };
54
+ /** Deterministic CBOR of ["Signature1", protected, h'', payload] (§10.5), from the exact header and payload bytes. */
55
+ export declare function sigStructureBytes(protectedBytes: Uint8Array, payloadBytes: Uint8Array): Uint8Array;
56
+ /** Object ID: SHA-256 of the exact signed-object bytes (§10.6). */
57
+ export declare function objectId(signedObjectBytes: Uint8Array): Hash32;
58
+ /**
59
+ * Signs deterministic payload bytes into a new canonical LFCP COSE_Sign1.
60
+ * The payload is used exactly as given (never decoded and re-encoded); bytes
61
+ * that are not deterministic CBOR are refused.
62
+ */
63
+ export declare function signObject(payloadBytes: Uint8Array, signer: Signer): SignedBytes;
64
+ /**
65
+ * Parses received signed-object bytes without verifying the signature. It
66
+ * checks the canonical shape and that the object, its protected header and
67
+ * its payload are each deterministic CBOR (§5.2), and keeps every byte
68
+ * string exactly as received.
69
+ */
70
+ export declare function parseSignedObject(bytes: Uint8Array): SignedObject;
71
+ /**
72
+ * Verifies a parsed object against the Principal expected to have signed
73
+ * it. The kid must name that Principal, and the Ed25519 signature must
74
+ * verify over the Sig_structure of the exact received header and payload.
75
+ *
76
+ * Which Principal is expected (the Data Unit actor, the owner, the
77
+ * coordinator, ...) is decided by the object-specific rules in higher
78
+ * layers, not here. A failure surfaces on the wire as INVALID_SIGNATURE.
79
+ */
80
+ export declare function verifySignedObject(object: SignedObject, expectedSigner: PrincipalDescriptor): VerifyResult;
81
+ //# sourceMappingURL=cose.d.ts.map
package/dist/cose.js ADDED
@@ -0,0 +1,133 @@
1
+ import { bytesEqual, hash32, LfcpError, principalId, } from "@openlfcp/core";
2
+ import { sha256, verifyEd25519 } from "@openlfcp/crypto";
3
+ import { cborMap, decodeDeterministic, encode, isCborMap, isDeterministic, } from "./cbor/index.js";
4
+ import { derivePrincipalId } from "./principal.js";
5
+ /**
6
+ * Canonical LFCP COSE_Sign1 (LFCP-WIRE-01 §10).
7
+ *
8
+ * Every persistent LFCP signed object is the untagged array
9
+ * [protected, {}, payload, signature]:
10
+ *
11
+ * - protected: a bstr holding exactly the deterministic map {1: -8, 4: kid},
12
+ * where kid is the signer's 32-byte Principal ID (§10.1);
13
+ * - unprotected: the empty map (§10.2);
14
+ * - payload: a present bstr with the deterministic CBOR of the LFCP structure (§10.3);
15
+ * - signature: 64-byte Ed25519 over Sig_structure ["Signature1", protected, h'', payload] (§10.4, §10.5).
16
+ *
17
+ * The object ID is SHA-256 of the exact object bytes (§10.6).
18
+ *
19
+ * Signing (`signObject`) and checking received bytes (`parseSignedObject`,
20
+ * `verifySignedObject`) are separate. A parsed object keeps the exact
21
+ * received bytes, and nothing here ever re-encodes them into a "fixed" object.
22
+ *
23
+ * Errors and their wire mapping (ADR 0001):
24
+ * - COSE_MALFORMED and CBOR_* (tags, shape, non-deterministic bytes) → MALFORMED_MESSAGE;
25
+ * - a failed `verifySignedObject` (wrong kid or bad signature) → INVALID_SIGNATURE.
26
+ */
27
+ /** COSE algorithm identifier for EdDSA (RFC 9053); the only one LFCP-WIRE-01 allows. */
28
+ export const COSE_ALG_EDDSA = -8;
29
+ const HEADER_ALG = 1;
30
+ const HEADER_KID = 4;
31
+ const SIGNATURE_LENGTH = 64;
32
+ const EMPTY = new Uint8Array(0);
33
+ /** Deterministic CBOR of ["Signature1", protected, h'', payload] (§10.5), from the exact header and payload bytes. */
34
+ export function sigStructureBytes(protectedBytes, payloadBytes) {
35
+ return encode(["Signature1", protectedBytes, EMPTY, payloadBytes]);
36
+ }
37
+ /** Object ID: SHA-256 of the exact signed-object bytes (§10.6). */
38
+ export function objectId(signedObjectBytes) {
39
+ return hash32(sha256(signedObjectBytes));
40
+ }
41
+ function protectedHeaderBytes(kid) {
42
+ return encode(cborMap([
43
+ [HEADER_ALG, COSE_ALG_EDDSA],
44
+ [HEADER_KID, kid],
45
+ ]));
46
+ }
47
+ /**
48
+ * Signs deterministic payload bytes into a new canonical LFCP COSE_Sign1.
49
+ * The payload is used exactly as given (never decoded and re-encoded); bytes
50
+ * that are not deterministic CBOR are refused.
51
+ */
52
+ export function signObject(payloadBytes, signer) {
53
+ const { key, descriptor } = signer;
54
+ if (!bytesEqual(key.publicKey, descriptor.ed25519PublicKey)) {
55
+ throw new LfcpError("COSE_SIGNER_MISMATCH", "the signing key does not match the descriptor's Ed25519 public key");
56
+ }
57
+ if (!bytesEqual(derivePrincipalId(descriptor.ed25519PublicKey, descriptor.x25519PublicKey), descriptor.principalId)) {
58
+ throw new LfcpError("PRINCIPAL_ID_MISMATCH", "the descriptor's Principal ID does not match its public keys");
59
+ }
60
+ if (!(payloadBytes instanceof Uint8Array) || !isDeterministic(payloadBytes)) {
61
+ throw new LfcpError("CBOR_NON_CANONICAL", "the payload is not deterministic CBOR (§10.3)");
62
+ }
63
+ const protectedBytes = protectedHeaderBytes(descriptor.principalId);
64
+ const signature = key.sign(sigStructureBytes(protectedBytes, payloadBytes));
65
+ const bytes = encode([protectedBytes, cborMap([]), payloadBytes, signature]);
66
+ return Object.freeze({ bytes, id: objectId(bytes) });
67
+ }
68
+ function malformed(why) {
69
+ throw new LfcpError("COSE_MALFORMED", `not a canonical LFCP COSE_Sign1: ${why}`);
70
+ }
71
+ /**
72
+ * Parses received signed-object bytes without verifying the signature. It
73
+ * checks the canonical shape and that the object, its protected header and
74
+ * its payload are each deterministic CBOR (§5.2), and keeps every byte
75
+ * string exactly as received.
76
+ */
77
+ export function parseSignedObject(bytes) {
78
+ if (bytes.length > 0 && bytes[0] >> 5 === 6)
79
+ malformed("tagged objects are not allowed (§10)");
80
+ const value = decodeDeterministic(bytes);
81
+ if (!Array.isArray(value) || value.length !== 4)
82
+ malformed("not a four-element array");
83
+ const [protectedBytes, unprotected, payloadBytes, signature] = value;
84
+ if (!(protectedBytes instanceof Uint8Array))
85
+ malformed("protected header is not a byte string");
86
+ const header = decodeDeterministic(protectedBytes);
87
+ if (!isCborMap(header) || header.entries.length !== 2)
88
+ malformed("protected header must be exactly {1: -8, 4: kid}");
89
+ const fields = new Map(header.entries);
90
+ if (fields.get(HEADER_ALG) !== COSE_ALG_EDDSA)
91
+ malformed("alg must be -8 (EdDSA)");
92
+ const kid = fields.get(HEADER_KID);
93
+ if (!(kid instanceof Uint8Array) || kid.length !== 32)
94
+ malformed("kid must be a 32-byte Principal ID");
95
+ if (!isCborMap(unprotected) || unprotected.entries.length !== 0)
96
+ malformed("unprotected header must be the empty map");
97
+ if (!(payloadBytes instanceof Uint8Array))
98
+ malformed("payload must be a present byte string (no detached payload)");
99
+ decodeDeterministic(payloadBytes);
100
+ if (!(signature instanceof Uint8Array) || signature.length !== SIGNATURE_LENGTH) {
101
+ malformed(`signature must be ${SIGNATURE_LENGTH} bytes`);
102
+ }
103
+ const exact = Uint8Array.from(bytes);
104
+ return Object.freeze({
105
+ bytes: exact,
106
+ protectedBytes,
107
+ payloadBytes,
108
+ signature,
109
+ kid: principalId(kid),
110
+ alg: COSE_ALG_EDDSA,
111
+ id: objectId(exact),
112
+ });
113
+ }
114
+ /**
115
+ * Verifies a parsed object against the Principal expected to have signed
116
+ * it. The kid must name that Principal, and the Ed25519 signature must
117
+ * verify over the Sig_structure of the exact received header and payload.
118
+ *
119
+ * Which Principal is expected (the Data Unit actor, the owner, the
120
+ * coordinator, ...) is decided by the object-specific rules in higher
121
+ * layers, not here. A failure surfaces on the wire as INVALID_SIGNATURE.
122
+ */
123
+ export function verifySignedObject(object, expectedSigner) {
124
+ const expectedId = derivePrincipalId(expectedSigner.ed25519PublicKey, expectedSigner.x25519PublicKey);
125
+ if (!bytesEqual(object.kid, expectedSigner.principalId) || !bytesEqual(object.kid, expectedId)) {
126
+ return { valid: false, reason: "KID_MISMATCH" };
127
+ }
128
+ const message = sigStructureBytes(object.protectedBytes, object.payloadBytes);
129
+ return verifyEd25519(expectedSigner.ed25519PublicKey, message, object.signature)
130
+ ? { valid: true }
131
+ : { valid: false, reason: "BAD_SIGNATURE" };
132
+ }
133
+ //# sourceMappingURL=cose.js.map