@openlfcp/client 0.4.0 → 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.
@@ -4,7 +4,7 @@ import { type LfcpStorage, type SecretStore } from "@openlfcp/storage";
4
4
  import { type DataUnitPayload, type Parsed, type PrincipalDescriptor, type Signer } from "@openlfcp/wire";
5
5
  import { type IdentityDocumentPort, type IdentityIntent } from "./document.js";
6
6
  import { type JournalPhase, type OperationJournal, type PackageLedger } from "./journal.js";
7
- import type { IdentityChannel } from "./network.js";
7
+ import { type IdentityChannel } from "./network.js";
8
8
  import type { LinearChain, PhaseState } from "./phases.js";
9
9
  /** The storage the identity flows use. */
10
10
  export type IdentityStorage = Pick<LfcpStorage, "control" | "commit" | "localMarks" | "actorSequences">;
@@ -61,6 +61,13 @@ export interface PhaseOutcome {
61
61
  * with the same ACK, W §47), a record rebuilt on a new head otherwise.
62
62
  */
63
63
  export declare function runControlPhase(storage: IdentityStorage, channel: IdentityChannel, journal: OperationJournal, operation: string, spec: ControlPhaseSpec): Promise<PhaseOutcome>;
64
+ /**
65
+ * The client-local refusal code of a device record or same-root Key Package
66
+ * this client does not send to a server whose READY lacks `identity-01`
67
+ * (LFCP-WIRE-01 §37): the Resource is hosted on a server without identity
68
+ * support (IDENTITY-PROFILE-01 §6). No wire code.
69
+ */
70
+ export declare const IDENTITY_UNSUPPORTED = "IDENTITY_UNSUPPORTED";
64
71
  /** The DEKs this client holds for `chain`'s epochs, each checked against its commitment. */
65
72
  export declare function heldDeks(secrets: SecretStore, chain: LinearChain): Promise<Map<string, ResourceDEK>>;
66
73
  /** Stores the DEK of `epoch` (secret first) and links the epoch rows to it (LFCP-034). */
