@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,495 @@
1
+ import { actorSequence, bytesEqual, controlRecordId, dataEpoch, fromHex, hash32, LfcpError, toHex, } from "@openlfcp/core";
2
+ import { dekCommitment, exportSecretKeyBytes, importResourceDEK, sha256, } from "@openlfcp/crypto";
3
+ import { dekSecretRef } from "@openlfcp/storage";
4
+ import { ABILITY, hasAbility, InMemorySeenUnits, parseDataUnit, parseKeyPackage, receiveDataUnit, receiveKeyPackage, sealDataUnit, sealKeyPackage, validateControlChain, verifyKeyPackage, verifySignedObject, } from "@openlfcp/wire";
5
+ import { adoptStoredDeks, checkWriteBarrier, loadControlChain, saveControlChain, saveControlConflict, } from "../storage.js";
6
+ import { identityPlaintextCodec, identityWriterClass, } from "./document.js";
7
+ import { phaseOf, } from "./journal.js";
8
+ import { IdentityUnavailable } from "./network.js";
9
+ /** The Resource or the Identity Resource is forked (§9.7): approval, reconciliation and rotation are suspended. */
10
+ export class IdentityConflict extends Error {
11
+ resource;
12
+ constructor(resource, message) {
13
+ super(message);
14
+ this.resource = resource;
15
+ this.name = "IdentityConflict";
16
+ }
17
+ }
18
+ /**
19
+ * Synchronises the Control Chain of `resource` from the coordinator and
20
+ * stores it: the local chain extended with the coordinator's later
21
+ * records, or the coordinator's whole chain when the local one does not
22
+ * extend. A fork is stored as a conflict and thrown (§9.7).
23
+ */
24
+ export async function syncChain(storage, channel, resource) {
25
+ // A fork this installation already knows stays blocking (§9.7): no branch is chosen.
26
+ if ((await storage.control.conflict(resource)) !== undefined)
27
+ throw new IdentityConflict(resource, "the Control Chain is forked (§9.7)");
28
+ const local = await loadControlChain(storage, resource);
29
+ const have = local?.kind === "linear" ? local.records.map((r) => r.signed.bytes) : [];
30
+ let chain = validateControlChain([
31
+ ...have,
32
+ ...(await channel.control(resource, BigInt(have.length))),
33
+ ]);
34
+ if (chain.kind !== "linear" && have.length > 0) {
35
+ // The coordinator's chain does not extend the stored one: validate both
36
+ // together, so a competing record on a known prev is a conflict (§9.7),
37
+ // never a silent switch to the coordinator's branch.
38
+ const all = new Map();
39
+ for (const r of [...have, ...(await channel.control(resource, 0n))])
40
+ all.set(toHex(sha256(r)), r);
41
+ chain = validateControlChain([...all.values()]);
42
+ }
43
+ if (chain.kind === "conflict") {
44
+ await saveControlConflict(storage, resource, chain);
45
+ throw new IdentityConflict(resource, "the Control Chain is forked (§9.7)");
46
+ }
47
+ if (chain.kind !== "linear")
48
+ throw new LfcpError("INVALID_CONTROL_CHAIN", "the coordinator's Control Chain does not validate");
49
+ if (!bytesEqual(chain.state.resourceId, resource))
50
+ throw new LfcpError("INVALID_CONTROL_CHAIN", "the coordinator sent another Resource's chain");
51
+ await storeChain(storage, chain, local?.kind === "linear" ? local : undefined);
52
+ return chain;
53
+ }
54
+ async function storeChain(storage, chain, previous) {
55
+ if (previous !== undefined && bytesEqual(previous.state.head, chain.state.head))
56
+ return;
57
+ const prefix = previous !== undefined &&
58
+ previous.records.length <= chain.records.length &&
59
+ previous.records.every((r, i) => bytesEqual(r.signed.id, chain.records[i]?.signed.id ?? new Uint8Array()));
60
+ const r = await saveControlChain(storage, chain, prefix ? previous.state.head : null);
61
+ if (!r.ok && prefix)
62
+ throw new Error(`the Control Chain was not stored: ${r.reason}`);
63
+ }
64
+ /** Validates `chain` extended with `record` (e.g. after an ACK) and stores it. */
65
+ export async function extendChain(storage, chain, record) {
66
+ const next = validateControlChain([...chain.records.map((r) => r.signed.bytes), record]);
67
+ if (next.kind !== "linear")
68
+ throw new LfcpError("INVALID_CONTROL_CHAIN", "an acknowledged record does not validate locally");
69
+ await storeChain(storage, next, chain);
70
+ return next;
71
+ }
72
+ const MAX_HEAD_RETRIES = 8;
73
+ /**
74
+ * Runs one Control Plane phase durably (§9.1–§9.3). The phase is
75
+ * classified on the synchronised chain first; while it is `pending`, the
76
+ * exact bytes are persisted before they are sent: the same bytes again
77
+ * while the head is the one they were built on (a lost ACK is answered
78
+ * with the same ACK, W §47), a record rebuilt on a new head otherwise.
79
+ */
80
+ export async function runControlPhase(storage, channel, journal, operation, spec) {
81
+ const R = spec.resource;
82
+ for (let attempt = 0;; attempt++) {
83
+ const chain = await syncChain(storage, channel, R);
84
+ const before = phaseOf(await journal.load(operation), spec.id);
85
+ const sent = new Set(before?.sent ?? []);
86
+ const state = spec.classify(chain, (id) => sent.has(id));
87
+ if (state !== "pending") {
88
+ const recordId = state === "committed-own" ? ownRecordOn(chain, sent) : undefined;
89
+ await journal.putPhase(operation, {
90
+ ...(before ?? {}),
91
+ id: spec.id,
92
+ kind: spec.kind,
93
+ resource: toHex(R),
94
+ state,
95
+ ...(spec.facts === undefined ? {} : { facts: spec.facts }),
96
+ ...(recordId === undefined ? {} : { recordId }),
97
+ });
98
+ return Object.freeze({
99
+ state,
100
+ chain,
101
+ ...(recordId === undefined ? {} : { recordId: controlRecordId(fromHex(recordId)) }),
102
+ });
103
+ }
104
+ if (attempt >= MAX_HEAD_RETRIES)
105
+ throw new IdentityUnavailable(`the head kept moving while proposing ${spec.id}`);
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);
112
+ const id = toHex(validateRecordId(bytes));
113
+ await journal.putPhase(operation, {
114
+ id: spec.id,
115
+ kind: spec.kind,
116
+ resource: toHex(R),
117
+ state: "pending",
118
+ bytes: toHex(bytes),
119
+ expectedHead: head,
120
+ sent: [...sent, id],
121
+ ...(spec.facts === undefined ? {} : { facts: spec.facts }),
122
+ });
123
+ }
124
+ const answer = await channel.putControl(R, chain.state.head, bytes);
125
+ if (answer.kind === "ack") {
126
+ const next = await extendChain(storage, chain, bytes);
127
+ const recordId = toHex(next.state.head);
128
+ const phase = phaseOf(await journal.load(operation), spec.id);
129
+ await journal.putPhase(operation, { ...phase, state: "committed-own", recordId });
130
+ return Object.freeze({ state: "committed-own", chain: next, recordId: next.state.head });
131
+ }
132
+ if (answer.code === "CONTROL_HEAD_MISMATCH")
133
+ continue;
134
+ const phase = phaseOf(await journal.load(operation), spec.id);
135
+ await journal.putPhase(operation, { ...phase, state: "refused", code: answer.code });
136
+ return Object.freeze({ state: "refused", chain, code: answer.code });
137
+ }
138
+ }
139
+ /** A Control Record's ID: SHA-256 of its exact bytes (W §13). */
140
+ const validateRecordId = (bytes) => controlRecordId(sha256(bytes));
141
+ function ownRecordOn(chain, sent) {
142
+ for (const r of chain.records) {
143
+ const id = toHex(r.signed.id);
144
+ if (sent.has(id))
145
+ return id;
146
+ }
147
+ return undefined;
148
+ }
149
+ // ---------------------------------------------------------------------------
150
+ // DEKs
151
+ /** The DEKs this client holds for `chain`'s epochs, each checked against its commitment. */
152
+ export async function heldDeks(secrets, chain) {
153
+ const R = chain.state.resourceId;
154
+ const out = new Map();
155
+ for (const e of chain.state.epochs.values()) {
156
+ const bytes = await secrets.get(dekSecretRef(R, e.epoch));
157
+ if (bytes === undefined)
158
+ continue;
159
+ try {
160
+ const dek = importResourceDEK(bytes);
161
+ if (bytesEqual(dekCommitment(R, e.epoch, dek), e.dekCommitment))
162
+ out.set(String(e.epoch), dek);
163
+ }
164
+ finally {
165
+ bytes.fill(0);
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+ /** Stores the DEK of `epoch` (secret first) and links the epoch rows to it (LFCP-034). */
171
+ export async function storeDek(storage, secrets, chain, epoch, dek) {
172
+ const e = chain.state.epochs.get(String(epoch));
173
+ if (e !== undefined &&
174
+ !bytesEqual(dekCommitment(chain.state.resourceId, epoch, dek), e.dekCommitment))
175
+ throw new LfcpError("DEK_COMMITMENT_MISMATCH", `the DEK is not the one committed for epoch ${epoch}`);
176
+ const bytes = exportSecretKeyBytes(dek);
177
+ try {
178
+ await secrets.put(dekSecretRef(chain.state.resourceId, epoch), bytes);
179
+ }
180
+ finally {
181
+ bytes.fill(0);
182
+ }
183
+ await adoptStoredDeks(storage, secrets, chain);
184
+ }
185
+ /**
186
+ * KEY_PACKAGE_GET for `recipient` over every epoch of `chain` (§52: at most
187
+ * 256 per request, the channel splits), opening and storing each DEK.
188
+ */
189
+ export async function fetchDeks(storage, secrets, channel, chain, recipient) {
190
+ const R = chain.state.resourceId;
191
+ const out = await heldDeks(secrets, chain);
192
+ const missing = [...chain.state.epochs.values()]
193
+ .map((e) => e.epoch)
194
+ .filter((e) => !out.has(String(e)));
195
+ if (missing.length === 0)
196
+ return out;
197
+ for (const bytes of await channel.keyPackages(R, recipient.signer.descriptor.principalId, missing)) {
198
+ const r = await receiveKeyPackage(chain, bytes, {
199
+ descriptor: recipient.signer.descriptor,
200
+ agreement: recipient.agreement,
201
+ });
202
+ if (r.kind !== "opened" || out.has(String(r.epoch)))
203
+ continue;
204
+ await storeDek(storage, secrets, chain, r.epoch, r.dek);
205
+ out.set(String(r.epoch), r.dek);
206
+ }
207
+ return out;
208
+ }
209
+ // ---------------------------------------------------------------------------
210
+ // Readers and key delivery
211
+ /** Every Principal with `holds(P, 1)` at the head (W §19, §25.2): the reader set, by ID. */
212
+ export function readersAt(state) {
213
+ const all = new Map([
214
+ [toHex(state.owner.principalId), state.owner],
215
+ ...[...state.principals.entries()],
216
+ ]);
217
+ return Object.freeze([...all.entries()]
218
+ .filter(([, d]) => hasAbility(state, d.principalId, ABILITY.DATA_READ))
219
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
220
+ .map(([, d]) => d));
221
+ }
222
+ /**
223
+ * Seals every DEK of `deks` to each recipient at the chain's head and
224
+ * records each acknowledged package (§9.5). A package the ledger records is
225
+ * `complete` unless `resend` lists its epoch for that recipient (a
226
+ * `missing_keys` hint, §7.1 step 3); a recipient that is no longer a
227
+ * reader, or a package that would not be authorized, is skipped (§9.2).
228
+ */
229
+ export async function deliverKeys(options) {
230
+ const { chain, sender, ledger } = options;
231
+ const R = chain.state.resourceId;
232
+ const sent = [];
233
+ const skipped = [];
234
+ let complete = 0;
235
+ const epochs = [...options.deks.keys()].sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : 1));
236
+ for (const recipient of options.recipients) {
237
+ const rid = recipient.principalId;
238
+ const hex = toHex(rid);
239
+ if (bytesEqual(rid, sender.descriptor.principalId))
240
+ continue;
241
+ if (!hasAbility(chain.state, rid, ABILITY.DATA_READ)) {
242
+ skipped.push({ recipient: hex, reason: "not a reader at the head" });
243
+ continue;
244
+ }
245
+ for (const e of epochs) {
246
+ const epoch = dataEpoch(BigInt(e));
247
+ if ((await ledger.has(R, epoch, rid)) && !(options.resend?.get(hex)?.has(e) ?? false)) {
248
+ complete++;
249
+ continue;
250
+ }
251
+ // §9.1: a package sent before is sent again as the same bytes, while it is still authorized.
252
+ let bytes = await ledger.pending(R, epoch, rid);
253
+ if (bytes !== undefined &&
254
+ verifyKeyPackage(chain, parseKeyPackage(bytes)).kind !== "authorized")
255
+ bytes = undefined;
256
+ if (bytes === undefined) {
257
+ const sealed = await sealKeyPackage({
258
+ resourceId: R,
259
+ epoch,
260
+ controlHead: chain.state.head,
261
+ recipient,
262
+ dek: options.deks.get(e),
263
+ signer: sender,
264
+ });
265
+ const check = verifyKeyPackage(chain, parseKeyPackage(sealed.bytes));
266
+ if (check.kind !== "authorized") {
267
+ skipped.push({ recipient: hex, reason: check.message });
268
+ break;
269
+ }
270
+ bytes = sealed.bytes;
271
+ await ledger.putPending(R, epoch, rid, bytes);
272
+ }
273
+ const answer = await options.channel.putKeyPackage(R, bytes);
274
+ if (answer.kind !== "ack") {
275
+ skipped.push({ recipient: hex, reason: answer.code });
276
+ continue;
277
+ }
278
+ await ledger.record({
279
+ resource: R,
280
+ epoch,
281
+ recipient: rid,
282
+ packageId: hash32(parseKeyPackage(bytes).signed.id),
283
+ });
284
+ sent.push({ epoch, recipient: hex });
285
+ }
286
+ }
287
+ return Object.freeze({ sent: Object.freeze(sent), complete, skipped: Object.freeze(skipped) });
288
+ }
289
+ // ---------------------------------------------------------------------------
290
+ // The Identity Resource's document
291
+ /**
292
+ * Fetches every Data Unit of the Identity Resource that the coordinator
293
+ * advertises and applies the readable ones to `doc` in actor-sequence
294
+ * order, each with its writer class at its control_ref (§2.3). Returns the
295
+ * highest accepted sequence per actor (hex).
296
+ */
297
+ export async function loadIdentityDocument(options) {
298
+ const { chain, doc } = options;
299
+ const R = chain.state.resourceId;
300
+ const root = genesisOwner(chain);
301
+ const opened = await options.channel.open(R);
302
+ if (opened.kind === "nack")
303
+ throw new IdentityUnavailable(`RESOURCE_OPEN was refused: ${opened.code}`);
304
+ const ranges = opened.haves.flatMap((h) => {
305
+ const out = h.contiguous > 0n ? [{ actor: h.principalId, start: 1n, end: h.contiguous }] : [];
306
+ for (const [start, end] of h.ranges ?? [])
307
+ out.push({ actor: h.principalId, start, end });
308
+ return out;
309
+ });
310
+ // Units in the order of their Control Heads, then by actor and sequence: a writer
311
+ // synchronises before writing, so a change rarely references a head older than
312
+ // the changes it saw, and changes reach the document after their dependencies
313
+ // (a held change is still admitted later; provenance comes from the port).
314
+ // Both units of an equivocation are excluded first (W §26.2).
315
+ const headSeq = (head) => chain.stateAt(head)?.seq ?? chain.state.seq + 1n;
316
+ const units = withoutEquivocations(chain, (await options.channel.dataUnits(R, ranges)).map((bytes) => {
317
+ const parsed = parseDataUnit(bytes);
318
+ return { bytes, parsed, at: headSeq(parsed.payload.controlHead) };
319
+ })).sort((a, b) => {
320
+ if (a.at !== b.at)
321
+ return a.at < b.at ? -1 : 1;
322
+ const x = toHex(a.parsed.payload.actor);
323
+ const y = toHex(b.parsed.payload.actor);
324
+ if (x !== y)
325
+ return x < y ? -1 : 1;
326
+ return a.parsed.payload.actorSeq < b.parsed.payload.actorSeq ? -1 : 1;
327
+ });
328
+ const seen = new InMemorySeenUnits();
329
+ const latest = new Map();
330
+ const pending = [];
331
+ const apply = (plaintext, author) => doc.receive(plaintext, author);
332
+ for (const { bytes, parsed } of units) {
333
+ const r = await receiveDataUnit(chain, bytes, {
334
+ seen,
335
+ dek: (epoch) => options.deks.get(String(epoch)),
336
+ profile: identityPlaintextCodec,
337
+ });
338
+ if (r.kind !== "accepted")
339
+ continue;
340
+ const at = chain.stateAt(parsed.payload.controlHead) ?? chain.state;
341
+ const author = {
342
+ signer: r.actor,
343
+ actorSeq: r.seq,
344
+ writerClass: identityWriterClass(at, root, r.actor),
345
+ };
346
+ if (!apply(r.value, author))
347
+ pending.push({ plaintext: r.value, author, unitId: r.unitId });
348
+ latest.set(toHex(r.actor), { seq: r.seq, unitId: r.unitId });
349
+ }
350
+ // Changes of other actors that depend on a change applied later (SOP §14.1).
351
+ for (let progress = true; progress && pending.length > 0;) {
352
+ progress = false;
353
+ for (let i = 0; i < pending.length;) {
354
+ const p = pending[i];
355
+ if (apply(p.plaintext, p.author)) {
356
+ pending.splice(i, 1);
357
+ progress = true;
358
+ }
359
+ else
360
+ i++;
361
+ }
362
+ }
363
+ const unapplied = pending.map((p) => Object.freeze({ actor: p.author.signer, seq: p.author.actorSeq, unitId: p.unitId }));
364
+ return { latest, unapplied: Object.freeze(unapplied) };
365
+ }
366
+ /** The Genesis owner of a chain: the root of an Identity Resource (§2.3). */
367
+ export function genesisOwner(chain) {
368
+ const g = chain.records[0];
369
+ return g?.body.type === "GENESIS" ? g.body.owner.principalId : chain.state.owner.principalId;
370
+ }
371
+ /**
372
+ * A new unit of `signer` in the Identity Resource carrying `intents` as
373
+ * one change: the next actor sequence from the storage's reservation
374
+ * (durable, never reused), the current epoch's DEK, the signer's latest
375
+ * accepted unit as previous. The change is applied to `doc`.
376
+ */
377
+ export async function sealIdentityUnit(options) {
378
+ const { chain, signer } = options;
379
+ const R = chain.state.resourceId;
380
+ await checkWriteBarrier(options.storage, R);
381
+ const epoch = chain.state.epoch.epoch;
382
+ const dek = options.deks.get(String(epoch));
383
+ if (dek === undefined)
384
+ throw new LfcpError("KEY_PACKAGE_OPEN_FAILED", `the DEK of epoch ${epoch} is not held`);
385
+ const me = signer.descriptor.principalId;
386
+ const previous = options.latest.get(toHex(me));
387
+ let seq = options.seq;
388
+ if (seq === undefined) {
389
+ seq = await options.storage.actorSequences.reserveNext(R, me);
390
+ while (previous !== undefined && seq <= previous.seq)
391
+ seq = await options.storage.actorSequences.reserveNext(R, me);
392
+ }
393
+ const plaintext = options.doc.write({
394
+ signer: me,
395
+ actorSeq: seq,
396
+ writerClass: identityWriterClass(chain.state, genesisOwner(chain), me),
397
+ }, options.intents);
398
+ return sealDataUnit({
399
+ resourceId: R,
400
+ dataEpoch: epoch,
401
+ actorSeq: actorSequence(seq),
402
+ prevDataUnitId: previous?.unitId ?? null,
403
+ controlHead: chain.state.head,
404
+ }, plaintext, dek, signer).bytes;
405
+ }
406
+ /**
407
+ * W §26.2: two different units at one (resource, actor, sequence), each
408
+ * with a valid signature by its actor, are equivocation, and both are
409
+ * excluded (their dependants then wait for a dependency that never comes:
410
+ * the actor's later units for its hash chain, other changes for the
411
+ * document's). Only units of `chain`'s Resource whose signature verifies
412
+ * take part: an unsigned or forged copy proves nothing and is dropped
413
+ * without excluding the signed unit. Authorization and decryption are not
414
+ * conditions here. Deciding before anything is applied keeps the result
415
+ * independent of the delivery order.
416
+ */
417
+ export function withoutEquivocations(chain, units) {
418
+ const signed = units.filter((u) => {
419
+ const p = u.parsed.payload;
420
+ if (!bytesEqual(p.resourceId, chain.state.resourceId))
421
+ return false;
422
+ const actor = chain.state.principals.get(toHex(p.actor));
423
+ return actor !== undefined && verifySignedObject(u.parsed.signed, actor).valid;
424
+ });
425
+ const key = (u) => `${toHex(u.parsed.payload.actor)}:${u.parsed.payload.actorSeq}`;
426
+ const ids = new Map();
427
+ for (const u of signed) {
428
+ const set = ids.get(key(u)) ?? new Set();
429
+ set.add(toHex(sha256(u.bytes)));
430
+ ids.set(key(u), set);
431
+ }
432
+ return signed.filter((u) => (ids.get(key(u))?.size ?? 0) <= 1);
433
+ }
434
+ /**
435
+ * Sends one Data Unit durably (§9.1): its exact bytes are persisted before
436
+ * they are sent and re-sent as they are after a restart, so an actor
437
+ * sequence is never used twice (W §26.2). A coordinator answers a unit it
438
+ * already holds with the same ACK.
439
+ */
440
+ export async function runUnitPhase(channel, journal, operation, spec) {
441
+ for (let attempt = 0;; attempt++) {
442
+ const before = phaseOf(await journal.load(operation), spec.id);
443
+ if (before?.state === "committed-own")
444
+ return { state: "committed-own" };
445
+ if (before?.state === "complete")
446
+ return { state: "complete" };
447
+ const retry = before?.state === "refused" &&
448
+ before.code !== undefined &&
449
+ ((spec.rebuildOn ?? []).includes(before.code) ||
450
+ (attempt === 0 && spec.rebuildRefused === true));
451
+ if (retry && attempt > 3)
452
+ return { state: "refused", code: before?.code ?? "refused" };
453
+ let bytes;
454
+ if (before?.bytes !== undefined && !retry)
455
+ bytes = fromHex(before.bytes);
456
+ else {
457
+ const built = await spec.build();
458
+ const facts = before?.bytes !== undefined && retry
459
+ ? { facts: { ...before.facts, [`stale${attempt}`]: before.bytes } }
460
+ : before?.facts === undefined
461
+ ? {}
462
+ : { facts: before.facts };
463
+ if (built === null) {
464
+ // Another hand wrote the effect on the state the unit would build on (§9.2).
465
+ await journal.putPhase(operation, {
466
+ id: spec.id,
467
+ kind: "unit",
468
+ resource: toHex(spec.resource),
469
+ state: "complete",
470
+ ...facts,
471
+ });
472
+ return { state: "complete" };
473
+ }
474
+ bytes = built;
475
+ await journal.putPhase(operation, {
476
+ id: spec.id,
477
+ kind: "unit",
478
+ resource: toHex(spec.resource),
479
+ state: "pending",
480
+ bytes: toHex(bytes),
481
+ ...facts,
482
+ });
483
+ }
484
+ const answer = await channel.putDataUnit(spec.resource, bytes);
485
+ const phase = phaseOf(await journal.load(operation), spec.id);
486
+ if (answer.kind === "ack") {
487
+ await journal.putPhase(operation, { ...phase, state: "committed-own" });
488
+ return { state: "committed-own" };
489
+ }
490
+ await journal.putPhase(operation, { ...phase, state: "refused", code: answer.code });
491
+ if (!(spec.rebuildOn ?? []).includes(answer.code))
492
+ return { state: "refused", code: answer.code };
493
+ }
494
+ }
495
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1,127 @@
1
+ import { type PrincipalId, type ResourceId } from "@openlfcp/core";
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";
4
+ import { type IdentityStorage, type Installation, type KeyDelivery } from "./context.js";
5
+ import { type IdentityNetwork } from "./network.js";
6
+ import { type LinearChain } from "./phases.js";
7
+ /** The mask presets of IDENTITY-PROFILE-01 §5.4 (decision I6). Codes 9 and 11 are never listed. */
8
+ export declare const MASK_PRESETS: Readonly<{
9
+ readonly full: Readonly<{
10
+ kind: 0;
11
+ mask: bigint[];
12
+ }>;
13
+ readonly edit: Readonly<{
14
+ kind: 0;
15
+ mask: bigint[];
16
+ }>;
17
+ readonly read: Readonly<{
18
+ kind: 0;
19
+ mask: bigint[];
20
+ }>;
21
+ readonly service: Readonly<{
22
+ kind: 1;
23
+ mask: bigint[];
24
+ }>;
25
+ }>;
26
+ export type MaskPreset = keyof typeof MASK_PRESETS;
27
+ /** What every identity flow needs of the installation. */
28
+ export interface FlowOptions {
29
+ readonly installation: Installation;
30
+ readonly storage: IdentityStorage;
31
+ readonly secrets: SecretStore;
32
+ readonly network: IdentityNetwork;
33
+ readonly now: () => number;
34
+ /**
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.
38
+ */
39
+ readonly dataProfiles?: readonly DataProfileCodec<unknown>[];
40
+ }
41
+ /**
42
+ * The certificates above a leaf that `issuer` signs for `root` in the
43
+ * state: none when the issuer is the root, else the issuer's own current
44
+ * chain, which must be active there (W §7.3 (c3), §17.4 rule 6).
45
+ */
46
+ export declare function issuerCertificates(state: ControlState, issuer: PrincipalId, root: PrincipalId): readonly DeviceCertificate[] | undefined;
47
+ /** A leaf payload for `device` with a fresh serial (W §7.3). */
48
+ export declare function leafPayload(root: PrincipalId, device: PrincipalDescriptor, kind: DeviceKind, mask: readonly bigint[], scope: readonly ResourceId[]): DeviceCertificatePayload;
49
+ export type Feasibility = {
50
+ readonly feasible: true;
51
+ } | {
52
+ readonly feasible: false;
53
+ readonly reason: string;
54
+ };
55
+ /**
56
+ * IDENTITY-PROFILE-01 §5.3 step 4: whether the DEVICE_GRANT that would
57
+ * register (or bind) `leaf` through `issuer` would be accepted by the
58
+ * Resource's current state, before anything is committed or signed for
59
+ * the device (N9). The complete future chain is built with the leaf's
60
+ * signature taken as given (a placeholder never leaves this function) and
61
+ * checked with the validators' own rules: structure (kind, mask, scope,
62
+ * c1, c5, c7), c3 and c4 against the issuer's chain, and W §17.4 rules 4
63
+ * to 8 on the state after `consume` when a pairing session is given (its
64
+ * device then counts as revoked, rule 8), else on the current state.
65
+ */
66
+ export declare function checkIssuance(options: {
67
+ readonly chain: LinearChain;
68
+ readonly issuer: Signer;
69
+ readonly leaf: DeviceCertificatePayload;
70
+ /** The pairing device whose consume precedes the grant (§5.3 step 5). */
71
+ readonly pairing?: PrincipalId;
72
+ }): Feasibility;
73
+ /** Signs `leaf` through `issuer` and returns the complete chain (§5.3 step 6). */
74
+ export declare function signChain(state: ControlState, issuer: Signer, leaf: DeviceCertificatePayload): CertificateChain;
75
+ /**
76
+ * Whether `actor` may propose device records for `root`'s devices in the
77
+ * Resource (§5.3 step 10, §7.1): it is the root, an active device of the
78
+ * root with `12`, or, when the root owns the Resource, an active device
79
+ * with `13`.
80
+ */
81
+ export declare function mayManageDevices(state: ControlState, actor: PrincipalId, root: PrincipalId): boolean;
82
+ /** Where a device registration ended in one Resource. */
83
+ export type RegistrationStatus =
84
+ /** The record is committed (or the effect already existed); keys follow. */
85
+ "registered"
86
+ /** The leaf or an issuer is revoked there: never registered (§9.2 superseded). */
87
+ | "superseded"
88
+ /** The Resource refused the record (reported, not repeated, §7.1). */
89
+ | "refused"
90
+ /** This installation may not act there (§5.3 step 10): left to reconciliation. */
91
+ | "not-authorized"
92
+ /** The coordinator does not advertise identity-01 (W §37). */
93
+ | "unsupported"
94
+ /** The coordinator could not be reached: left to reconciliation. */
95
+ | "unreachable"
96
+ /** The Resource is forked (§9.7). */
97
+ | "conflict";
98
+ export interface RegistrationResult {
99
+ readonly resource: ResourceId;
100
+ readonly status: RegistrationStatus;
101
+ readonly code?: string;
102
+ readonly keys?: KeyDelivery;
103
+ }
104
+ export interface RegisterTarget {
105
+ readonly resource: ResourceId;
106
+ /** The coordinator URL (the roster entry's coordinator, or the chain's route). */
107
+ readonly url: string;
108
+ }
109
+ /**
110
+ * Registers a device in Resources (§5.3 step 10, §7.2, §7.3, §7.1 step
111
+ * 2): `DEVICE_GRANT {R, chain}` on each target where this installation may
112
+ * act, then a Key Package of every epoch DEK it holds there to the device,
113
+ * each recorded in the ledger. One journaled operation; each Resource is
114
+ * a phase classified by §9.2 before anything is proposed, so a
115
+ * registration that exists by another hand is `complete` and is not
116
+ * proposed again. Resources this installation cannot reach are listed.
117
+ */
118
+ export declare function registerDevice(options: FlowOptions & {
119
+ readonly chain: CertificateChain;
120
+ readonly targets: readonly RegisterTarget[];
121
+ /** Resume this operation instead of starting one. */
122
+ readonly operation?: string;
123
+ }): Promise<{
124
+ readonly operation: string;
125
+ readonly results: readonly RegistrationResult[];
126
+ }>;
127
+ //# sourceMappingURL=devices.d.ts.map