@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.
@@ -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 steps 1 and 2 for one target, in their normative order. For the
14
- * target `I` itself both steps are vacuous. Registrations are planned on
15
- * the target's state as the revocations leave it (a device whose issuer
16
- * `I` revoked is never registered before the cascade reaches the target).
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 planReconciliation(identity, target) {
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 steps = [];
25
- // Step 1: every revoked pair of I not revoked in X, in I's chain order.
26
- const revokedInX = (d) => revocationOf(X, R, d) !== undefined;
27
- const revokedNow = new Set();
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 (revokedInX(d) || revokedNow.has(toHex(d)))
54
+ if (revocationOf(X, R, d) !== undefined || seen.has(toHex(d)))
33
55
  continue;
34
- revokedNow.add(toHex(d));
35
- steps.push({
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
- const revokedAfter = (d) => revokedInX(d) || revokedNow.has(toHex(d));
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 in I's chain without a valid entry (labels default to the fingerprint).
168
- if (displayIntents(I, doc, R).length > 0 && doc.initialized()) {
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 intents = displayIntents(now, fresh, R);
182
- if (intents.length === 0)
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
- /** §7.1 step 4: a device entry for each device of R in I's chain without one. */
218
- function displayIntents(I, doc, R) {
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
- for (const [hex, d] of I.state.devices) {
221
- if (!bytesEqual(d.root, R) || d.chain === null || d.kind === DEVICE_KIND.PAIRING)
284
+ const retire = [];
285
+ for (const [hex, d] of identity.devices) {
286
+ if (!bytesEqual(d.root, root) || d.chain === null)
222
287
  continue;
223
- const key = principalRefOf(principalId(fromHex(hex)));
224
- if (doc.devices().has(key))
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(principalId(fromHex(hex))),
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
- // Step 3: every reader of the target whose root is R (pairing devices of I included), by the ledger.
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 = readersAt(chain.state).filter((r) => bytesEqual(actsFor(chain.state, r.principalId), R));
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
- : await rotateIfExposed({ ...o, channel, operation, resource: X });
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" | "unreachable" | "unsupported" | "conflict";
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
- const one = async (c, X) => {
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({ ...o, channel: c, operation, resource: X });
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
- if (now.entry === undefined || now.entry.revoked === true)
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 written only by the device (§3 AI5).
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: [{ intent: "device.put", key, entry: { ...display, revoked: true } }],
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 DataProfileCodec, type HaveVector, type PrincipalDescriptor } from "@openlfcp/wire";
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 codec, when the application gives it: its
77
- * `decode` is the profile's acceptance. Without it only decryption is
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?: DataProfileCodec<unknown>;
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;