@@ -72,6 +79,16 @@ export declare function storeDek(storage: IdentityStorage, secrets: SecretStore,
72
79
  export declare function fetchDeks(storage: IdentityStorage, secrets: SecretStore, channel: IdentityChannel, chain: LinearChain, recipient: Installation): Promise<Map<string, ResourceDEK>>;
73
80
  /** Every Principal with `holds(P, 1)` at the head (W §19, §25.2): the reader set, by ID. */
74
81
  export declare function readersAt(state: LinearChain["state"]): readonly PrincipalDescriptor[];
82
+ /**
83
+ * IDENTITY-PROFILE-01 §7.1 step 1: whether `principal`, a reader of the
84
+ * target state, is a device that the Identity Resource `identity` has
85
+ * revoked, or whose current chain in the target passes through such a
86
+ * device. Until the pair is propagated the target still counts the device
87
+ * as active (an unpropagated pair), but the installations of `identity`'s
88
+ * root treat it as revoked there and seal nothing to it, in a rotation as
89
+ * in step 3.
90
+ */
91
+ export declare function revokedByIdentity(identity: LinearChain, target: LinearChain["state"], principal: PrincipalId): boolean;
75
92
  export interface KeyDelivery {
76
93
  /** Packages sealed and acknowledged now, as (epoch, recipient hex). */
77
94
  readonly sent: readonly {
@@ -136,6 +153,14 @@ export interface IdentityDocumentLoad {
136
153
  }
137
154
  /** The Genesis owner of a chain: the root of an Identity Resource (§2.3). */
138
155
  export declare function genesisOwner(chain: LinearChain): PrincipalId;
156
+ /**
157
+ * Whether the retired entry `devices[key]` needs its flag (§7.1 step 4):
158
+ * some value of its `revoked` is not `true`, or the field is absent. A port
159
+ * without `retirement` shows one value. False when there is no valid entry.
160
+ */
161
+ export declare function retiredFlagMissing(entries: ReadonlyMap<string, {
162
+ readonly revoked?: boolean;
163
+ }>, key: string, revokedValues?: (key: string) => readonly unknown[]): boolean;
139
164
  /**
140
165
  * A new unit of `signer` in the Identity Resource carrying `intents` as
141
166
  * one change: the next actor sequence from the storage's reservation
@@ -150,6 +175,13 @@ export declare function sealIdentityUnit(options: {
150
175
  readonly doc: IdentityDocumentPort;
151
176
  readonly latest: IdentityDocumentLoad["latest"];
152
177
  readonly intents: readonly IdentityIntent[];
178
+ /**
179
+ * Retired entries whose `revoked` flag the change sets (§7.1 step 4,
180
+ * §8.2): field by field when the port has `retirement`, else with
181
+ * `device.put` of the entry's display fields (its `missing_keys` hint is
182
+ * the device's own, §3 AI5).
183
+ */
184
+ readonly retire?: readonly string[];
153
185
  /** The sequence to use instead of a reservation (a pairing device shared by installations, §5.3). */
154
186
  readonly seq?: bigint;
155
187
  }): Promise<Uint8Array>;
@@ -1,11 +1,11 @@
1
1
  import { actorSequence, bytesEqual, controlRecordId, dataEpoch, fromHex, hash32, LfcpError, toHex, } from "@openlfcp/core";
2
2
  import { dekCommitment, exportSecretKeyBytes, importResourceDEK, sha256, } from "@openlfcp/crypto";
3
3
  import { dekSecretRef } from "@openlfcp/storage";
4
- import { ABILITY, hasAbility, InMemorySeenUnits, parseDataUnit, parseKeyPackage, receiveDataUnit, receiveKeyPackage, sealDataUnit, sealKeyPackage, validateControlChain, verifyKeyPackage, verifySignedObject, } from "@openlfcp/wire";
4
+ import { ABILITY, decodeControlRecord, hasAbility, InMemorySeenUnits, parseDataUnit, parseKeyPackage, receiveDataUnit, receiveKeyPackage, revocationOf, sealDataUnit, sealKeyPackage, validateControlChain, verifyKeyPackage, verifySignedObject, } from "@openlfcp/wire";
5
5
  import { adoptStoredDeks, checkWriteBarrier, loadControlChain, saveControlChain, saveControlConflict, } from "../storage.js";
6
6
  import { identityPlaintextCodec, identityWriterClass, } from "./document.js";
7
7
  import { phaseOf, } from "./journal.js";
8
- import { IdentityUnavailable } from "./network.js";
8
+ import { IDENTITY_EXTENSION, IdentityUnavailable } from "./network.js";
9
9
  /** The Resource or the Identity Resource is forked (§9.7): approval, reconciliation and rotation are suspended. */
10
10
  export class IdentityConflict extends Error {
11
11
  resource;
@@ -104,11 +104,22 @@ export async function runControlPhase(storage, channel, journal, operation, spec
104
104
  if (attempt >= MAX_HEAD_RETRIES)
105
105
  throw new IdentityUnavailable(`the head kept moving while proposing ${spec.id}`);
106
106
  const head = toHex(chain.state.head);
107
- let bytes;
108
- if (spec.resend !== false && before?.bytes !== undefined && before.expectedHead === head)
109
- bytes = fromHex(before.bytes);
110
- else {
111
- bytes = await spec.build(chain);
107
+ const journaled = spec.resend !== false && before?.bytes !== undefined && before.expectedHead === head;
108
+ const bytes = journaled ? fromHex(before.bytes) : await spec.build(chain);
109
+ if (!channel.extensions.includes(IDENTITY_EXTENSION) && isDeviceRecord(bytes)) {
110
+ // W §37: no record of type 9 or 10 to a server whose READY lacks identity-01,
111
+ // journaled bytes included (a resumed operation checks the current READY).
112
+ await journal.putPhase(operation, {
113
+ ...(before ?? {}),
114
+ id: spec.id,
115
+ kind: spec.kind,
116
+ resource: toHex(R),
117
+ state: "refused",
118
+ code: IDENTITY_UNSUPPORTED,
119
+ });
120
+ return Object.freeze({ state: "refused", chain, code: IDENTITY_UNSUPPORTED });
121
+ }
122
+ if (!journaled) {
112
123
  const id = toHex(validateRecordId(bytes));
113
124
  await journal.putPhase(operation, {
114
125
  id: spec.id,
@@ -136,6 +147,18 @@ export async function runControlPhase(storage, channel, journal, operation, spec
136
147
  return Object.freeze({ state: "refused", chain, code: answer.code });
137
148
  }
138
149
  }
150
+ /**
151
+ * The client-local refusal code of a device record or same-root Key Package
152
+ * this client does not send to a server whose READY lacks `identity-01`
153
+ * (LFCP-WIRE-01 §37): the Resource is hosted on a server without identity
154
+ * support (IDENTITY-PROFILE-01 §6). No wire code.
155
+ */
156
+ export const IDENTITY_UNSUPPORTED = "IDENTITY_UNSUPPORTED";
157
+ /** A DEVICE_GRANT (type 9) or DEVICE_REVOKE (type 10). */
158
+ function isDeviceRecord(bytes) {
159
+ const type = decodeControlRecord(bytes).body.type;
160
+ return type === "DEVICE_GRANT" || type === "DEVICE_REVOKE";
161
+ }
139
162
  /** A Control Record's ID: SHA-256 of its exact bytes (W §13). */
140
163
  const validateRecordId = (bytes) => controlRecordId(sha256(bytes));
141
164
  function ownRecordOn(chain, sent) {
@@ -219,6 +242,23 @@ export function readersAt(state) {
219
242
  .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
220
243
  .map(([, d]) => d));
221
244
  }
245
+ /**
246
+ * IDENTITY-PROFILE-01 §7.1 step 1: whether `principal`, a reader of the
247
+ * target state, is a device that the Identity Resource `identity` has
248
+ * revoked, or whose current chain in the target passes through such a
249
+ * device. Until the pair is propagated the target still counts the device
250
+ * as active (an unpropagated pair), but the installations of `identity`'s
251
+ * root treat it as revoked there and seal nothing to it, in a rotation as
252
+ * in step 3.
253
+ */
254
+ export function revokedByIdentity(identity, target, principal) {
255
+ const R = genesisOwner(identity);
256
+ const revoked = (d) => revocationOf(identity.state, R, d) !== undefined;
257
+ if (revoked(principal))
258
+ return true;
259
+ const binding = target.devices.get(toHex(principal));
260
+ return binding !== undefined && bytesEqual(binding.root, R) && binding.issuers.some(revoked);
261
+ }
222
262
  /**
223
263
  * Seals every DEK of `deks` to each recipient at the chain's head and
224
264
  * records each acknowledged package (§9.5). A package the ledger records is
@@ -233,6 +273,21 @@ export async function deliverKeys(options) {
233
273
  const skipped = [];
234
274
  let complete = 0;
235
275
  const epochs = [...options.deks.keys()].sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : 1));
276
+ // W §37: without identity-01 the server applies no same-root rule (W §25.2); a sender
277
+ // without key/distribute is authorized only by it, and sends nothing there.
278
+ const identity = options.channel.extensions.includes(IDENTITY_EXTENSION);
279
+ const sameRootOnly = !identity && !hasAbility(chain.state, sender.descriptor.principalId, ABILITY.KEY_DISTRIBUTE);
280
+ // The same for a pending package, at its own referenced head: a later key/distribute does
281
+ // not authorize it there (W §25.2), so it is re-sealed at the current head instead.
282
+ const resendable = (bytes) => {
283
+ const parsed = parseKeyPackage(bytes);
284
+ if (verifyKeyPackage(chain, parsed).kind !== "authorized")
285
+ return false;
286
+ if (identity)
287
+ return true;
288
+ const atHead = chain.stateAt(parsed.payload.controlHead);
289
+ return (atHead !== undefined && hasAbility(atHead, parsed.payload.sender, ABILITY.KEY_DISTRIBUTE));
290
+ };
236
291
  for (const recipient of options.recipients) {
237
292
  const rid = recipient.principalId;
238
293
  const hex = toHex(rid);
@@ -242,6 +297,10 @@ export async function deliverKeys(options) {
242
297
  skipped.push({ recipient: hex, reason: "not a reader at the head" });
243
298
  continue;
244
299
  }
300
+ if (sameRootOnly) {
301
+ skipped.push({ recipient: hex, reason: IDENTITY_UNSUPPORTED });
302
+ continue;
303
+ }
245
304
  for (const e of epochs) {
246
305
  const epoch = dataEpoch(BigInt(e));
247
306
  if ((await ledger.has(R, epoch, rid)) && !(options.resend?.get(hex)?.has(e) ?? false)) {
@@ -250,8 +309,7 @@ export async function deliverKeys(options) {
250
309
  }
251
310
  // §9.1: a package sent before is sent again as the same bytes, while it is still authorized.
252
311
  let bytes = await ledger.pending(R, epoch, rid);
253
- if (bytes !== undefined &&
254
- verifyKeyPackage(chain, parseKeyPackage(bytes)).kind !== "authorized")
312
+ if (bytes !== undefined && !resendable(bytes))
255
313
  bytes = undefined;
256
314
  if (bytes === undefined) {
257
315
  const sealed = await sealKeyPackage({
@@ -368,6 +426,28 @@ export function genesisOwner(chain) {
368
426
  const g = chain.records[0];
369
427
  return g?.body.type === "GENESIS" ? g.body.owner.principalId : chain.state.owner.principalId;
370
428
  }
429
+ /** 0.4's flag write on a port without `retirement`: the entry's display fields and the flag. */
430
+ function retiredPut(doc, key) {
431
+ const entry = doc.devices().get(key);
432
+ if (entry === undefined)
433
+ return [];
434
+ const { missing_keys: _hint, ...display } = entry;
435
+ return [{ intent: "device.put", key, entry: { ...display, revoked: true } }];
436
+ }
437
+ /**
438
+ * Whether the retired entry `devices[key]` needs its flag (§7.1 step 4):
439
+ * some value of its `revoked` is not `true`, or the field is absent. A port
440
+ * without `retirement` shows one value. False when there is no valid entry.
441
+ */
442
+ export function retiredFlagMissing(entries, key, revokedValues) {
443
+ const entry = entries.get(key);
444
+ if (entry === undefined)
445
+ return false;
446
+ if (revokedValues === undefined)
447
+ return entry.revoked !== true;
448
+ const values = revokedValues(key);
449
+ return values.length === 0 || values.some((v) => v !== true);
450
+ }
371
451
  /**
372
452
  * A new unit of `signer` in the Identity Resource carrying `intents` as
373
453
  * one change: the next actor sequence from the storage's reservation
@@ -390,11 +470,18 @@ export async function sealIdentityUnit(options) {
390
470
  while (previous !== undefined && seq <= previous.seq)
391
471
  seq = await options.storage.actorSequences.reserveNext(R, me);
392
472
  }
393
- const plaintext = options.doc.write({
473
+ const author = {
394
474
  signer: me,
395
475
  actorSeq: seq,
396
476
  writerClass: identityWriterClass(chain.state, genesisOwner(chain), me),
397
- }, options.intents);
477
+ };
478
+ const retire = options.retire ?? [];
479
+ const { doc } = options;
480
+ const plaintext = retire.length === 0
481
+ ? doc.write(author, options.intents)
482
+ : doc.retirement !== undefined
483
+ ? doc.retirement.write(author, options.intents, retire)
484
+ : doc.write(author, [...options.intents, ...retire.flatMap((key) => retiredPut(doc, key))]);
398
485
  return sealDataUnit({
399
486
  resourceId: R,
400
487
  dataEpoch: epoch,
@@ -95,6 +95,29 @@ export interface IdentityDocumentPort {
95
95
  readonly hash: string;
96
96
  readonly signer: PrincipalId;
97
97
  }[];
98
+ /**
99
+ * Optional (0.5.0): a device entry's `revoked` flag field by field, for
100
+ * a retired entry (§7.1 step 4, §8.2). With it, reconciliation sees every
101
+ * concurrent value of the flag and sets it without writing the entry's
102
+ * other fields, so it never resolves a conflict on them (a concurrent
103
+ * rename). A port without it shows one value per field (`devices()`), and
104
+ * the flag is written with `device.put` of the entry's display fields, as
105
+ * in 0.4. IdentityReplica provides both (`fieldValues`, `write`'s
106
+ * `retire` option).
107
+ */
108
+ readonly retirement?: IdentityRetirementPort;
109
+ }
110
+ /** IdentityDocumentPort.retirement. */
111
+ export interface IdentityRetirementPort {
112
+ /** Every value `devices[key].revoked` holds now: several under concurrent writes, none when absent. */
113
+ revokedValues(key: string): readonly unknown[];
114
+ /**
115
+ * Like IdentityDocumentPort.write, the plaintext of one change by
116
+ * `author`: `intents` in order (possibly none), then `revoked = true` in
117
+ * each existing entry `devices[key]` of `retire`, no other field of those
118
+ * entries written.
119
+ */
120
+ write(author: IdentityAuthor, intents: readonly IdentityIntent[], retire: readonly string[]): Uint8Array;
98
121
  }
99
122
  /** A passthrough Data Unit codec: the port produces and consumes the plaintext. */
100
123
  export declare const identityPlaintextCodec: DataProfileCodec<Uint8Array>;
@@ -2,14 +2,14 @@
2
2
  export { acceptInvitationAsDevice, claimForRootRecord, createResourceAsDevice, type DeviceAcceptResult, type DeviceCreateResult, type DeviceResourceResult, type IncompleteDeviceResource, selfRegistrationRecord, } from "./accept.js";
3
3
  export { type DocumentWrite, deliverKeys, extendChain, fetchDeks, genesisOwner, heldDeks, IdentityConflict, type IdentityDocumentLoad, type IdentityStorage, type Installation, type KeyDelivery, loadIdentityDocument, type PhaseOutcome, readersAt, runControlPhase, runUnitPhase, sealIdentityUnit, storeDek, syncChain, } from "./context.js";
4
4
  export { checkIssuance, type Feasibility, type FlowOptions, issuerCertificates, leafPayload, MASK_PRESETS, type MaskPreset, mayManageDevices, type RegisterTarget, type RegistrationResult, type RegistrationStatus, registerDevice, signChain, type UnitProfile, } from "./devices.js";
5
- export { type DeviceEntryValue, IDENTITY_PROFILE, type IdentityAuthor, type IdentityDocumentPort, type IdentityIntent, type IdentityWriterClass, identityPlaintextCodec, identityWriterClass, type PairingRequestValue, principalOfRef, principalRefOf, type RosterEntryValue, resourceRef, } from "./document.js";
5
+ export { type DeviceEntryValue, IDENTITY_PROFILE, type IdentityAuthor, type IdentityDocumentPort, type IdentityIntent, type IdentityRetirementPort, type IdentityWriterClass, identityPlaintextCodec, identityWriterClass, type PairingRequestValue, principalOfRef, principalRefOf, type RosterEntryValue, resourceRef, } from "./document.js";
6
6
  export { type JournalOperation, type JournalPhase, type LedgerEntry, OperationJournal, type OperationKind, type OperationStatus, PackageLedger, pairingSecretRef, pendingDekRef, } from "./journal.js";
7
7
  export { ChannelPool, type ConnectionNetworkOptions, connectionNetwork, IDENTITY_EXTENSION, type IdentityAnswer, type IdentityChannel, type IdentityNetwork, IdentityRefused, IdentityUnavailable, type OpenAnswer, } from "./network.js";
8
8
  export { type ApprovalResult, approvedGrantRecord, approvePairing, type CancelResult, cancelPairing, cancelRecord, consumeRecord, createPairingSession, openPairingSessions, PairingLinkText, type PairingReview, type PairingSessionResult, pairingRequests, resumeApproval, resumePairingSession, } from "./pairing.js";
9
- export { type CandidateEvidence, type CandidateResult, type CandidateStatus, candidateEvidence, completePairing, type PairingOutcome, type RequestPairingOptions, type RequestPairingResult, requestPairing, } from "./pairing-request.js";
9
+ export { type CandidateEvidence, type CandidateResult, type CandidateStatus, candidateEvidence, completePairing, type PairingOutcome, pairingLinkRejection, type RequestPairingOptions, type RequestPairingResult, requestPairing, } from "./pairing-request.js";
10
10
  export { certificateInScope, classifyCancel, classifyConsume, classifyPackage, classifyRegister, classifyRevoke, classifyRotate, keyEpochAfter, type LinearChain, type PhaseState, type RegisterPhase, recordSeq, } from "./phases.js";
11
11
  export { type RevocationStatus, revocationStatus } from "./proof.js";
12
- export { planReconciliation, type ReconcileStep, reconcileDevices, reconcileRecord, type TargetReconciliation, } from "./reconcile.js";
12
+ export { type DisplayPlan, keyRecipients, planDisplayData, planReconciliation, planRevocations, type ReconcileStep, reconcileDevices, reconcileRecord, revocationRelevant, type TargetReconciliation, } from "./reconcile.js";
13
13
  export { type CreatedIdentityResource, type CreateIdentityResourceOptions, createIdentityResource, type IdentityDocumentFactory, type IdentityFlowOptions, identityTarget, localIdentityResource, resumeIdentityCreation, setLocalIdentityResource, } from "./resource.js";
14
14
  export { previewRevocation, type ResourcePreview, type RevocationPreview, type RevocationResult, type RevokeDeviceResult, revokeDevice, rotateIfExposed, } from "./revocation.js";
15
15
  export { type AddToRosterOptions, type AddToRosterResult, addToRoster, type RosterWrite, writeRosterEntry, } from "./roster.js";
@@ -6,10 +6,10 @@ export { IDENTITY_PROFILE, identityPlaintextCodec, identityWriterClass, principa
6
6
  export { OperationJournal, PackageLedger, pairingSecretRef, pendingDekRef, } from "./journal.js";
7
7
  export { ChannelPool, connectionNetwork, IDENTITY_EXTENSION, IdentityRefused, IdentityUnavailable, } from "./network.js";
8
8
  export { approvedGrantRecord, approvePairing, cancelPairing, cancelRecord, consumeRecord, createPairingSession, openPairingSessions, PairingLinkText, pairingRequests, resumeApproval, resumePairingSession, } from "./pairing.js";
9
- export { candidateEvidence, completePairing, requestPairing, } from "./pairing-request.js";
9
+ export { candidateEvidence, completePairing, pairingLinkRejection, requestPairing, } from "./pairing-request.js";
10
10
  export { certificateInScope, classifyCancel, classifyConsume, classifyPackage, classifyRegister, classifyRevoke, classifyRotate, keyEpochAfter, recordSeq, } from "./phases.js";
11
11
  export { revocationStatus } from "./proof.js";
12
- export { planReconciliation, reconcileDevices, reconcileRecord, } from "./reconcile.js";
12
+ export { keyRecipients, planDisplayData, planReconciliation, planRevocations, reconcileDevices, reconcileRecord, revocationRelevant, } from "./reconcile.js";
13
13
  export { createIdentityResource, identityTarget, localIdentityResource, resumeIdentityCreation, setLocalIdentityResource, } from "./resource.js";
14
14
  export { previewRevocation, revokeDevice, rotateIfExposed, } from "./revocation.js";
15
15
  export { addToRoster, writeRosterEntry, } from "./roster.js";
@@ -81,6 +81,18 @@ export type RequestPairingResult = {
81
81
  readonly operation?: string;
82
82
  readonly reason: string;
83
83
  };
84
+ /**
85
+ * §5.2, the checks after the link parses and its secret is its device's
86
+ * (`parsePairingUri`, `pairingDeviceSigner`), on what opening `I` as `J`
87
+ * gave: the session as `J` is refused (W §41), `I`'s Genesis profile is not
88
+ * `org.openlfcp.identity.v1`, or `J` is not a registered, active kind-2
89
+ * device of `I`'s owner in `I`'s chain. Returns why the link is rejected
90
+ * (a client-local outcome, the corpora's PAIRING_LINK_REJECTED), or
91
+ * undefined when it passes; a rejected link is not used any further.
92
+ */
93
+ export declare function pairingLinkRejection(J: PrincipalId, opened: LinearChain | {
94
+ readonly refused: string;
95
+ }): string | undefined;
84
96
  /** §5.3 steps 2–3. */
85
97
  export declare function requestPairing(o: RequestPairingOptions): Promise<RequestPairingResult>;
86
98
  /** §6: the status of one Resource for the new device, from its own observations only. */
@@ -5,6 +5,32 @@ import { IDENTITY_PROFILE, principalRefOf, resourceRef, } from "./document.js";
5
5
  import { newOperationId, OperationJournal, pairingSecretRef, phaseOf } from "./journal.js";
6
6
  import { ChannelPool, IDENTITY_EXTENSION, IdentityRefused, IdentityUnavailable, } from "./network.js";
7
7
  import { setLocalIdentityResource } from "./resource.js";
8
+ /**
9
+ * §5.2, the checks after the link parses and its secret is its device's
10
+ * (`parsePairingUri`, `pairingDeviceSigner`), on what opening `I` as `J`
11
+ * gave: the session as `J` is refused (W §41), `I`'s Genesis profile is not
12
+ * `org.openlfcp.identity.v1`, or `J` is not a registered, active kind-2
13
+ * device of `I`'s owner in `I`'s chain. Returns why the link is rejected
14
+ * (a client-local outcome, the corpora's PAIRING_LINK_REJECTED), or
15
+ * undefined when it passes; a rejected link is not used any further.
16
+ */
17
+ export function pairingLinkRejection(J, opened) {
18
+ if ("refused" in opened)
19
+ return `the session as the link's device is refused (${opened.refused})`;
20
+ const state = opened.state;
21
+ if (state.dataProfile !== IDENTITY_PROFILE)
22
+ return `the Resource is not an Identity Resource (its Genesis profile is ${state.dataProfile})`;
23
+ const R = genesisOwner(opened);
24
+ const binding = state.devices.get(toHex(J));
25
+ const j = activeDevice(state, J);
26
+ if (binding === undefined ||
27
+ !binding.registered ||
28
+ j === undefined ||
29
+ j.kind !== DEVICE_KIND.PAIRING ||
30
+ !bytesEqual(j.root, R))
31
+ return "the link's device is not a registered, active pairing device of the owner";
32
+ return undefined;
33
+ }
8
34
  /** §5.3 steps 2–3. */
9
35
  export async function requestPairing(o) {
10
36
  let link;
@@ -39,16 +65,21 @@ export async function requestPairing(o) {
39
65
  };
40
66
  }
41
67
  const channel = await pool.get(J, url);
42
- const chain = await syncChain(o.storage, channel, I);
68
+ let chain;
69
+ try {
70
+ chain = await syncChain(o.storage, channel, I);
71
+ }
72
+ catch (e) {
73
+ // §5.2 check 2: the coordinator refuses the session as J (W §41).
74
+ if (!(e instanceof IdentityRefused))
75
+ throw e;
76
+ const reason = pairingLinkRejection(J.descriptor.principalId, { refused: e.code });
77
+ return { kind: "not-a-session", reason: reason ?? e.message };
78
+ }
43
79
  const R = genesisOwner(chain);
44
- if (chain.state.dataProfile !== IDENTITY_PROFILE)
45
- return { kind: "not-a-session", reason: "the Resource is not an Identity Resource" };
46
- const j = activeDevice(chain.state, J.descriptor.principalId);
47
- if (j === undefined || j.kind !== DEVICE_KIND.PAIRING || !bytesEqual(j.root, R))
48
- return {
49
- kind: "not-a-session",
50
- reason: "the link's device is not an active pairing device of the owner",
51
- };
80
+ const rejected = pairingLinkRejection(J.descriptor.principalId, chain);
81
+ if (rejected !== undefined)
82
+ return { kind: "not-a-session", reason: rejected };
52
83
  const pairing = { signer: J, agreement: link.secret.agreementKey };
53
84
  const deks = await fetchDeks(o.storage, o.secrets, channel, chain, pairing);
54
85
  const doc = o.document(I, R);
@@ -43,6 +43,17 @@ export type PairingSessionResult = {
43
43
  | {
44
44
  readonly kind: "not-authorized";
45
45
  readonly reason: string;
46
+ }
47
+ /**
48
+ * `I`'s coordinator does not list `identity-01` in READY: it is hosted on
49
+ * a server without identity support (IDENTITY-PROFILE-01 §6). Nothing is
50
+ * proposed or uploaded there: no DEVICE_GRANT, no same-root Key Package
51
+ * (LFCP-WIRE-01 §37).
52
+ */
53
+ | {
54
+ readonly kind: "unsupported";
55
+ readonly session?: string;
56
+ readonly reason: string;
46
57
  } | {
47
58
  readonly kind: "refused";
48
59
  readonly code: string;
@@ -5,7 +5,7 @@ import { deliverKeys, fetchDeks, genesisOwner, IdentityConflict, loadIdentityDoc
5
5
  import { checkIssuance, leafPayload, MASK_PRESETS, mayManageDevices, registerDevice, signChain, } from "./devices.js";
6
6
  import { identityPlaintextCodec, principalOfRef, principalRefOf, } from "./document.js";
7
7
  import { newOperationId, OperationJournal, PackageLedger, pairingSecretRef, phaseOf, } from "./journal.js";
8
- import { ChannelPool, IdentityUnavailable } from "./network.js";
8
+ import { ChannelPool, IDENTITY_EXTENSION, IdentityUnavailable, } from "./network.js";
9
9
  import { classifyCancel, classifyConsume, classifyRegister } from "./phases.js";
10
10
  import { identityTarget } from "./resource.js";
11
11
  import { rotateIfExposed } from "./revocation.js";
@@ -40,6 +40,8 @@ export class PairingLinkText {
40
40
  return "[redacted]";
41
41
  }
42
42
  }
43
+ /** W §37: the reason of an `unsupported` result. */
44
+ const NO_IDENTITY_SUPPORT = "the Identity Resource is hosted on a server without identity support (its READY does not list identity-01)";
43
45
  const PAIRING_MASK = [1n, 2n];
44
46
  /** Wraps a flow body: transport failures and forks become results, the journal stays. */
45
47
  async function guarded(pool, body, unavailable, conflict) {
@@ -70,6 +72,8 @@ export async function createPairingSession(o) {
70
72
  const me = o.installation.signer;
71
73
  return guarded(pool, async () => {
72
74
  const channel = await pool.get(me, target.url);
75
+ if (!channel.extensions.includes(IDENTITY_EXTENSION))
76
+ return { kind: "unsupported", reason: NO_IDENTITY_SUPPORT };
73
77
  const chain = await syncChain(o.storage, channel, target.resource);
74
78
  const R = genesisOwner(chain);
75
79
  if (!mayManageDevices(chain.state, me.descriptor.principalId, R))
@@ -143,7 +147,14 @@ async function missingMaterial(o, channel, chain) {
143
147
  dek: (epoch) => deks.get(String(epoch)),
144
148
  profile: identityPlaintextCodec,
145
149
  });
146
- if (opened?.kind !== "accepted")
150
+ // A unit held whose only failure is the missing DEK of a real epoch of I (every other
151
+ // check passed, and that DEK is listed above) is not missing itself. A unit naming an
152
+ // epoch I does not have, or failing any other check, is missing (§5.3 step 1).
153
+ const onlyDekMissing = opened?.kind === "local-failure" &&
154
+ opened.reason === "NO_DEK" &&
155
+ unit !== undefined &&
156
+ chain.state.epochs.has(String(parseDataUnit(unit).payload.dataEpoch));
157
+ if (opened?.kind !== "accepted" && !onlyDekMissing)
147
158
  missing.push("the root's Data Unit with actor sequence 1");
148
159
  return missing;
149
160
  }
@@ -156,6 +167,9 @@ async function finishSession(o, pool, session) {
156
167
  const chainJ = certificateChain(op.intent.certificates.map((c) => parseDeviceCertificate(fromHex(c))), R);
157
168
  const me = o.installation.signer;
158
169
  const channel = await pool.get(me, op.intent.url);
170
+ // A resumed session may meet a server that no longer lists identity-01 (W §37).
171
+ if (!channel.extensions.includes(IDENTITY_EXTENSION))
172
+ return { kind: "unsupported", session, reason: NO_IDENTITY_SUPPORT };
159
173
  const grant = await runControlPhase(o.storage, channel, journal, session, {
160
174
  id: "grant",
161
175
  kind: "register",
@@ -1,6 +1,7 @@
1
1
  import { type PrincipalId, type ResourceId } from "@openlfcp/core";
2
- import { type CertificateChain, type Signer } from "@openlfcp/wire";
2
+ import { type CertificateChain, type ControlState, type DeviceBinding, type PrincipalDescriptor, type Signer } from "@openlfcp/wire";
3
3
  import { type DocumentWrite, type KeyDelivery } from "./context.js";
4
+ import { type DeviceEntryValue, type IdentityIntent } from "./document.js";
4
5
  import { type LinearChain, type PhaseState } from "./phases.js";
5
6
  import { type IdentityFlowOptions } from "./resource.js";
6
7
  import { type RotationOutcome } from "./rotation.js";
@@ -26,11 +27,40 @@ export type ReconcileStep = {
26
27
  /** `I`'s chain for the device. */
27
28
  readonly chain: CertificateChain;
28
29
  };
30
+ /**
31
+ * §7.1 step 1: whether the revocation of `device` in `I` is relevant in the
32
+ * target `X`. It is when the device is bound in `X` (as a leaf or an
33
+ * intermediate, W §17.4 rule 9), or when it could become registered in `X`
34
+ * or issue a chain there: bound in `I` with kind 0, or with kind 1 and `X`
35
+ * in its scope, or not bound in `I` at all (its kind is unknown). A pairing
36
+ * device, or a service device whose scope does not list `X`, that is not
37
+ * bound in `X` can never act there (W §7.3 c3, c6; N8): its revocation
38
+ * would protect nothing and would take one of the 64 unbound pairs of the
39
+ * root that W §17.5 rule 5 allows in `X` and never frees.
40
+ *
41
+ * `binding` is the device's binding in `I` (undefined when unbound there).
42
+ */
43
+ export declare function revocationRelevant(binding: Pick<DeviceBinding, "kind" | "scope"> | undefined, target: ControlState, device: PrincipalId): boolean;
44
+ /**
45
+ * §7.1 step 1 for one target: the revocations of `I` to propose on `X`
46
+ * (the pairs of `I`'s `revoked` not in `X`'s that are relevant there, those
47
+ * whose device is bound in `X` first, each group in `I`'s chain order), and
48
+ * the devices whose revocation is not relevant in `X` and is not
49
+ * propagated. Empty for the target `I` itself.
50
+ */
51
+ export declare function planRevocations(identity: LinearChain, target: LinearChain): {
52
+ readonly propose: readonly Extract<ReconcileStep, {
53
+ kind: "revoke";
54
+ }>[];
55
+ readonly notRelevant: readonly PrincipalId[];
56
+ };
29
57
  /**
30
58
  * §7.1 steps 1 and 2 for one target, in their normative order. For the
31
59
  * target `I` itself both steps are vacuous. Registrations are planned on
32
- * the target's state as the revocations leave it (a device whose issuer
33
- * `I` revoked is never registered before the cascade reaches the target).
60
+ * the target's state as the revocations leave it, and every pair of `I`'s
61
+ * `revoked` counts as revoked in `X` whether its propagation succeeds or
62
+ * not (an unpropagated pair, refused by W §17.5 rule 5, or a pair that is
63
+ * not relevant): no device whose issuer `I` has revoked is ever registered.
34
64
  */
35
65
  export declare function planReconciliation(identity: LinearChain, target: LinearChain): readonly ReconcileStep[];
36
66
  /** The record of a step on the target's head, signed by the reconciler. */
@@ -43,6 +73,17 @@ export interface TargetReconciliation {
43
73
  readonly step: ReconcileStep;
44
74
  readonly state: PhaseState | "refused";
45
75
  }[];
76
+ /**
77
+ * §7.1 step 1: devices whose revocation in `I` the target refused (the
78
+ * limit of W §17.5 rule 5, or the reconciler's authority there), to
79
+ * report to the human with the Resource. Each stays unpropagated: this
80
+ * run registered no device through it and sealed nothing to it. A later
81
+ * run proposes it again once the target's state changed since the
82
+ * refusal (its head moved, for one by the device's binding there); on the
83
+ * same state it reports the pair without sending anything. Empty when
84
+ * every relevant pair reached the target.
85
+ */
86
+ readonly unpropagated?: readonly PrincipalId[];
46
87
  readonly keys?: KeyDelivery;
47
88
  readonly rotation?: RotationOutcome;
48
89
  }
@@ -70,4 +111,33 @@ export declare function reconcileDevices(o: IdentityFlowOptions & {
70
111
  */
71
112
  readonly document: DocumentWrite;
72
113
  }>;
114
+ /** §7.1 step 4's writes in `I`'s document: new entries, and the retired entries to flag. */
115
+ export interface DisplayPlan {
116
+ readonly intents: readonly IdentityIntent[];
117
+ /** Retired entries (PrincipalRefs) whose `revoked` is set to `true`, and nothing else of them. */
118
+ readonly retire: readonly string[];
119
+ }
120
+ /**
121
+ * §7.1 step 4: the display data reconciliation writes in `I`'s document,
122
+ * from `I`'s Control state and the valid device entries (§4). For every
123
+ * device of `R` of kind 0 or 1 bound in `I` as the leaf of an accepted
124
+ * chain (an intermediate-only binding issues and does not act in `I`):
125
+ * when it is not revoked in `I` and has no valid entry, an entry labelled
126
+ * with its fingerprint (§5.2); when it is revoked in `I` and some value of
127
+ * its entry's `revoked` (a retired entry) is not `true`, or the field is
128
+ * absent, that flag alone. `revokedValues` gives every concurrent value
129
+ * of the flag (IdentityDocumentPort.retirement); without it the entry's
130
+ * one shown value is read. No entry is created for a revoked device,
131
+ * nothing is written for a pairing device, nothing is relabelled or
132
+ * deleted, and a second run on the same states plans nothing.
133
+ */
134
+ export declare function planDisplayData(identity: ControlState, entries: ReadonlyMap<string, DeviceEntryValue>, root: PrincipalId, revokedValues?: (key: string) => readonly unknown[]): DisplayPlan;
135
+ /**
136
+ * §7.1 step 3: the devices of `R` reconciliation seals the target's keys
137
+ * to: every reader of the target whose root is `R` (pairing devices of `I`
138
+ * included, in `I`), except a device revoked in `I` and a device whose
139
+ * current chain in the target passes through a device revoked in `I` (an
140
+ * unpropagated pair of step 1, which the target still counts as active).
141
+ */
142
+ export declare function keyRecipients(identity: LinearChain, target: LinearChain): readonly PrincipalDescriptor[];
73
143
  //# sourceMappingURL=reconcile.d.ts.map
@@ -1,8 +1,8 @@
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";
@@ -10,36 +10,83 @@ import { identityTarget } from "./resource.js";
10
10
  import { rotateIfExposed } from "./revocation.js";
11
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)
@@ -115,10 +162,11 @@ export async function reconcileDevices(o) {
115
162
  let document = { state: "none" };
116
163
  let open = false;
117
164
  try {
118
- // A rotation an earlier run committed but did not adopt is finished first (§9.1).
119
- await resumeRotations({ ...o, pool });
120
165
  const channel = await pool.get(me, target.url);
121
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 });
122
170
  const R = genesisOwner(I);
123
171
  if (!mayManageDevices(I.state, me.descriptor.principalId, R))
124
172
  return {
@@ -165,8 +213,9 @@ export async function reconcileDevices(o) {
165
213
  throw e;
166
214
  }
167
215
  }
168
- // Step 4: display data for devices in I's chain without a valid entry (labels default to the fingerprint).
169
- 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()) {
170
219
  // Built on the current state of I: a rotation of I by this run, or by
171
220
  // anyone, makes an earlier unit stale (W §19.1), and it is written again.
172
221
  document = await runUnitPhase(channel, journal, operation, {
@@ -179,8 +228,8 @@ export async function reconcileDevices(o) {
179
228
  const fresh = o.document(target.resource, R);
180
229
  const loaded = await loadIdentityDocument({ channel, chain: now, deks, doc: fresh });
181
230
  // Another manager may have written the entries since the first read (§7.1).
182
- const intents = displayIntents(now, fresh, R);
183
- if (intents.length === 0)
231
+ const plan = displayPlan(now, fresh, R);
232
+ if (nothingToWrite(plan))
184
233
  return null;
185
234
  return sealIdentityUnit({
186
235
  storage: o.storage,
@@ -189,7 +238,8 @@ export async function reconcileDevices(o) {
189
238
  signer: me,
190
239
  doc: fresh,
191
240
  latest: loaded.latest,
192
- intents,
241
+ intents: plan.intents,
242
+ retire: plan.retire,
193
243
  });
194
244
  },
195
245
  });
@@ -215,25 +265,76 @@ export async function reconcileDevices(o) {
215
265
  }
216
266
  return { operation, targets: Object.freeze(out), document };
217
267
  }
218
- /** §7.1 step 4: a device entry for each device of R in I's chain without one. */
219
- 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) {
220
283
  const intents = [];
221
- for (const [hex, d] of I.state.devices) {
222
- 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)
223
287
  continue;
224
- const key = principalRefOf(principalId(fromHex(hex)));
225
- 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))
226
298
  continue;
227
299
  intents.push({
228
300
  intent: "device.put",
229
301
  key,
230
302
  entry: {
231
- label: deviceFingerprint(principalId(fromHex(hex))),
303
+ label: deviceFingerprint(D),
232
304
  kind: d.kind === DEVICE_KIND.SERVICE ? 1 : 0,
233
305
  },
234
306
  });
235
307
  }
236
- 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;
237
338
  }
238
339
  async function reconcileTarget(o, channel, journal, ledger, operation, I, X, resend) {
239
340
  const me = o.installation.signer;
@@ -241,9 +342,29 @@ async function reconcileTarget(o, channel, journal, ledger, operation, I, X, res
241
342
  let chain = await syncChain(o.storage, channel, X);
242
343
  const steps = [];
243
344
  const manages = mayManageDevices(chain.state, me.descriptor.principalId, R);
345
+ const refused = manages
346
+ ? await refusedRevocations(journal, operation, X)
347
+ : new Map();
244
348
  if (manages)
245
349
  for (const step of planReconciliation(I, chain)) {
246
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
+ }
247
368
  const outcome = await runControlPhase(o.storage, channel, journal, operation, {
248
369
  id,
249
370
  kind: step.kind,
@@ -256,17 +377,24 @@ async function reconcileTarget(o, channel, journal, ledger, operation, I, X, res
256
377
  steps.push({ step, state: outcome.state });
257
378
  chain = outcome.chain;
258
379
  }
259
- // 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.
260
386
  const deks = await fetchDeks(o.storage, o.secrets, channel, chain, o.installation);
261
- const recipients = readersAt(chain.state).filter((r) => bytesEqual(actsFor(chain.state, r.principalId), R));
387
+ const recipients = keyRecipients(I, chain);
262
388
  const keys = await deliverKeys({ channel, ledger, chain, sender: me, recipients, deks, resend });
263
389
  const rotation = o.rotate === false
264
390
  ? undefined
265
- : 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 });
266
393
  return {
267
394
  resource: X,
268
395
  status: manages ? "done" : "not-authorized",
269
396
  steps: Object.freeze(steps),
397
+ ...(unpropagated.length === 0 ? {} : { unpropagated: Object.freeze(unpropagated) }),
270
398
  keys,
271
399
  ...(rotation === undefined ? {} : { rotation }),
272
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,11 +1,12 @@
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
11
  import { checkFlowProfiles, exposingRevocationsSince, resumeRotations, rotateAfter, rotatorTest, } from "./rotation.js";
11
12
  const toKeep = (state, root) => [...state.devices.entries()]
@@ -139,9 +140,6 @@ export async function revokeDevice(o) {
139
140
  const pool = new ChannelPool(o.network);
140
141
  const operation = o.operation ?? newOperationId("revoke-device", secureRandom(8));
141
142
  try {
142
- // Rotations an earlier operation committed but did not adopt are finished first (§9.1).
143
- if (!selfRetire)
144
- await resumeRotations({ ...o, pool });
145
143
  const channel = await pool.get(o.installation.signer, target.url);
146
144
  const known = await journal.get(operation);
147
145
  let I;
@@ -154,6 +152,10 @@ export async function revokeDevice(o) {
154
152
  throw e;
155
153
  return retireElsewhere(o, pool, journal, known);
156
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 });
157
159
  const R = genesisOwner(I);
158
160
  if (!selfRetire && !mayManageDevices(I.state, me, R))
159
161
  return { kind: "not-authorized" };
@@ -172,6 +174,9 @@ export async function revokeDevice(o) {
172
174
  device: toHex(o.device),
173
175
  root: toHex(R),
174
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))),
175
180
  targets: (await rosterTargets(o, channel, I)).map((t) => ({
176
181
  resource: toHex(t.resource),
177
182
  url: t.url,
@@ -179,8 +184,13 @@ export async function revokeDevice(o) {
179
184
  },
180
185
  });
181
186
  const roster = targetsOf(await journal.load(operation));
187
+ const binding = I.state.devices.get(toHex(o.device));
182
188
  const results = [];
183
- 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" };
184
194
  let outcome;
185
195
  try {
186
196
  outcome = await runControlPhase(o.storage, c, journal, operation, {
@@ -209,12 +219,20 @@ export async function revokeDevice(o) {
209
219
  return { resource: X, revoke: "superseded" };
210
220
  if (outcome.state === "refused")
211
221
  return { resource: X, revoke: "refused" };
222
+ if (!inRoster)
223
+ identityChain = outcome.chain;
212
224
  const revoke = outcome.state === "committed-own" ? "revoked" : "complete";
213
225
  if (selfRetire && o.reason === 0n)
214
226
  return { resource: X, revoke };
215
227
  let rotation;
216
228
  try {
217
- 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
+ });
218
236
  }
219
237
  catch (e) {
220
238
  // The revocation stands; the rotation resumes from the journal (§9.1).
@@ -224,7 +242,7 @@ export async function revokeDevice(o) {
224
242
  }
225
243
  return { resource: X, revoke, rotation };
226
244
  };
227
- const first = await one(channel, target.resource);
245
+ const first = await one(channel, target.resource, false);
228
246
  results.push(first);
229
247
  if (first.revoke === "superseded") {
230
248
  await journal.setStatus(operation, "superseded");
@@ -272,6 +290,35 @@ export async function revokeDevice(o) {
272
290
  pool.closeAll();
273
291
  }
274
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
+ }
275
322
  const targetsOf = (op) => (op.intent.targets ?? []).map((t) => ({
276
323
  resource: resourceId(fromHex(t.resource)),
277
324
  url: t.url,
@@ -282,9 +329,14 @@ async function retireElsewhere(o, pool, journal, op) {
282
329
  const results = [
283
330
  { resource: resourceId(fromHex(op.intent.identity)), revoke: "complete" },
284
331
  ];
332
+ const binding = bindingOf(op);
285
333
  for (const t of targetsOf(op)) {
286
334
  try {
287
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
+ }
288
340
  const outcome = await runControlPhase(o.storage, c, journal, op.id, {
289
341
  id: `revoke:${toHex(t.resource)}`,
290
342
  kind: "revoke",
@@ -354,10 +406,11 @@ async function markRevoked(o, channel, journal, operation, I, device) {
354
406
  build: async () => {
355
407
  const now = await current();
356
408
  gone = now.entry === undefined;
357
- 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))
358
412
  return null;
359
- // The device's missing_keys hint is written only by the device (§3 AI5).
360
- const { missing_keys: _hint, ...display } = now.entry;
413
+ // The flag alone (§8.2); the device's missing_keys hint is its own (§3 AI5).
361
414
  return sealIdentityUnit({
362
415
  storage: o.storage,
363
416
  chain: now.chain,
@@ -365,7 +418,8 @@ async function markRevoked(o, channel, journal, operation, I, device) {
365
418
  signer: o.installation.signer,
366
419
  doc: now.doc,
367
420
  latest: now.load.latest,
368
- intents: [{ intent: "device.put", key, entry: { ...display, revoked: true } }],
421
+ intents: [],
422
+ retire: [key],
369
423
  });
370
424
  },
371
425
  });
@@ -112,13 +112,19 @@ export interface RotationOutcome {
112
112
  * reuse it (§9.4). After the ACK the DEK goes to every reader of the new
113
113
  * head, each package recorded in the ledger; the revoked device, a
114
114
  * consumed or cancelled pairing device and suspended devices are not
115
- * 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.
116
120
  */
117
121
  export declare function rotateAfter(o: FlowOptions & {
118
122
  readonly channel: IdentityChannel;
119
123
  readonly operation: string;
120
124
  readonly resource: ResourceId;
121
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;
122
128
  }): Promise<RotationOutcome>;
123
129
  /** The flow options of a rotation: 0.3.1's bare `dataProfiles` is refused, never checked less. */
124
130
  export declare function checkFlowProfiles(o: FlowOptions): void;
@@ -130,6 +136,8 @@ export declare function checkFlowProfiles(o: FlowOptions): void;
130
136
  */
131
137
  export declare function resumeRotations(o: FlowOptions & {
132
138
  readonly pool: ChannelPool;
139
+ /** The Identity Resource of this installation's root: see rotateAfter. */
140
+ readonly identityChain?: LinearChain;
133
141
  }): Promise<readonly RotationOutcome[]>;
134
142
  /** The control sequence of a record ID (hex) on a chain. */
135
143
  export declare const seqOf: (chain: LinearChain, recordIdHex: string) => bigint | undefined;
@@ -3,7 +3,7 @@ import { generateResourceDEK, sha256 } from "@openlfcp/crypto";
3
3
  import { ABILITY, actsFor, addSequence, checkDataUnit, hasAbility, hasSequence, InMemorySeenUnits, isExposingRevocation, KEY_EPOCH_REASON, parseDataUnit, receiveDataUnit, rotateEpoch, } from "@openlfcp/wire";
4
4
  import { snapshotFrontier } from "../outbound.js";
5
5
  import { loadControlChain } from "../storage.js";
6
- import { deliverKeys, fetchDeks, readersAt, runControlPhase, storeDek, withoutEquivocations, } from "./context.js";
6
+ import { deliverKeys, fetchDeks, readersAt, revokedByIdentity, runControlPhase, storeDek, withoutEquivocations, } from "./context.js";
7
7
  import { getPendingDek, OperationJournal, PackageLedger, pendingDekRef, phaseOf, putPendingDek, } from "./journal.js";
8
8
  import { IdentityUnavailable } from "./network.js";
9
9
  import { keyEpochAfter, recordSeq } from "./phases.js";
@@ -191,7 +191,11 @@ async function acceptedAsData(chain, bytes, unitId, actor, local, data) {
191
191
  * reuse it (§9.4). After the ACK the DEK goes to every reader of the new
192
192
  * head, each package recorded in the ledger; the revoked device, a
193
193
  * consumed or cancelled pairing device and suspended devices are not
194
- * readers and receive nothing.
194
+ * readers and receive nothing. With `identityChain`, neither does a reader that
195
+ * the rotator's Identity Resource has revoked, or whose chain in the
196
+ * Resource passes through such a device: a pair not yet propagated there
197
+ * (IDENTITY-PROFILE-01 §7.1 step 1) is treated as revoked, and the rotation
198
+ * closes the epoch for it as for the device just revoked.
195
199
  */
196
200
  export async function rotateAfter(o) {
197
201
  checkFlowProfiles(o);
@@ -266,7 +270,8 @@ export async function rotateAfter(o) {
266
270
  ledger: new PackageLedger(o.storage),
267
271
  chain,
268
272
  sender: me,
269
- recipients: readersAt(chain.state),
273
+ recipients: readersAt(chain.state).filter((r) => o.identityChain === undefined ||
274
+ !revokedByIdentity(o.identityChain, chain.state, r.principalId)),
270
275
  deks: send,
271
276
  });
272
277
  const phase = phaseOf(await journal.load(o.operation), id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlfcp/client",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "LFCP client: session, Control Plane and Data Plane synchronization.",
5
5
  "keywords": [
6
6
  "openlfcp",
@@ -42,9 +42,9 @@
42
42
  "tag": "latest"
43
43
  },
44
44
  "dependencies": {
45
- "@openlfcp/core": "^0.4.0",
46
- "@openlfcp/storage": "^0.4.0",
47
- "@openlfcp/wire": "^0.4.0",
48
- "@openlfcp/crypto": "^0.4.0"
45
+ "@openlfcp/core": "^0.5.0",
46
+ "@openlfcp/crypto": "^0.5.0",
47
+ "@openlfcp/storage": "^0.5.0",
48
+ "@openlfcp/wire": "^0.5.0"
49
49
  }
50
50
  }