@openlfcp/client 0.3.1 → 0.5.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/identity/context.d.ts +33 -1
- package/dist/identity/context.js +98 -11
- package/dist/identity/devices.d.ts +18 -5
- package/dist/identity/document.d.ts +23 -0
- package/dist/identity/index.d.ts +4 -4
- package/dist/identity/index.js +2 -2
- package/dist/identity/pairing-request.d.ts +12 -0
- package/dist/identity/pairing-request.js +40 -9
- package/dist/identity/pairing.d.ts +11 -0
- package/dist/identity/pairing.js +20 -2
- package/dist/identity/reconcile.d.ts +73 -3
- package/dist/identity/reconcile.js +165 -36
- package/dist/identity/revocation.d.ts +11 -1
- package/dist/identity/revocation.js +69 -13
- package/dist/identity/rotation.d.ts +20 -7
- package/dist/identity/rotation.js +35 -8
- package/package.json +5 -5
|
@@ -1,45 +1,92 @@
|
|
|
1
1
|
import { bytesEqual, fromBase64url, fromHex, principalId, resourceId, secureRandom, toHex, } from "@openlfcp/core";
|
|
2
|
-
import { activeDevice, actsFor, DEVICE_KIND, deviceFingerprint, revocationOf, signControlRecord, } from "@openlfcp/wire";
|
|
3
|
-
import { deliverKeys, fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, readersAt, runControlPhase, runUnitPhase, sealIdentityUnit, syncChain, } from "./context.js";
|
|
2
|
+
import { activeDevice, actsFor, DEVICE_KIND, deviceFingerprint, isBound, revocationOf, signControlRecord, } from "@openlfcp/wire";
|
|
3
|
+
import { deliverKeys, fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, readersAt, retiredFlagMissing, revokedByIdentity, runControlPhase, runUnitPhase, sealIdentityUnit, syncChain, } from "./context.js";
|
|
4
4
|
import { mayManageDevices } from "./devices.js";
|
|
5
|
-
import { principalRefOf, resourceRef } from "./document.js";
|
|
5
|
+
import { principalRefOf, resourceRef, } from "./document.js";
|
|
6
6
|
import { newOperationId, OperationJournal, PackageLedger } from "./journal.js";
|
|
7
7
|
import { ChannelPool, IDENTITY_EXTENSION, IdentityRefused, IdentityUnavailable, } from "./network.js";
|
|
8
8
|
import { classifyRegister, classifyRevoke } from "./phases.js";
|
|
9
9
|
import { identityTarget } from "./resource.js";
|
|
10
10
|
import { rotateIfExposed } from "./revocation.js";
|
|
11
|
-
import { resumeRotations } from "./rotation.js";
|
|
11
|
+
import { checkFlowProfiles, resumeRotations } from "./rotation.js";
|
|
12
12
|
/**
|
|
13
|
-
* §7.1
|
|
14
|
-
* target `
|
|
15
|
-
*
|
|
16
|
-
* `I`
|
|
13
|
+
* §7.1 step 1: whether the revocation of `device` in `I` is relevant in the
|
|
14
|
+
* target `X`. It is when the device is bound in `X` (as a leaf or an
|
|
15
|
+
* intermediate, W §17.4 rule 9), or when it could become registered in `X`
|
|
16
|
+
* or issue a chain there: bound in `I` with kind 0, or with kind 1 and `X`
|
|
17
|
+
* in its scope, or not bound in `I` at all (its kind is unknown). A pairing
|
|
18
|
+
* device, or a service device whose scope does not list `X`, that is not
|
|
19
|
+
* bound in `X` can never act there (W §7.3 c3, c6; N8): its revocation
|
|
20
|
+
* would protect nothing and would take one of the 64 unbound pairs of the
|
|
21
|
+
* root that W §17.5 rule 5 allows in `X` and never frees.
|
|
22
|
+
*
|
|
23
|
+
* `binding` is the device's binding in `I` (undefined when unbound there).
|
|
17
24
|
*/
|
|
18
|
-
export function
|
|
25
|
+
export function revocationRelevant(binding, target, device) {
|
|
26
|
+
if (isBound(target, device))
|
|
27
|
+
return true;
|
|
28
|
+
if (binding === undefined || binding.kind === DEVICE_KIND.PERSONAL)
|
|
29
|
+
return true;
|
|
30
|
+
return (binding.kind === DEVICE_KIND.SERVICE &&
|
|
31
|
+
binding.scope.some((s) => bytesEqual(s, target.resourceId)));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* §7.1 step 1 for one target: the revocations of `I` to propose on `X`
|
|
35
|
+
* (the pairs of `I`'s `revoked` not in `X`'s that are relevant there, those
|
|
36
|
+
* whose device is bound in `X` first, each group in `I`'s chain order), and
|
|
37
|
+
* the devices whose revocation is not relevant in `X` and is not
|
|
38
|
+
* propagated. Empty for the target `I` itself.
|
|
39
|
+
*/
|
|
40
|
+
export function planRevocations(identity, target) {
|
|
19
41
|
if (bytesEqual(identity.state.resourceId, target.state.resourceId))
|
|
20
|
-
return [];
|
|
42
|
+
return { propose: [], notRelevant: [] };
|
|
21
43
|
const R = genesisOwner(identity);
|
|
22
44
|
const I = identity.state;
|
|
23
45
|
const X = target.state;
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const
|
|
46
|
+
const bound = [];
|
|
47
|
+
const unbound = [];
|
|
48
|
+
const notRelevant = [];
|
|
49
|
+
const seen = new Set();
|
|
28
50
|
for (const r of identity.records) {
|
|
29
51
|
if (r.body.type !== "DEVICE_REVOKE" || !bytesEqual(r.body.root, R))
|
|
30
52
|
continue;
|
|
31
53
|
const d = r.body.device;
|
|
32
|
-
if (
|
|
54
|
+
if (revocationOf(X, R, d) !== undefined || seen.has(toHex(d)))
|
|
33
55
|
continue;
|
|
34
|
-
|
|
35
|
-
|
|
56
|
+
seen.add(toHex(d));
|
|
57
|
+
if (!revocationRelevant(I.devices.get(toHex(d)), X, d)) {
|
|
58
|
+
notRelevant.push(d);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
(isBound(X, d) ? bound : unbound).push({
|
|
36
62
|
kind: "revoke",
|
|
37
63
|
root: R,
|
|
38
64
|
device: d,
|
|
39
65
|
reason: r.body.reason === 3n ? 0n : r.body.reason,
|
|
40
66
|
});
|
|
41
67
|
}
|
|
42
|
-
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
propose: Object.freeze([...bound, ...unbound]),
|
|
70
|
+
notRelevant: Object.freeze(notRelevant),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* §7.1 steps 1 and 2 for one target, in their normative order. For the
|
|
75
|
+
* target `I` itself both steps are vacuous. Registrations are planned on
|
|
76
|
+
* the target's state as the revocations leave it, and every pair of `I`'s
|
|
77
|
+
* `revoked` counts as revoked in `X` whether its propagation succeeds or
|
|
78
|
+
* not (an unpropagated pair, refused by W §17.5 rule 5, or a pair that is
|
|
79
|
+
* not relevant): no device whose issuer `I` has revoked is ever registered.
|
|
80
|
+
*/
|
|
81
|
+
export function planReconciliation(identity, target) {
|
|
82
|
+
if (bytesEqual(identity.state.resourceId, target.state.resourceId))
|
|
83
|
+
return [];
|
|
84
|
+
const R = genesisOwner(identity);
|
|
85
|
+
const I = identity.state;
|
|
86
|
+
const X = target.state;
|
|
87
|
+
// Step 1: the relevant revocations of I, bound in X first.
|
|
88
|
+
const steps = [...planRevocations(identity, target).propose];
|
|
89
|
+
const revokedAfter = (d) => revocationOf(X, R, d) !== undefined || revocationOf(I, R, d) !== undefined;
|
|
43
90
|
// Step 2: registrations, in I's binding order.
|
|
44
91
|
for (const [hex, d] of I.devices) {
|
|
45
92
|
if (!bytesEqual(d.root, R) || d.chain === null || d.leafId === null)
|
|
@@ -98,6 +145,7 @@ export function reconcileRecord(target, step, signer) {
|
|
|
98
145
|
* opened and this installation is the rotator, it rotates (§8.4).
|
|
99
146
|
*/
|
|
100
147
|
export async function reconcileDevices(o) {
|
|
148
|
+
checkFlowProfiles(o);
|
|
101
149
|
const target = await identityTarget(o);
|
|
102
150
|
const me = o.installation.signer;
|
|
103
151
|
const journal = new OperationJournal(o.storage);
|
|
@@ -114,10 +162,11 @@ export async function reconcileDevices(o) {
|
|
|
114
162
|
let document = { state: "none" };
|
|
115
163
|
let open = false;
|
|
116
164
|
try {
|
|
117
|
-
// A rotation an earlier run committed but did not adopt is finished first (§9.1).
|
|
118
|
-
await resumeRotations({ ...o, pool });
|
|
119
165
|
const channel = await pool.get(me, target.url);
|
|
120
166
|
const I = await syncChain(o.storage, channel, target.resource);
|
|
167
|
+
// A rotation an earlier run committed but did not adopt is finished first (§9.1). It
|
|
168
|
+
// seals nothing to a device I has revoked (§7.1 step 1).
|
|
169
|
+
await resumeRotations({ ...o, pool, identityChain: I });
|
|
121
170
|
const R = genesisOwner(I);
|
|
122
171
|
if (!mayManageDevices(I.state, me.descriptor.principalId, R))
|
|
123
172
|
return {
|
|
@@ -164,8 +213,9 @@ export async function reconcileDevices(o) {
|
|
|
164
213
|
throw e;
|
|
165
214
|
}
|
|
166
215
|
}
|
|
167
|
-
// Step 4: display data for devices
|
|
168
|
-
|
|
216
|
+
// Step 4: display data for the leaf-bound personal and service devices of I without a valid
|
|
217
|
+
// entry (labels default to the fingerprint), and the flag of a retired entry.
|
|
218
|
+
if (!nothingToWrite(displayPlan(I, doc, R)) && doc.initialized()) {
|
|
169
219
|
// Built on the current state of I: a rotation of I by this run, or by
|
|
170
220
|
// anyone, makes an earlier unit stale (W §19.1), and it is written again.
|
|
171
221
|
document = await runUnitPhase(channel, journal, operation, {
|
|
@@ -178,8 +228,8 @@ export async function reconcileDevices(o) {
|
|
|
178
228
|
const fresh = o.document(target.resource, R);
|
|
179
229
|
const loaded = await loadIdentityDocument({ channel, chain: now, deks, doc: fresh });
|
|
180
230
|
// Another manager may have written the entries since the first read (§7.1).
|
|
181
|
-
const
|
|
182
|
-
if (
|
|
231
|
+
const plan = displayPlan(now, fresh, R);
|
|
232
|
+
if (nothingToWrite(plan))
|
|
183
233
|
return null;
|
|
184
234
|
return sealIdentityUnit({
|
|
185
235
|
storage: o.storage,
|
|
@@ -188,7 +238,8 @@ export async function reconcileDevices(o) {
|
|
|
188
238
|
signer: me,
|
|
189
239
|
doc: fresh,
|
|
190
240
|
latest: loaded.latest,
|
|
191
|
-
intents,
|
|
241
|
+
intents: plan.intents,
|
|
242
|
+
retire: plan.retire,
|
|
192
243
|
});
|
|
193
244
|
},
|
|
194
245
|
});
|
|
@@ -214,25 +265,76 @@ export async function reconcileDevices(o) {
|
|
|
214
265
|
}
|
|
215
266
|
return { operation, targets: Object.freeze(out), document };
|
|
216
267
|
}
|
|
217
|
-
/**
|
|
218
|
-
|
|
268
|
+
/**
|
|
269
|
+
* §7.1 step 4: the display data reconciliation writes in `I`'s document,
|
|
270
|
+
* from `I`'s Control state and the valid device entries (§4). For every
|
|
271
|
+
* device of `R` of kind 0 or 1 bound in `I` as the leaf of an accepted
|
|
272
|
+
* chain (an intermediate-only binding issues and does not act in `I`):
|
|
273
|
+
* when it is not revoked in `I` and has no valid entry, an entry labelled
|
|
274
|
+
* with its fingerprint (§5.2); when it is revoked in `I` and some value of
|
|
275
|
+
* its entry's `revoked` (a retired entry) is not `true`, or the field is
|
|
276
|
+
* absent, that flag alone. `revokedValues` gives every concurrent value
|
|
277
|
+
* of the flag (IdentityDocumentPort.retirement); without it the entry's
|
|
278
|
+
* one shown value is read. No entry is created for a revoked device,
|
|
279
|
+
* nothing is written for a pairing device, nothing is relabelled or
|
|
280
|
+
* deleted, and a second run on the same states plans nothing.
|
|
281
|
+
*/
|
|
282
|
+
export function planDisplayData(identity, entries, root, revokedValues) {
|
|
219
283
|
const intents = [];
|
|
220
|
-
|
|
221
|
-
|
|
284
|
+
const retire = [];
|
|
285
|
+
for (const [hex, d] of identity.devices) {
|
|
286
|
+
if (!bytesEqual(d.root, root) || d.chain === null)
|
|
222
287
|
continue;
|
|
223
|
-
|
|
224
|
-
|
|
288
|
+
if (d.kind !== DEVICE_KIND.PERSONAL && d.kind !== DEVICE_KIND.SERVICE)
|
|
289
|
+
continue;
|
|
290
|
+
const D = principalId(fromHex(hex));
|
|
291
|
+
const key = principalRefOf(D);
|
|
292
|
+
if (revocationOf(identity, root, D) !== undefined) {
|
|
293
|
+
if (retiredFlagMissing(entries, key, revokedValues))
|
|
294
|
+
retire.push(key);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (entries.has(key))
|
|
225
298
|
continue;
|
|
226
299
|
intents.push({
|
|
227
300
|
intent: "device.put",
|
|
228
301
|
key,
|
|
229
302
|
entry: {
|
|
230
|
-
label: deviceFingerprint(
|
|
303
|
+
label: deviceFingerprint(D),
|
|
231
304
|
kind: d.kind === DEVICE_KIND.SERVICE ? 1 : 0,
|
|
232
305
|
},
|
|
233
306
|
});
|
|
234
307
|
}
|
|
235
|
-
return intents;
|
|
308
|
+
return Object.freeze({ intents: Object.freeze(intents), retire: Object.freeze(retire) });
|
|
309
|
+
}
|
|
310
|
+
const displayPlan = (I, doc, R) => planDisplayData(I.state, doc.devices(), R, doc.retirement === undefined ? undefined : (key) => doc.retirement?.revokedValues(key) ?? []);
|
|
311
|
+
const nothingToWrite = (p) => p.intents.length === 0 && p.retire.length === 0;
|
|
312
|
+
/**
|
|
313
|
+
* §7.1 step 3: the devices of `R` reconciliation seals the target's keys
|
|
314
|
+
* to: every reader of the target whose root is `R` (pairing devices of `I`
|
|
315
|
+
* included, in `I`), except a device revoked in `I` and a device whose
|
|
316
|
+
* current chain in the target passes through a device revoked in `I` (an
|
|
317
|
+
* unpropagated pair of step 1, which the target still counts as active).
|
|
318
|
+
*/
|
|
319
|
+
export function keyRecipients(identity, target) {
|
|
320
|
+
const R = genesisOwner(identity);
|
|
321
|
+
return Object.freeze(readersAt(target.state).filter((r) => bytesEqual(actsFor(target.state, r.principalId), R) &&
|
|
322
|
+
!revokedByIdentity(identity, target.state, r.principalId)));
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* The revocations of `I` that `X` refused in an earlier reconciliation of
|
|
326
|
+
* this installation, as `<phase ID>@<head of X they were proposed on>`,
|
|
327
|
+
* with the refusal code (§7.1 step 1). A head names one state of `X`.
|
|
328
|
+
*/
|
|
329
|
+
async function refusedRevocations(journal, operation, X) {
|
|
330
|
+
const out = new Map();
|
|
331
|
+
const prefix = `revoke:${toHex(X)}:`;
|
|
332
|
+
for (const op of await journal.list({ kind: "reconcile" }))
|
|
333
|
+
if (op.id !== operation)
|
|
334
|
+
for (const p of op.phases)
|
|
335
|
+
if (p.id.startsWith(prefix) && p.state === "refused" && p.expectedHead !== undefined)
|
|
336
|
+
out.set(`${p.id}@${p.expectedHead}`, p.code ?? "refused");
|
|
337
|
+
return out;
|
|
236
338
|
}
|
|
237
339
|
async function reconcileTarget(o, channel, journal, ledger, operation, I, X, resend) {
|
|
238
340
|
const me = o.installation.signer;
|
|
@@ -240,9 +342,29 @@ async function reconcileTarget(o, channel, journal, ledger, operation, I, X, res
|
|
|
240
342
|
let chain = await syncChain(o.storage, channel, X);
|
|
241
343
|
const steps = [];
|
|
242
344
|
const manages = mayManageDevices(chain.state, me.descriptor.principalId, R);
|
|
345
|
+
const refused = manages
|
|
346
|
+
? await refusedRevocations(journal, operation, X)
|
|
347
|
+
: new Map();
|
|
243
348
|
if (manages)
|
|
244
349
|
for (const step of planReconciliation(I, chain)) {
|
|
245
350
|
const id = `${step.kind}:${toHex(X)}:${toHex(step.device)}`;
|
|
351
|
+
// §7.1 step 1: a refused propagation is not repeated as such. It is proposed again once
|
|
352
|
+
// X's state changed since the refusal (its head moved; binding the device moves it too,
|
|
353
|
+
// and a bound device's revocation is never limited), not on every run.
|
|
354
|
+
const head = toHex(chain.state.head);
|
|
355
|
+
const code = step.kind === "revoke" ? refused.get(`${id}@${head}`) : undefined;
|
|
356
|
+
if (code !== undefined && !isBound(chain.state, step.device)) {
|
|
357
|
+
await journal.putPhase(operation, {
|
|
358
|
+
id,
|
|
359
|
+
kind: "revoke",
|
|
360
|
+
resource: toHex(X),
|
|
361
|
+
state: "refused",
|
|
362
|
+
expectedHead: head,
|
|
363
|
+
code,
|
|
364
|
+
});
|
|
365
|
+
steps.push({ step, state: "refused" });
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
246
368
|
const outcome = await runControlPhase(o.storage, channel, journal, operation, {
|
|
247
369
|
id,
|
|
248
370
|
kind: step.kind,
|
|
@@ -255,17 +377,24 @@ async function reconcileTarget(o, channel, journal, ledger, operation, I, X, res
|
|
|
255
377
|
steps.push({ step, state: outcome.state });
|
|
256
378
|
chain = outcome.chain;
|
|
257
379
|
}
|
|
258
|
-
//
|
|
380
|
+
// §7.1 step 1: a refused revocation is reported and stays unpropagated.
|
|
381
|
+
const unpropagated = steps
|
|
382
|
+
.filter((s) => s.step.kind === "revoke" && s.state === "refused")
|
|
383
|
+
.map((s) => s.step.device);
|
|
384
|
+
// Step 3: every reader of the target whose root is R (pairing devices of I included), by the
|
|
385
|
+
// ledger, except the devices I has revoked and those whose chain passes through one.
|
|
259
386
|
const deks = await fetchDeks(o.storage, o.secrets, channel, chain, o.installation);
|
|
260
|
-
const recipients =
|
|
387
|
+
const recipients = keyRecipients(I, chain);
|
|
261
388
|
const keys = await deliverKeys({ channel, ledger, chain, sender: me, recipients, deks, resend });
|
|
262
389
|
const rotation = o.rotate === false
|
|
263
390
|
? undefined
|
|
264
|
-
:
|
|
391
|
+
: // §7.1 step 1: the new epoch is sealed to no device I has revoked, nor through one.
|
|
392
|
+
await rotateIfExposed({ ...o, channel, operation, resource: X, identityChain: I });
|
|
265
393
|
return {
|
|
266
394
|
resource: X,
|
|
267
395
|
status: manages ? "done" : "not-authorized",
|
|
268
396
|
steps: Object.freeze(steps),
|
|
397
|
+
...(unpropagated.length === 0 ? {} : { unpropagated: Object.freeze(unpropagated) }),
|
|
269
398
|
keys,
|
|
270
399
|
...(rotation === undefined ? {} : { rotation }),
|
|
271
400
|
};
|
|
@@ -2,6 +2,7 @@ import { type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
|
2
2
|
import { type DocumentWrite } from "./context.js";
|
|
3
3
|
import { type FlowOptions } from "./devices.js";
|
|
4
4
|
import { type IdentityChannel } from "./network.js";
|
|
5
|
+
import { type LinearChain } from "./phases.js";
|
|
5
6
|
import { type IdentityFlowOptions } from "./resource.js";
|
|
6
7
|
import { type RotationOutcome } from "./rotation.js";
|
|
7
8
|
/**
|
|
@@ -39,7 +40,14 @@ export interface RevocationResult {
|
|
|
39
40
|
readonly resource: ResourceId;
|
|
40
41
|
readonly revoke: "revoked" | "complete" | "superseded" | "not-authorized"
|
|
41
42
|
/** The revoker's own chain passes through the device here: re-certify it here first (§8.1). */
|
|
42
|
-
| "self-dependent" | "refused"
|
|
43
|
+
| "self-dependent" | "refused"
|
|
44
|
+
/**
|
|
45
|
+
* §8.4, §7.1 step 1: a roster Resource where the device is not bound and
|
|
46
|
+
* can never act (a pairing device, or a service device whose scope does
|
|
47
|
+
* not list it): no revocation is proposed there, so that it does not take
|
|
48
|
+
* one of the root's 64 unbound pairs (W §17.5 rule 5).
|
|
49
|
+
*/
|
|
50
|
+
| "not-relevant" | "unreachable" | "unsupported" | "conflict";
|
|
43
51
|
readonly rotation?: RotationOutcome;
|
|
44
52
|
}
|
|
45
53
|
export type RevokeDeviceResult = {
|
|
@@ -102,5 +110,7 @@ export declare function rotateIfExposed(o: FlowOptions & {
|
|
|
102
110
|
readonly channel: IdentityChannel;
|
|
103
111
|
readonly operation: string;
|
|
104
112
|
readonly resource: ResourceId;
|
|
113
|
+
/** The Identity Resource of this installation's root: see rotateAfter. */
|
|
114
|
+
readonly identityChain?: LinearChain;
|
|
105
115
|
}): Promise<RotationOutcome>;
|
|
106
116
|
//# sourceMappingURL=revocation.d.ts.map
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { bytesEqual, fromBase64url, fromHex, principalId, resourceId, secureRandom, toHex, } from "@openlfcp/core";
|
|
2
|
-
import { activeDevice, signControlRecord, validateControlChain, } from "@openlfcp/wire";
|
|
3
|
-
import { fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, runControlPhase, runUnitPhase, sealIdentityUnit, syncChain, } from "./context.js";
|
|
2
|
+
import { activeDevice, revocationOf, signControlRecord, validateControlChain, } from "@openlfcp/wire";
|
|
3
|
+
import { fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, retiredFlagMissing, runControlPhase, runUnitPhase, sealIdentityUnit, syncChain, } from "./context.js";
|
|
4
4
|
import { mayManageDevices } from "./devices.js";
|
|
5
5
|
import { principalRefOf } from "./document.js";
|
|
6
6
|
import { newOperationId, OperationJournal, phaseOf } from "./journal.js";
|
|
7
7
|
import { ChannelPool, IDENTITY_EXTENSION, IdentityRefused, IdentityUnavailable, } from "./network.js";
|
|
8
8
|
import { classifyRevoke } from "./phases.js";
|
|
9
|
+
import { revocationRelevant } from "./reconcile.js";
|
|
9
10
|
import { identityTarget } from "./resource.js";
|
|
10
|
-
import { exposingRevocationsSince, resumeRotations, rotateAfter, rotatorTest, } from "./rotation.js";
|
|
11
|
+
import { checkFlowProfiles, exposingRevocationsSince, resumeRotations, rotateAfter, rotatorTest, } from "./rotation.js";
|
|
11
12
|
const toKeep = (state, root) => [...state.devices.entries()]
|
|
12
13
|
.filter(([, d]) => bytesEqual(d.root, root))
|
|
13
14
|
.map(([hex]) => principalId(fromHex(hex)))
|
|
@@ -131,6 +132,7 @@ class SelfDependent extends Error {
|
|
|
131
132
|
* another reason is `complete` and the rotation test still runs.
|
|
132
133
|
*/
|
|
133
134
|
export async function revokeDevice(o) {
|
|
135
|
+
checkFlowProfiles(o);
|
|
134
136
|
const target = await identityTarget(o);
|
|
135
137
|
const me = o.installation.signer.descriptor.principalId;
|
|
136
138
|
const selfRetire = bytesEqual(me, o.device);
|
|
@@ -138,9 +140,6 @@ export async function revokeDevice(o) {
|
|
|
138
140
|
const pool = new ChannelPool(o.network);
|
|
139
141
|
const operation = o.operation ?? newOperationId("revoke-device", secureRandom(8));
|
|
140
142
|
try {
|
|
141
|
-
// Rotations an earlier operation committed but did not adopt are finished first (§9.1).
|
|
142
|
-
if (!selfRetire)
|
|
143
|
-
await resumeRotations({ ...o, pool });
|
|
144
143
|
const channel = await pool.get(o.installation.signer, target.url);
|
|
145
144
|
const known = await journal.get(operation);
|
|
146
145
|
let I;
|
|
@@ -153,6 +152,10 @@ export async function revokeDevice(o) {
|
|
|
153
152
|
throw e;
|
|
154
153
|
return retireElsewhere(o, pool, journal, known);
|
|
155
154
|
}
|
|
155
|
+
// Rotations an earlier operation committed but did not adopt are finished first (§9.1),
|
|
156
|
+
// sealing nothing to a device I has revoked (§7.1 step 1).
|
|
157
|
+
if (!selfRetire)
|
|
158
|
+
await resumeRotations({ ...o, pool, identityChain: I });
|
|
156
159
|
const R = genesisOwner(I);
|
|
157
160
|
if (!selfRetire && !mayManageDevices(I.state, me, R))
|
|
158
161
|
return { kind: "not-authorized" };
|
|
@@ -171,6 +174,9 @@ export async function revokeDevice(o) {
|
|
|
171
174
|
device: toHex(o.device),
|
|
172
175
|
root: toHex(R),
|
|
173
176
|
reason: String(o.reason),
|
|
177
|
+
// §8.4: the device's binding in I decides where its revocation is relevant,
|
|
178
|
+
// also for a resumed self-retirement that can no longer read I.
|
|
179
|
+
...bindingIntent(I.state.devices.get(toHex(o.device))),
|
|
174
180
|
targets: (await rosterTargets(o, channel, I)).map((t) => ({
|
|
175
181
|
resource: toHex(t.resource),
|
|
176
182
|
url: t.url,
|
|
@@ -178,8 +184,13 @@ export async function revokeDevice(o) {
|
|
|
178
184
|
},
|
|
179
185
|
});
|
|
180
186
|
const roster = targetsOf(await journal.load(operation));
|
|
187
|
+
const binding = I.state.devices.get(toHex(o.device));
|
|
181
188
|
const results = [];
|
|
182
|
-
|
|
189
|
+
// I as this operation leaves it: a rotation seals nothing to a device it has revoked.
|
|
190
|
+
let identityChain = I;
|
|
191
|
+
const one = async (c, X, inRoster = true) => {
|
|
192
|
+
if (inRoster && !(await relevantIn(o, c, X, R, binding)))
|
|
193
|
+
return { resource: X, revoke: "not-relevant" };
|
|
183
194
|
let outcome;
|
|
184
195
|
try {
|
|
185
196
|
outcome = await runControlPhase(o.storage, c, journal, operation, {
|
|
@@ -208,12 +219,20 @@ export async function revokeDevice(o) {
|
|
|
208
219
|
return { resource: X, revoke: "superseded" };
|
|
209
220
|
if (outcome.state === "refused")
|
|
210
221
|
return { resource: X, revoke: "refused" };
|
|
222
|
+
if (!inRoster)
|
|
223
|
+
identityChain = outcome.chain;
|
|
211
224
|
const revoke = outcome.state === "committed-own" ? "revoked" : "complete";
|
|
212
225
|
if (selfRetire && o.reason === 0n)
|
|
213
226
|
return { resource: X, revoke };
|
|
214
227
|
let rotation;
|
|
215
228
|
try {
|
|
216
|
-
rotation = await rotateIfExposed({
|
|
229
|
+
rotation = await rotateIfExposed({
|
|
230
|
+
...o,
|
|
231
|
+
channel: c,
|
|
232
|
+
operation,
|
|
233
|
+
resource: X,
|
|
234
|
+
identityChain,
|
|
235
|
+
});
|
|
217
236
|
}
|
|
218
237
|
catch (e) {
|
|
219
238
|
// The revocation stands; the rotation resumes from the journal (§9.1).
|
|
@@ -223,7 +242,7 @@ export async function revokeDevice(o) {
|
|
|
223
242
|
}
|
|
224
243
|
return { resource: X, revoke, rotation };
|
|
225
244
|
};
|
|
226
|
-
const first = await one(channel, target.resource);
|
|
245
|
+
const first = await one(channel, target.resource, false);
|
|
227
246
|
results.push(first);
|
|
228
247
|
if (first.revoke === "superseded") {
|
|
229
248
|
await journal.setStatus(operation, "superseded");
|
|
@@ -271,6 +290,35 @@ export async function revokeDevice(o) {
|
|
|
271
290
|
pool.closeAll();
|
|
272
291
|
}
|
|
273
292
|
}
|
|
293
|
+
/** The device's kind and scope in I, journaled with a revocation (§8.4). */
|
|
294
|
+
const bindingIntent = (b) => b === undefined ? {} : { binding: { kind: b.kind, scope: b.scope.map(toHex) } };
|
|
295
|
+
/**
|
|
296
|
+
* The journaled binding; `null` when the operation predates it (0.4.0),
|
|
297
|
+
* which revokes in every roster Resource as before.
|
|
298
|
+
*/
|
|
299
|
+
function bindingOf(op) {
|
|
300
|
+
if (!Object.hasOwn(op.intent, "targets"))
|
|
301
|
+
return null;
|
|
302
|
+
const b = op.intent.binding;
|
|
303
|
+
if (b === undefined)
|
|
304
|
+
return Object.hasOwn(op.intent, "binding") ? undefined : null;
|
|
305
|
+
return {
|
|
306
|
+
kind: b.kind,
|
|
307
|
+
scope: b.scope.map((h) => resourceId(fromHex(h))),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* §8.4: whether the revocation of the device is proposed on the roster
|
|
312
|
+
* Resource `X`: when it is already revoked there (the phase classifies it
|
|
313
|
+
* as complete), or when it is relevant there by §7.1 step 1.
|
|
314
|
+
*/
|
|
315
|
+
async function relevantIn(o, c, X, R, binding) {
|
|
316
|
+
if (binding === null)
|
|
317
|
+
return true;
|
|
318
|
+
const x = await syncChain(o.storage, c, X);
|
|
319
|
+
return (revocationOf(x.state, R, o.device) !== undefined ||
|
|
320
|
+
revocationRelevant(binding, x.state, o.device));
|
|
321
|
+
}
|
|
274
322
|
const targetsOf = (op) => (op.intent.targets ?? []).map((t) => ({
|
|
275
323
|
resource: resourceId(fromHex(t.resource)),
|
|
276
324
|
url: t.url,
|
|
@@ -281,9 +329,14 @@ async function retireElsewhere(o, pool, journal, op) {
|
|
|
281
329
|
const results = [
|
|
282
330
|
{ resource: resourceId(fromHex(op.intent.identity)), revoke: "complete" },
|
|
283
331
|
];
|
|
332
|
+
const binding = bindingOf(op);
|
|
284
333
|
for (const t of targetsOf(op)) {
|
|
285
334
|
try {
|
|
286
335
|
const c = await pool.get(o.installation.signer, t.url);
|
|
336
|
+
if (!(await relevantIn(o, c, t.resource, R, binding))) {
|
|
337
|
+
results.push({ resource: t.resource, revoke: "not-relevant" });
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
287
340
|
const outcome = await runControlPhase(o.storage, c, journal, op.id, {
|
|
288
341
|
id: `revoke:${toHex(t.resource)}`,
|
|
289
342
|
kind: "revoke",
|
|
@@ -353,10 +406,11 @@ async function markRevoked(o, channel, journal, operation, I, device) {
|
|
|
353
406
|
build: async () => {
|
|
354
407
|
const now = await current();
|
|
355
408
|
gone = now.entry === undefined;
|
|
356
|
-
|
|
409
|
+
const retirement = now.doc.retirement;
|
|
410
|
+
const values = retirement === undefined ? undefined : (k) => retirement.revokedValues(k);
|
|
411
|
+
if (!retiredFlagMissing(now.doc.devices(), key, values))
|
|
357
412
|
return null;
|
|
358
|
-
// The device's missing_keys hint is
|
|
359
|
-
const { missing_keys: _hint, ...display } = now.entry;
|
|
413
|
+
// The flag alone (§8.2); the device's missing_keys hint is its own (§3 AI5).
|
|
360
414
|
return sealIdentityUnit({
|
|
361
415
|
storage: o.storage,
|
|
362
416
|
chain: now.chain,
|
|
@@ -364,7 +418,8 @@ async function markRevoked(o, channel, journal, operation, I, device) {
|
|
|
364
418
|
signer: o.installation.signer,
|
|
365
419
|
doc: now.doc,
|
|
366
420
|
latest: now.load.latest,
|
|
367
|
-
intents: [
|
|
421
|
+
intents: [],
|
|
422
|
+
retire: [key],
|
|
368
423
|
});
|
|
369
424
|
},
|
|
370
425
|
});
|
|
@@ -387,6 +442,7 @@ async function markRevoked(o, channel, journal, operation, I, device) {
|
|
|
387
442
|
* nothing exposing happened since the last rotation.
|
|
388
443
|
*/
|
|
389
444
|
export async function rotateIfExposed(o) {
|
|
445
|
+
checkFlowProfiles(o);
|
|
390
446
|
// A rotation this operation already started resumes from its journaled phase: its
|
|
391
447
|
// KEY_EPOCH may be committed while its DEK still waits in the pending slot (§9.1).
|
|
392
448
|
const started = phaseOf(await new OperationJournal(o.storage).load(o.operation), `rotate:${toHex(o.resource)}`);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type ControlRecordId, type DataEpoch, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
2
|
import { type ResourceDEK } from "@openlfcp/crypto";
|
|
3
|
-
import { type ControlState, type
|
|
3
|
+
import { type ControlState, type HaveVector, type PrincipalDescriptor } from "@openlfcp/wire";
|
|
4
4
|
import { type KeyDelivery } from "./context.js";
|
|
5
|
-
import type { FlowOptions } from "./devices.js";
|
|
5
|
+
import type { FlowOptions, UnitProfile } from "./devices.js";
|
|
6
6
|
import { type ChannelPool, type IdentityChannel } from "./network.js";
|
|
7
7
|
import { type LinearChain } from "./phases.js";
|
|
8
8
|
/**
|
|
@@ -73,12 +73,15 @@ export interface FrontierData {
|
|
|
73
73
|
/** The DEKs held for the Resource's epochs, by epoch number. */
|
|
74
74
|
readonly deks: ReadonlyMap<string, ResourceDEK>;
|
|
75
75
|
/**
|
|
76
|
-
* The Resource's Data Profile
|
|
77
|
-
*
|
|
76
|
+
* The Resource's Data Profile, when the application gives it, as its
|
|
77
|
+
* receiver checks one unit: `codecFor({resourceId, actor})` is bound to the
|
|
78
|
+
* unit's Resource and signer, and the codec's `decode` is the profile's
|
|
79
|
+
* acceptance (the change's actor included). Without it only decryption is
|
|
78
80
|
* checked here, and a profile refusal this installation recorded still
|
|
79
|
-
* counts (LocalAccepted.refused).
|
|
81
|
+
* counts (LocalAccepted.refused). 0.3.1 took a bare codec, which cannot
|
|
82
|
+
* bind the actor: one without `codecFor` is refused (TypeError).
|
|
80
83
|
*/
|
|
81
|
-
readonly profile?:
|
|
84
|
+
readonly profile?: UnitProfile;
|
|
82
85
|
}
|
|
83
86
|
export type RotationStatus =
|
|
84
87
|
/** This installation committed the KEY_EPOCH and sealed the new DEK to every reader. */
|
|
@@ -109,14 +112,22 @@ export interface RotationOutcome {
|
|
|
109
112
|
* reuse it (§9.4). After the ACK the DEK goes to every reader of the new
|
|
110
113
|
* head, each package recorded in the ledger; the revoked device, a
|
|
111
114
|
* consumed or cancelled pairing device and suspended devices are not
|
|
112
|
-
* readers and receive nothing.
|
|
115
|
+
* readers and receive nothing. With `identityChain`, neither does a reader that
|
|
116
|
+
* the rotator's Identity Resource has revoked, or whose chain in the
|
|
117
|
+
* Resource passes through such a device: a pair not yet propagated there
|
|
118
|
+
* (IDENTITY-PROFILE-01 §7.1 step 1) is treated as revoked, and the rotation
|
|
119
|
+
* closes the epoch for it as for the device just revoked.
|
|
113
120
|
*/
|
|
114
121
|
export declare function rotateAfter(o: FlowOptions & {
|
|
115
122
|
readonly channel: IdentityChannel;
|
|
116
123
|
readonly operation: string;
|
|
117
124
|
readonly resource: ResourceId;
|
|
118
125
|
readonly revokedAt: bigint;
|
|
126
|
+
/** The Identity Resource of the rotator's root (§7.1 step 1), when the flow knows it. */
|
|
127
|
+
readonly identityChain?: LinearChain;
|
|
119
128
|
}): Promise<RotationOutcome>;
|
|
129
|
+
/** The flow options of a rotation: 0.3.1's bare `dataProfiles` is refused, never checked less. */
|
|
130
|
+
export declare function checkFlowProfiles(o: FlowOptions): void;
|
|
120
131
|
/**
|
|
121
132
|
* Resumes every rotation a journaled operation started and did not adopt:
|
|
122
133
|
* a KEY_EPOCH whose ACK was lost leaves its DEK in the operation's pending
|
|
@@ -125,6 +136,8 @@ export declare function rotateAfter(o: FlowOptions & {
|
|
|
125
136
|
*/
|
|
126
137
|
export declare function resumeRotations(o: FlowOptions & {
|
|
127
138
|
readonly pool: ChannelPool;
|
|
139
|
+
/** The Identity Resource of this installation's root: see rotateAfter. */
|
|
140
|
+
readonly identityChain?: LinearChain;
|
|
128
141
|
}): Promise<readonly RotationOutcome[]>;
|
|
129
142
|
/** The control sequence of a record ID (hex) on a chain. */
|
|
130
143
|
export declare const seqOf: (chain: LinearChain, recordIdHex: string) => bigint | undefined;
|