@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,422 @@
|
|
|
1
|
+
import { bytesEqual, fromBase64url, fromHex, hash32, principalId, resourceId, secureRandom, toBase64url, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { activeDevice, certificateChain, DEVICE_KIND, decodeInviteSecret, deviceFingerprint, encodeInviteSecret, encodePrincipalDescriptor, invitationPrincipal, pairingDeviceSigner, parseDataUnit, parseDeviceCertificate, parsePairingUri, revocationOf, } from "@openlfcp/wire";
|
|
3
|
+
import { fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, runUnitPhase, sealIdentityUnit, syncChain, } from "./context.js";
|
|
4
|
+
import { IDENTITY_PROFILE, principalRefOf, resourceRef, } from "./document.js";
|
|
5
|
+
import { newOperationId, OperationJournal, pairingSecretRef, phaseOf } from "./journal.js";
|
|
6
|
+
import { ChannelPool, IDENTITY_EXTENSION, IdentityRefused, IdentityUnavailable, } from "./network.js";
|
|
7
|
+
import { setLocalIdentityResource } from "./resource.js";
|
|
8
|
+
/** §5.3 steps 2–3. */
|
|
9
|
+
export async function requestPairing(o) {
|
|
10
|
+
let link;
|
|
11
|
+
let J;
|
|
12
|
+
try {
|
|
13
|
+
link = parsePairingUri(typeof o.link === "string" ? o.link : o.link.reveal());
|
|
14
|
+
J = pairingDeviceSigner(link);
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
return { kind: "invalid-link", reason: e instanceof Error ? e.message : String(e) };
|
|
18
|
+
}
|
|
19
|
+
const I = link.identityResourceId;
|
|
20
|
+
const url = link.endpoints[0];
|
|
21
|
+
const B = o.installation.signer.descriptor;
|
|
22
|
+
const pool = new ChannelPool(o.network);
|
|
23
|
+
const journal = new OperationJournal(o.storage);
|
|
24
|
+
let operation;
|
|
25
|
+
try {
|
|
26
|
+
// A request already journaled for this session and installation is resumed (§9.1).
|
|
27
|
+
const open = (await journal.list({ kind: "pairing-request", status: "open" })).find((op) => op.intent.pairingDevice === toHex(J.descriptor.principalId) &&
|
|
28
|
+
op.intent.device === toHex(B.principalId));
|
|
29
|
+
if (open !== undefined) {
|
|
30
|
+
operation = open.id;
|
|
31
|
+
const sent = await sendRequest(o, pool, open.id);
|
|
32
|
+
if (sent !== "committed-own")
|
|
33
|
+
return { kind: "refused", operation, code: sent.code };
|
|
34
|
+
return {
|
|
35
|
+
kind: "requested",
|
|
36
|
+
operation,
|
|
37
|
+
identity: I,
|
|
38
|
+
fingerprint: deviceFingerprint(B.principalId),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const channel = await pool.get(J, url);
|
|
42
|
+
const chain = await syncChain(o.storage, channel, I);
|
|
43
|
+
const R = genesisOwner(chain);
|
|
44
|
+
if (chain.state.dataProfile !== IDENTITY_PROFILE)
|
|
45
|
+
return { kind: "not-a-session", reason: "the Resource is not an Identity Resource" };
|
|
46
|
+
const j = activeDevice(chain.state, J.descriptor.principalId);
|
|
47
|
+
if (j === undefined || j.kind !== DEVICE_KIND.PAIRING || !bytesEqual(j.root, R))
|
|
48
|
+
return {
|
|
49
|
+
kind: "not-a-session",
|
|
50
|
+
reason: "the link's device is not an active pairing device of the owner",
|
|
51
|
+
};
|
|
52
|
+
const pairing = { signer: J, agreement: link.secret.agreementKey };
|
|
53
|
+
const deks = await fetchDeks(o.storage, o.secrets, channel, chain, pairing);
|
|
54
|
+
const doc = o.document(I, R);
|
|
55
|
+
await loadIdentityDocument({ channel, chain, deks, doc });
|
|
56
|
+
if (!doc.initialized())
|
|
57
|
+
return {
|
|
58
|
+
kind: "missing-material",
|
|
59
|
+
missing: deks.size === 0 ? ["the DEKs of the Identity Resource"] : ["the root's unit 1"],
|
|
60
|
+
};
|
|
61
|
+
// Step 3: the candidates B may be registered in, persisted before the request.
|
|
62
|
+
const chosen = new Set((o.scope ?? []).map((r) => resourceRef(r)));
|
|
63
|
+
const offered = [...doc.roster()].filter(([ref, e]) => e.archived !== true && ref !== resourceRef(I));
|
|
64
|
+
if (o.sections !== undefined) {
|
|
65
|
+
const unknown = [];
|
|
66
|
+
for (const name of o.sections) {
|
|
67
|
+
const hits = offered.filter(([, e]) => e.name === name);
|
|
68
|
+
if (hits.length === 1)
|
|
69
|
+
chosen.add(hits[0][0]);
|
|
70
|
+
else
|
|
71
|
+
unknown.push(name);
|
|
72
|
+
}
|
|
73
|
+
if (unknown.length > 0)
|
|
74
|
+
return {
|
|
75
|
+
kind: "unknown-sections",
|
|
76
|
+
unknown,
|
|
77
|
+
available: offered
|
|
78
|
+
.map(([, e]) => e.name)
|
|
79
|
+
.filter((n) => n !== undefined)
|
|
80
|
+
.sort(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const candidates = offered
|
|
84
|
+
.filter(([ref]) => o.kind === 0 || chosen.has(ref))
|
|
85
|
+
.map(([ref, e]) => ({
|
|
86
|
+
resource: toHex(fromBase64url(ref)),
|
|
87
|
+
url: e.coordinator,
|
|
88
|
+
profile: e.profile,
|
|
89
|
+
...(e.name === undefined ? {} : { name: e.name }),
|
|
90
|
+
}));
|
|
91
|
+
const secret = encodeInviteSecret(link.secret);
|
|
92
|
+
try {
|
|
93
|
+
await o.secrets.put(pairingSecretRef(J.descriptor.principalId), secret);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
secret.fill(0);
|
|
97
|
+
}
|
|
98
|
+
// The whole request is durable before anything is sent (§9.1): a restart
|
|
99
|
+
// resumes it from the journal (completePairing), never a second request.
|
|
100
|
+
operation = newOperationId("pairing-request", secureRandom(8));
|
|
101
|
+
await journal.begin({
|
|
102
|
+
id: operation,
|
|
103
|
+
kind: "pairing-request",
|
|
104
|
+
createdAt: o.now(),
|
|
105
|
+
intent: {
|
|
106
|
+
identity: toHex(I),
|
|
107
|
+
url,
|
|
108
|
+
root: toHex(R),
|
|
109
|
+
pairingDevice: toHex(J.descriptor.principalId),
|
|
110
|
+
device: toHex(B.principalId),
|
|
111
|
+
kind: o.kind,
|
|
112
|
+
label: o.label,
|
|
113
|
+
candidates,
|
|
114
|
+
request: {
|
|
115
|
+
descriptor: toBase64url(encodePrincipalDescriptor(B)),
|
|
116
|
+
pairing: principalRefOf(J.descriptor.principalId),
|
|
117
|
+
label: o.label,
|
|
118
|
+
kind: o.kind,
|
|
119
|
+
...(o.kind === 1
|
|
120
|
+
? { scope: candidates.map((c) => toBase64url(fromHex(c.resource))) }
|
|
121
|
+
: {}),
|
|
122
|
+
...(o.platform === undefined ? {} : { platform: o.platform }),
|
|
123
|
+
...(o.requestedAt === undefined ? {} : { requested_at: o.requestedAt }),
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const sent = await sendRequest(o, pool, operation);
|
|
128
|
+
if (sent !== "committed-own")
|
|
129
|
+
return { kind: "refused", operation, code: sent.code };
|
|
130
|
+
return {
|
|
131
|
+
kind: "requested",
|
|
132
|
+
operation,
|
|
133
|
+
identity: I,
|
|
134
|
+
fingerprint: deviceFingerprint(B.principalId),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
if (e instanceof IdentityUnavailable ||
|
|
139
|
+
e instanceof IdentityRefused ||
|
|
140
|
+
e instanceof IdentityConflict)
|
|
141
|
+
return {
|
|
142
|
+
kind: "unavailable",
|
|
143
|
+
...(operation === undefined ? {} : { operation }),
|
|
144
|
+
reason: e.message,
|
|
145
|
+
};
|
|
146
|
+
throw e;
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
pool.closeAll();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** The pairing device of a journaled request, from the SecretStore (§9.8). */
|
|
153
|
+
async function pairingSigner(o, J) {
|
|
154
|
+
const bytes = await o.secrets.get(pairingSecretRef(J));
|
|
155
|
+
if (bytes === undefined)
|
|
156
|
+
return undefined;
|
|
157
|
+
try {
|
|
158
|
+
const secret = decodeInviteSecret(bytes);
|
|
159
|
+
return {
|
|
160
|
+
signer: { key: secret.signingKey, descriptor: invitationPrincipal(secret) },
|
|
161
|
+
agreement: secret.agreementKey,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
bytes.fill(0);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Sends the journaled request as the pairing device: the journaled exact
|
|
170
|
+
* bytes when they exist, else a unit built from the journaled request at the
|
|
171
|
+
* next sequence the pairing device has on the coordinator (§5.3 step 3).
|
|
172
|
+
*/
|
|
173
|
+
async function sendRequest(o, pool, operation) {
|
|
174
|
+
const journal = new OperationJournal(o.storage);
|
|
175
|
+
const op = await journal.load(operation);
|
|
176
|
+
if (phaseOf(op, "request")?.state === "committed-own")
|
|
177
|
+
return "committed-own";
|
|
178
|
+
const I = resourceId(fromHex(op.intent.identity));
|
|
179
|
+
const R = principalId(fromHex(op.intent.root));
|
|
180
|
+
const pairing = await pairingSigner(o, principalId(fromHex(op.intent.pairingDevice)));
|
|
181
|
+
if (pairing === undefined)
|
|
182
|
+
return { code: "PAIRING_SECRET_GONE" };
|
|
183
|
+
const channel = await pool.get(pairing.signer, op.intent.url);
|
|
184
|
+
const B = principalId(fromHex(op.intent.device));
|
|
185
|
+
const unit = await runUnitPhase(channel, journal, operation, {
|
|
186
|
+
id: "request",
|
|
187
|
+
resource: I,
|
|
188
|
+
// A request refused as stale (I rotated before it arrived) is written again
|
|
189
|
+
// in the current epoch (W §19.1); its refused bytes stay in the phase.
|
|
190
|
+
rebuildOn: ["STALE_DATA_EPOCH"],
|
|
191
|
+
build: async () => {
|
|
192
|
+
// Never the sequence of a unit already signed for this request (W §26.2).
|
|
193
|
+
const signed = phaseOf(await journal.load(operation), "request")?.bytes;
|
|
194
|
+
const used = signed === undefined ? 0n : parseDataUnit(fromHex(signed)).payload.actorSeq;
|
|
195
|
+
const chain = await syncChain(o.storage, channel, I);
|
|
196
|
+
const deks = await fetchDeks(o.storage, o.secrets, channel, chain, pairing);
|
|
197
|
+
const doc = o.document(I, R);
|
|
198
|
+
const load = await loadIdentityDocument({ channel, chain, deks, doc });
|
|
199
|
+
const previous = load.latest.get(toHex(pairing.signer.descriptor.principalId));
|
|
200
|
+
return sealIdentityUnit({
|
|
201
|
+
storage: o.storage,
|
|
202
|
+
chain,
|
|
203
|
+
deks,
|
|
204
|
+
signer: pairing.signer,
|
|
205
|
+
doc,
|
|
206
|
+
latest: load.latest,
|
|
207
|
+
// J may be used by another installation: the next sequence after the one it sees (§5.3).
|
|
208
|
+
seq: ((previous?.seq ?? 0n) > used ? (previous?.seq ?? 0n) : used) + 1n,
|
|
209
|
+
intents: [
|
|
210
|
+
{
|
|
211
|
+
intent: "request.put",
|
|
212
|
+
key: principalRefOf(B),
|
|
213
|
+
entry: op.intent.request,
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
return unit.state === "committed-own" ? "committed-own" : { code: unit.code ?? "refused" };
|
|
220
|
+
}
|
|
221
|
+
/** The accepted DEVICE_GRANT leaves naming `device` in a chain's history, oldest first. */
|
|
222
|
+
function leavesOf(chain, device) {
|
|
223
|
+
return chain.records
|
|
224
|
+
.filter((r) => r.body.type === "DEVICE_GRANT" &&
|
|
225
|
+
bytesEqual(r.body.chain.leaf.payload.device.principalId, device))
|
|
226
|
+
.map((r) => r.body.chain);
|
|
227
|
+
}
|
|
228
|
+
const sameSet = (a, b) => a.length === b.length && a.every((x) => b.includes(x));
|
|
229
|
+
export function candidateEvidence(chain, device, digest, result) {
|
|
230
|
+
const leaves = leavesOf(chain, device);
|
|
231
|
+
const proven = leaves.some((l) => bytesEqual(l.leaf.payloadDigest, digest));
|
|
232
|
+
const d = chain.state.devices.get(toHex(device));
|
|
233
|
+
const active = d?.registered === true && activeDevice(chain.state, device) !== undefined;
|
|
234
|
+
const same = d !== undefined &&
|
|
235
|
+
d.kind === result.kind &&
|
|
236
|
+
sameSet(d.mask.map(String), result.mask.map(String)) &&
|
|
237
|
+
sameSet(d.scope.map(toHex), result.scope.map(toHex));
|
|
238
|
+
return Object.freeze({
|
|
239
|
+
leaves: Object.freeze(leaves.map((l) => l.leaf.id)),
|
|
240
|
+
proven,
|
|
241
|
+
registration: active ? "active" : "pending",
|
|
242
|
+
usable: proven && active && same,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* §5.3 steps 8 and 11: learns the session's result and the status of
|
|
247
|
+
* every candidate. Resumable at any point from the persisted candidates
|
|
248
|
+
* (and while `J` is active, from the link's secret in the SecretStore).
|
|
249
|
+
*/
|
|
250
|
+
export async function completePairing(o) {
|
|
251
|
+
const journal = new OperationJournal(o.storage);
|
|
252
|
+
let op = await journal.load(o.operation);
|
|
253
|
+
const I = resourceId(fromHex(op.intent.identity));
|
|
254
|
+
const R = principalId(fromHex(op.intent.root));
|
|
255
|
+
const Jid = principalId(fromHex(op.intent.pairingDevice));
|
|
256
|
+
const B = o.installation.signer;
|
|
257
|
+
const D = B.descriptor.principalId;
|
|
258
|
+
const kind = op.intent.kind;
|
|
259
|
+
const pool = new ChannelPool(o.network);
|
|
260
|
+
try {
|
|
261
|
+
// Step 8: the session's state first, from J's view of I (W §41 after its
|
|
262
|
+
// revocation): a closed session's result is never hidden by a failed send.
|
|
263
|
+
if (op.intent.result === undefined) {
|
|
264
|
+
const bytes = await o.secrets.get(pairingSecretRef(Jid));
|
|
265
|
+
if (bytes === undefined)
|
|
266
|
+
return { kind: "unknown", reason: "the pairing secret is gone" };
|
|
267
|
+
let J;
|
|
268
|
+
try {
|
|
269
|
+
const secret = decodeInviteSecret(bytes);
|
|
270
|
+
J = { key: secret.signingKey, descriptor: invitationPrincipal(secret) };
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
bytes.fill(0);
|
|
274
|
+
}
|
|
275
|
+
let chain;
|
|
276
|
+
try {
|
|
277
|
+
chain = await syncChain(o.storage, await pool.get(J, op.intent.url), I);
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
if (e instanceof IdentityRefused)
|
|
281
|
+
return {
|
|
282
|
+
kind: "unknown",
|
|
283
|
+
reason: "the pairing device can no longer read and no proof is served",
|
|
284
|
+
};
|
|
285
|
+
throw e;
|
|
286
|
+
}
|
|
287
|
+
const r = revocationOf(chain.state, R, Jid);
|
|
288
|
+
if (r === undefined) {
|
|
289
|
+
// J is active: a request that never reached the coordinator is sent now,
|
|
290
|
+
// from the journal, or written again in the current epoch (§9.1).
|
|
291
|
+
if (phaseOf(op, "request")?.state !== "committed-own") {
|
|
292
|
+
const sent = await sendRequest(o, pool, o.operation);
|
|
293
|
+
if (sent !== "committed-own")
|
|
294
|
+
return { kind: "unknown", reason: `the request was refused: ${sent.code}` };
|
|
295
|
+
}
|
|
296
|
+
return { kind: "waiting" };
|
|
297
|
+
}
|
|
298
|
+
if (r.reason === 3n && r.approval !== null)
|
|
299
|
+
op = await journal.updateIntent(o.operation, {
|
|
300
|
+
result: "approved",
|
|
301
|
+
digest: toHex(r.approval),
|
|
302
|
+
});
|
|
303
|
+
else {
|
|
304
|
+
await journal.updateIntent(o.operation, { result: "rejected" });
|
|
305
|
+
await journal.setStatus(o.operation, "abandoned");
|
|
306
|
+
await o.secrets.delete(pairingSecretRef(Jid));
|
|
307
|
+
return { kind: "rejected" };
|
|
308
|
+
}
|
|
309
|
+
await o.secrets.delete(pairingSecretRef(Jid));
|
|
310
|
+
}
|
|
311
|
+
if (op.intent.result === "rejected")
|
|
312
|
+
return { kind: "rejected" };
|
|
313
|
+
const digest = hash32(fromHex(op.intent.digest));
|
|
314
|
+
const candidates = op.intent.candidates.map((c) => ({
|
|
315
|
+
resource: resourceId(fromHex(c.resource)),
|
|
316
|
+
url: c.url,
|
|
317
|
+
...(c.name === undefined ? {} : { name: c.name }),
|
|
318
|
+
}));
|
|
319
|
+
// A personal B is registered in I itself; a service B only in its candidates.
|
|
320
|
+
const places = kind === 0 ? [{ resource: I, url: op.intent.url }, ...candidates] : candidates;
|
|
321
|
+
let certificate = op.intent.certificate === undefined
|
|
322
|
+
? null
|
|
323
|
+
: certificateChain(op.intent.certificate.map((c) => parseDeviceCertificate(fromHex(c))), R);
|
|
324
|
+
let foreign = 0;
|
|
325
|
+
const seen = new Map();
|
|
326
|
+
for (const p of places) {
|
|
327
|
+
try {
|
|
328
|
+
const channel = await pool.get(B, p.url);
|
|
329
|
+
if (!channel.extensions.includes(IDENTITY_EXTENSION)) {
|
|
330
|
+
seen.set(toHex(p.resource), "unsupported");
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const chain = await syncChain(o.storage, channel, p.resource);
|
|
334
|
+
seen.set(toHex(p.resource), { chain });
|
|
335
|
+
for (const leaf of leavesOf(chain, D)) {
|
|
336
|
+
if (!bytesEqual(leaf.leaf.payloadDigest, digest)) {
|
|
337
|
+
foreign++;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
// The first verified certificate decides (§5.3 step 8).
|
|
341
|
+
if (certificate === null &&
|
|
342
|
+
bytesEqual(leaf.root, R) &&
|
|
343
|
+
bytesEqual(leaf.leaf.payload.root, R) &&
|
|
344
|
+
bytesEqual(leaf.leaf.payload.device.principalId, D)) {
|
|
345
|
+
certificate = leaf;
|
|
346
|
+
op = await journal.updateIntent(o.operation, {
|
|
347
|
+
certificate: leaf.certificates.map((c) => toHex(c.signed.bytes)),
|
|
348
|
+
});
|
|
349
|
+
if (kind === 0)
|
|
350
|
+
await setLocalIdentityResource(o.storage, I);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
catch (e) {
|
|
355
|
+
if (e instanceof IdentityUnavailable)
|
|
356
|
+
seen.set(toHex(p.resource), "unreachable");
|
|
357
|
+
else if (e instanceof IdentityRefused)
|
|
358
|
+
seen.set(toHex(p.resource), "closed");
|
|
359
|
+
else if (e instanceof IdentityConflict)
|
|
360
|
+
seen.set(toHex(p.resource), "unreachable");
|
|
361
|
+
else
|
|
362
|
+
throw e;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (certificate === null)
|
|
366
|
+
// "approved, waiting for registration": no candidate's outcome is known yet.
|
|
367
|
+
return { kind: "approved", certificate: null, candidates: [], foreignCertificates: foreign };
|
|
368
|
+
const result = certificate.leaf.payload;
|
|
369
|
+
const out = [];
|
|
370
|
+
for (const p of places) {
|
|
371
|
+
const s = seen.get(toHex(p.resource));
|
|
372
|
+
const status = async () => {
|
|
373
|
+
if (result.kind !== DEVICE_KIND.PERSONAL &&
|
|
374
|
+
!result.scope.some((r) => bytesEqual(r, p.resource)))
|
|
375
|
+
return bytesEqual(p.resource, I) ? "pending-registration" : "not-granted";
|
|
376
|
+
if (s === "unreachable" || s === undefined)
|
|
377
|
+
return "unreachable";
|
|
378
|
+
if (s === "unsupported")
|
|
379
|
+
return "unsupported";
|
|
380
|
+
if (s === "closed")
|
|
381
|
+
return "pending-registration";
|
|
382
|
+
const evidence = candidateEvidence(s.chain, D, digest, result);
|
|
383
|
+
if (evidence.registration === "pending")
|
|
384
|
+
return "pending-registration";
|
|
385
|
+
if (!evidence.usable)
|
|
386
|
+
return "unproven";
|
|
387
|
+
const channel = await pool.get(B, p.url);
|
|
388
|
+
const deks = await fetchDeks(o.storage, o.secrets, channel, s.chain, o.installation);
|
|
389
|
+
return [...s.chain.state.epochs.keys()].every((e) => deks.has(e))
|
|
390
|
+
? "ready"
|
|
391
|
+
: "waiting-for-keys";
|
|
392
|
+
};
|
|
393
|
+
const named = p.name === undefined ? {} : { name: p.name };
|
|
394
|
+
try {
|
|
395
|
+
out.push({ resource: p.resource, status: await status(), ...named });
|
|
396
|
+
}
|
|
397
|
+
catch (e) {
|
|
398
|
+
if (e instanceof IdentityUnavailable || e instanceof IdentityRefused)
|
|
399
|
+
out.push({ resource: p.resource, status: "unreachable", ...named });
|
|
400
|
+
else
|
|
401
|
+
throw e;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (out.every((c) => c.status === "ready" || c.status === "not-granted"))
|
|
405
|
+
await journal.setStatus(o.operation, "done");
|
|
406
|
+
return {
|
|
407
|
+
kind: "approved",
|
|
408
|
+
certificate,
|
|
409
|
+
candidates: Object.freeze(out),
|
|
410
|
+
foreignCertificates: foreign,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
catch (e) {
|
|
414
|
+
if (e instanceof IdentityUnavailable || e instanceof IdentityConflict)
|
|
415
|
+
return { kind: "unavailable", reason: e.message };
|
|
416
|
+
throw e;
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
pool.closeAll();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
//# sourceMappingURL=pairing-request.js.map
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
|
|
2
|
+
import { type CertificateChain, type Signer } from "@openlfcp/wire";
|
|
3
|
+
import { type FlowOptions, type MaskPreset, type RegistrationResult } from "./devices.js";
|
|
4
|
+
import { type LinearChain } from "./phases.js";
|
|
5
|
+
import { type IdentityFlowOptions } from "./resource.js";
|
|
6
|
+
import type { RotationOutcome } from "./rotation.js";
|
|
7
|
+
/**
|
|
8
|
+
* Pairing a new installation, the managing side `A` (IDENTITY-PROFILE-01
|
|
9
|
+
* §5.3 steps 1, 4–7, 9 and 10): a pairing device `J` registered in the
|
|
10
|
+
* Identity Resource for one session, the request it carries validated and
|
|
11
|
+
* checked against the complete future certificate chain, then the two
|
|
12
|
+
* phases: consume `J` with the approved payload's digest, sign and register
|
|
13
|
+
* exactly that payload. Every phase is journaled and classified by §9.2, so
|
|
14
|
+
* a crash or a lost answer resumes without signing anything else; a
|
|
15
|
+
* pairing session yields at most one approved payload.
|
|
16
|
+
*/
|
|
17
|
+
/** A bearer link (§5.2): its text is revealed on demand and never logged. */
|
|
18
|
+
export declare class PairingLinkText {
|
|
19
|
+
#private;
|
|
20
|
+
constructor(uri: string);
|
|
21
|
+
/** The lfcp://pair URI with its #secret= fragment. Show it to the human; never log it. */
|
|
22
|
+
reveal(): string;
|
|
23
|
+
toJSON(): string;
|
|
24
|
+
toString(): string;
|
|
25
|
+
}
|
|
26
|
+
export type PairingSessionResult = {
|
|
27
|
+
readonly kind: "created";
|
|
28
|
+
readonly session: string;
|
|
29
|
+
readonly pairingDevice: PrincipalId;
|
|
30
|
+
readonly link: PairingLinkText;
|
|
31
|
+
}
|
|
32
|
+
/** §5.3 step 1: the root already has 32 live devices in I; revoke one first. */
|
|
33
|
+
| {
|
|
34
|
+
readonly kind: "limit";
|
|
35
|
+
readonly reason: string;
|
|
36
|
+
}
|
|
37
|
+
/** A lacks the root's unit 1 or a DEK of I: no link (§5.3 step 1). */
|
|
38
|
+
| {
|
|
39
|
+
readonly kind: "missing-material";
|
|
40
|
+
readonly missing: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
/** This installation is not the root or an active managing device of it. */
|
|
43
|
+
| {
|
|
44
|
+
readonly kind: "not-authorized";
|
|
45
|
+
readonly reason: string;
|
|
46
|
+
} | {
|
|
47
|
+
readonly kind: "refused";
|
|
48
|
+
readonly code: string;
|
|
49
|
+
} | {
|
|
50
|
+
readonly kind: "conflict";
|
|
51
|
+
} | {
|
|
52
|
+
readonly kind: "unavailable";
|
|
53
|
+
readonly session?: string;
|
|
54
|
+
readonly reason: string;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* §5.3 step 1: creates a pairing device `J` (kind 2, mask {1, 2}, scope
|
|
58
|
+
* [I]), registers it in `I`, seals to it the DEK of every epoch of `I`, and
|
|
59
|
+
* returns the link. Before anything is committed `A` must hold, verified,
|
|
60
|
+
* the root's unit 1 and the DEK of every epoch of `I` (otherwise no link),
|
|
61
|
+
* and the DEVICE_GRANT must fit the root's 32 live devices (W §17.4 rule 8).
|
|
62
|
+
*/
|
|
63
|
+
export declare function createPairingSession(o: IdentityFlowOptions & {
|
|
64
|
+
readonly endpoints?: readonly string[];
|
|
65
|
+
}): Promise<PairingSessionResult>;
|
|
66
|
+
/**
|
|
67
|
+
* §5.3 step 1 after a crash before the link was shown: completes the
|
|
68
|
+
* registration of `J` and its packages from the journal, then returns the
|
|
69
|
+
* link again.
|
|
70
|
+
*/
|
|
71
|
+
export declare function resumePairingSession(o: IdentityFlowOptions, session: string): Promise<PairingSessionResult>;
|
|
72
|
+
/** A pairing request `A` may show (§5.3 step 4). */
|
|
73
|
+
export interface PairingReview {
|
|
74
|
+
readonly session: string;
|
|
75
|
+
/** `requests[k]`: the PrincipalRef of `D_B`. */
|
|
76
|
+
readonly requestKey: string;
|
|
77
|
+
readonly device: PrincipalId;
|
|
78
|
+
/** base64url of the exact descriptor bytes. */
|
|
79
|
+
readonly descriptor: string;
|
|
80
|
+
readonly label: string;
|
|
81
|
+
readonly platform?: string;
|
|
82
|
+
readonly kind: 0 | 1;
|
|
83
|
+
/** The requested scope (kind 1), the Identity Resource already removed. */
|
|
84
|
+
readonly requestedScope: readonly ResourceId[];
|
|
85
|
+
/** §5.2 device fingerprint of `D_B`, for the human to compare with B's screen. */
|
|
86
|
+
readonly fingerprint: string;
|
|
87
|
+
/** Each preset of §5.4 for the request's kind, and whether the future chain passes (§5.3 step 4). */
|
|
88
|
+
readonly presets: readonly {
|
|
89
|
+
readonly preset: MaskPreset;
|
|
90
|
+
readonly offered: boolean;
|
|
91
|
+
readonly reason?: string;
|
|
92
|
+
}[];
|
|
93
|
+
/**
|
|
94
|
+
* The hashes of the changes that wrote the request, in causal order: the
|
|
95
|
+
* request change hash §5.3 step 4 journals with the approval.
|
|
96
|
+
*/
|
|
97
|
+
readonly requestChanges: readonly string[];
|
|
98
|
+
/** The confirmed tuple: when the entry or any change to it differs, the confirmation is asked again. */
|
|
99
|
+
readonly tuple: string;
|
|
100
|
+
}
|
|
101
|
+
/** §5.3 step 4: the valid requests of a session, each with the presets whose future chain passes. */
|
|
102
|
+
export declare function pairingRequests(o: IdentityFlowOptions & {
|
|
103
|
+
readonly session: string;
|
|
104
|
+
}): Promise<readonly PairingReview[]>;
|
|
105
|
+
export type ApprovalResult = {
|
|
106
|
+
readonly kind: "approved";
|
|
107
|
+
readonly operation: string;
|
|
108
|
+
readonly device: PrincipalId;
|
|
109
|
+
readonly chain: CertificateChain;
|
|
110
|
+
/** §5.3 step 10: the Resources of the roster (or the approved scope). */
|
|
111
|
+
readonly registrations: readonly RegistrationResult[];
|
|
112
|
+
/** §5.3 step 9: the consumed pairing device was a reader of I (§8.4). */
|
|
113
|
+
readonly rotation: RotationOutcome;
|
|
114
|
+
}
|
|
115
|
+
/** The entry changed since the human confirmed it: ask again (§5.3 step 4). */
|
|
116
|
+
| {
|
|
117
|
+
readonly kind: "reconfirm";
|
|
118
|
+
readonly review: PairingReview | undefined;
|
|
119
|
+
}
|
|
120
|
+
/** The preset's future chain does not pass on the current head (§5.3 step 4). */
|
|
121
|
+
| {
|
|
122
|
+
readonly kind: "infeasible";
|
|
123
|
+
readonly reason: string;
|
|
124
|
+
}
|
|
125
|
+
/** The session was consumed by another approval or cancelled: nothing is signed (§9.2). */
|
|
126
|
+
| {
|
|
127
|
+
readonly kind: "superseded";
|
|
128
|
+
readonly operation: string;
|
|
129
|
+
}
|
|
130
|
+
/** Phase 2 meets the 32-device limit: retry after a revocation; the approval stays consumed (§5.3 step 6). */
|
|
131
|
+
| {
|
|
132
|
+
readonly kind: "limit";
|
|
133
|
+
readonly operation: string;
|
|
134
|
+
} | {
|
|
135
|
+
readonly kind: "refused";
|
|
136
|
+
readonly operation: string;
|
|
137
|
+
readonly code: string;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Approved in the chain (phases 1 and 2, keys, rotation and registrations
|
|
141
|
+
* as for `approved`), but the coordinator refused the document unit of
|
|
142
|
+
* §5.3 step 6 with `code`: the device entry is not written and the request
|
|
143
|
+
* not removed. The operation stays open; resumeApproval writes the unit
|
|
144
|
+
* again on the current state.
|
|
145
|
+
*/
|
|
146
|
+
| {
|
|
147
|
+
readonly kind: "document-incomplete";
|
|
148
|
+
readonly operation: string;
|
|
149
|
+
readonly device: PrincipalId;
|
|
150
|
+
readonly chain: CertificateChain;
|
|
151
|
+
readonly registrations: readonly RegistrationResult[];
|
|
152
|
+
readonly rotation: RotationOutcome;
|
|
153
|
+
readonly code: string;
|
|
154
|
+
} | {
|
|
155
|
+
readonly kind: "conflict";
|
|
156
|
+
} | {
|
|
157
|
+
readonly kind: "unavailable";
|
|
158
|
+
readonly operation?: string;
|
|
159
|
+
readonly reason: string;
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* §5.3 steps 4–7 and 10: approves the confirmed request with a preset
|
|
163
|
+
* (and, for a service device, a scope within the requested one). The
|
|
164
|
+
* complete certificate payload and its digest are journaled before
|
|
165
|
+
* phase 1; phase 2 signs exactly that payload.
|
|
166
|
+
*/
|
|
167
|
+
export declare function approvePairing(o: IdentityFlowOptions & {
|
|
168
|
+
readonly review: PairingReview;
|
|
169
|
+
readonly preset: MaskPreset;
|
|
170
|
+
/** Service only: the approved scope, a subset of the requested one. */
|
|
171
|
+
readonly scope?: readonly ResourceId[];
|
|
172
|
+
/** RFC 3339 UTC for `devices[...].added_at`. */
|
|
173
|
+
readonly addedAt?: string;
|
|
174
|
+
}): Promise<ApprovalResult>;
|
|
175
|
+
/** Phase 1 (§5.3 step 5): `DEVICE_REVOKE {R, J, 3, digest}` on the chain's head. */
|
|
176
|
+
export declare const consumeRecord: (chain: LinearChain, signer: Signer, root: PrincipalId, pairing: PrincipalId, digest: Hash32) => Uint8Array;
|
|
177
|
+
/** Phase 2 (§5.3 step 6): `DEVICE_GRANT {R, chain_B, 2: J}` on the chain's head. */
|
|
178
|
+
export declare const approvedGrantRecord: (chain: LinearChain, signer: Signer, root: PrincipalId, device: CertificateChain, pairing: PrincipalId) => Uint8Array;
|
|
179
|
+
/** §5.3 step 9: `DEVICE_REVOKE {R, J, 0}` on the chain's head. */
|
|
180
|
+
export declare const cancelRecord: (chain: LinearChain, signer: Signer, root: PrincipalId, pairing: PrincipalId) => Uint8Array;
|
|
181
|
+
/** Resumes a journaled approval from its next phase (§9.1: "If A crashes after step 5 …"). */
|
|
182
|
+
export declare function resumeApproval(o: IdentityFlowOptions, operation: string): Promise<ApprovalResult>;
|
|
183
|
+
export type CancelResult = {
|
|
184
|
+
readonly kind: "cancelled";
|
|
185
|
+
readonly rotation: RotationOutcome;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Cancelled in the chain, but the coordinator refused the unit that
|
|
189
|
+
* deletes the rejected request (§5.3 step 4) with `code`: the request
|
|
190
|
+
* entry stays. The session stays open; cancelPairing with the same
|
|
191
|
+
* `reject` writes the unit again.
|
|
192
|
+
*/
|
|
193
|
+
| {
|
|
194
|
+
readonly kind: "document-incomplete";
|
|
195
|
+
readonly rotation: RotationOutcome;
|
|
196
|
+
readonly code: string;
|
|
197
|
+
}
|
|
198
|
+
/** The session was consumed by an approval: not cancelled (§9.2 Cancel: superseded). */
|
|
199
|
+
| {
|
|
200
|
+
readonly kind: "consumed";
|
|
201
|
+
} | {
|
|
202
|
+
readonly kind: "refused";
|
|
203
|
+
readonly code: string;
|
|
204
|
+
} | {
|
|
205
|
+
readonly kind: "conflict";
|
|
206
|
+
} | {
|
|
207
|
+
readonly kind: "unavailable";
|
|
208
|
+
readonly reason: string;
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* §5.3 step 9 (rejection, timeout, cancel): `DEVICE_REVOKE {R, J, 0}`.
|
|
212
|
+
* With `reject`, the request entry is deleted first (step 4: a request the
|
|
213
|
+
* human does not confirm is rejected).
|
|
214
|
+
*/
|
|
215
|
+
export declare function cancelPairing(o: IdentityFlowOptions & {
|
|
216
|
+
readonly session: string;
|
|
217
|
+
readonly reject?: string;
|
|
218
|
+
}): Promise<CancelResult>;
|
|
219
|
+
/** Open pairing sessions of this installation, oldest first. */
|
|
220
|
+
export declare function openPairingSessions(o: Pick<FlowOptions, "storage">): Promise<{
|
|
221
|
+
session: string;
|
|
222
|
+
identity: ResourceId;
|
|
223
|
+
pairingDevice: PrincipalId;
|
|
224
|
+
}[]>;
|
|
225
|
+
//# sourceMappingURL=pairing.d.ts.map
|