@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/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: new Map(),
107
- principals: new Map([[toHex(body.owner.principalId), body.owner]]),
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
- principals.set(toHex(body.subject.principalId), body.subject);
147
+ known(body.subject, true);
124
148
  if (body.type === "CAPABILITY_CLAIM")
125
- principals.set(toHex(body.claimant.principalId), body.claimant);
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
- const newOwner = transferIssuerDescriptor(record);
129
- if (newOwner !== undefined)
130
- principals.set(toHex(newOwner.principalId), newOwner);
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
- grants: applyCapabilities(state.grants, record),
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" ? "GENESIS_SIGNER" : "SIGNATURE", at(g), `Genesis is not signed by its owner (${v.reason}, §15)`));
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-8
91
- * get their closed-map structure; 9-31 are UNSUPPORTED_VALUE (§14:
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. The record ID is
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. A failure surfaces as INVALID_SIGNATURE.
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, parseControlRecord, } from "./objects.js";
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
- * 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:
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: principalDescriptorFromCbor(f.any(1)),
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. The record ID is
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
- const parsed = parseControlRecord(bytes);
172
- const body = controlBodyFromCbor(parsed.payload.controlType, parsed.payload.body);
173
- return Object.freeze({ ...parsed, body, mvpSupported: isMvpSupported(body.type) });
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. A failure surfaces as INVALID_SIGNATURE.
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
- if (record.body.type !== "GENESIS")
261
+ const body = record.body;
262
+ if (body.type !== "GENESIS")
191
263
  return { valid: false, reason: "NOT_GENESIS" };
192
- if (!bytesEqual(record.payload.issuer, record.body.owner.principalId))
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
- return verifySignedObject(record.signed, record.body.owner);
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
- if (!bytesEqual(issuer, body.owner.principalId))
304
- refuse("Genesis must be issued by the owner in its body (§15)");
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