@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.
@@ -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,
@@ -1,6 +1,7 @@
1
1
  import { type PrincipalId, type ResourceId } from "@openlfcp/core";
2
2
  import type { SecretStore } from "@openlfcp/storage";
3
- import { type CertificateChain, type ControlState, type DataProfileCodec, type DeviceCertificate, type DeviceCertificatePayload, type DeviceKind, type PrincipalDescriptor, type Signer } from "@openlfcp/wire";
3
+ import { type CertificateChain, type ControlState, type DeviceCertificate, type DeviceCertificatePayload, type DeviceKind, type PrincipalDescriptor, type Signer } from "@openlfcp/wire";
4
+ import type { DataProfileHandler } from "../apply.js";
4
5
  import { type IdentityStorage, type Installation, type KeyDelivery } from "./context.js";
5
6
  import { type IdentityNetwork } from "./network.js";
6
7
  import { type LinearChain } from "./phases.js";
@@ -32,12 +33,24 @@ export interface FlowOptions {
32
33
  readonly network: IdentityNetwork;
33
34
  readonly now: () => number;
34
35
  /**
35
- * The application's Data Profile codecs. A rotation uses the Resource's
36
- * codec to judge which units recipients accept as data (W §26.3 rule 7);
37
- * without one, only decryption and recorded refusals are checked.
36
+ * The application's Data Profiles, each as its receiver checks one unit:
37
+ * `codecFor({resourceId, actor})`, the codec bound to the unit's Resource
38
+ * and signer (the DataProfileHandler hook; e.g. shared-objects'
39
+ * sharedObjectsUnitProfile, sectionsUnitProfile, identityUnitProfile). A
40
+ * rotation judges each unit with its Resource's profile, as recipients do
41
+ * (W §26.3 rules 6 and 7, including the change's actor); without one, only
42
+ * decryption and recorded refusals are checked. Replaces 0.3.1's
43
+ * `dataProfiles` (bare codecs, which cannot bind the actor): a flow given
44
+ * `dataProfiles` throws instead of checking less.
38
45
  */
39
- readonly dataProfiles?: readonly DataProfileCodec<unknown>[];
46
+ readonly profiles?: readonly UnitProfile[];
40
47
  }
48
+ /**
49
+ * One Data Profile's per-unit check (the receiver's DataProfileHandler
50
+ * `dataProfile` and `codecFor`): `decode` of the codec it returns throws to
51
+ * reject the unit's plaintext.
52
+ */
53
+ export type UnitProfile = Pick<DataProfileHandler<unknown>, "dataProfile" | "codecFor">;
41
54
  /**
42
55
  * The certificates above a leaf that `issuer` signs for `root` in the
43
56
  * state: none when the issuer is the root, else the issuer's own current
@@ -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>;
@@ -1,15 +1,15 @@
1
1
  /** Identity flows (IDENTITY-PROFILE-01 §§5–9, LFCP-04-410). */
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
- export { checkIssuance, type Feasibility, type FlowOptions, issuerCertificates, leafPayload, MASK_PRESETS, type MaskPreset, mayManageDevices, type RegisterTarget, type RegistrationResult, type RegistrationStatus, registerDevice, signChain, } 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";
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 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,10 +5,11 @@ 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";
12
+ import { checkFlowProfiles } from "./rotation.js";
12
13
  /**
13
14
  * Pairing a new installation, the managing side `A` (IDENTITY-PROFILE-01
14
15
  * §5.3 steps 1, 4–7, 9 and 10): a pairing device `J` registered in the
@@ -39,6 +40,8 @@ export class PairingLinkText {
39
40
  return "[redacted]";
40
41
  }
41
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)";
42
45
  const PAIRING_MASK = [1n, 2n];
43
46
  /** Wraps a flow body: transport failures and forks become results, the journal stays. */
44
47
  async function guarded(pool, body, unavailable, conflict) {
@@ -69,6 +72,8 @@ export async function createPairingSession(o) {
69
72
  const me = o.installation.signer;
70
73
  return guarded(pool, async () => {
71
74
  const channel = await pool.get(me, target.url);
75
+ if (!channel.extensions.includes(IDENTITY_EXTENSION))
76
+ return { kind: "unsupported", reason: NO_IDENTITY_SUPPORT };
72
77
  const chain = await syncChain(o.storage, channel, target.resource);
73
78
  const R = genesisOwner(chain);
74
79
  if (!mayManageDevices(chain.state, me.descriptor.principalId, R))
@@ -142,7 +147,14 @@ async function missingMaterial(o, channel, chain) {
142
147
  dek: (epoch) => deks.get(String(epoch)),
143
148
  profile: identityPlaintextCodec,
144
149
  });
145
- 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)
146
158
  missing.push("the root's Data Unit with actor sequence 1");
147
159
  return missing;
148
160
  }
@@ -155,6 +167,9 @@ async function finishSession(o, pool, session) {
155
167
  const chainJ = certificateChain(op.intent.certificates.map((c) => parseDeviceCertificate(fromHex(c))), R);
156
168
  const me = o.installation.signer;
157
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 };
158
173
  const grant = await runControlPhase(o.storage, channel, journal, session, {
159
174
  id: "grant",
160
175
  kind: "register",
@@ -323,6 +338,7 @@ export async function pairingRequests(o) {
323
338
  * phase 1; phase 2 signs exactly that payload.
324
339
  */
325
340
  export async function approvePairing(o) {
341
+ checkFlowProfiles(o);
326
342
  const { I, R, J, url } = await readSession(o, o.review.session);
327
343
  const pool = new ChannelPool(o.network);
328
344
  const me = o.installation.signer;
@@ -413,6 +429,7 @@ export const approvedGrantRecord = (chain, signer, root, device, pairing) => sig
413
429
  export const cancelRecord = (chain, signer, root, pairing) => signControlRecord(next(chain), { type: "DEVICE_REVOKE", root, device: pairing, reason: 0n }, signer).bytes;
414
430
  /** Resumes a journaled approval from its next phase (§9.1: "If A crashes after step 5 …"). */
415
431
  export async function resumeApproval(o, operation) {
432
+ checkFlowProfiles(o);
416
433
  const journal = new OperationJournal(o.storage);
417
434
  const pool = new ChannelPool(o.network);
418
435
  const me = o.installation.signer;
@@ -565,6 +582,7 @@ export async function resumeApproval(o, operation) {
565
582
  * human does not confirm is rejected).
566
583
  */
567
584
  export async function cancelPairing(o) {
585
+ checkFlowProfiles(o);
568
586
  const { I, R, J, url } = await readSession(o, o.session);
569
587
  const journal = new OperationJournal(o.storage);
570
588
  const pool = new ChannelPool(o.network);
@@ -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