@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.
- 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/capability.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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(
|
|
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
|
|
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
|
package/dist/capability.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
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
|
-
|
|
381
|
-
if (!acceptSigned.valid)
|
|
475
|
+
if (!bytesEqual(record.payload.issuer, (leaf ?? o.proposedOwner).principalId))
|
|
382
476
|
return {
|
|
383
|
-
authorization:
|
|
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
|
-
|
|
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
|
|
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
|
}
|