@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.
- package/dist/data-unit.d.ts +11 -0
- package/dist/data-unit.js +9 -0
- package/dist/identity/accept.d.ts +115 -0
- package/dist/identity/accept.js +335 -0
- package/dist/identity/context.d.ts +214 -0
- package/dist/identity/context.js +495 -0
- package/dist/identity/devices.d.ts +127 -0
- package/dist/identity/devices.js +221 -0
- package/dist/identity/document.d.ts +112 -0
- package/dist/identity/document.js +54 -0
- package/dist/identity/index.d.ts +17 -0
- package/dist/identity/index.js +17 -0
- package/dist/identity/journal.d.ts +139 -0
- package/dist/identity/journal.js +202 -0
- package/dist/identity/network.d.ts +88 -0
- package/dist/identity/network.js +243 -0
- package/dist/identity/pairing-request.d.ts +147 -0
- package/dist/identity/pairing-request.js +422 -0
- package/dist/identity/pairing.d.ts +225 -0
- package/dist/identity/pairing.js +624 -0
- package/dist/identity/phases.d.ts +75 -0
- package/dist/identity/phases.js +104 -0
- package/dist/identity/proof.d.ts +45 -0
- package/dist/identity/proof.js +71 -0
- package/dist/identity/reconcile.d.ts +73 -0
- package/dist/identity/reconcile.js +273 -0
- package/dist/identity/resource.d.ts +74 -0
- package/dist/identity/resource.js +201 -0
- package/dist/identity/revocation.d.ts +106 -0
- package/dist/identity/revocation.js +403 -0
- package/dist/identity/roster.d.ts +89 -0
- package/dist/identity/roster.js +130 -0
- package/dist/identity/rotation.d.ts +131 -0
- package/dist/identity/rotation.js +363 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/revoke.d.ts +8 -0
- package/dist/revoke.js +12 -8
- package/dist/storage.d.ts +18 -0
- package/dist/storage.js +26 -0
- package/dist/sync-client.d.ts +2 -0
- package/dist/sync-client.js +1 -0
- package/package.json +5 -5
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { bytesEqual, fromBase64url, fromHex, principalId, resourceId, secureRandom, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { activeDevice, signControlRecord, validateControlChain, } from "@openlfcp/wire";
|
|
3
|
+
import { fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, runControlPhase, runUnitPhase, sealIdentityUnit, syncChain, } from "./context.js";
|
|
4
|
+
import { mayManageDevices } from "./devices.js";
|
|
5
|
+
import { principalRefOf } from "./document.js";
|
|
6
|
+
import { newOperationId, OperationJournal, phaseOf } from "./journal.js";
|
|
7
|
+
import { ChannelPool, IDENTITY_EXTENSION, IdentityRefused, IdentityUnavailable, } from "./network.js";
|
|
8
|
+
import { classifyRevoke } from "./phases.js";
|
|
9
|
+
import { identityTarget } from "./resource.js";
|
|
10
|
+
import { exposingRevocationsSince, resumeRotations, rotateAfter, rotatorTest, } from "./rotation.js";
|
|
11
|
+
const toKeep = (state, root) => [...state.devices.entries()]
|
|
12
|
+
.filter(([, d]) => bytesEqual(d.root, root))
|
|
13
|
+
.map(([hex]) => principalId(fromHex(hex)))
|
|
14
|
+
.filter((p) => activeDevice(state, p) !== undefined);
|
|
15
|
+
/** The DEVICE_REVOKE on the chain's head, if this installation may issue it there. */
|
|
16
|
+
function revokeOn(chain, o, root, device, reason) {
|
|
17
|
+
const signed = signControlRecord({
|
|
18
|
+
resourceId: chain.state.resourceId,
|
|
19
|
+
controlSeq: chain.state.seq + 1n,
|
|
20
|
+
prevControlId: chain.state.head,
|
|
21
|
+
}, { type: "DEVICE_REVOKE", root, device, reason }, o.installation.signer);
|
|
22
|
+
const after = validateControlChain([...chain.records.map((r) => r.signed.bytes), signed.bytes]);
|
|
23
|
+
if (after.kind !== "linear")
|
|
24
|
+
return undefined;
|
|
25
|
+
// §8.1: the revoker must stay active in this Resource's post-state; a chain
|
|
26
|
+
// through the target is re-certified first, here, not only in I.
|
|
27
|
+
const me = o.installation.signer.descriptor.principalId;
|
|
28
|
+
if (!bytesEqual(me, device) &&
|
|
29
|
+
activeDevice(chain.state, me) !== undefined &&
|
|
30
|
+
activeDevice(after.state, me) === undefined)
|
|
31
|
+
return "self-dependent";
|
|
32
|
+
return { bytes: signed.bytes, after };
|
|
33
|
+
}
|
|
34
|
+
function previewOn(chain, o, root, device, reason) {
|
|
35
|
+
const R = chain.state.resourceId;
|
|
36
|
+
const empty = { suspended: [], exposing: false, rotator: false };
|
|
37
|
+
if (chain.state.revoked.has(`${toHex(root)}:${toHex(device)}`))
|
|
38
|
+
return { resource: R, status: "already-revoked", ...empty };
|
|
39
|
+
const sim = revokeOn(chain, o, root, device, reason);
|
|
40
|
+
if (sim === "self-dependent")
|
|
41
|
+
return { resource: R, status: "self-dependent", ...empty };
|
|
42
|
+
if (sim === undefined)
|
|
43
|
+
return { resource: R, status: "not-authorized", ...empty };
|
|
44
|
+
const before = new Set(toKeep(chain.state, root).map(toHex));
|
|
45
|
+
const after = new Set(toKeep(sim.after.state, root).map(toHex));
|
|
46
|
+
const suspended = [...before]
|
|
47
|
+
.filter((p) => !after.has(p) && p !== toHex(device))
|
|
48
|
+
.map((p) => principalId(fromHex(p)));
|
|
49
|
+
const exposing = exposingRevocationsSince(sim.after).some((e) => e.seq === sim.after.state.seq);
|
|
50
|
+
return {
|
|
51
|
+
resource: R,
|
|
52
|
+
status: "ok",
|
|
53
|
+
suspended,
|
|
54
|
+
exposing,
|
|
55
|
+
rotator: rotatorTest(sim.after.state, o.installation.signer.descriptor.principalId).rotator,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Roster targets of the Identity Resource (not archived), I excluded. */
|
|
59
|
+
async function rosterTargets(o, channel, chain) {
|
|
60
|
+
const doc = o.document(chain.state.resourceId, genesisOwner(chain));
|
|
61
|
+
await loadIdentityDocument({
|
|
62
|
+
channel,
|
|
63
|
+
chain,
|
|
64
|
+
deks: await fetchDeks(o.storage, o.secrets, channel, chain, o.installation),
|
|
65
|
+
doc,
|
|
66
|
+
});
|
|
67
|
+
return [...doc.roster()]
|
|
68
|
+
.filter(([, e]) => e.archived !== true)
|
|
69
|
+
.map(([ref, e]) => ({ resource: resourceId(fromBase64url(ref)), url: e.coordinator }))
|
|
70
|
+
.filter((t) => !bytesEqual(t.resource, chain.state.resourceId));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* §8.1: the post-state of revoking `device` in `I` and every reachable
|
|
74
|
+
* roster Resource: suspended devices, exposure, whether this installation
|
|
75
|
+
* stays active and who rotates.
|
|
76
|
+
*/
|
|
77
|
+
export async function previewRevocation(o) {
|
|
78
|
+
const target = await identityTarget(o);
|
|
79
|
+
const pool = new ChannelPool(o.network);
|
|
80
|
+
const me = o.installation.signer.descriptor.principalId;
|
|
81
|
+
const reason = o.reason ?? 1n;
|
|
82
|
+
try {
|
|
83
|
+
const channel = await pool.get(o.installation.signer, target.url);
|
|
84
|
+
const I = await syncChain(o.storage, channel, target.resource);
|
|
85
|
+
const R = genesisOwner(I);
|
|
86
|
+
const mine = activeDevice(I.state, me);
|
|
87
|
+
const selfDependent = !bytesEqual(me, o.device) &&
|
|
88
|
+
mine !== undefined &&
|
|
89
|
+
mine.issuers.some((e) => bytesEqual(e, o.device));
|
|
90
|
+
const resources = [previewOn(I, o, R, o.device, reason)];
|
|
91
|
+
for (const t of await rosterTargets(o, channel, I)) {
|
|
92
|
+
const empty = { suspended: [], exposing: false, rotator: false };
|
|
93
|
+
try {
|
|
94
|
+
const c = await pool.get(o.installation.signer, t.url);
|
|
95
|
+
if (!c.extensions.includes(IDENTITY_EXTENSION)) {
|
|
96
|
+
resources.push({ resource: t.resource, status: "unsupported", ...empty });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
resources.push(previewOn(await syncChain(o.storage, c, t.resource), o, R, o.device, reason));
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
if (e instanceof IdentityUnavailable || e instanceof IdentityRefused)
|
|
103
|
+
resources.push({ resource: t.resource, status: "unreachable", ...empty });
|
|
104
|
+
else if (e instanceof IdentityConflict)
|
|
105
|
+
resources.push({ resource: t.resource, status: "conflict", ...empty });
|
|
106
|
+
else
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
device: o.device,
|
|
112
|
+
selfDependent: selfDependent || resources.some((r) => r.status === "self-dependent"),
|
|
113
|
+
resources,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
pool.closeAll();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
class NotAuthorized extends Error {
|
|
121
|
+
}
|
|
122
|
+
class SelfDependent extends Error {
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* §8.2–§8.5: `DEVICE_REVOKE {R, D, r}` in `I`, then in every roster
|
|
126
|
+
* Resource where this installation may act, each followed by the rotation
|
|
127
|
+
* when the revocation is exposing there and this installation is the
|
|
128
|
+
* rotator (else `awaiting-rotation`). A self-retiring device (`D` is this
|
|
129
|
+
* installation, reason 0) commits the revocations only. Every phase is
|
|
130
|
+
* journaled and classified by §9.2: a revocation by another hand with
|
|
131
|
+
* another reason is `complete` and the rotation test still runs.
|
|
132
|
+
*/
|
|
133
|
+
export async function revokeDevice(o) {
|
|
134
|
+
const target = await identityTarget(o);
|
|
135
|
+
const me = o.installation.signer.descriptor.principalId;
|
|
136
|
+
const selfRetire = bytesEqual(me, o.device);
|
|
137
|
+
const journal = new OperationJournal(o.storage);
|
|
138
|
+
const pool = new ChannelPool(o.network);
|
|
139
|
+
const operation = o.operation ?? newOperationId("revoke-device", secureRandom(8));
|
|
140
|
+
try {
|
|
141
|
+
// Rotations an earlier operation committed but did not adopt are finished first (§9.1).
|
|
142
|
+
if (!selfRetire)
|
|
143
|
+
await resumeRotations({ ...o, pool });
|
|
144
|
+
const channel = await pool.get(o.installation.signer, target.url);
|
|
145
|
+
const known = await journal.get(operation);
|
|
146
|
+
let I;
|
|
147
|
+
try {
|
|
148
|
+
I = await syncChain(o.storage, channel, target.resource);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
// A resumed self-retirement no longer reads I: its targets come from the journal.
|
|
152
|
+
if (!(e instanceof IdentityRefused) || !selfRetire || known === undefined)
|
|
153
|
+
throw e;
|
|
154
|
+
return retireElsewhere(o, pool, journal, known);
|
|
155
|
+
}
|
|
156
|
+
const R = genesisOwner(I);
|
|
157
|
+
if (!selfRetire && !mayManageDevices(I.state, me, R))
|
|
158
|
+
return { kind: "not-authorized" };
|
|
159
|
+
const mine = activeDevice(I.state, me);
|
|
160
|
+
if (!selfRetire && mine !== undefined && mine.issuers.some((e) => bytesEqual(e, o.device)))
|
|
161
|
+
return { kind: "self-dependent" };
|
|
162
|
+
// The targets are journaled before the first record: a self-retiring device
|
|
163
|
+
// cannot read I's roster once I has revoked it (§9.1).
|
|
164
|
+
if (known === undefined)
|
|
165
|
+
await journal.begin({
|
|
166
|
+
id: operation,
|
|
167
|
+
kind: "revoke-device",
|
|
168
|
+
createdAt: o.now(),
|
|
169
|
+
intent: {
|
|
170
|
+
identity: toHex(target.resource),
|
|
171
|
+
device: toHex(o.device),
|
|
172
|
+
root: toHex(R),
|
|
173
|
+
reason: String(o.reason),
|
|
174
|
+
targets: (await rosterTargets(o, channel, I)).map((t) => ({
|
|
175
|
+
resource: toHex(t.resource),
|
|
176
|
+
url: t.url,
|
|
177
|
+
})),
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
const roster = targetsOf(await journal.load(operation));
|
|
181
|
+
const results = [];
|
|
182
|
+
const one = async (c, X) => {
|
|
183
|
+
let outcome;
|
|
184
|
+
try {
|
|
185
|
+
outcome = await runControlPhase(o.storage, c, journal, operation, {
|
|
186
|
+
id: `revoke:${toHex(X)}`,
|
|
187
|
+
kind: "revoke",
|
|
188
|
+
resource: X,
|
|
189
|
+
classify: (chain) => classifyRevoke(chain.state, R, o.device),
|
|
190
|
+
build: (chain) => {
|
|
191
|
+
const sim = revokeOn(chain, o, R, o.device, o.reason);
|
|
192
|
+
if (sim === "self-dependent")
|
|
193
|
+
throw new SelfDependent();
|
|
194
|
+
if (sim === undefined)
|
|
195
|
+
throw new NotAuthorized();
|
|
196
|
+
return sim.bytes;
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
if (e instanceof NotAuthorized)
|
|
202
|
+
return { resource: X, revoke: "not-authorized" };
|
|
203
|
+
if (e instanceof SelfDependent)
|
|
204
|
+
return { resource: X, revoke: "self-dependent" };
|
|
205
|
+
throw e;
|
|
206
|
+
}
|
|
207
|
+
if (outcome.state === "superseded")
|
|
208
|
+
return { resource: X, revoke: "superseded" };
|
|
209
|
+
if (outcome.state === "refused")
|
|
210
|
+
return { resource: X, revoke: "refused" };
|
|
211
|
+
const revoke = outcome.state === "committed-own" ? "revoked" : "complete";
|
|
212
|
+
if (selfRetire && o.reason === 0n)
|
|
213
|
+
return { resource: X, revoke };
|
|
214
|
+
let rotation;
|
|
215
|
+
try {
|
|
216
|
+
rotation = await rotateIfExposed({ ...o, channel: c, operation, resource: X });
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
// The revocation stands; the rotation resumes from the journal (§9.1).
|
|
220
|
+
if (!(e instanceof IdentityUnavailable))
|
|
221
|
+
throw e;
|
|
222
|
+
rotation = { resource: X, status: "unreachable" };
|
|
223
|
+
}
|
|
224
|
+
return { resource: X, revoke, rotation };
|
|
225
|
+
};
|
|
226
|
+
const first = await one(channel, target.resource);
|
|
227
|
+
results.push(first);
|
|
228
|
+
if (first.revoke === "superseded") {
|
|
229
|
+
await journal.setStatus(operation, "superseded");
|
|
230
|
+
return { kind: "superseded", operation };
|
|
231
|
+
}
|
|
232
|
+
// §8.2: display only.
|
|
233
|
+
const document = !selfRetire && (first.revoke === "revoked" || first.revoke === "complete")
|
|
234
|
+
? await markRevoked(o, channel, journal, operation, target.resource, o.device)
|
|
235
|
+
: { state: "none" };
|
|
236
|
+
for (const t of roster) {
|
|
237
|
+
try {
|
|
238
|
+
const c = await pool.get(o.installation.signer, t.url);
|
|
239
|
+
if (!c.extensions.includes(IDENTITY_EXTENSION)) {
|
|
240
|
+
results.push({ resource: t.resource, revoke: "unsupported" });
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
results.push(await one(c, t.resource));
|
|
244
|
+
}
|
|
245
|
+
catch (e) {
|
|
246
|
+
if (e instanceof IdentityUnavailable || e instanceof IdentityRefused)
|
|
247
|
+
results.push({ resource: t.resource, revoke: "unreachable" });
|
|
248
|
+
else if (e instanceof IdentityConflict)
|
|
249
|
+
results.push({ resource: t.resource, revoke: "conflict" });
|
|
250
|
+
else
|
|
251
|
+
throw e;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const open = document.state === "refused" ||
|
|
255
|
+
document.state === "unreachable" ||
|
|
256
|
+
results.some((r) => r.revoke === "unreachable" ||
|
|
257
|
+
r.revoke === "conflict" ||
|
|
258
|
+
r.rotation?.status === "awaiting-key-holder" ||
|
|
259
|
+
r.rotation?.status === "unreachable");
|
|
260
|
+
await journal.setStatus(operation, open ? "open" : "done");
|
|
261
|
+
return { kind: "revoked", operation, results: Object.freeze(results), document };
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
if (e instanceof IdentityUnavailable)
|
|
265
|
+
return { kind: "unavailable", operation, reason: e.message };
|
|
266
|
+
if (e instanceof IdentityConflict)
|
|
267
|
+
return { kind: "conflict" };
|
|
268
|
+
throw e;
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
pool.closeAll();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const targetsOf = (op) => (op.intent.targets ?? []).map((t) => ({
|
|
275
|
+
resource: resourceId(fromHex(t.resource)),
|
|
276
|
+
url: t.url,
|
|
277
|
+
}));
|
|
278
|
+
/** A resumed self-retirement whose I revocation already holds: the journaled roster targets. */
|
|
279
|
+
async function retireElsewhere(o, pool, journal, op) {
|
|
280
|
+
const R = principalId(fromHex(op.intent.root));
|
|
281
|
+
const results = [
|
|
282
|
+
{ resource: resourceId(fromHex(op.intent.identity)), revoke: "complete" },
|
|
283
|
+
];
|
|
284
|
+
for (const t of targetsOf(op)) {
|
|
285
|
+
try {
|
|
286
|
+
const c = await pool.get(o.installation.signer, t.url);
|
|
287
|
+
const outcome = await runControlPhase(o.storage, c, journal, op.id, {
|
|
288
|
+
id: `revoke:${toHex(t.resource)}`,
|
|
289
|
+
kind: "revoke",
|
|
290
|
+
resource: t.resource,
|
|
291
|
+
classify: (chain) => classifyRevoke(chain.state, R, o.device),
|
|
292
|
+
build: (chain) => {
|
|
293
|
+
const sim = revokeOn(chain, o, R, o.device, o.reason);
|
|
294
|
+
if (sim === undefined || sim === "self-dependent")
|
|
295
|
+
throw new NotAuthorized();
|
|
296
|
+
return sim.bytes;
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
results.push({
|
|
300
|
+
resource: t.resource,
|
|
301
|
+
revoke: outcome.state === "committed-own"
|
|
302
|
+
? "revoked"
|
|
303
|
+
: outcome.state === "refused"
|
|
304
|
+
? "refused"
|
|
305
|
+
: outcome.state === "superseded"
|
|
306
|
+
? "superseded"
|
|
307
|
+
: "complete",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
if (e instanceof NotAuthorized)
|
|
312
|
+
results.push({ resource: t.resource, revoke: "not-authorized" });
|
|
313
|
+
else if (e instanceof IdentityUnavailable || e instanceof IdentityRefused)
|
|
314
|
+
results.push({ resource: t.resource, revoke: "unreachable" });
|
|
315
|
+
else
|
|
316
|
+
throw e;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const open = results.some((r) => r.revoke === "unreachable");
|
|
320
|
+
await journal.setStatus(op.id, open ? "open" : "done");
|
|
321
|
+
// A self-retirement writes no display flag (§8.2).
|
|
322
|
+
return {
|
|
323
|
+
kind: "revoked",
|
|
324
|
+
operation: op.id,
|
|
325
|
+
results: Object.freeze(results),
|
|
326
|
+
document: { state: "none" },
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
async function markRevoked(o, channel, journal, operation, I, device) {
|
|
330
|
+
const key = principalRefOf(device);
|
|
331
|
+
/** I's current state, and the device's entry as a flag to write, if any. */
|
|
332
|
+
const current = async () => {
|
|
333
|
+
const chain = await syncChain(o.storage, channel, I);
|
|
334
|
+
const doc = o.document(I, genesisOwner(chain));
|
|
335
|
+
const deks = await fetchDeks(o.storage, o.secrets, channel, chain, o.installation);
|
|
336
|
+
const load = await loadIdentityDocument({ channel, chain, deks, doc });
|
|
337
|
+
const entry = doc.devices().get(key);
|
|
338
|
+
return { chain, doc, deks, load, entry };
|
|
339
|
+
};
|
|
340
|
+
try {
|
|
341
|
+
const { entry } = await current();
|
|
342
|
+
if (entry === undefined)
|
|
343
|
+
return { state: "none" };
|
|
344
|
+
// Built on the current state of I (a rotation after the revocation makes an
|
|
345
|
+
// earlier unit stale, W §19.1); a resumed operation writes a refused flag again.
|
|
346
|
+
// Another writer may delete the entry or set the flag meanwhile: nothing is sent.
|
|
347
|
+
let gone = false;
|
|
348
|
+
const unit = await runUnitPhase(channel, journal, operation, {
|
|
349
|
+
id: "display",
|
|
350
|
+
resource: I,
|
|
351
|
+
rebuildOn: ["STALE_DATA_EPOCH"],
|
|
352
|
+
rebuildRefused: true,
|
|
353
|
+
build: async () => {
|
|
354
|
+
const now = await current();
|
|
355
|
+
gone = now.entry === undefined;
|
|
356
|
+
if (now.entry === undefined || now.entry.revoked === true)
|
|
357
|
+
return null;
|
|
358
|
+
// The device's missing_keys hint is written only by the device (§3 AI5).
|
|
359
|
+
const { missing_keys: _hint, ...display } = now.entry;
|
|
360
|
+
return sealIdentityUnit({
|
|
361
|
+
storage: o.storage,
|
|
362
|
+
chain: now.chain,
|
|
363
|
+
deks: now.deks,
|
|
364
|
+
signer: o.installation.signer,
|
|
365
|
+
doc: now.doc,
|
|
366
|
+
latest: now.load.latest,
|
|
367
|
+
intents: [{ intent: "device.put", key, entry: { ...display, revoked: true } }],
|
|
368
|
+
});
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
if (unit.state === "refused")
|
|
372
|
+
return { state: "refused", code: unit.code ?? "refused" };
|
|
373
|
+
return { state: gone ? "none" : unit.state };
|
|
374
|
+
}
|
|
375
|
+
catch (e) {
|
|
376
|
+
// The revocation stands without its display flag; anything else is an error.
|
|
377
|
+
if (e instanceof IdentityUnavailable)
|
|
378
|
+
return { state: "unreachable" };
|
|
379
|
+
throw e;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* §8.4 rotation policy: when the Resource holds an exposing revocation
|
|
384
|
+
* committed after its current epoch opened and this installation is the
|
|
385
|
+
* rotator, rotate once (covering every such revocation) and seal the new
|
|
386
|
+
* DEK to every reader; otherwise `awaiting-rotation`, or `not-needed` when
|
|
387
|
+
* nothing exposing happened since the last rotation.
|
|
388
|
+
*/
|
|
389
|
+
export async function rotateIfExposed(o) {
|
|
390
|
+
// A rotation this operation already started resumes from its journaled phase: its
|
|
391
|
+
// KEY_EPOCH may be committed while its DEK still waits in the pending slot (§9.1).
|
|
392
|
+
const started = phaseOf(await new OperationJournal(o.storage).load(o.operation), `rotate:${toHex(o.resource)}`);
|
|
393
|
+
const at = started?.facts?.revokedAt;
|
|
394
|
+
if (at !== undefined && started?.state !== "awaiting-rotation")
|
|
395
|
+
return rotateAfter({ ...o, revokedAt: BigInt(at) });
|
|
396
|
+
const chain = await syncChain(o.storage, o.channel, o.resource);
|
|
397
|
+
const exposing = exposingRevocationsSince(chain);
|
|
398
|
+
const last = exposing.at(-1);
|
|
399
|
+
if (last === undefined)
|
|
400
|
+
return { resource: o.resource, status: "not-needed" };
|
|
401
|
+
return rotateAfter({ ...o, revokedAt: last.seq });
|
|
402
|
+
}
|
|
403
|
+
//# sourceMappingURL=revocation.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type ResourceId } from "@openlfcp/core";
|
|
2
|
+
import { type RosterEntryValue } from "./document.js";
|
|
3
|
+
import { OperationJournal } from "./journal.js";
|
|
4
|
+
import { type IdentityChannel } from "./network.js";
|
|
5
|
+
import { type IdentityFlowOptions } from "./resource.js";
|
|
6
|
+
/**
|
|
7
|
+
* The roster of the Identity Resource (IDENTITY-PROFILE-01 §2.1): how the
|
|
8
|
+
* human's other installations learn a Resource (§6). An entry is written as
|
|
9
|
+
* its own durable phase of an operation (§9.1): a unit refused as stale
|
|
10
|
+
* after a rotation of `I` is written again in the current epoch (W §19.1),
|
|
11
|
+
* and an entry already present, by this installation or another writer,
|
|
12
|
+
* is not written again.
|
|
13
|
+
*/
|
|
14
|
+
export type RosterWrite =
|
|
15
|
+
/** This installation's unit carrying the entry was accepted. */
|
|
16
|
+
{
|
|
17
|
+
readonly state: "written";
|
|
18
|
+
}
|
|
19
|
+
/** The roster already held an entry for the Resource. */
|
|
20
|
+
| {
|
|
21
|
+
readonly state: "present";
|
|
22
|
+
}
|
|
23
|
+
/** The coordinator refused the unit for another reason; the phase stays open. */
|
|
24
|
+
| {
|
|
25
|
+
readonly state: "refused";
|
|
26
|
+
readonly code: string;
|
|
27
|
+
};
|
|
28
|
+
/** Writes `roster[ref(resource)] = entry` in `I` as the phase `roster` of `operation`. */
|
|
29
|
+
export declare function writeRosterEntry(o: IdentityFlowOptions, channel: IdentityChannel, journal: OperationJournal, operation: string, identity: ResourceId, resource: ResourceId, entry: RosterEntryValue): Promise<RosterWrite>;
|
|
30
|
+
export interface AddToRosterOptions extends IdentityFlowOptions {
|
|
31
|
+
/** The Resource to list (a Resource of the human: created or joined here). */
|
|
32
|
+
readonly resource: ResourceId;
|
|
33
|
+
/** A display name for the roster entry. */
|
|
34
|
+
readonly name?: string;
|
|
35
|
+
/**
|
|
36
|
+
* The entry's fields; by default taken from the Resource's Control Chain:
|
|
37
|
+
* its Data Profile, route endpoints and coordinator (stored here, or read
|
|
38
|
+
* from `url`).
|
|
39
|
+
*/
|
|
40
|
+
readonly entry?: RosterEntryValue;
|
|
41
|
+
/** The Resource's coordinator, when its chain is not stored here. */
|
|
42
|
+
readonly url?: string;
|
|
43
|
+
/** Resume this operation. */
|
|
44
|
+
readonly operation?: string;
|
|
45
|
+
}
|
|
46
|
+
export type AddToRosterResult =
|
|
47
|
+
/** This installation wrote the entry. */
|
|
48
|
+
{
|
|
49
|
+
readonly kind: "added";
|
|
50
|
+
readonly operation: string;
|
|
51
|
+
readonly resource: ResourceId;
|
|
52
|
+
}
|
|
53
|
+
/** The roster already lists the Resource (written earlier, or by another device). */
|
|
54
|
+
| {
|
|
55
|
+
readonly kind: "present";
|
|
56
|
+
readonly operation: string;
|
|
57
|
+
readonly resource: ResourceId;
|
|
58
|
+
}
|
|
59
|
+
/** This installation cannot write `I` (`holds(D, 2)` is false, e.g. a Read device, §7.2). */
|
|
60
|
+
| {
|
|
61
|
+
readonly kind: "needs-writer";
|
|
62
|
+
}
|
|
63
|
+
/** The coordinator refused the unit: the operation stays open; resume it with `operation`. */
|
|
64
|
+
| {
|
|
65
|
+
readonly kind: "document-incomplete";
|
|
66
|
+
readonly operation: string;
|
|
67
|
+
readonly resource: ResourceId;
|
|
68
|
+
readonly code: string;
|
|
69
|
+
} | {
|
|
70
|
+
readonly kind: "conflict";
|
|
71
|
+
} | {
|
|
72
|
+
readonly kind: "unavailable";
|
|
73
|
+
readonly operation?: string;
|
|
74
|
+
readonly reason: string;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Adds a Resource to the roster of the Identity Resource as one durable
|
|
78
|
+
* operation (IDENTITY-PROFILE-01 §2.1, §6, §9): the entry the human's other
|
|
79
|
+
* installations read to reach the Resource (pairing step 10, reconciliation
|
|
80
|
+
* §7.1). For a Resource created or joined after `I` was filled (§5.1), for
|
|
81
|
+
* example a section created later on any personal device. Idempotent: an
|
|
82
|
+
* entry already present is not written again; a unit refused as stale
|
|
83
|
+
* after a rotation of `I` is written again in the current epoch; any other
|
|
84
|
+
* refusal leaves the operation open. Only a writer of `I` (the root or a
|
|
85
|
+
* personal device holding data/write there) may add entries (§3 AI4: a
|
|
86
|
+
* pairing or service device never writes the roster).
|
|
87
|
+
*/
|
|
88
|
+
export declare function addToRoster(o: AddToRosterOptions): Promise<AddToRosterResult>;
|
|
89
|
+
//# sourceMappingURL=roster.d.ts.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { fromHex, LfcpError, resourceId, secureRandom, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { ABILITY, hasAbility } from "@openlfcp/wire";
|
|
3
|
+
import { loadControlChain } from "../storage.js";
|
|
4
|
+
import { fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, runUnitPhase, sealIdentityUnit, syncChain, } from "./context.js";
|
|
5
|
+
import { resourceRef } from "./document.js";
|
|
6
|
+
import { newOperationId, OperationJournal } from "./journal.js";
|
|
7
|
+
import { ChannelPool, IdentityRefused, IdentityUnavailable, } from "./network.js";
|
|
8
|
+
import { identityTarget } from "./resource.js";
|
|
9
|
+
/** Writes `roster[ref(resource)] = entry` in `I` as the phase `roster` of `operation`. */
|
|
10
|
+
export async function writeRosterEntry(o, channel, journal, operation, identity, resource, entry) {
|
|
11
|
+
const me = o.installation.signer;
|
|
12
|
+
const openI = async () => {
|
|
13
|
+
const chain = await syncChain(o.storage, channel, identity);
|
|
14
|
+
const doc = o.document(identity, genesisOwner(chain));
|
|
15
|
+
const deks = await fetchDeks(o.storage, o.secrets, channel, chain, o.installation);
|
|
16
|
+
const load = await loadIdentityDocument({ channel, chain, deks, doc });
|
|
17
|
+
return { chain, doc, deks, load };
|
|
18
|
+
};
|
|
19
|
+
if ((await openI()).doc.roster().has(resourceRef(resource)))
|
|
20
|
+
return { state: "present" };
|
|
21
|
+
const r = await runUnitPhase(channel, journal, operation, {
|
|
22
|
+
id: "roster",
|
|
23
|
+
resource: identity,
|
|
24
|
+
// A unit refused after a rotation of I is written again in the new epoch (W §19.1).
|
|
25
|
+
rebuildOn: ["STALE_DATA_EPOCH"],
|
|
26
|
+
// A resumed operation whose unit was refused writes the entry again on the
|
|
27
|
+
// current document, previous unit and actor sequence: the refused bytes may
|
|
28
|
+
// name a previous unit this actor has since moved past (W §26.2). Exact
|
|
29
|
+
// bytes are re-sent only after an unknown outcome (§9.1).
|
|
30
|
+
rebuildRefused: true,
|
|
31
|
+
build: async () => {
|
|
32
|
+
const { chain, doc, deks, load } = await openI();
|
|
33
|
+
// Another writer may have listed the Resource since the last check (a stale
|
|
34
|
+
// retry, a resume): its entry stands and the phase is complete (§9.2).
|
|
35
|
+
if (doc.roster().has(resourceRef(resource)))
|
|
36
|
+
return null;
|
|
37
|
+
return sealIdentityUnit({
|
|
38
|
+
storage: o.storage,
|
|
39
|
+
chain,
|
|
40
|
+
deks,
|
|
41
|
+
signer: me,
|
|
42
|
+
doc,
|
|
43
|
+
latest: load.latest,
|
|
44
|
+
intents: [{ intent: "roster.put", key: resourceRef(resource), entry }],
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
if (r.state === "committed-own")
|
|
49
|
+
return { state: "written" };
|
|
50
|
+
if (r.state === "complete")
|
|
51
|
+
return { state: "present" };
|
|
52
|
+
return { state: "refused", code: r.code ?? "refused" };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Adds a Resource to the roster of the Identity Resource as one durable
|
|
56
|
+
* operation (IDENTITY-PROFILE-01 §2.1, §6, §9): the entry the human's other
|
|
57
|
+
* installations read to reach the Resource (pairing step 10, reconciliation
|
|
58
|
+
* §7.1). For a Resource created or joined after `I` was filled (§5.1), for
|
|
59
|
+
* example a section created later on any personal device. Idempotent: an
|
|
60
|
+
* entry already present is not written again; a unit refused as stale
|
|
61
|
+
* after a rotation of `I` is written again in the current epoch; any other
|
|
62
|
+
* refusal leaves the operation open. Only a writer of `I` (the root or a
|
|
63
|
+
* personal device holding data/write there) may add entries (§3 AI4: a
|
|
64
|
+
* pairing or service device never writes the roster).
|
|
65
|
+
*/
|
|
66
|
+
export async function addToRoster(o) {
|
|
67
|
+
const target = await identityTarget(o);
|
|
68
|
+
const me = o.installation.signer;
|
|
69
|
+
const journal = new OperationJournal(o.storage);
|
|
70
|
+
const pool = new ChannelPool(o.network);
|
|
71
|
+
let operation = o.operation;
|
|
72
|
+
try {
|
|
73
|
+
const channel = await pool.get(me, target.url);
|
|
74
|
+
const I = await syncChain(o.storage, channel, target.resource);
|
|
75
|
+
if (!hasAbility(I.state, me.descriptor.principalId, ABILITY.DATA_WRITE))
|
|
76
|
+
return { kind: "needs-writer" };
|
|
77
|
+
if (operation === undefined || (await journal.get(operation)) === undefined) {
|
|
78
|
+
const entry = o.entry ?? (await entryOf(o, pool, o.resource));
|
|
79
|
+
operation = operation ?? newOperationId("add-to-roster", secureRandom(8));
|
|
80
|
+
await journal.begin({
|
|
81
|
+
id: operation,
|
|
82
|
+
kind: "add-to-roster",
|
|
83
|
+
createdAt: o.now(),
|
|
84
|
+
intent: {
|
|
85
|
+
identity: toHex(target.resource),
|
|
86
|
+
resource: toHex(o.resource),
|
|
87
|
+
entry: { ...entry, ...(o.name === undefined ? {} : { name: o.name }) },
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const op = await journal.load(operation);
|
|
92
|
+
const resource = resourceId(fromHex(op.intent.resource));
|
|
93
|
+
const written = await writeRosterEntry(o, channel, journal, op.id, target.resource, resource, op.intent.entry);
|
|
94
|
+
if (written.state === "refused")
|
|
95
|
+
return { kind: "document-incomplete", operation: op.id, resource, code: written.code };
|
|
96
|
+
await journal.setStatus(op.id, "done");
|
|
97
|
+
return { kind: written.state === "written" ? "added" : "present", operation: op.id, resource };
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
if (e instanceof IdentityUnavailable || e instanceof IdentityRefused)
|
|
101
|
+
return {
|
|
102
|
+
kind: "unavailable",
|
|
103
|
+
...(operation === undefined ? {} : { operation }),
|
|
104
|
+
reason: e.message,
|
|
105
|
+
};
|
|
106
|
+
if (e instanceof IdentityConflict)
|
|
107
|
+
return { kind: "conflict" };
|
|
108
|
+
throw e;
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
pool.closeAll();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** The roster entry of a Resource from its Control Chain (W §15, §20): profile, routes, coordinator. */
|
|
115
|
+
async function entryOf(o, pool, resource) {
|
|
116
|
+
const stored = await loadControlChain(o.storage, resource);
|
|
117
|
+
let chain;
|
|
118
|
+
if (stored?.kind === "linear")
|
|
119
|
+
chain = stored;
|
|
120
|
+
else if (o.url !== undefined)
|
|
121
|
+
chain = await syncChain(o.storage, await pool.get(o.installation.signer, o.url), resource);
|
|
122
|
+
else
|
|
123
|
+
throw new LfcpError("MISSING_DEPENDENCY", "the Resource's chain is not stored here: give its entry or its coordinator URL");
|
|
124
|
+
return {
|
|
125
|
+
profile: chain.state.dataProfile,
|
|
126
|
+
endpoints: chain.state.route.endpoints.map((e) => e.url),
|
|
127
|
+
coordinator: chain.state.route.coordinatorUrl,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=roster.js.map
|