@openlfcp/client 0.2.2 → 0.3.1

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.
Files changed (43) hide show
  1. package/dist/data-unit.d.ts +11 -0
  2. package/dist/data-unit.js +9 -0
  3. package/dist/identity/accept.d.ts +115 -0
  4. package/dist/identity/accept.js +335 -0
  5. package/dist/identity/context.d.ts +214 -0
  6. package/dist/identity/context.js +495 -0
  7. package/dist/identity/devices.d.ts +127 -0
  8. package/dist/identity/devices.js +221 -0
  9. package/dist/identity/document.d.ts +112 -0
  10. package/dist/identity/document.js +54 -0
  11. package/dist/identity/index.d.ts +17 -0
  12. package/dist/identity/index.js +17 -0
  13. package/dist/identity/journal.d.ts +139 -0
  14. package/dist/identity/journal.js +202 -0
  15. package/dist/identity/network.d.ts +88 -0
  16. package/dist/identity/network.js +243 -0
  17. package/dist/identity/pairing-request.d.ts +147 -0
  18. package/dist/identity/pairing-request.js +422 -0
  19. package/dist/identity/pairing.d.ts +225 -0
  20. package/dist/identity/pairing.js +624 -0
  21. package/dist/identity/phases.d.ts +75 -0
  22. package/dist/identity/phases.js +104 -0
  23. package/dist/identity/proof.d.ts +45 -0
  24. package/dist/identity/proof.js +71 -0
  25. package/dist/identity/reconcile.d.ts +73 -0
  26. package/dist/identity/reconcile.js +273 -0
  27. package/dist/identity/resource.d.ts +74 -0
  28. package/dist/identity/resource.js +201 -0
  29. package/dist/identity/revocation.d.ts +106 -0
  30. package/dist/identity/revocation.js +403 -0
  31. package/dist/identity/roster.d.ts +89 -0
  32. package/dist/identity/roster.js +130 -0
  33. package/dist/identity/rotation.d.ts +131 -0
  34. package/dist/identity/rotation.js +363 -0
  35. package/dist/index.d.ts +2 -1
  36. package/dist/index.js +2 -1
  37. package/dist/revoke.d.ts +8 -0
  38. package/dist/revoke.js +12 -8
  39. package/dist/storage.d.ts +18 -0
  40. package/dist/storage.js +26 -0
  41. package/dist/sync-client.d.ts +2 -0
  42. package/dist/sync-client.js +1 -0
  43. package/package.json +5 -5
