@openlfcp/wire 0.2.2 → 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.
@@ -15,6 +15,11 @@ import type { PrincipalDescriptor } from "./principal.js";
15
15
  * H). Evaluating at an older H means using the state at that H (see
16
16
  * `stateAt` on a linear chain result), so a later revocation never
17
17
  * changes an earlier answer.
18
+ *
19
+ * With devices (§80.1): `hasAbility` is `holds`, owner-only rules use
20
+ * `ownerAction`, issuer comparisons use `actsFor` at the previous head, and
21
+ * a bound Principal is never a grant subject, claimant or new owner. A
22
+ * chain without device records gives the answers of §80 alone.
18
23
  */
19
24
  /** §17.1 standard ability codes. */
20
25
  export declare const ABILITY: Readonly<{
@@ -44,6 +49,8 @@ export interface Grant {
44
49
  readonly id: ControlRecordId;
45
50
  readonly source: "grant" | "claim";
46
51
  readonly issuer: PrincipalId;
52
+ /** §79 `issuer_root`: `actsFor(issuer)` at the head the grant was committed on. */
53
+ readonly issuerRoot: PrincipalId;
47
54
  readonly subject: PrincipalId;
48
55
  readonly abilities: readonly bigint[];
49
56
  readonly delegable: readonly bigint[];
@@ -68,12 +75,30 @@ export interface CapabilityState {
68
75
  */
69
76
  export declare function isGrantActive(state: CapabilityState, grantId: Uint8Array): boolean;
70
77
  /**
71
- * Whether `principal` holds `ability` at the head of `state`: the owner
72
- * holds every standard ability implicitly (§17.1, no self-grant); anyone
73
- * else holds it through an active grant that confers it. Unknown codes and
74
- * the reserved ability 9 are held by no one (§17.1, §23.1).
78
+ * `holds(P, A)` (§80.1): whether `principal` holds `ability` at the head of
79
+ * `state`. A Principal that no device record names holds it directly: the
80
+ * owner implicitly (§17.1), anyone else through an active grant that
81
+ * confers it. A bound Principal holds nothing of its own (N4): only an
82
+ * active device holds, everything when it is an owner-acting device of
83
+ * the owner, else what its mask lists and its root holds. Unknown codes,
84
+ * the reserved 9, and 12 and 13 (mask-only codes) are held by no one.
85
+ *
86
+ * `holds(P, 11)` is false for every bound Principal, `owner/act` included
87
+ * (§80.1, spec#2 1cfe51b): invite/claim belongs to invitation grants, and
88
+ * owner/act confers the standard abilities 1-10 only.
75
89
  */
76
90
  export declare function hasAbility(state: ControlState, principal: PrincipalId, ability: bigint): boolean;
91
+ /**
92
+ * W §17.5 Exposure: the Principals registered at `before` (the state a
93
+ * DEVICE_REVOKE is accepted on) that hold `data/read` there and not at
94
+ * `after` (the state after it): the revoked device itself or any device
95
+ * its revocation suspends through the cascade. The record is exposing
96
+ * exactly when this is non-empty. Computed from the Resource's own chain
97
+ * alone; it is what the rotation policy of IDENTITY-PROFILE-01 §8 reacts to.
98
+ */
99
+ export declare function exposedReaders(before: ControlState, after: ControlState): readonly PrincipalId[];
100
+ /** Whether the revocation that took `before` to `after` is exposing (W §17.5). */
101
+ export declare const isExposingRevocation: (before: ControlState, after: ControlState) => boolean;
77
102
  /** Every standard ability `principal` holds at the head of `state`, ascending. */
78
103
  export declare function abilitiesOf(state: ControlState, principal: PrincipalId): readonly bigint[];
79
104
  /**
@@ -98,10 +123,11 @@ export type Authorization = {
98
123
  */
99
124
  export declare function authorizeControlRecord(record: ControlRecord, state: ControlState): Authorization;
100
125
  /** The capability transitions of an applied record (called by applyRecord). */
101
- export declare function applyCapabilities(grants: ReadonlyMap<string, Grant>, record: ControlRecord): ReadonlyMap<string, Grant>;
126
+ export declare function applyCapabilities(state: ControlState, record: ControlRecord): ReadonlyMap<string, Grant>;
102
127
  /**
103
128
  * §25.2 Key Package authority at the package's referenced Control Head:
104
- * the sender must hold key/distribute, and the recipient must hold
129
+ * the sender must hold key/distribute or be of the recipient's root
130
+ * (`sameRoot`, §80.1), and the recipient must hold
105
131
  * data/read, "except for an Invitation Principal explicitly authorized by
106
132
  * an active invite grant: a recipient that is the subject of an active
107
133
  * grant that includes and still confers invite/claim". `epoch` is the
@@ -140,4 +166,6 @@ export declare function verifyOwnerTransfer(record: ControlRecord, state: Contro
140
166
  * before can still be verified. Undefined for other records.
141
167
  */
142
168
  export declare function transferIssuerDescriptor(record: ControlRecord): PrincipalDescriptor | undefined;
169
+ /** The new owner a transfer commit names (offer field 3), whoever signed the commit. */
170
+ export declare function transferNewOwner(record: ControlRecord): PrincipalDescriptor | undefined;
143
171
  //# sourceMappingURL=capability.d.ts.map
@@ -1,6 +1,8 @@
1
- import { bytesEqual, controlRecordId, toHex, } from "@openlfcp/core";
1
+ import { bytesEqual, controlRecordId, fromHex, principalId, toHex, } from "@openlfcp/core";
2
2
  import { parseOwnerTransferAccept, parseOwnerTransferOffer, } from "./control.js";
3
3
  import { objectId, verifySignedObject } from "./cose.js";
4
+ import { activeDevice, actsFor, authorizeDeviceGrant, authorizeDeviceRevoke, authorizeOwnerChain, isBound, offerSigner, ownerAction, sameRoot, } from "./device-state.js";
5
+ import { put } from "./persistent-map.js";
4
6
  /**
5
7
  * The LFCP capability engine (LFCP-WIRE-01 §17, §18, §20, §19, §25.2).
6
8
  *
@@ -14,6 +16,11 @@ import { objectId, verifySignedObject } from "./cose.js";
14
16
  * H). Evaluating at an older H means using the state at that H (see
15
17
  * `stateAt` on a linear chain result), so a later revocation never
16
18
  * changes an earlier answer.
19
+ *
20
+ * With devices (§80.1): `hasAbility` is `holds`, owner-only rules use
21
+ * `ownerAction`, issuer comparisons use `actsFor` at the previous head, and
22
+ * a bound Principal is never a grant subject, claimant or new owner. A
23
+ * chain without device records gives the answers of §80 alone.
17
24
  */
18
25
  /** §17.1 standard ability codes. */
19
26
  export const ABILITY = Object.freeze({
@@ -90,15 +97,8 @@ function grantConfers(state, g, ability) {
90
97
  return false;
91
98
  return ability !== ABILITY.INVITE_CLAIM || !claimsExhausted(g);
92
99
  }
93
- /**
94
- * Whether `principal` holds `ability` at the head of `state`: the owner
95
- * holds every standard ability implicitly (§17.1, no self-grant); anyone
96
- * else holds it through an active grant that confers it. Unknown codes and
97
- * the reserved ability 9 are held by no one (§17.1, §23.1).
98
- */
99
- export function hasAbility(state, principal, ability) {
100
- if (!confers(ability))
101
- return false;
100
+ /** `direct(P, A)` (§80.1): §80 steps 2–7 for P itself; the owner holds every standard ability. */
101
+ function direct(state, principal, ability) {
102
102
  if (isOwner(state, principal))
103
103
  return true;
104
104
  for (const g of state.grants.values())
@@ -106,6 +106,54 @@ export function hasAbility(state, principal, ability) {
106
106
  return true;
107
107
  return false;
108
108
  }
109
+ /**
110
+ * `holds(P, A)` (§80.1): whether `principal` holds `ability` at the head of
111
+ * `state`. A Principal that no device record names holds it directly: the
112
+ * owner implicitly (§17.1), anyone else through an active grant that
113
+ * confers it. A bound Principal holds nothing of its own (N4): only an
114
+ * active device holds, everything when it is an owner-acting device of
115
+ * the owner, else what its mask lists and its root holds. Unknown codes,
116
+ * the reserved 9, and 12 and 13 (mask-only codes) are held by no one.
117
+ *
118
+ * `holds(P, 11)` is false for every bound Principal, `owner/act` included
119
+ * (§80.1, spec#2 1cfe51b): invite/claim belongs to invitation grants, and
120
+ * owner/act confers the standard abilities 1-10 only.
121
+ */
122
+ export function hasAbility(state, principal, ability) {
123
+ if (!confers(ability))
124
+ return false;
125
+ if (!isBound(state, principal))
126
+ return direct(state, principal, ability);
127
+ if (ability === ABILITY.INVITE_CLAIM)
128
+ return false;
129
+ const d = activeDevice(state, principal);
130
+ if (d === undefined)
131
+ return false;
132
+ if (ownerAction(state, principal))
133
+ return true;
134
+ return d.mask.includes(ability) && direct(state, d.root, ability);
135
+ }
136
+ /**
137
+ * W §17.5 Exposure: the Principals registered at `before` (the state a
138
+ * DEVICE_REVOKE is accepted on) that hold `data/read` there and not at
139
+ * `after` (the state after it): the revoked device itself or any device
140
+ * its revocation suspends through the cascade. The record is exposing
141
+ * exactly when this is non-empty. Computed from the Resource's own chain
142
+ * alone; it is what the rotation policy of IDENTITY-PROFILE-01 §8 reacts to.
143
+ */
144
+ export function exposedReaders(before, after) {
145
+ const out = [];
146
+ for (const [hex, d] of before.devices) {
147
+ if (!d.registered)
148
+ continue;
149
+ const p = principalId(fromHex(hex));
150
+ if (hasAbility(before, p, ABILITY.DATA_READ) && !hasAbility(after, p, ABILITY.DATA_READ))
151
+ out.push(p);
152
+ }
153
+ return Object.freeze(out);
154
+ }
155
+ /** Whether the revocation that took `before` to `after` is exposing (W §17.5). */
156
+ export const isExposingRevocation = (before, after) => exposedReaders(before, after).length > 0;
109
157
  /** Every standard ability `principal` holds at the head of `state`, ascending. */
110
158
  export function abilitiesOf(state, principal) {
111
159
  return [...ABILITY_NAMES.keys()].filter((a) => hasAbility(state, principal, a));
@@ -124,7 +172,7 @@ function covers(state, target, revoker) {
124
172
  const seen = new Set();
125
173
  let grant = target;
126
174
  while (grant !== undefined && !seen.has(key(grant.id))) {
127
- if (bytesEqual(grant.issuer, revoker))
175
+ if (bytesEqual(grant.issuerRoot, revoker))
128
176
  return true;
129
177
  seen.add(key(grant.id));
130
178
  grant = grant.parentGrantId === null ? undefined : state.grants.get(key(grant.parentGrantId));
@@ -144,6 +192,10 @@ export function authorizeControlRecord(record, state) {
144
192
  case "GENESIS":
145
193
  return ALLOW;
146
194
  case "CAPABILITY_GRANT": {
195
+ // §80.1 role exclusivity: a bound subject would get direct authority.
196
+ if (isBound(state, body.subject.principalId))
197
+ return deny("the subject is bound as a device in this Resource (§17.2, §80.1)");
198
+ const owner = ownerAction(state, issuer);
147
199
  // §17.2: "The owner MAY grant any abilities without a parent grant. A
148
200
  // non-owner issuer MUST hold capability/grant and MUST reference a
149
201
  // parent grant." "If parent grant id is present: the parent grant
@@ -157,18 +209,25 @@ export function authorizeControlRecord(record, state) {
157
209
  return deny("the parent grant does not exist (§17.2)");
158
210
  if (!isGrantActive(state, parent.id))
159
211
  return deny("the parent grant is not active (§17.2)");
160
- if (!bytesEqual(parent.subject, issuer))
212
+ // §17.2 with devices: actsFor(kid) at the previous head is the parent's subject.
213
+ if (!bytesEqual(parent.subject, actsFor(state, issuer)))
161
214
  return deny("the issuer is not the subject of the parent grant (§17.2)");
162
215
  if (!subset(body.abilities, parent.delegable))
163
216
  return deny("a granted ability is not delegable under the parent grant (§17.2)");
164
217
  if (!subset(body.delegable, parent.delegable))
165
218
  return deny("a delegable ability is not delegable under the parent grant (§17.2)");
166
219
  }
167
- else if (!isOwner(state, issuer)) {
220
+ else if (!owner) {
168
221
  return deny("a non-owner grant must prove its authority through a parent grant (§17.2)");
169
222
  }
170
- if (!isOwner(state, issuer) && !hasAbility(state, issuer, ABILITY.CAPABILITY_GRANT))
223
+ if (!owner && !hasAbility(state, issuer, ABILITY.CAPABILITY_GRANT))
171
224
  return deny("the issuer does not hold capability/grant (§17.2)");
225
+ // §17.2 delegation bound: a device that is not owner-acting lists no
226
+ // code 1-10 outside its mask; 11, 12, 13 and unknown codes pass.
227
+ const device = owner ? undefined : state.devices.get(key(issuer));
228
+ if (device !== undefined &&
229
+ [...body.abilities, ...body.delegable].some((a) => a >= 1n && a <= 10n && !device.mask.includes(a)))
230
+ return deny("the grant lists an ability outside the issuing device's mask (§17.2)");
172
231
  return ALLOW;
173
232
  }
174
233
  case "CAPABILITY_REVOKE": {
@@ -179,10 +238,11 @@ export function authorizeControlRecord(record, state) {
179
238
  return deny("the revoked grant does not exist (§17.3)");
180
239
  // "The owner may revoke any grant." Otherwise the issuer MUST "possess
181
240
  // capability/revoke authority that covers the target grant".
182
- if (!isOwner(state, issuer)) {
241
+ if (!ownerAction(state, issuer)) {
183
242
  if (!hasAbility(state, issuer, ABILITY.CAPABILITY_REVOKE))
184
243
  return deny("the issuer does not hold capability/revoke (§17.3)");
185
- if (!covers(state, target, issuer))
244
+ // §17.3 with devices: coverage compares actsFor(kid) with issuer_root.
245
+ if (!covers(state, target, actsFor(state, issuer)))
186
246
  return deny("the issuer's capability/revoke authority does not cover the grant (§17.3)");
187
247
  }
188
248
  if (target.revokedBy !== null)
@@ -216,6 +276,9 @@ export function authorizeControlRecord(record, state) {
216
276
  const transferable = invitation.abilities.filter((a) => a !== ABILITY.INVITE_CLAIM || invitation.delegable.includes(ABILITY.INVITE_CLAIM));
217
277
  if (!subset(body.abilities.filter(isStandardAbility), transferable))
218
278
  return deny("the claim requests abilities beyond the invitation grant (§18.1 rule 4)");
279
+ // §80.1: the claimant is a root, never a bound device.
280
+ if (isBound(state, body.claimant.principalId))
281
+ return deny("the claimant is bound as a device in this Resource (§18.1, §80.1)");
219
282
  return ALLOW;
220
283
  }
221
284
  case "KEY_EPOCH":
@@ -223,8 +286,14 @@ export function authorizeControlRecord(record, state) {
223
286
  return hasAbility(state, issuer, ABILITY.KEY_ROTATE)
224
287
  ? ALLOW
225
288
  : deny("the issuer does not hold key/rotate (§19)");
226
- case "OWNER_TRANSFER_COMMIT":
289
+ case "OWNER_TRANSFER_COMMIT": {
290
+ if (body.deviceChain !== undefined) {
291
+ const chain = authorizeOwnerChain(state, body.deviceChain);
292
+ if (!chain.allowed)
293
+ return chain;
294
+ }
227
295
  return verifyOwnerTransfer(record, state).authorization;
296
+ }
228
297
  case "ROUTE_UPDATE":
229
298
  // §20: "The issuer MUST possess route/update"; "Each Route Update MUST
230
299
  // carry a route version strictly greater than the current one: 0 after
@@ -240,25 +309,32 @@ export function authorizeControlRecord(record, state) {
240
309
  // owner authority: its issuer MUST be the Resource owner at the
241
310
  // record's position in the chain, whether or not the receiver supports
242
311
  // the extension." §14 names no code: AUTHORIZATION_FAILED here.
243
- return isOwner(state, issuer)
312
+ return ownerAction(state, issuer)
244
313
  ? ALLOW
245
314
  : deny("an extension record must be issued by the owner (§14)");
246
315
  case "COORDINATOR_RECOVERY":
247
316
  case "RESOURCE_TOMBSTONE":
248
317
  return deny(`${body.type} is refused in MVP 0.1 (scope §4, DV1)`);
318
+ case "DEVICE_GRANT":
319
+ return authorizeDeviceGrant(state, issuer, body);
320
+ case "DEVICE_REVOKE":
321
+ return authorizeDeviceRevoke(state, issuer, body);
249
322
  }
250
323
  }
251
324
  /** The capability transitions of an applied record (called by applyRecord). */
252
- export function applyCapabilities(grants, record) {
325
+ export function applyCapabilities(state, record) {
326
+ const grants = state.grants;
327
+ // §79: issuer_root is actsFor(issuer) at the head the grant is committed on.
328
+ const issuerRoot = actsFor(state, record.payload.issuer);
253
329
  const body = record.body;
254
330
  const id = controlRecordId(record.signed.id);
255
331
  switch (body.type) {
256
332
  case "CAPABILITY_GRANT": {
257
- const next = new Map(grants);
258
- next.set(key(id), Object.freeze({
333
+ return put(grants, key(id), Object.freeze({
259
334
  id,
260
335
  source: "grant",
261
336
  issuer: record.payload.issuer,
337
+ issuerRoot,
262
338
  subject: body.subject.principalId,
263
339
  abilities: body.abilities,
264
340
  delegable: body.delegable,
@@ -267,15 +343,12 @@ export function applyCapabilities(grants, record) {
267
343
  claimsUsed: 0n,
268
344
  revokedBy: null,
269
345
  }));
270
- return next;
271
346
  }
272
347
  case "CAPABILITY_REVOKE": {
273
348
  const target = grants.get(key(body.grantId));
274
349
  if (target === undefined)
275
350
  return grants;
276
- const next = new Map(grants);
277
- next.set(key(target.id), Object.freeze({ ...target, revokedBy: id }));
278
- return next;
351
+ return put(grants, key(target.id), Object.freeze({ ...target, revokedBy: id }));
279
352
  }
280
353
  case "CAPABILITY_CLAIM": {
281
354
  // §18.1: "A successful claim creates a new capability grant to the
@@ -285,13 +358,14 @@ export function applyCapabilities(grants, record) {
285
358
  // claimed abilities; it has no parent grant and an empty delegable
286
359
  // list, so revoking the Invitation Grant later does not revoke it."
287
360
  const invitation = grants.get(key(body.invitationGrantId));
288
- const next = new Map(grants);
289
- if (invitation !== undefined)
290
- next.set(key(invitation.id), Object.freeze({ ...invitation, claimsUsed: invitation.claimsUsed + 1n }));
291
- next.set(key(id), Object.freeze({
361
+ const used = invitation === undefined
362
+ ? grants
363
+ : put(grants, key(invitation.id), Object.freeze({ ...invitation, claimsUsed: invitation.claimsUsed + 1n }));
364
+ return put(used, key(id), Object.freeze({
292
365
  id,
293
366
  source: "claim",
294
367
  issuer: record.payload.issuer,
368
+ issuerRoot,
295
369
  subject: body.claimant.principalId,
296
370
  abilities: body.abilities,
297
371
  delegable: [],
@@ -300,7 +374,6 @@ export function applyCapabilities(grants, record) {
300
374
  claimsUsed: 0n,
301
375
  revokedBy: null,
302
376
  }));
303
- return next;
304
377
  }
305
378
  default:
306
379
  return grants;
@@ -308,7 +381,8 @@ export function applyCapabilities(grants, record) {
308
381
  }
309
382
  /**
310
383
  * §25.2 Key Package authority at the package's referenced Control Head:
311
- * the sender must hold key/distribute, and the recipient must hold
384
+ * the sender must hold key/distribute or be of the recipient's root
385
+ * (`sameRoot`, §80.1), and the recipient must hold
312
386
  * data/read, "except for an Invitation Principal explicitly authorized by
313
387
  * an active invite grant: a recipient that is the subject of an active
314
388
  * grant that includes and still confers invite/claim". `epoch` is the
@@ -316,8 +390,10 @@ export function applyCapabilities(grants, record) {
316
390
  * checked with the epoch rules (LFCP-023, LFCP-024).
317
391
  */
318
392
  export function canDistributeKey(state, sender, recipient, _epoch) {
319
- if (!hasAbility(state, sender, ABILITY.KEY_DISTRIBUTE))
320
- return deny("the sender does not hold key/distribute (§25.2)");
393
+ // §25.2: key/distribute, or the same-root rule (a device delivering to
394
+ // its root or an active sibling, or the root to its active device).
395
+ if (!sameRoot(state, sender, recipient) && !hasAbility(state, sender, ABILITY.KEY_DISTRIBUTE))
396
+ return deny("the sender holds no key/distribute and is not of the recipient's root (§25.2)");
321
397
  if (hasAbility(state, recipient, ABILITY.DATA_READ))
322
398
  return ALLOW;
323
399
  if (hasAbility(state, recipient, ABILITY.INVITE_CLAIM))
@@ -360,13 +436,32 @@ export function verifyOwnerTransfer(record, state) {
360
436
  }
361
437
  const o = offer.payload;
362
438
  const a = accept.payload;
363
- if (!bytesEqual(o.resourceId, state.resourceId) || !bytesEqual(a.resourceId, state.resourceId))
364
- return { authorization: deny("the offer or accept is for another Resource (§23.1, §23.2)") };
365
- const offerSigned = verifySignedObject(offer.signed, state.owner);
366
- if (!offerSigned.valid)
439
+ // §62 class 4, before any authority: the offer verifies under its kid
440
+ // when that kid is the owner or a device bound to it (any other offer
441
+ // fails rule 1 below), the accept under the new owner or, with field 2,
442
+ // the leaf device. validateControlChain has checked both already (with
443
+ // the dependency class first); these repeat them for a direct caller.
444
+ const leaf = body.deviceChain?.leaf.payload.device;
445
+ const acceptor = leaf ?? o.proposedOwner;
446
+ const signer = offerSigner(state, offer.signed.kid);
447
+ const offerSigned = signer === undefined ? undefined : verifySignedObject(offer.signed, signer);
448
+ if (offerSigned !== undefined && !offerSigned.valid)
367
449
  return {
368
- authorization: badSignature(`the offer is not signed by the current owner (${offerSigned.reason}, §23.3 rule 1)`),
450
+ authorization: badSignature(`the offer's signature does not verify (${offerSigned.reason}, §23.3 rule 1)`),
369
451
  };
452
+ const acceptSigned = verifySignedObject(accept.signed, acceptor);
453
+ if (!acceptSigned.valid)
454
+ return {
455
+ authorization: badSignature(`the accept is not signed by the new owner or its leaf device (${acceptSigned.reason}, §23.3 rule 4)`),
456
+ };
457
+ // §80.1: the new owner is a root, never a bound device.
458
+ if (isBound(state, o.proposedOwner.principalId))
459
+ return { authorization: deny("the new owner is bound as a device here (§23.3, §80.1)") };
460
+ if (!bytesEqual(o.resourceId, state.resourceId) || !bytesEqual(a.resourceId, state.resourceId))
461
+ return { authorization: deny("the offer or accept is for another Resource (§23.1, §23.2)") };
462
+ // Rule 1 with devices: ownerAction(offer.kid) at the offer's head.
463
+ if (!ownerAction(state, offer.signed.kid))
464
+ return { authorization: deny("the offer is not signed by the current owner (§23.3 rule 1)") };
370
465
  if (!bytesEqual(o.controlHead, state.head))
371
466
  return {
372
467
  authorization: deny("the offer does not reference the current Control Head (§23.3 rule 2)"),
@@ -377,13 +472,10 @@ export function verifyOwnerTransfer(record, state) {
377
472
  };
378
473
  if (!bytesEqual(a.offerId, objectId(offer.signed.bytes)))
379
474
  return { authorization: deny("the accept does not name this offer (§23.2)") };
380
- const acceptSigned = verifySignedObject(accept.signed, o.proposedOwner);
381
- if (!acceptSigned.valid)
475
+ if (!bytesEqual(record.payload.issuer, (leaf ?? o.proposedOwner).principalId))
382
476
  return {
383
- authorization: badSignature(`the accept is not signed by the new owner (${acceptSigned.reason}, §23.3 rule 4)`),
477
+ authorization: deny("the commit is not issued by the new owner or its leaf (§23.3 rule 5)"),
384
478
  };
385
- if (!bytesEqual(record.payload.issuer, o.proposedOwner.principalId))
386
- return { authorization: deny("the commit is not issued by the new owner (§23.3 rule 5)") };
387
479
  if (o.expectedControlSeq !== record.payload.controlSeq)
388
480
  return {
389
481
  authorization: deny("the offer's expected Control Sequence is not the commit's (§23.3 rule 6)"),
@@ -396,11 +488,17 @@ export function verifyOwnerTransfer(record, state) {
396
488
  * before can still be verified. Undefined for other records.
397
489
  */
398
490
  export function transferIssuerDescriptor(record) {
491
+ const proposed = transferNewOwner(record);
492
+ return proposed !== undefined && bytesEqual(proposed.principalId, record.payload.issuer)
493
+ ? proposed
494
+ : undefined;
495
+ }
496
+ /** The new owner a transfer commit names (offer field 3), whoever signed the commit. */
497
+ export function transferNewOwner(record) {
399
498
  if (record.body.type !== "OWNER_TRANSFER_COMMIT")
400
499
  return undefined;
401
500
  try {
402
- const proposed = parseOwnerTransferOffer(record.body.offer).payload.proposedOwner;
403
- return bytesEqual(proposed.principalId, record.payload.issuer) ? proposed : undefined;
501
+ return parseOwnerTransferOffer(record.body.offer).payload.proposedOwner;
404
502
  }
405
503
  catch {
406
504
  return undefined;
package/dist/chain.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type ControlRecordId, type DataEpoch, type Hash32, LfcpError, type PrincipalId, type ResourceId } from "@openlfcp/core";
2
2
  import { type Authorization, type Grant } from "./capability.js";
3
3
  import { type ControlRecord } from "./control.js";
4
+ import { type DeviceState } from "./device-state.js";
4
5
  import type { Endpoint } from "./endpoint.js";
5
6
  import type { ActorHave } from "./have.js";
6
7
  import type { PrincipalDescriptor } from "./principal.js";
@@ -54,7 +55,7 @@ export interface EpochHistory extends ControlEpoch {
54
55
  * LFCP-021 adds the grants and applies route updates; LFCP-023 applies
55
56
  * Key Epoch rotation (`epoch`, `epochs`).
56
57
  */
57
- export interface ControlState {
58
+ export interface ControlState extends DeviceState {
58
59
  readonly resourceId: ResourceId;
59
60
  readonly genesisId: ControlRecordId;
60
61
  /** The current Control Head: the record ID of the last validated record. */
@@ -74,7 +75,8 @@ export interface ControlState {
74
75
  readonly grants: ReadonlyMap<string, Grant>;
75
76
  /**
76
77
  * Principal Descriptors carried by applied records (Genesis owner, grant
77
- * subjects, claimants), by Principal ID hex. Descriptors are
78
+ * subjects, claimants, new owners, every device of an accepted
79
+ * certificate chain), by Principal ID hex. Descriptors are
78
80
  * self-certifying (§7), so knowing one grants nothing; it only lets later
79
81
  * records by that Principal be verified.
80
82
  */
@@ -120,14 +122,18 @@ export interface ChainOptions {
120
122
  readonly start?: ControlState;
121
123
  /**
122
124
  * Resolves an issuer that no earlier record in the chain describes.
123
- * The returned descriptor is checked against the ID like any other.
125
+ * The returned descriptor is checked against the ID like any other. Not
126
+ * consulted for DEVICE_GRANT and DEVICE_REVOKE, whose issuer must be
127
+ * known to the chain (§17.4 rule 2, §17.5 rule 2).
124
128
  */
125
129
  readonly resolvePrincipal?: (id: PrincipalId) => PrincipalDescriptor | undefined;
126
130
  /**
127
131
  * Authorization, called for every non-Genesis record with the state
128
132
  * before it (the previous head). Defaults to authorizeControlRecord, the
129
133
  * LFCP-021 capability engine; a caller may substitute a policy (tests).
130
- * A refusal makes the chain invalid (UNAUTHORIZED).
134
+ * A refusal makes the chain invalid (UNAUTHORIZED). A substitute applies
135
+ * §23.3 item 1 itself: a transfer offer whose kid is neither the owner
136
+ * nor a device bound to it reaches it with its signature unchecked.
131
137
  */
132
138
  readonly authorize?: (record: ControlRecord, state: ControlState) => boolean | Authorization;
133
139
  }