@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.
- package/LICENSE +201 -0
- package/README.md +27 -0
- package/dist/capability.d.ts +143 -0
- package/dist/capability.js +409 -0
- package/dist/cbor/decode.d.ts +23 -0
- package/dist/cbor/decode.js +163 -0
- package/dist/cbor/encode.d.ts +10 -0
- package/dist/cbor/encode.js +149 -0
- package/dist/cbor/index.d.ts +10 -0
- package/dist/cbor/index.js +10 -0
- package/dist/cbor/text.d.ts +11 -0
- package/dist/cbor/text.js +5 -0
- package/dist/cbor/value.d.ts +30 -0
- package/dist/cbor/value.js +23 -0
- package/dist/chain.d.ts +140 -0
- package/dist/chain.js +339 -0
- package/dist/control-sync.d.ts +69 -0
- package/dist/control-sync.js +44 -0
- package/dist/control.d.ts +173 -0
- package/dist/control.js +369 -0
- package/dist/cose.d.ts +81 -0
- package/dist/cose.js +133 -0
- package/dist/data-unit.d.ts +204 -0
- package/dist/data-unit.js +314 -0
- package/dist/endpoint.d.ts +51 -0
- package/dist/endpoint.js +116 -0
- package/dist/epoch.d.ts +109 -0
- package/dist/epoch.js +128 -0
- package/dist/fields.d.ts +18 -0
- package/dist/fields.js +78 -0
- package/dist/handshake.d.ts +175 -0
- package/dist/handshake.js +297 -0
- package/dist/have.d.ts +101 -0
- package/dist/have.js +268 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +21 -0
- package/dist/invite.d.ts +80 -0
- package/dist/invite.js +247 -0
- package/dist/key-package.d.ts +92 -0
- package/dist/key-package.js +132 -0
- package/dist/message.d.ts +367 -0
- package/dist/message.js +690 -0
- package/dist/objects.d.ts +154 -0
- package/dist/objects.js +156 -0
- package/dist/principal.d.ts +43 -0
- package/dist/principal.js +81 -0
- package/dist/session-state.d.ts +47 -0
- package/dist/session-state.js +49 -0
- package/dist/snapshot.d.ts +86 -0
- package/dist/snapshot.js +189 -0
- package/dist/transition.d.ts +97 -0
- package/dist/transition.js +130 -0
- package/package.json +53 -0
package/dist/control.js
ADDED
|
@@ -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
|