@@ -0,0 +1,75 @@
1
+ import { type ControlRecordId, type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
2
+ import { type CertificateChain, type ChainResult, type ControlRecord, type ControlState, type DeviceCertificatePayload } from "@openlfcp/wire";
3
+ /**
4
+ * Phase states of a durable multi-record operation (IDENTITY-PROFILE-01
5
+ * §9.2). After a CONTROL_HEAD_MISMATCH or a restart the client synchronises
6
+ * the Resource and classifies each phase against the accepted chain; one
7
+ * rule applies per kind of phase, first match wins. These functions are
8
+ * pure: the same chain and the same intent give the same state on every
9
+ * installation.
10
+ */
11
+ export type PhaseState =
12
+ /** Propose, or propose again on the new head. */
13
+ "pending"
14
+ /** The chain holds this installation's own record: continue with the next phase. */
15
+ | "committed-own"
16
+ /** The effect exists by someone else's hand and the phase is idempotent: skip it. */
17
+ | "complete"
18
+ /** The chain makes the phase impossible: the operation ends, nothing further is signed. */
19
+ | "superseded"
20
+ /** The rotator test of §8.1 fails on the current head. */
21
+ | "awaiting-rotation"
22
+ /** A KEY_EPOCH is committed whose DEK this client does not hold (§9.2). */
23
+ | "awaiting-key-holder";
24
+ export type LinearChain = Extract<ChainResult, {
25
+ kind: "linear";
26
+ }>;
27
+ /** The Consume phase: `DEVICE_REVOKE {R, J, 3, digest}` (§5.3 step 5). */
28
+ export declare function classifyConsume(state: ControlState, root: PrincipalId, pairing: PrincipalId, digest: Hash32): PhaseState;
29
+ /** The Cancel phase of a pairing device: `DEVICE_REVOKE {R, J, 0}` (§5.3 step 9). */
30
+ export declare function classifyCancel(state: ControlState, root: PrincipalId, pairing: PrincipalId): PhaseState;
31
+ /**
32
+ * The Revoke phase `(R, D)` with a reason in {0, 1, 2}: a revocation with
33
+ * another reason by anyone is `complete` (the operation continues with the
34
+ * rotation test), never `committed-own` or `superseded` (§9.2).
35
+ */
36
+ export declare function classifyRevoke(state: ControlState, root: PrincipalId, device: PrincipalId): PhaseState;
37
+ /** Whether a certificate's scope admits `resource` (W §7.3 rule 5; kind 0 has no scope). */
38
+ export declare function certificateInScope(leaf: DeviceCertificatePayload, resource: ResourceId): boolean;
39
+ /** The accepted DEVICE_GRANT record whose leaf is `leafId`, if any (the latest). */
40
+ export declare function grantRecordOf(chain: LinearChain, leafId: Hash32): ControlRecord | undefined;
41
+ export interface RegisterPhase {
42
+ readonly root: PrincipalId;
43
+ /** The certificate chain the phase proposes (leaf = the device). */
44
+ readonly chain: CertificateChain;
45
+ /** Phase 2 of a pairing approval: a binding with another leaf is `superseded`. */
46
+ readonly pairingPhase2?: boolean;
47
+ /** Whether a record ID (hex) is one this installation sent for the phase (§9.1: persisted before sending). */
48
+ readonly isOwnRecord: (recordIdHex: string) => boolean;
49
+ }
50
+ /**
51
+ * The Register or re-certify phase of leaf `D` with certificate `c`
52
+ * (`DEVICE_GRANT`, §9.2). A matching effect (same leaf certificate ID,
53
+ * `registered` agreeing with `c`'s scope here) is `committed-own` when this
54
+ * installation's record produced it, else `complete`; it is never
55
+ * re-proposed as a new transition (W §17.4 rule 5).
56
+ */
57
+ export declare function classifyRegister(chain: LinearChain, phase: RegisterPhase): PhaseState;
58
+ /** The control sequence of the record `id` on `chain`, if it is there. */
59
+ export declare function recordSeq(chain: LinearChain, id: ControlRecordId | Uint8Array): bigint | undefined;
60
+ /**
61
+ * The Rotate phase after a revocation at control sequence `revokedAt`
62
+ * (§9.2, §9.3): `complete` when a KEY_EPOCH is accepted after it;
63
+ * `awaiting-rotation` when the rotator test of §8.1 fails on the current
64
+ * head; otherwise `pending` (the KEY_EPOCH is rebuilt, never re-sent).
65
+ */
66
+ export declare function classifyRotate(chain: LinearChain, revokedAt: bigint, rotator: boolean): PhaseState;
67
+ /** Whether a KEY_EPOCH is accepted at a control sequence greater than `seq` (§8.4: the mark clears). */
68
+ export declare function keyEpochAfter(chain: LinearChain, seq: bigint): boolean;
69
+ /**
70
+ * A Package phase `(resource, epoch, recipient)` (§9.2): never superseded;
71
+ * `complete` when the ledger records it; a recipient that is no longer a
72
+ * reader at the head is skipped.
73
+ */
74
+ export declare function classifyPackage(ledgered: boolean, readerAtHead: boolean): PhaseState | "skipped";
75
+ //# sourceMappingURL=phases.d.ts.map
@@ -0,0 +1,104 @@
1
+ import { bytesEqual, toHex, } from "@openlfcp/core";
2
+ import { DEVICE_KIND, revocationOf, } from "@openlfcp/wire";
3
+ const REASON_CONSUMED = 3n;
4
+ /** The Consume phase: `DEVICE_REVOKE {R, J, 3, digest}` (§5.3 step 5). */
5
+ export function classifyConsume(state, root, pairing, digest) {
6
+ const r = revocationOf(state, root, pairing);
7
+ if (r === undefined)
8
+ return "pending";
9
+ return r.reason === REASON_CONSUMED && r.approval !== null && bytesEqual(r.approval, digest)
10
+ ? "committed-own"
11
+ : "superseded";
12
+ }
13
+ /** The Cancel phase of a pairing device: `DEVICE_REVOKE {R, J, 0}` (§5.3 step 9). */
14
+ export function classifyCancel(state, root, pairing) {
15
+ const r = revocationOf(state, root, pairing);
16
+ if (r === undefined)
17
+ return "pending";
18
+ return r.reason === REASON_CONSUMED ? "superseded" : "complete";
19
+ }
20
+ /**
21
+ * The Revoke phase `(R, D)` with a reason in {0, 1, 2}: a revocation with
22
+ * another reason by anyone is `complete` (the operation continues with the
23
+ * rotation test), never `committed-own` or `superseded` (§9.2).
24
+ */
25
+ export function classifyRevoke(state, root, device) {
26
+ const r = revocationOf(state, root, device);
27
+ if (r === undefined)
28
+ return "pending";
29
+ return r.reason === REASON_CONSUMED ? "superseded" : "complete";
30
+ }
31
+ /** Whether a certificate's scope admits `resource` (W §7.3 rule 5; kind 0 has no scope). */
32
+ export function certificateInScope(leaf, resource) {
33
+ return leaf.kind === DEVICE_KIND.PERSONAL || leaf.scope.some((r) => bytesEqual(r, resource));
34
+ }
35
+ const isGrantOf = (record, leafId) => record.body.type === "DEVICE_GRANT" && bytesEqual(record.body.chain.leaf.id, leafId);
36
+ /** The accepted DEVICE_GRANT record whose leaf is `leafId`, if any (the latest). */
37
+ export function grantRecordOf(chain, leafId) {
38
+ for (let i = chain.records.length - 1; i >= 0; i--) {
39
+ const r = chain.records[i];
40
+ if (isGrantOf(r, leafId))
41
+ return r;
42
+ }
43
+ return undefined;
44
+ }
45
+ /**
46
+ * The Register or re-certify phase of leaf `D` with certificate `c`
47
+ * (`DEVICE_GRANT`, §9.2). A matching effect (same leaf certificate ID,
48
+ * `registered` agreeing with `c`'s scope here) is `committed-own` when this
49
+ * installation's record produced it, else `complete`; it is never
50
+ * re-proposed as a new transition (W §17.4 rule 5).
51
+ */
52
+ export function classifyRegister(chain, phase) {
53
+ const state = chain.state;
54
+ const leaf = phase.chain.leaf;
55
+ const device = leaf.payload.device.principalId;
56
+ if (revocationOf(state, phase.root, device) !== undefined)
57
+ return "superseded";
58
+ if (phase.chain.issuers.some((e) => revocationOf(state, phase.root, e) !== undefined))
59
+ return "superseded";
60
+ const d = state.devices.get(toHex(device));
61
+ if (d?.leafId !== null && d?.leafId !== undefined) {
62
+ if (bytesEqual(d.leafId, leaf.id) &&
63
+ d.registered === certificateInScope(leaf.payload, state.resourceId)) {
64
+ const record = grantRecordOf(chain, leaf.id);
65
+ return record !== undefined && phase.isOwnRecord(toHex(record.signed.id))
66
+ ? "committed-own"
67
+ : "complete";
68
+ }
69
+ if (!bytesEqual(d.leafId, leaf.id) && phase.pairingPhase2 === true)
70
+ return "superseded";
71
+ }
72
+ return "pending";
73
+ }
74
+ /** The control sequence of the record `id` on `chain`, if it is there. */
75
+ export function recordSeq(chain, id) {
76
+ const hex = toHex(id);
77
+ return chain.records.find((r) => toHex(r.signed.id) === hex)?.payload.controlSeq;
78
+ }
79
+ /**
80
+ * The Rotate phase after a revocation at control sequence `revokedAt`
81
+ * (§9.2, §9.3): `complete` when a KEY_EPOCH is accepted after it;
82
+ * `awaiting-rotation` when the rotator test of §8.1 fails on the current
83
+ * head; otherwise `pending` (the KEY_EPOCH is rebuilt, never re-sent).
84
+ */
85
+ export function classifyRotate(chain, revokedAt, rotator) {
86
+ if (keyEpochAfter(chain, revokedAt))
87
+ return "complete";
88
+ return rotator ? "pending" : "awaiting-rotation";
89
+ }
90
+ /** Whether a KEY_EPOCH is accepted at a control sequence greater than `seq` (§8.4: the mark clears). */
91
+ export function keyEpochAfter(chain, seq) {
92
+ return chain.records.some((r) => r.body.type === "KEY_EPOCH" && r.payload.controlSeq > seq);
93
+ }
94
+ /**
95
+ * A Package phase `(resource, epoch, recipient)` (§9.2): never superseded;
96
+ * `complete` when the ledger records it; a recipient that is no longer a
97
+ * reader at the head is skipped.
98
+ */
99
+ export function classifyPackage(ledgered, readerAtHead) {
100
+ if (ledgered)
101
+ return "complete";
102
+ return readerAtHead ? "pending" : "skipped";
103
+ }
104
+ //# sourceMappingURL=phases.js.map
@@ -0,0 +1,45 @@
1
+ import { type ControlRecordId, type ResourceId } from "@openlfcp/core";
2
+ import type { FlowOptions } from "./devices.js";
3
+ /**
4
+ * What a device that may have been revoked shows for a Resource
5
+ * (IDENTITY-PROFILE-01 §8.6). The device distinguishes, from evidence
6
+ * only:
7
+ *
8
+ * - `access-refused`: the coordinator refuses its reads
9
+ * (`NACK(AUTHORIZATION_FAILED)`) and proves nothing: "This device no
10
+ * longer has access to this section; it may have been removed from your
11
+ * identity". A server without `identity-01`, or a device that never
12
+ * read, stays here.
13
+ * - `revocation-proven`: the Control prefix through the revoking record,
14
+ * obtained through the revoked-reader read of W §41 and verified here
15
+ * (the record revokes this device, which held `data/read` just before
16
+ * it and holds nothing after): "This device was removed from your
17
+ * identity".
18
+ *
19
+ * In both states the device must not create Data Units for the Resource,
20
+ * and it is never re-paired under the same Principal (N5). A timeout is
21
+ * `unreachable`, never a decision.
22
+ */
23
+ export type RevocationStatus = {
24
+ readonly kind: "active";
25
+ } | {
26
+ readonly kind: "access-refused";
27
+ readonly code: string;
28
+ } | {
29
+ readonly kind: "revocation-proven";
30
+ /** The revoking DEVICE_REVOKE. */
31
+ readonly record: ControlRecordId;
32
+ readonly reason: bigint;
33
+ /** The verified prefix, through the revoking record; empty when recalled. */
34
+ readonly prefix: readonly Uint8Array[];
35
+ /** The proof was established earlier and is kept (§8.6, N5): a revocation is permanent. */
36
+ readonly recalled?: true;
37
+ } | {
38
+ readonly kind: "unreachable";
39
+ readonly reason: string;
40
+ };
41
+ export declare function revocationStatus(o: FlowOptions & {
42
+ readonly resource: ResourceId;
43
+ readonly url: string;
44
+ }): Promise<RevocationStatus>;
45
+ //# sourceMappingURL=proof.d.ts.map
@@ -0,0 +1,71 @@
1
+ import { bytesEqual, controlRecordId, fromHex, toHex, } from "@openlfcp/core";
2
+ import { ABILITY, hasAbility, revokedReaderPrefix, validateControlChain } from "@openlfcp/wire";
3
+ import { setWriteBarrier, writeBarrier } from "../storage.js";
4
+ import { ChannelPool, IdentityRefused, IdentityUnavailable } from "./network.js";
5
+ export async function revocationStatus(o) {
6
+ const status = await observe(o);
7
+ const known = await writeBarrier(o.storage, o.resource);
8
+ // A proven revocation is permanent (N5): no later observation lifts or
9
+ // downgrades it, whatever an older prefix or a refusal shows (§8.6).
10
+ if (known?.state === "revocation-proven" && status.kind !== "revocation-proven")
11
+ return {
12
+ kind: "revocation-proven",
13
+ record: controlRecordId(fromHex(known.record ?? "")),
14
+ reason: BigInt(known.reason ?? "0"),
15
+ prefix: [],
16
+ recalled: true,
17
+ };
18
+ // §8.6: in both states the device creates no Data Unit for the Resource; the
19
+ // barrier is durable, so a restart on an old chain cannot write either.
20
+ if (status.kind === "access-refused")
21
+ await setWriteBarrier(o.storage, o.resource, { state: "access-refused", reason: status.code });
22
+ else if (status.kind === "revocation-proven")
23
+ await setWriteBarrier(o.storage, o.resource, {
24
+ state: "revocation-proven",
25
+ record: toHex(status.record),
26
+ reason: String(status.reason),
27
+ });
28
+ // Evidence of activity lifts only an access refusal.
29
+ else if (status.kind === "active" && known !== undefined)
30
+ await setWriteBarrier(o.storage, o.resource, null);
31
+ return status;
32
+ }
33
+ async function observe(o) {
34
+ const me = o.installation.signer;
35
+ const pool = new ChannelPool(o.network);
36
+ try {
37
+ const channel = await pool.get(me, o.url);
38
+ const opened = await channel.open(o.resource);
39
+ if (opened.kind === "nack")
40
+ return { kind: "access-refused", code: opened.code };
41
+ // The whole readable chain, never merged into a longer local one: a prefix proves only itself.
42
+ const records = await channel.control(o.resource, 0n);
43
+ const chain = validateControlChain([...records]);
44
+ if (chain.kind !== "linear" || !bytesEqual(chain.state.resourceId, o.resource))
45
+ return { kind: "access-refused", code: "INVALID_CONTROL_CHAIN" };
46
+ const p = me.descriptor.principalId;
47
+ if (hasAbility(chain.state, p, ABILITY.DATA_READ))
48
+ return { kind: "active" };
49
+ const prefix = revokedReaderPrefix(chain, p);
50
+ // The prefix ends at the revoking record (W §41); anything else proves nothing.
51
+ if (prefix === undefined || prefix.index !== chain.records.length - 1)
52
+ return { kind: "access-refused", code: "AUTHORIZATION_FAILED" };
53
+ return {
54
+ kind: "revocation-proven",
55
+ record: prefix.recordId,
56
+ reason: prefix.reason,
57
+ prefix: Object.freeze([...records]),
58
+ };
59
+ }
60
+ catch (e) {
61
+ if (e instanceof IdentityRefused)
62
+ return { kind: "access-refused", code: e.code };
63
+ if (e instanceof IdentityUnavailable)
64
+ return { kind: "unreachable", reason: e.message };
65
+ throw e;
66
+ }
67
+ finally {
68
+ pool.closeAll();
69
+ }
70
+ }
71
+ //# sourceMappingURL=proof.js.map
@@ -0,0 +1,73 @@
1
+ import { type PrincipalId, type ResourceId } from "@openlfcp/core";
2
+ import { type CertificateChain, type Signer } from "@openlfcp/wire";
3
+ import { type DocumentWrite, type KeyDelivery } from "./context.js";
4
+ import { type LinearChain, type PhaseState } from "./phases.js";
5
+ import { type IdentityFlowOptions } from "./resource.js";
6
+ import { type RotationOutcome } from "./rotation.js";
7
+ /**
8
+ * Reconciliation (IDENTITY-PROFILE-01 §7.1): carries the Identity
9
+ * Resource's device records into every roster Resource and keys every
10
+ * device of the root, idempotently. Targets are `{I} ∪ roster`; the input
11
+ * is `I`'s Control state, never the document. Revocations first, then
12
+ * registrations, then keys (by the ledger, unconditionally), then display
13
+ * data. Suspended while `I` or the target is forked (§9.7).
14
+ */
15
+ /** One record reconciliation proposes on a target. */
16
+ export type ReconcileStep = {
17
+ readonly kind: "revoke";
18
+ readonly root: PrincipalId;
19
+ readonly device: PrincipalId;
20
+ /** Reason 3 is propagated as 0; the approval travels nowhere. */
21
+ readonly reason: bigint;
22
+ } | {
23
+ readonly kind: "register";
24
+ readonly root: PrincipalId;
25
+ readonly device: PrincipalId;
26
+ /** `I`'s chain for the device. */
27
+ readonly chain: CertificateChain;
28
+ };
29
+ /**
30
+ * §7.1 steps 1 and 2 for one target, in their normative order. For the
31
+ * 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).
34
+ */
35
+ export declare function planReconciliation(identity: LinearChain, target: LinearChain): readonly ReconcileStep[];
36
+ /** The record of a step on the target's head, signed by the reconciler. */
37
+ export declare function reconcileRecord(target: LinearChain, step: ReconcileStep, signer: Signer): Uint8Array;
38
+ export interface TargetReconciliation {
39
+ readonly resource: ResourceId;
40
+ readonly status: "done" | "unsupported" | "unreachable" | "conflict" | "not-authorized";
41
+ /** Each proposed step and its §9.2 phase state. */
42
+ readonly steps: readonly {
43
+ readonly step: ReconcileStep;
44
+ readonly state: PhaseState | "refused";
45
+ }[];
46
+ readonly keys?: KeyDelivery;
47
+ readonly rotation?: RotationOutcome;
48
+ }
49
+ /**
50
+ * §7.1: reconciles `{I} ∪ roster` from this managing installation (the
51
+ * root, or an active personal device with `12`). Proposals the target
52
+ * refuses are reported, not repeated as such; a second run proposes
53
+ * nothing. When the target holds an exposing revocation since its epoch
54
+ * opened and this installation is the rotator, it rotates (§8.4).
55
+ */
56
+ export declare function reconcileDevices(o: IdentityFlowOptions & {
57
+ /** Only these targets (default: `I` and the whole roster). */
58
+ readonly only?: readonly ResourceId[];
59
+ /** Apply the rotation policy of §8.4 to each target (default true). */
60
+ readonly rotate?: boolean;
61
+ }): Promise<{
62
+ readonly operation: string;
63
+ readonly targets: readonly TargetReconciliation[];
64
+ /**
65
+ * Step 4's unit of display data in `I`: not needed (`none`), written,
66
+ * found written by another manager when built (`complete`, nothing sent),
67
+ * or refused with `code`. A refused unit leaves this operation open.
68
+ * Reconciliation has no resume: a later call is a new operation that
69
+ * proposes the entries still missing then, and does not close this one.
70
+ */
71
+ readonly document: DocumentWrite;
72
+ }>;
73
+ //# sourceMappingURL=reconcile.d.ts.map
@@ -0,0 +1,273 @@
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";
4
+ import { mayManageDevices } from "./devices.js";
5
+ import { principalRefOf, resourceRef } from "./document.js";
6
+ import { newOperationId, OperationJournal, PackageLedger } from "./journal.js";
7
+ import { ChannelPool, IDENTITY_EXTENSION, IdentityRefused, IdentityUnavailable, } from "./network.js";
8
+ import { classifyRegister, classifyRevoke } from "./phases.js";
9
+ import { identityTarget } from "./resource.js";
10
+ import { rotateIfExposed } from "./revocation.js";
11
+ import { resumeRotations } from "./rotation.js";
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).
17
+ */
18
+ export function planReconciliation(identity, target) {
19
+ if (bytesEqual(identity.state.resourceId, target.state.resourceId))
20
+ return [];
21
+ const R = genesisOwner(identity);
22
+ const I = identity.state;
23
+ 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();
28
+ for (const r of identity.records) {
29
+ if (r.body.type !== "DEVICE_REVOKE" || !bytesEqual(r.body.root, R))
30
+ continue;
31
+ const d = r.body.device;
32
+ if (revokedInX(d) || revokedNow.has(toHex(d)))
33
+ continue;
34
+ revokedNow.add(toHex(d));
35
+ steps.push({
36
+ kind: "revoke",
37
+ root: R,
38
+ device: d,
39
+ reason: r.body.reason === 3n ? 0n : r.body.reason,
40
+ });
41
+ }
42
+ const revokedAfter = (d) => revokedInX(d) || revokedNow.has(toHex(d));
43
+ // Step 2: registrations, in I's binding order.
44
+ for (const [hex, d] of I.devices) {
45
+ if (!bytesEqual(d.root, R) || d.chain === null || d.leafId === null)
46
+ continue;
47
+ const D = principalId(fromHex(hex));
48
+ if (revokedAfter(D) || d.chain.issuers.some((e) => revokedAfter(e)))
49
+ continue;
50
+ const x = X.devices.get(hex);
51
+ if (d.kind === DEVICE_KIND.PERSONAL) {
52
+ if (activeDevice(I, D) === undefined)
53
+ continue;
54
+ if (x?.registered && x.leafId !== null && bytesEqual(x.leafId, d.leafId))
55
+ continue;
56
+ steps.push({ kind: "register", root: R, device: D, chain: d.chain });
57
+ }
58
+ else if (d.kind === DEVICE_KIND.SERVICE) {
59
+ if (revocationOf(I, R, D) !== undefined)
60
+ continue;
61
+ if (!d.scope.some((s) => bytesEqual(s, X.resourceId)))
62
+ continue;
63
+ // Absent from X, or bound there with another leaf and suspended by the cascade:
64
+ // I's chain re-certifies it (§7.1 step 2, third item; F09). Kind, mask and
65
+ // scope are immutable (N8): a binding with others is left alone.
66
+ if (x !== undefined) {
67
+ if (x.leafId !== null && bytesEqual(x.leafId, d.leafId))
68
+ continue;
69
+ if (activeDevice(X, D) !== undefined || !sameBinding(x, d))
70
+ continue;
71
+ }
72
+ steps.push({ kind: "register", root: R, device: D, chain: d.chain });
73
+ }
74
+ }
75
+ return Object.freeze(steps);
76
+ }
77
+ const sameSet = (a, b) => a.length === b.length && a.every((v) => b.includes(v));
78
+ /** N8: the same kind, mask and scope. */
79
+ const sameBinding = (a, b) => a.kind === b.kind &&
80
+ sameSet(a.mask.map(String), b.mask.map(String)) &&
81
+ sameSet(a.scope.map(toHex), b.scope.map(toHex));
82
+ /** The record of a step on the target's head, signed by the reconciler. */
83
+ export function reconcileRecord(target, step, signer) {
84
+ const header = {
85
+ resourceId: target.state.resourceId,
86
+ controlSeq: target.state.seq + 1n,
87
+ prevControlId: target.state.head,
88
+ };
89
+ return step.kind === "revoke"
90
+ ? signControlRecord(header, { type: "DEVICE_REVOKE", root: step.root, device: step.device, reason: step.reason }, signer).bytes
91
+ : signControlRecord(header, { type: "DEVICE_GRANT", root: step.root, chain: step.chain }, signer).bytes;
92
+ }
93
+ /**
94
+ * §7.1: reconciles `{I} ∪ roster` from this managing installation (the
95
+ * root, or an active personal device with `12`). Proposals the target
96
+ * refuses are reported, not repeated as such; a second run proposes
97
+ * nothing. When the target holds an exposing revocation since its epoch
98
+ * opened and this installation is the rotator, it rotates (§8.4).
99
+ */
100
+ export async function reconcileDevices(o) {
101
+ const target = await identityTarget(o);
102
+ const me = o.installation.signer;
103
+ const journal = new OperationJournal(o.storage);
104
+ const ledger = new PackageLedger(o.storage);
105
+ const operation = newOperationId("reconcile", secureRandom(8));
106
+ await journal.begin({
107
+ id: operation,
108
+ kind: "reconcile",
109
+ createdAt: o.now(),
110
+ intent: { identity: toHex(target.resource) },
111
+ });
112
+ const pool = new ChannelPool(o.network);
113
+ const out = [];
114
+ let document = { state: "none" };
115
+ let open = false;
116
+ try {
117
+ // A rotation an earlier run committed but did not adopt is finished first (§9.1).
118
+ await resumeRotations({ ...o, pool });
119
+ const channel = await pool.get(me, target.url);
120
+ const I = await syncChain(o.storage, channel, target.resource);
121
+ const R = genesisOwner(I);
122
+ if (!mayManageDevices(I.state, me.descriptor.principalId, R))
123
+ return {
124
+ operation,
125
+ targets: [{ resource: target.resource, status: "not-authorized", steps: [] }],
126
+ document,
127
+ };
128
+ const doc = o.document(target.resource, R);
129
+ const deksI = await fetchDeks(o.storage, o.secrets, channel, I, o.installation);
130
+ await loadIdentityDocument({ channel, chain: I, deks: deksI, doc });
131
+ const hints = (resource) => {
132
+ const ref = resourceRef(resource);
133
+ const m = new Map();
134
+ for (const [key, e] of doc.devices()) {
135
+ const epochs = e.missing_keys?.[ref];
136
+ const d = key.startsWith("p:") ? toHex(fromBase64url(key.slice(2))) : undefined;
137
+ if (epochs !== undefined && d !== undefined)
138
+ m.set(d, new Set(epochs.map(String)));
139
+ }
140
+ return m;
141
+ };
142
+ const targets = [
143
+ { resource: target.resource, url: target.url },
144
+ ...[...doc.roster()]
145
+ .filter(([, e]) => e.archived !== true)
146
+ .map(([ref, e]) => ({ resource: resourceId(fromBase64url(ref)), url: e.coordinator }))
147
+ .filter((t) => !bytesEqual(t.resource, target.resource)),
148
+ ].filter((t) => o.only === undefined || o.only.some((r) => bytesEqual(r, t.resource)));
149
+ for (const t of targets) {
150
+ try {
151
+ const c = await pool.get(me, t.url);
152
+ if (!c.extensions.includes(IDENTITY_EXTENSION)) {
153
+ out.push({ resource: t.resource, status: "unsupported", steps: [] });
154
+ continue;
155
+ }
156
+ out.push(await reconcileTarget(o, c, journal, ledger, operation, I, t.resource, hints(t.resource)));
157
+ }
158
+ catch (e) {
159
+ if (e instanceof IdentityUnavailable || e instanceof IdentityRefused)
160
+ out.push({ resource: t.resource, status: "unreachable", steps: [] });
161
+ else if (e instanceof IdentityConflict)
162
+ out.push({ resource: t.resource, status: "conflict", steps: [] });
163
+ else
164
+ throw e;
165
+ }
166
+ }
167
+ // Step 4: display data for devices in I's chain without a valid entry (labels default to the fingerprint).
168
+ if (displayIntents(I, doc, R).length > 0 && doc.initialized()) {
169
+ // Built on the current state of I: a rotation of I by this run, or by
170
+ // anyone, makes an earlier unit stale (W §19.1), and it is written again.
171
+ document = await runUnitPhase(channel, journal, operation, {
172
+ id: "display",
173
+ resource: target.resource,
174
+ rebuildOn: ["STALE_DATA_EPOCH"],
175
+ build: async () => {
176
+ const now = await syncChain(o.storage, channel, target.resource);
177
+ const deks = await fetchDeks(o.storage, o.secrets, channel, now, o.installation);
178
+ const fresh = o.document(target.resource, R);
179
+ const loaded = await loadIdentityDocument({ channel, chain: now, deks, doc: fresh });
180
+ // Another manager may have written the entries since the first read (§7.1).
181
+ const intents = displayIntents(now, fresh, R);
182
+ if (intents.length === 0)
183
+ return null;
184
+ return sealIdentityUnit({
185
+ storage: o.storage,
186
+ chain: now,
187
+ deks,
188
+ signer: me,
189
+ doc: fresh,
190
+ latest: loaded.latest,
191
+ intents,
192
+ });
193
+ },
194
+ });
195
+ }
196
+ open =
197
+ document.state === "refused" ||
198
+ out.some((t) => t.status === "unreachable" ||
199
+ t.status === "conflict" ||
200
+ t.rotation?.status === "unreachable" ||
201
+ t.rotation?.status === "awaiting-key-holder");
202
+ await journal.setStatus(operation, open ? "open" : "done");
203
+ }
204
+ catch (e) {
205
+ if (e instanceof IdentityConflict)
206
+ out.push({ resource: target.resource, status: "conflict", steps: [] });
207
+ else if (e instanceof IdentityUnavailable)
208
+ out.push({ resource: target.resource, status: "unreachable", steps: [] });
209
+ else
210
+ throw e;
211
+ }
212
+ finally {
213
+ pool.closeAll();
214
+ }
215
+ return { operation, targets: Object.freeze(out), document };
216
+ }
217
+ /** §7.1 step 4: a device entry for each device of R in I's chain without one. */
218
+ function displayIntents(I, doc, R) {
219
+ const intents = [];
220
+ for (const [hex, d] of I.state.devices) {
221
+ if (!bytesEqual(d.root, R) || d.chain === null || d.kind === DEVICE_KIND.PAIRING)
222
+ continue;
223
+ const key = principalRefOf(principalId(fromHex(hex)));
224
+ if (doc.devices().has(key))
225
+ continue;
226
+ intents.push({
227
+ intent: "device.put",
228
+ key,
229
+ entry: {
230
+ label: deviceFingerprint(principalId(fromHex(hex))),
231
+ kind: d.kind === DEVICE_KIND.SERVICE ? 1 : 0,
232
+ },
233
+ });
234
+ }
235
+ return intents;
236
+ }
237
+ async function reconcileTarget(o, channel, journal, ledger, operation, I, X, resend) {
238
+ const me = o.installation.signer;
239
+ const R = genesisOwner(I);
240
+ let chain = await syncChain(o.storage, channel, X);
241
+ const steps = [];
242
+ const manages = mayManageDevices(chain.state, me.descriptor.principalId, R);
243
+ if (manages)
244
+ for (const step of planReconciliation(I, chain)) {
245
+ const id = `${step.kind}:${toHex(X)}:${toHex(step.device)}`;
246
+ const outcome = await runControlPhase(o.storage, channel, journal, operation, {
247
+ id,
248
+ kind: step.kind,
249
+ resource: X,
250
+ classify: (c, isOwn) => step.kind === "revoke"
251
+ ? classifyRevoke(c.state, step.root, step.device)
252
+ : classifyRegister(c, { root: step.root, chain: step.chain, isOwnRecord: isOwn }),
253
+ build: (c) => reconcileRecord(c, step, me),
254
+ });
255
+ steps.push({ step, state: outcome.state });
256
+ chain = outcome.chain;
257
+ }
258
+ // Step 3: every reader of the target whose root is R (pairing devices of I included), by the ledger.
259
+ const deks = await fetchDeks(o.storage, o.secrets, channel, chain, o.installation);
260
+ const recipients = readersAt(chain.state).filter((r) => bytesEqual(actsFor(chain.state, r.principalId), R));
261
+ const keys = await deliverKeys({ channel, ledger, chain, sender: me, recipients, deks, resend });
262
+ const rotation = o.rotate === false
263
+ ? undefined
264
+ : await rotateIfExposed({ ...o, channel, operation, resource: X });
265
+ return {
266
+ resource: X,
267
+ status: manages ? "done" : "not-authorized",
268
+ steps: Object.freeze(steps),
269
+ keys,
270
+ ...(rotation === undefined ? {} : { rotation }),
271
+ };
272
+ }
273
+ //# sourceMappingURL=reconcile.js.map