@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/chain.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { controlRecordId, dataEpoch, LfcpError, toHex, } from "@openlfcp/core";
|
|
2
|
-
import { applyCapabilities, authorizeControlRecord, transferIssuerDescriptor, } from "./capability.js";
|
|
3
|
-
import { controlRecordSigner, decodeControlRecord, verifyGenesis, } from "./control.js";
|
|
1
|
+
import { bytesEqual, controlRecordId, dataEpoch, LfcpError, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { applyCapabilities, authorizeControlRecord, transferIssuerDescriptor, transferNewOwner, } from "./capability.js";
|
|
3
|
+
import { controlRecordSigner, decodeControlRecord, parseOwnerTransferAccept, parseOwnerTransferOffer, verifyGenesis, } from "./control.js";
|
|
4
4
|
import { objectId, verifySignedObject } from "./cose.js";
|
|
5
|
+
import { verifyChainSignatures } from "./device.js";
|
|
6
|
+
import { bindChain, EMPTY_DEVICE_STATE, grantInScope, nonDevice, offerSigner, recordRevocation, } from "./device-state.js";
|
|
7
|
+
import { PersistentMap, put } from "./persistent-map.js";
|
|
5
8
|
const WIRE = {
|
|
6
9
|
MALFORMED: "MALFORMED_MESSAGE",
|
|
7
10
|
// §14: "Unknown core Control Record types MUST cause validation failure, with INVALID_CONTROL_CHAIN."
|
|
@@ -103,10 +106,21 @@ function genesisState(record) {
|
|
|
103
106
|
}),
|
|
104
107
|
],
|
|
105
108
|
]),
|
|
106
|
-
grants:
|
|
107
|
-
|
|
109
|
+
grants: PersistentMap.empty(),
|
|
110
|
+
...genesisDevices(body),
|
|
108
111
|
});
|
|
109
112
|
}
|
|
113
|
+
/** §15: the owner is `non-device`; with field 5 every chain device is bound and the leaf registered. */
|
|
114
|
+
function genesisDevices(body) {
|
|
115
|
+
const owner = {
|
|
116
|
+
...EMPTY_DEVICE_STATE,
|
|
117
|
+
roles: nonDevice(EMPTY_DEVICE_STATE.roles, body.owner.principalId),
|
|
118
|
+
principals: PersistentMap.from([[toHex(body.owner.principalId), body.owner]]),
|
|
119
|
+
};
|
|
120
|
+
if (body.deviceChain === undefined)
|
|
121
|
+
return owner;
|
|
122
|
+
return { ...owner, ...bindChain(owner, body.deviceChain, true, false) };
|
|
123
|
+
}
|
|
110
124
|
/**
|
|
111
125
|
* The state after a validated record. Applied records (mvpSupported)
|
|
112
126
|
* change derived state: descriptors (LFCP-020), grants, revocations, claims
|
|
@@ -117,17 +131,36 @@ function applyRecord(state, record) {
|
|
|
117
131
|
const next = { head: controlRecordId(record.signed.id), seq: record.payload.controlSeq };
|
|
118
132
|
if (!record.mvpSupported)
|
|
119
133
|
return Object.freeze({ ...state, ...next });
|
|
120
|
-
const principals = new Map(state.principals);
|
|
121
134
|
const body = record.body;
|
|
135
|
+
// §79: roles on acceptance; device bindings and revocations (§17.4, §17.5).
|
|
136
|
+
// Maps are persistent: a record copies only the entries it changes, so
|
|
137
|
+
// the states kept for every head share the rest (review of #8, R8-1).
|
|
138
|
+
let devices = state;
|
|
139
|
+
const known = (d, role) => {
|
|
140
|
+
devices = {
|
|
141
|
+
...devices,
|
|
142
|
+
principals: put(devices.principals, toHex(d.principalId), d),
|
|
143
|
+
...(role ? { roles: nonDevice(devices.roles, d.principalId) } : {}),
|
|
144
|
+
};
|
|
145
|
+
};
|
|
122
146
|
if (body.type === "CAPABILITY_GRANT")
|
|
123
|
-
|
|
147
|
+
known(body.subject, true);
|
|
124
148
|
if (body.type === "CAPABILITY_CLAIM")
|
|
125
|
-
|
|
149
|
+
known(body.claimant, true);
|
|
126
150
|
// §23.3 "After commit, the accepting Principal becomes the Resource owner"
|
|
127
|
-
// (verified by authorizeControlRecord before this record was accepted)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
151
|
+
// (verified by authorizeControlRecord before this record was accepted);
|
|
152
|
+
// with field 2 its chain is bound and the leaf registered, a leaf already
|
|
153
|
+
// registered with the same certificate left unchanged.
|
|
154
|
+
const newOwner = transferNewOwner(record);
|
|
155
|
+
if (newOwner !== undefined) {
|
|
156
|
+
known(newOwner, true);
|
|
157
|
+
if (body.type === "OWNER_TRANSFER_COMMIT" && body.deviceChain !== undefined)
|
|
158
|
+
devices = bindChain(devices, body.deviceChain, true, true);
|
|
159
|
+
}
|
|
160
|
+
if (body.type === "DEVICE_GRANT")
|
|
161
|
+
devices = bindChain(state, body.chain, grantInScope(state, body), false, true);
|
|
162
|
+
if (body.type === "DEVICE_REVOKE")
|
|
163
|
+
devices = { ...devices, ...recordRevocation(state, record, body) };
|
|
131
164
|
const route = body.type === "ROUTE_UPDATE"
|
|
132
165
|
? {
|
|
133
166
|
route: Object.freeze({ endpoints: body.endpoints, coordinatorUrl: body.coordinatorUrl }),
|
|
@@ -140,8 +173,14 @@ function applyRecord(state, record) {
|
|
|
140
173
|
...route,
|
|
141
174
|
...(body.type === "KEY_EPOCH" ? rotate(state, record, body) : {}),
|
|
142
175
|
...(newOwner !== undefined ? { owner: newOwner } : {}),
|
|
143
|
-
principals,
|
|
144
|
-
|
|
176
|
+
principals: devices.principals,
|
|
177
|
+
roles: devices.roles,
|
|
178
|
+
devices: devices.devices,
|
|
179
|
+
revoked: devices.revoked,
|
|
180
|
+
liveBound: devices.liveBound,
|
|
181
|
+
preemptive: devices.preemptive,
|
|
182
|
+
preemptiveRoots: devices.preemptiveRoots,
|
|
183
|
+
grants: applyCapabilities(state, record),
|
|
145
184
|
});
|
|
146
185
|
}
|
|
147
186
|
/**
|
|
@@ -164,6 +203,91 @@ function rotate(state, record, body) {
|
|
|
164
203
|
}));
|
|
165
204
|
return { epoch: Object.freeze({ epoch: body.epoch, dekCommitment: body.dekCommitment }), epochs };
|
|
166
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* §62 classes 3 and 4 for a non-Genesis record at `state`: every
|
|
208
|
+
* dependency first (a Device Grant's root known to the Resource's state,
|
|
209
|
+
* the issuer's descriptor), then every signature (the record under its
|
|
210
|
+
* issuer, the certificates of an embedded chain leaf first, the offer
|
|
211
|
+
* under its kid when that is the owner or a device bound to it, and the
|
|
212
|
+
* accept under the new owner or, with commit field 2, under the leaf). §13: kid MUST equal the
|
|
213
|
+
* issuer (controlRecordSigner); with commit field 2 the issuer MUST be the
|
|
214
|
+
* leaf. Undefined when all hold.
|
|
215
|
+
*/
|
|
216
|
+
function dependenciesAndSignatures(record, state, options) {
|
|
217
|
+
const body = record.body;
|
|
218
|
+
const issuer = controlRecordSigner(record);
|
|
219
|
+
const known = (id) => state.principals.get(toHex(id));
|
|
220
|
+
const missing = (reason) => ({ problem: "UNRESOLVED_ISSUER", reason });
|
|
221
|
+
const bad = (reason) => ({ problem: "SIGNATURE", reason });
|
|
222
|
+
// Class 3.
|
|
223
|
+
let root;
|
|
224
|
+
if (body.type === "DEVICE_GRANT") {
|
|
225
|
+
// §17.4 rule 2: the root's descriptor is known to the previous state.
|
|
226
|
+
root = known(body.root);
|
|
227
|
+
if (root === undefined)
|
|
228
|
+
return missing("the Device Grant's root is not known (§17.4 rule 2)");
|
|
229
|
+
}
|
|
230
|
+
const offer = body.type === "OWNER_TRANSFER_COMMIT" ? parseOwnerTransferOffer(body.offer) : null;
|
|
231
|
+
const accept = body.type === "OWNER_TRANSFER_COMMIT" ? parseOwnerTransferAccept(body.accept) : null;
|
|
232
|
+
if (offer !== null)
|
|
233
|
+
root = offer.payload.proposedOwner;
|
|
234
|
+
const chain = body.type === "DEVICE_GRANT"
|
|
235
|
+
? body.chain
|
|
236
|
+
: body.type === "OWNER_TRANSFER_COMMIT"
|
|
237
|
+
? body.deviceChain
|
|
238
|
+
: undefined;
|
|
239
|
+
const leaf = chain?.leaf.payload.device;
|
|
240
|
+
const required = body.type === "OWNER_TRANSFER_COMMIT" ? leaf : undefined;
|
|
241
|
+
const descriptor = required ??
|
|
242
|
+
known(issuer) ??
|
|
243
|
+
(body.type === "DEVICE_GRANT"
|
|
244
|
+
? body.chain.certificates.find((c) => toHex(c.payload.device.principalId) === toHex(issuer))
|
|
245
|
+
?.payload.device
|
|
246
|
+
: undefined) ??
|
|
247
|
+
transferIssuerDescriptor(record) ??
|
|
248
|
+
// §17.4 rule 2, §17.5 rule 2: a device record's issuer is known to the
|
|
249
|
+
// Resource's state (or carried by the grant's chain), never resolved
|
|
250
|
+
// from outside, so its verdict is a function of the chain alone
|
|
251
|
+
// (review of #8, R8-3).
|
|
252
|
+
(body.type === "DEVICE_GRANT" || body.type === "DEVICE_REVOKE"
|
|
253
|
+
? undefined
|
|
254
|
+
: options.resolvePrincipal?.(issuer));
|
|
255
|
+
if (descriptor === undefined)
|
|
256
|
+
return missing("no descriptor is known for the issuer");
|
|
257
|
+
// Class 4. §13: the kid is the payload issuer; with commit field 2 that
|
|
258
|
+
// issuer is the leaf, whose descriptor the commit is verified under
|
|
259
|
+
// (review of #8, R8-2: a policy substituted for authority never sees a
|
|
260
|
+
// commit whose field 4 names another Principal).
|
|
261
|
+
if (!bytesEqual(record.signed.kid, issuer))
|
|
262
|
+
return bad("the record's kid is not its issuer (§13)");
|
|
263
|
+
if (required !== undefined && !bytesEqual(issuer, required.principalId))
|
|
264
|
+
return bad("the commit's issuer is not the leaf device of field 2 (§13, §23.3)");
|
|
265
|
+
const v = verifySignedObject(record.signed, descriptor);
|
|
266
|
+
if (!v.valid)
|
|
267
|
+
return bad(required !== undefined
|
|
268
|
+
? `the commit is not signed by the leaf device of field 2 (${v.reason}, §23.3)`
|
|
269
|
+
: `the record is not signed by its issuer (${v.reason})`);
|
|
270
|
+
if (chain !== undefined && root !== undefined) {
|
|
271
|
+
const c = verifyChainSignatures(chain, root);
|
|
272
|
+
if (!c.valid)
|
|
273
|
+
return bad(`${c.reason} (§7.3)`);
|
|
274
|
+
}
|
|
275
|
+
if (offer !== null && accept !== null && root !== undefined) {
|
|
276
|
+
// §23.3, §62 class 4: the offer's signer is never a dependency; its
|
|
277
|
+
// signature is checked only under the owner or a device bound to it.
|
|
278
|
+
const signer = offerSigner(state, offer.signed.kid);
|
|
279
|
+
const o = signer === undefined ? undefined : verifySignedObject(offer.signed, signer);
|
|
280
|
+
if (o !== undefined && !o.valid)
|
|
281
|
+
return bad(`the offer's signature does not verify (${o.reason}, §23.3)`);
|
|
282
|
+
// PENDING spec clarification: §62 checks the accept under its required
|
|
283
|
+
// signer before authority, so a wrong accept kid is INVALID_SIGNATURE
|
|
284
|
+
// (as sdk-rs), with or without field 2.
|
|
285
|
+
const a = verifySignedObject(accept.signed, leaf ?? root);
|
|
286
|
+
if (!a.valid)
|
|
287
|
+
return bad(`the accept is not signed by the new owner or its leaf (${a.reason}, §23.3)`);
|
|
288
|
+
}
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
167
291
|
/**
|
|
168
292
|
* Validates a set of exact signed Control Records of one Resource, from
|
|
169
293
|
* Genesis or after `options.start`. Input objects that are already decoded
|
|
@@ -236,7 +360,13 @@ export function validateControlChain(inputs, options = {}) {
|
|
|
236
360
|
for (const g of geneses) {
|
|
237
361
|
const v = verifyGenesis(g.record);
|
|
238
362
|
if (!v.valid)
|
|
239
|
-
bad.push(problem(v.reason === "ISSUER_NOT_OWNER"
|
|
363
|
+
bad.push(problem(v.reason === "ISSUER_NOT_OWNER"
|
|
364
|
+
? "GENESIS_SIGNER"
|
|
365
|
+
: v.reason === "DEVICE_CHAIN_UNAUTHORIZED"
|
|
366
|
+
? "UNAUTHORIZED"
|
|
367
|
+
: "SIGNATURE", at(g), v.reason === "DEVICE_CHAIN_UNAUTHORIZED"
|
|
368
|
+
? "the Genesis chain fails (c3), (c4) or (c6), or its leaf is not a personal device with owner/act (§15)"
|
|
369
|
+
: `Genesis is not signed by its owner (${v.reason}, §15)`));
|
|
240
370
|
}
|
|
241
371
|
if (bad.length > 0)
|
|
242
372
|
return invalid(bad);
|
|
@@ -282,25 +412,19 @@ export function validateControlChain(inputs, options = {}) {
|
|
|
282
412
|
problems.push(problem("SEQUENCE", at(k), `control_seq ${p.controlSeq} after ${state.seq} (§13.1)`));
|
|
283
413
|
continue;
|
|
284
414
|
}
|
|
285
|
-
// §13: the issuer signs, and kid MUST equal it (see controlRecordSigner).
|
|
286
|
-
const issuer = controlRecordSigner(k.record);
|
|
287
|
-
const descriptor = state.principals.get(toHex(issuer)) ??
|
|
288
|
-
transferIssuerDescriptor(k.record) ??
|
|
289
|
-
options.resolvePrincipal?.(issuer);
|
|
290
|
-
if (descriptor === undefined) {
|
|
291
|
-
problems.push(problem("UNRESOLVED_ISSUER", at(k), "no descriptor is known for the issuer"));
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
const v = verifySignedObject(k.record.signed, descriptor);
|
|
295
|
-
if (!v.valid) {
|
|
296
|
-
problems.push(problem("SIGNATURE", at(k), `the record is not signed by its issuer (${v.reason})`));
|
|
297
|
-
continue;
|
|
298
|
-
}
|
|
299
415
|
// §19: "The new epoch number MUST be exactly the previous Data Epoch plus one."
|
|
416
|
+
// A chain placement check, §62 class 2: before dependencies, signatures
|
|
417
|
+
// and authority, so a wrong number is INVALID_CONTROL_CHAIN even when
|
|
418
|
+
// the signature does not verify or the issuer is unknown (decision I9).
|
|
300
419
|
if (k.record.body.type === "KEY_EPOCH" && k.record.body.epoch !== state.epoch.epoch + 1n) {
|
|
301
420
|
problems.push(problem("EPOCH", at(k), `Key Epoch ${k.record.body.epoch} after epoch ${state.epoch.epoch}: must be the previous epoch plus one (§19)`));
|
|
302
421
|
continue;
|
|
303
422
|
}
|
|
423
|
+
const verified = dependenciesAndSignatures(k.record, state, options);
|
|
424
|
+
if (verified !== undefined) {
|
|
425
|
+
problems.push(problem(verified.problem, at(k), verified.reason));
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
304
428
|
const decision = authorize(k.record, state);
|
|
305
429
|
if (decision === false || (typeof decision === "object" && !decision.allowed)) {
|
|
306
430
|
const refusal = typeof decision === "object" && !decision.allowed ? decision : undefined;
|
package/dist/control.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ControlRecordId, type DataEpoch, type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
2
|
import { type CborValue } from "./cbor/index.js";
|
|
3
3
|
import { type SignedBytes, type Signer, type VerifyResult } from "./cose.js";
|
|
4
|
+
import { type CertificateChain } from "./device.js";
|
|
4
5
|
import { type Endpoint } from "./endpoint.js";
|
|
5
6
|
import { type ActorHave } from "./have.js";
|
|
6
7
|
import { type ControlRecordPayload, type Parsed } from "./objects.js";
|
|
@@ -32,6 +33,8 @@ export type ControlBody = {
|
|
|
32
33
|
readonly dekCommitment: Hash32;
|
|
33
34
|
readonly endpoints: readonly Endpoint[];
|
|
34
35
|
readonly coordinatorUrl: string;
|
|
36
|
+
/** Field 5 (§15): present when a device of the owner signs the Genesis; the chain's root is the owner. */
|
|
37
|
+
readonly deviceChain?: CertificateChain;
|
|
35
38
|
} | {
|
|
36
39
|
readonly type: "CAPABILITY_GRANT";
|
|
37
40
|
readonly subject: PrincipalDescriptor;
|
|
@@ -64,6 +67,8 @@ export type ControlBody = {
|
|
|
64
67
|
/** Exact COSE bytes of the transfer offer and accept (§23.3); see parseOwnerTransferOffer/Accept. */
|
|
65
68
|
readonly offer: Uint8Array;
|
|
66
69
|
readonly accept: Uint8Array;
|
|
70
|
+
/** Field 2 (§23.3): present when a device of the new owner signs accept and commit; rooted at offer field 3. */
|
|
71
|
+
readonly deviceChain?: CertificateChain;
|
|
67
72
|
} | {
|
|
68
73
|
readonly type: "COORDINATOR_RECOVERY";
|
|
69
74
|
readonly routeVersion: bigint;
|
|
@@ -75,6 +80,25 @@ export type ControlBody = {
|
|
|
75
80
|
readonly reason: bigint;
|
|
76
81
|
readonly note?: string;
|
|
77
82
|
}
|
|
83
|
+
/** §17.4: binds the chain's devices to `root`; registers or re-certifies the leaf. */
|
|
84
|
+
| {
|
|
85
|
+
readonly type: "DEVICE_GRANT";
|
|
86
|
+
readonly root: PrincipalId;
|
|
87
|
+
/** Rooted at `root`; the leaf is the device bound or registered. */
|
|
88
|
+
readonly chain: CertificateChain;
|
|
89
|
+
/** Field 2: the pairing device whose consumed approval names the leaf (§17.4 rule 7). */
|
|
90
|
+
readonly pairingDevice?: PrincipalId;
|
|
91
|
+
}
|
|
92
|
+
/** §17.5: revokes the pair (root, device) in this Resource. */
|
|
93
|
+
| {
|
|
94
|
+
readonly type: "DEVICE_REVOKE";
|
|
95
|
+
readonly root: PrincipalId;
|
|
96
|
+
readonly device: PrincipalId;
|
|
97
|
+
/** 0 retired, 1 lost, 2 compromised, 3 consumed; 4 and above kept as received. */
|
|
98
|
+
readonly reason: bigint;
|
|
99
|
+
/** Field 4, exactly when the reason is 3: the approved certificate's payload digest. */
|
|
100
|
+
readonly approval?: Hash32;
|
|
101
|
+
}
|
|
78
102
|
/** A §14 extension type (32 and up): kept, never interpreted. */
|
|
79
103
|
| {
|
|
80
104
|
readonly type: "EXTENSION";
|
|
@@ -87,8 +111,9 @@ export declare const isMvpSupported: (type: ControlBodyType) => boolean;
|
|
|
87
111
|
/** The §14 code of a body. */
|
|
88
112
|
export declare const controlTypeOf: (body: ControlBody) => bigint;
|
|
89
113
|
/**
|
|
90
|
-
* Decodes the body of a Control Record of §14 type `type`. Core types 0-
|
|
91
|
-
* get their closed-map structure
|
|
114
|
+
* Decodes the body of a Control Record of §14 type `type`. Core types 0-10
|
|
115
|
+
* get their closed-map structure, embedded certificates and transfer
|
|
116
|
+
* objects included (§62 class 1); 11-31 are UNSUPPORTED_VALUE (§14:
|
|
92
117
|
* INVALID_CONTROL_CHAIN on the wire); 32 and up are kept as opaque
|
|
93
118
|
* EXTENSION bodies.
|
|
94
119
|
*/
|
|
@@ -101,7 +126,8 @@ export interface ControlRecord extends Parsed<ControlRecordPayload> {
|
|
|
101
126
|
}
|
|
102
127
|
/**
|
|
103
128
|
* Parses a received Control Record: canonical COSE and the generic
|
|
104
|
-
* envelope (parseControlRecord), then the typed body
|
|
129
|
+
* envelope (parseControlRecord), then the typed body, then the Genesis
|
|
130
|
+
* placement rule (§62: structure before placement). The record ID is
|
|
105
131
|
* `signed.id`, SHA-256 of the exact received bytes. The signature is not
|
|
106
132
|
* checked here; see controlRecordSigner and verifyGenesis.
|
|
107
133
|
*/
|
|
@@ -118,11 +144,17 @@ export declare const controlRecordSigner: (record: Parsed<ControlRecordPayload>)
|
|
|
118
144
|
* Verifies a Genesis record against the owner in its own body (§15:
|
|
119
145
|
* "MUST be signed by the owner Principal contained in the body"): the
|
|
120
146
|
* issuer and the kid must be the owner and the signature must verify with
|
|
121
|
-
* the owner's key.
|
|
147
|
+
* the owner's key. With field 5 the chain's leaf device is the required
|
|
148
|
+
* signer instead, the owner included in "any other Principal"
|
|
149
|
+
* (ISSUER_NOT_OWNER), and every certificate must verify up to the owner
|
|
150
|
+
* (§7.3 (c2)); these are INVALID_SIGNATURE on the wire. Then, as
|
|
151
|
+
* authority (AUTHORIZATION_FAILED, DEVICE_CHAIN_UNAUTHORIZED): (c3), (c4),
|
|
152
|
+
* (c6) and a personal leaf with owner/act. S is empty at Genesis, so
|
|
153
|
+
* nothing about roles or revocations applies.
|
|
122
154
|
*/
|
|
123
155
|
export declare function verifyGenesis(record: ControlRecord): VerifyResult | {
|
|
124
156
|
readonly valid: false;
|
|
125
|
-
readonly reason: "NOT_GENESIS" | "ISSUER_NOT_OWNER";
|
|
157
|
+
readonly reason: "NOT_GENESIS" | "ISSUER_NOT_OWNER" | "DEVICE_CHAIN_UNAUTHORIZED";
|
|
126
158
|
};
|
|
127
159
|
/** The CBOR of a typed body, applying the writer rules. */
|
|
128
160
|
export declare function controlBodyToCbor(body: ControlBody): CborValue;
|
package/dist/control.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { bytesEqual, controlRecordId, dataEpoch, hash32, LfcpError, principalId, resourceId, } from "@openlfcp/core";
|
|
2
2
|
import { cborMap, decodeStrict, encode } from "./cbor/index.js";
|
|
3
3
|
import { parseSignedObject, signObject, verifySignedObject, } from "./cose.js";
|
|
4
|
+
import { certificateChainFromCbor, certificateChainToCbor, checkChainAuthority, DEVICE_ABILITY, DEVICE_KIND, verifyChainSignatures, } from "./device.js";
|
|
5
|
+
import { scanControlRecord } from "./device-scan.js";
|
|
4
6
|
import { checkReceivedUrl, checkWriterUrl, endpointFromCbor, endpointToCbor, } from "./endpoint.js";
|
|
5
7
|
import { Fields } from "./fields.js";
|
|
6
8
|
import { canonicalFrontierFromCbor, canonicalFrontierToCbor } from "./have.js";
|
|
7
|
-
import { CONTROL_TYPE, controlRecordPayloadFromCbor,
|
|
9
|
+
import { CONTROL_TYPE, checkGenesisPlacement, controlRecordPayloadFromCbor, readControlRecordPayload, } from "./objects.js";
|
|
8
10
|
import { principalDescriptorFromCbor, principalDescriptorToCbor, } from "./principal.js";
|
|
9
11
|
const CODE = CONTROL_TYPE;
|
|
10
12
|
/**
|
|
@@ -12,7 +14,8 @@ const CODE = CONTROL_TYPE;
|
|
|
12
14
|
* applied. OWNER_TRANSFER_COMMIT is verified and applied
|
|
13
15
|
* (MVP-0.1-PROTOCOL-SCOPE §4: "ownership transfer verification is in MVP
|
|
14
16
|
* 0.1"). A chain containing Coordinator Recovery or Resource Tombstone is
|
|
15
|
-
* refused (DV1, chain.ts); extensions are kept unapplied.
|
|
17
|
+
* refused (DV1, chain.ts); extensions are kept unapplied. DEVICE_GRANT and
|
|
18
|
+
* DEVICE_REVOKE (IDENTITY-01, §17.4, §17.5) are verified and applied.
|
|
16
19
|
*/
|
|
17
20
|
const MVP_SUPPORTED = new Set([
|
|
18
21
|
"GENESIS",
|
|
@@ -22,6 +25,8 @@ const MVP_SUPPORTED = new Set([
|
|
|
22
25
|
"KEY_EPOCH",
|
|
23
26
|
"ROUTE_UPDATE",
|
|
24
27
|
"OWNER_TRANSFER_COMMIT",
|
|
28
|
+
"DEVICE_GRANT",
|
|
29
|
+
"DEVICE_REVOKE",
|
|
25
30
|
]);
|
|
26
31
|
/** Whether MVP 0.1 implements a body type (false for deferred core types and extensions). */
|
|
27
32
|
export const isMvpSupported = (type) => MVP_SUPPORTED.has(type);
|
|
@@ -67,23 +72,42 @@ function abilityList(f, key, nonEmpty) {
|
|
|
67
72
|
f.fail(key, "lists an ability twice");
|
|
68
73
|
return list;
|
|
69
74
|
}
|
|
75
|
+
/** An optional `certificate-chain` field rooted at `root` (§15 field 5, §23.3 field 2). */
|
|
76
|
+
function optionalChain(f, key, root) {
|
|
77
|
+
return f.has(key) ? { deviceChain: certificateChainFromCbor(f.any(key), root) } : {};
|
|
78
|
+
}
|
|
70
79
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
80
|
+
* The embedded offer and accept of a transfer commit are structure of the
|
|
81
|
+
* commit (§62 class 1, with or without field 2): each must be a canonical
|
|
82
|
+
* signed object with its typed payload. Returns the parsed offer.
|
|
83
|
+
*/
|
|
84
|
+
function transferObjects(f) {
|
|
85
|
+
const offer = parseOwnerTransferOffer(f.bytes(0));
|
|
86
|
+
parseOwnerTransferAccept(f.bytes(1));
|
|
87
|
+
return offer;
|
|
88
|
+
}
|
|
89
|
+
/** `reason = 3` if and only if field 4 is present (§17.5 rule 1). */
|
|
90
|
+
const CONSUMED = 3n;
|
|
91
|
+
/**
|
|
92
|
+
* Decodes the body of a Control Record of §14 type `type`. Core types 0-10
|
|
93
|
+
* get their closed-map structure, embedded certificates and transfer
|
|
94
|
+
* objects included (§62 class 1); 11-31 are UNSUPPORTED_VALUE (§14:
|
|
73
95
|
* INVALID_CONTROL_CHAIN on the wire); 32 and up are kept as opaque
|
|
74
96
|
* EXTENSION bodies.
|
|
75
97
|
*/
|
|
76
98
|
export function controlBodyFromCbor(type, value) {
|
|
77
99
|
switch (type) {
|
|
78
100
|
case CONTROL_TYPE.GENESIS: {
|
|
79
|
-
const f = new Fields(value, "genesis-body", [0, 1, 2, 3, 4]);
|
|
101
|
+
const f = new Fields(value, "genesis-body", [0, 1, 2, 3, 4], [5]);
|
|
102
|
+
const owner = principalDescriptorFromCbor(f.any(1));
|
|
80
103
|
return Object.freeze({
|
|
81
104
|
type: "GENESIS",
|
|
82
105
|
dataProfile: f.text(0),
|
|
83
|
-
owner
|
|
106
|
+
owner,
|
|
84
107
|
dekCommitment: hash32(f.bytes(2, 32)),
|
|
85
108
|
endpoints: endpoints(f, 3),
|
|
86
109
|
coordinatorUrl: coordinatorUrl(f, 4),
|
|
110
|
+
...optionalChain(f, 5, owner.principalId),
|
|
87
111
|
});
|
|
88
112
|
}
|
|
89
113
|
case CONTROL_TYPE.CAPABILITY_GRANT: {
|
|
@@ -130,11 +154,13 @@ export function controlBodyFromCbor(type, value) {
|
|
|
130
154
|
});
|
|
131
155
|
}
|
|
132
156
|
case CONTROL_TYPE.OWNER_TRANSFER_COMMIT: {
|
|
133
|
-
const f = new Fields(value, "owner-transfer-commit-body", [0, 1]);
|
|
157
|
+
const f = new Fields(value, "owner-transfer-commit-body", [0, 1], [2]);
|
|
158
|
+
const offer = transferObjects(f);
|
|
134
159
|
return Object.freeze({
|
|
135
160
|
type: "OWNER_TRANSFER_COMMIT",
|
|
136
161
|
offer: f.bytes(0),
|
|
137
162
|
accept: f.bytes(1),
|
|
163
|
+
...optionalChain(f, 2, offer.payload.proposedOwner.principalId),
|
|
138
164
|
});
|
|
139
165
|
}
|
|
140
166
|
case CONTROL_TYPE.COORDINATOR_RECOVERY: {
|
|
@@ -155,6 +181,38 @@ export function controlBodyFromCbor(type, value) {
|
|
|
155
181
|
...(f.has(1) ? { note: f.text(1) } : {}),
|
|
156
182
|
});
|
|
157
183
|
}
|
|
184
|
+
case CONTROL_TYPE.DEVICE_GRANT: {
|
|
185
|
+
const f = new Fields(value, "device-grant-body", [0, 1], [2]);
|
|
186
|
+
const root = principalId(f.bytes(0, 32));
|
|
187
|
+
const chain = certificateChainFromCbor(f.any(1), root);
|
|
188
|
+
// §17.4 rule 1: a record whose leaf is of kind 2 MUST NOT carry field 2.
|
|
189
|
+
if (f.has(2) && chain.leaf.payload.kind === DEVICE_KIND.PAIRING)
|
|
190
|
+
f.fail(2, "must be absent when the leaf is a pairing device (§17.4)");
|
|
191
|
+
return Object.freeze({
|
|
192
|
+
type: "DEVICE_GRANT",
|
|
193
|
+
root,
|
|
194
|
+
chain,
|
|
195
|
+
...(f.has(2) ? { pairingDevice: principalId(f.bytes(2, 32)) } : {}),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
case CONTROL_TYPE.DEVICE_REVOKE: {
|
|
199
|
+
// Field 3 is reserved (§108.1): not in the closed map, so MALFORMED_MESSAGE.
|
|
200
|
+
const f = new Fields(value, "device-revoke-body", [0, 1, 2], [4]);
|
|
201
|
+
const root = principalId(f.bytes(0, 32));
|
|
202
|
+
const device = principalId(f.bytes(1, 32));
|
|
203
|
+
if (bytesEqual(root, device))
|
|
204
|
+
f.fail(1, "must differ from the root (§17.5 rule 1)");
|
|
205
|
+
const reason = f.uint(2);
|
|
206
|
+
if (f.has(4) !== (reason === CONSUMED))
|
|
207
|
+
f.fail(4, "must be present exactly when the reason is 3 (§17.5 rule 1)");
|
|
208
|
+
return Object.freeze({
|
|
209
|
+
type: "DEVICE_REVOKE",
|
|
210
|
+
root,
|
|
211
|
+
device,
|
|
212
|
+
reason,
|
|
213
|
+
...(f.has(4) ? { approval: hash32(f.bytes(4, 32)) } : {}),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
158
216
|
default:
|
|
159
217
|
if (type >= 32n)
|
|
160
218
|
return Object.freeze({ type: "EXTENSION", code: type, body: value });
|
|
@@ -163,14 +221,21 @@ export function controlBodyFromCbor(type, value) {
|
|
|
163
221
|
}
|
|
164
222
|
/**
|
|
165
223
|
* Parses a received Control Record: canonical COSE and the generic
|
|
166
|
-
* envelope (parseControlRecord), then the typed body
|
|
224
|
+
* envelope (parseControlRecord), then the typed body, then the Genesis
|
|
225
|
+
* placement rule (§62: structure before placement). The record ID is
|
|
167
226
|
* `signed.id`, SHA-256 of the exact received bytes. The signature is not
|
|
168
227
|
* checked here; see controlRecordSigner and verifyGenesis.
|
|
169
228
|
*/
|
|
170
229
|
export function decodeControlRecord(bytes) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
230
|
+
// A certificate chain's bounds, on the raw bytes, before anything is decoded.
|
|
231
|
+
scanControlRecord(bytes);
|
|
232
|
+
const signed = parseSignedObject(bytes);
|
|
233
|
+
const payload = readControlRecordPayload(decodeStrict(signed.payloadBytes));
|
|
234
|
+
// §62: the structure of the body and every embedded object (class 1)
|
|
235
|
+
// before the Genesis placement rule of §13.1 (class 2).
|
|
236
|
+
const body = controlBodyFromCbor(payload.controlType, payload.body);
|
|
237
|
+
checkGenesisPlacement(payload);
|
|
238
|
+
return Object.freeze({ signed, payload, body, mvpSupported: isMvpSupported(body.type) });
|
|
174
239
|
}
|
|
175
240
|
/**
|
|
176
241
|
* The Principal whose key must have signed a Control Record: its issuer
|
|
@@ -184,14 +249,35 @@ export const controlRecordSigner = (record) => record.payload.issuer;
|
|
|
184
249
|
* Verifies a Genesis record against the owner in its own body (§15:
|
|
185
250
|
* "MUST be signed by the owner Principal contained in the body"): the
|
|
186
251
|
* issuer and the kid must be the owner and the signature must verify with
|
|
187
|
-
* the owner's key.
|
|
252
|
+
* the owner's key. With field 5 the chain's leaf device is the required
|
|
253
|
+
* signer instead, the owner included in "any other Principal"
|
|
254
|
+
* (ISSUER_NOT_OWNER), and every certificate must verify up to the owner
|
|
255
|
+
* (§7.3 (c2)); these are INVALID_SIGNATURE on the wire. Then, as
|
|
256
|
+
* authority (AUTHORIZATION_FAILED, DEVICE_CHAIN_UNAUTHORIZED): (c3), (c4),
|
|
257
|
+
* (c6) and a personal leaf with owner/act. S is empty at Genesis, so
|
|
258
|
+
* nothing about roles or revocations applies.
|
|
188
259
|
*/
|
|
189
260
|
export function verifyGenesis(record) {
|
|
190
|
-
|
|
261
|
+
const body = record.body;
|
|
262
|
+
if (body.type !== "GENESIS")
|
|
191
263
|
return { valid: false, reason: "NOT_GENESIS" };
|
|
192
|
-
|
|
264
|
+
const chain = body.deviceChain;
|
|
265
|
+
const signer = chain?.leaf.payload.device ?? body.owner;
|
|
266
|
+
if (!bytesEqual(record.payload.issuer, signer.principalId))
|
|
193
267
|
return { valid: false, reason: "ISSUER_NOT_OWNER" };
|
|
194
|
-
|
|
268
|
+
const v = verifySignedObject(record.signed, signer);
|
|
269
|
+
if (!v.valid || chain === undefined)
|
|
270
|
+
return v;
|
|
271
|
+
const c = verifyChainSignatures(chain, body.owner);
|
|
272
|
+
if (!c.valid)
|
|
273
|
+
return { valid: false, reason: c.rule === "c2-kid" ? "KID_MISMATCH" : "BAD_SIGNATURE" };
|
|
274
|
+
const authority = checkChainAuthority(chain, record.payload.resourceId);
|
|
275
|
+
const leaf = chain.leaf.payload;
|
|
276
|
+
if (!authority.valid ||
|
|
277
|
+
leaf.kind !== DEVICE_KIND.PERSONAL ||
|
|
278
|
+
!leaf.mask.includes(DEVICE_ABILITY.OWNER_ACT))
|
|
279
|
+
return { valid: false, reason: "DEVICE_CHAIN_UNAUTHORIZED" };
|
|
280
|
+
return { valid: true };
|
|
195
281
|
}
|
|
196
282
|
// ---------------------------------------------------------------------------
|
|
197
283
|
// Encoding (writers)
|
|
@@ -222,6 +308,14 @@ function abilities(list, nonEmpty) {
|
|
|
222
308
|
refuse("an ability is listed twice");
|
|
223
309
|
return [...list];
|
|
224
310
|
}
|
|
311
|
+
/** An optional chain field; its root must be the Principal the body names (§7.3 (c1)). */
|
|
312
|
+
function writerChain(key, chain, root) {
|
|
313
|
+
if (chain === undefined)
|
|
314
|
+
return [];
|
|
315
|
+
if (!bytesEqual(chain.root, root))
|
|
316
|
+
refuse("the certificate chain is for another root (§7.3 (c1))");
|
|
317
|
+
return [[key, certificateChainToCbor(chain)]];
|
|
318
|
+
}
|
|
225
319
|
/** The CBOR of a typed body, applying the writer rules. */
|
|
226
320
|
export function controlBodyToCbor(body) {
|
|
227
321
|
switch (body.type) {
|
|
@@ -232,6 +326,7 @@ export function controlBodyToCbor(body) {
|
|
|
232
326
|
[2, hash32(body.dekCommitment)],
|
|
233
327
|
[3, writerEndpoints(body.endpoints)],
|
|
234
328
|
[4, writerUrl(body.coordinatorUrl)],
|
|
329
|
+
...writerChain(5, body.deviceChain, body.owner.principalId),
|
|
235
330
|
]);
|
|
236
331
|
case "CAPABILITY_GRANT":
|
|
237
332
|
return cborMap([
|
|
@@ -264,10 +359,43 @@ export function controlBodyToCbor(body) {
|
|
|
264
359
|
[1, writerEndpoints(body.endpoints)],
|
|
265
360
|
[2, writerUrl(body.coordinatorUrl)],
|
|
266
361
|
]);
|
|
267
|
-
case "OWNER_TRANSFER_COMMIT":
|
|
362
|
+
case "OWNER_TRANSFER_COMMIT": {
|
|
363
|
+
let offer;
|
|
364
|
+
try {
|
|
365
|
+
offer = parseOwnerTransferOffer(body.offer);
|
|
366
|
+
parseOwnerTransferAccept(body.accept);
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return refuse("the offer and accept must be canonical transfer objects (§23)");
|
|
370
|
+
}
|
|
268
371
|
return cborMap([
|
|
269
372
|
[0, Uint8Array.from(body.offer)],
|
|
270
373
|
[1, Uint8Array.from(body.accept)],
|
|
374
|
+
...writerChain(2, body.deviceChain, offer.payload.proposedOwner.principalId),
|
|
375
|
+
]);
|
|
376
|
+
}
|
|
377
|
+
case "DEVICE_GRANT":
|
|
378
|
+
if (!bytesEqual(body.chain.root, body.root))
|
|
379
|
+
refuse("the chain is for another root (§7.3 (c1))");
|
|
380
|
+
if (body.pairingDevice !== undefined && body.chain.leaf.payload.kind === DEVICE_KIND.PAIRING)
|
|
381
|
+
refuse("a grant of a pairing leaf carries no pairing device (§17.4)");
|
|
382
|
+
return cborMap([
|
|
383
|
+
[0, principalId(body.root)],
|
|
384
|
+
[1, certificateChainToCbor(body.chain)],
|
|
385
|
+
...(body.pairingDevice !== undefined
|
|
386
|
+
? [[2, principalId(body.pairingDevice)]]
|
|
387
|
+
: []),
|
|
388
|
+
]);
|
|
389
|
+
case "DEVICE_REVOKE":
|
|
390
|
+
if (bytesEqual(body.root, body.device))
|
|
391
|
+
refuse("the device must differ from the root (§17.5)");
|
|
392
|
+
if ((body.approval !== undefined) !== (body.reason === CONSUMED))
|
|
393
|
+
refuse("the approval is present exactly when the reason is 3 (§17.5)");
|
|
394
|
+
return cborMap([
|
|
395
|
+
[0, principalId(body.root)],
|
|
396
|
+
[1, principalId(body.device)],
|
|
397
|
+
[2, body.reason],
|
|
398
|
+
...(body.approval !== undefined ? [[4, hash32(body.approval)]] : []),
|
|
271
399
|
]);
|
|
272
400
|
case "COORDINATOR_RECOVERY":
|
|
273
401
|
return cborMap([
|
|
@@ -300,8 +428,10 @@ export function encodeControlRecordPayload(header, issuer, body) {
|
|
|
300
428
|
if (genesis) {
|
|
301
429
|
if (header.controlSeq !== 0n || header.prevControlId !== null)
|
|
302
430
|
refuse("Genesis must have control_seq 0 and a null previous record (§13.1)");
|
|
303
|
-
|
|
304
|
-
|
|
431
|
+
// §15: the owner signs, or with field 5 the chain's leaf device does.
|
|
432
|
+
const signer = body.deviceChain?.leaf.payload.device.principalId ?? body.owner.principalId;
|
|
433
|
+
if (!bytesEqual(issuer, signer))
|
|
434
|
+
refuse("Genesis must be issued by its owner, or by the leaf of field 5 (§15)");
|
|
305
435
|
}
|
|
306
436
|
else if (header.controlSeq < 1n || header.prevControlId === null) {
|
|
307
437
|
refuse("a non-Genesis record needs control_seq >= 1 and the previous record ID (§13.1)");
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks the envelope and the bounds of a standalone certificate on its
|
|
3
|
+
* exact bytes, before parseSignedObject decodes anything.
|
|
4
|
+
*/
|
|
5
|
+
export declare function scanDeviceCertificate(bytes: Uint8Array): void;
|
|
6
|
+
/**
|
|
7
|
+
* Checks the envelope of a Control Record and the bounds of the
|
|
8
|
+
* certificate chain a record of type 0, 6 or 9 carries, on its exact
|
|
9
|
+
* bytes. Every refusal found is final: nothing is left to the decoder
|
|
10
|
+
* once the scan has seen it.
|
|
11
|
+
*/
|
|
12
|
+
export declare function scanControlRecord(bytes: Uint8Array): void;
|
|
13
|
+
//# sourceMappingURL=device-scan.d.ts.map
|