@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,624 @@
|
|
|
1
|
+
import { bytesEqual, fromBase64url, fromHex, hash32, principalId, resourceId, secureRandom, toBase64url, toHex, } from "@openlfcp/core";
|
|
2
|
+
import { InvitationSecret } from "@openlfcp/crypto";
|
|
3
|
+
import { activeDevice, assemblePairingUri, certificateChain, DEVICE_KIND, decodeInviteSecret, decodePrincipalDescriptor, deviceCertificatePayloadDigest, deviceFingerprint, encodePrincipalDescriptor as encodeDescriptor, encodeInviteSecret, InMemorySeenUnits, invitationPrincipal, parseDataUnit, parseDeviceCertificate, receiveDataUnit, signControlRecord, } from "@openlfcp/wire";
|
|
4
|
+
import { deliverKeys, fetchDeks, genesisOwner, IdentityConflict, loadIdentityDocument, runControlPhase, runUnitPhase, sealIdentityUnit, syncChain, } from "./context.js";
|
|
5
|
+
import { checkIssuance, leafPayload, MASK_PRESETS, mayManageDevices, registerDevice, signChain, } from "./devices.js";
|
|
6
|
+
import { identityPlaintextCodec, principalOfRef, principalRefOf, } from "./document.js";
|
|
7
|
+
import { newOperationId, OperationJournal, PackageLedger, pairingSecretRef, phaseOf, } from "./journal.js";
|
|
8
|
+
import { ChannelPool, IdentityUnavailable } from "./network.js";
|
|
9
|
+
import { classifyCancel, classifyConsume, classifyRegister } from "./phases.js";
|
|
10
|
+
import { identityTarget } from "./resource.js";
|
|
11
|
+
import { rotateIfExposed } from "./revocation.js";
|
|
12
|
+
/**
|
|
13
|
+
* Pairing a new installation, the managing side `A` (IDENTITY-PROFILE-01
|
|
14
|
+
* §5.3 steps 1, 4–7, 9 and 10): a pairing device `J` registered in the
|
|
15
|
+
* Identity Resource for one session, the request it carries validated and
|
|
16
|
+
* checked against the complete future certificate chain, then the two
|
|
17
|
+
* phases: consume `J` with the approved payload's digest, sign and register
|
|
18
|
+
* exactly that payload. Every phase is journaled and classified by §9.2, so
|
|
19
|
+
* a crash or a lost answer resumes without signing anything else; a
|
|
20
|
+
* pairing session yields at most one approved payload.
|
|
21
|
+
*/
|
|
22
|
+
/** A bearer link (§5.2): its text is revealed on demand and never logged. */
|
|
23
|
+
export class PairingLinkText {
|
|
24
|
+
#uri;
|
|
25
|
+
constructor(uri) {
|
|
26
|
+
this.#uri = uri;
|
|
27
|
+
}
|
|
28
|
+
/** The lfcp://pair URI with its #secret= fragment. Show it to the human; never log it. */
|
|
29
|
+
reveal() {
|
|
30
|
+
return this.#uri;
|
|
31
|
+
}
|
|
32
|
+
toJSON() {
|
|
33
|
+
return "[redacted]";
|
|
34
|
+
}
|
|
35
|
+
toString() {
|
|
36
|
+
return "[redacted]";
|
|
37
|
+
}
|
|
38
|
+
[Symbol.for("nodejs.util.inspect.custom")]() {
|
|
39
|
+
return "[redacted]";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const PAIRING_MASK = [1n, 2n];
|
|
43
|
+
/** Wraps a flow body: transport failures and forks become results, the journal stays. */
|
|
44
|
+
async function guarded(pool, body, unavailable, conflict) {
|
|
45
|
+
try {
|
|
46
|
+
return await body();
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
if (e instanceof IdentityUnavailable)
|
|
50
|
+
return unavailable(e.message);
|
|
51
|
+
if (e instanceof IdentityConflict)
|
|
52
|
+
return conflict();
|
|
53
|
+
throw e;
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
pool.closeAll();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* §5.3 step 1: creates a pairing device `J` (kind 2, mask {1, 2}, scope
|
|
61
|
+
* [I]), registers it in `I`, seals to it the DEK of every epoch of `I`, and
|
|
62
|
+
* returns the link. Before anything is committed `A` must hold, verified,
|
|
63
|
+
* the root's unit 1 and the DEK of every epoch of `I` (otherwise no link),
|
|
64
|
+
* and the DEVICE_GRANT must fit the root's 32 live devices (W §17.4 rule 8).
|
|
65
|
+
*/
|
|
66
|
+
export async function createPairingSession(o) {
|
|
67
|
+
const target = await identityTarget(o);
|
|
68
|
+
const pool = new ChannelPool(o.network);
|
|
69
|
+
const me = o.installation.signer;
|
|
70
|
+
return guarded(pool, async () => {
|
|
71
|
+
const channel = await pool.get(me, target.url);
|
|
72
|
+
const chain = await syncChain(o.storage, channel, target.resource);
|
|
73
|
+
const R = genesisOwner(chain);
|
|
74
|
+
if (!mayManageDevices(chain.state, me.descriptor.principalId, R))
|
|
75
|
+
return {
|
|
76
|
+
kind: "not-authorized",
|
|
77
|
+
reason: "only the root or an active managing device creates a pairing session",
|
|
78
|
+
};
|
|
79
|
+
const missing = await missingMaterial(o, channel, chain);
|
|
80
|
+
if (missing.length > 0)
|
|
81
|
+
return { kind: "missing-material", missing };
|
|
82
|
+
// J's keys go to the SecretStore before the journal row names J (§9.8).
|
|
83
|
+
const secret = InvitationSecret.generate();
|
|
84
|
+
const J = invitationPrincipal(secret);
|
|
85
|
+
const leaf = leafPayload(R, J, DEVICE_KIND.PAIRING, PAIRING_MASK, [target.resource]);
|
|
86
|
+
const feasible = checkIssuance({ chain, issuer: me, leaf });
|
|
87
|
+
if (!feasible.feasible)
|
|
88
|
+
return /rule 8/.test(feasible.reason)
|
|
89
|
+
? { kind: "limit", reason: feasible.reason }
|
|
90
|
+
: { kind: "not-authorized", reason: feasible.reason };
|
|
91
|
+
const bytes = encodeInviteSecret(secret);
|
|
92
|
+
try {
|
|
93
|
+
await o.secrets.put(pairingSecretRef(J.principalId), bytes);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
bytes.fill(0);
|
|
97
|
+
}
|
|
98
|
+
const chainJ = signChain(chain.state, me, leaf);
|
|
99
|
+
const session = newOperationId("pairing-session", secureRandom(8));
|
|
100
|
+
await new OperationJournal(o.storage).begin({
|
|
101
|
+
id: session,
|
|
102
|
+
kind: "pairing-session",
|
|
103
|
+
createdAt: o.now(),
|
|
104
|
+
intent: {
|
|
105
|
+
identity: toHex(target.resource),
|
|
106
|
+
url: target.url,
|
|
107
|
+
endpoints: [...(o.endpoints ?? [target.url])],
|
|
108
|
+
pairingDevice: toHex(J.principalId),
|
|
109
|
+
root: toHex(R),
|
|
110
|
+
certificates: chainJ.certificates.map((c) => toHex(c.signed.bytes)),
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
return finishSession(o, pool, session);
|
|
114
|
+
}, (reason) => ({ kind: "unavailable", reason }), () => ({ kind: "conflict" }));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* §5.3 step 1 after a crash before the link was shown: completes the
|
|
118
|
+
* registration of `J` and its packages from the journal, then returns the
|
|
119
|
+
* link again.
|
|
120
|
+
*/
|
|
121
|
+
export async function resumePairingSession(o, session) {
|
|
122
|
+
const pool = new ChannelPool(o.network);
|
|
123
|
+
return guarded(pool, () => finishSession(o, pool, session), (reason) => ({ kind: "unavailable", session, reason }), () => ({ kind: "conflict" }));
|
|
124
|
+
}
|
|
125
|
+
async function missingMaterial(o, channel, chain) {
|
|
126
|
+
const missing = [];
|
|
127
|
+
const deks = await fetchDeks(o.storage, o.secrets, channel, chain, o.installation);
|
|
128
|
+
for (const e of chain.state.epochs.values())
|
|
129
|
+
if (!deks.has(String(e.epoch)))
|
|
130
|
+
missing.push(`the DEK of epoch ${e.epoch}`);
|
|
131
|
+
// The carrier of the initial change (§2.2), verified, and retrievable for B.
|
|
132
|
+
const R = genesisOwner(chain);
|
|
133
|
+
// Exactly the root's unit at actor sequence 1, whatever else DATA_GET returns.
|
|
134
|
+
const unit = (await channel.dataUnits(chain.state.resourceId, [{ actor: R, start: 1n, end: 1n }])).find((u) => {
|
|
135
|
+
const p = parseDataUnit(u).payload;
|
|
136
|
+
return bytesEqual(p.actor, R) && p.actorSeq === 1n && p.prevDataUnitId === null;
|
|
137
|
+
});
|
|
138
|
+
const opened = unit === undefined
|
|
139
|
+
? undefined
|
|
140
|
+
: await receiveDataUnit(chain, unit, {
|
|
141
|
+
seen: new InMemorySeenUnits(),
|
|
142
|
+
dek: (epoch) => deks.get(String(epoch)),
|
|
143
|
+
profile: identityPlaintextCodec,
|
|
144
|
+
});
|
|
145
|
+
if (opened?.kind !== "accepted")
|
|
146
|
+
missing.push("the root's Data Unit with actor sequence 1");
|
|
147
|
+
return missing;
|
|
148
|
+
}
|
|
149
|
+
async function finishSession(o, pool, session) {
|
|
150
|
+
const journal = new OperationJournal(o.storage);
|
|
151
|
+
const op = await journal.load(session);
|
|
152
|
+
const I = resourceId(fromHex(op.intent.identity));
|
|
153
|
+
const R = principalId(fromHex(op.intent.root));
|
|
154
|
+
const J = principalId(fromHex(op.intent.pairingDevice));
|
|
155
|
+
const chainJ = certificateChain(op.intent.certificates.map((c) => parseDeviceCertificate(fromHex(c))), R);
|
|
156
|
+
const me = o.installation.signer;
|
|
157
|
+
const channel = await pool.get(me, op.intent.url);
|
|
158
|
+
const grant = await runControlPhase(o.storage, channel, journal, session, {
|
|
159
|
+
id: "grant",
|
|
160
|
+
kind: "register",
|
|
161
|
+
resource: I,
|
|
162
|
+
classify: (chain, isOwn) => classifyRegister(chain, { root: R, chain: chainJ, isOwnRecord: isOwn }),
|
|
163
|
+
build: (chain) => signControlRecord({ resourceId: I, controlSeq: chain.state.seq + 1n, prevControlId: chain.state.head }, { type: "DEVICE_GRANT", root: R, chain: chainJ }, me).bytes,
|
|
164
|
+
});
|
|
165
|
+
if (grant.state === "refused" || grant.state === "superseded") {
|
|
166
|
+
await journal.setStatus(session, "abandoned");
|
|
167
|
+
if (grant.state === "refused" && grant.code === "AUTHORIZATION_FAILED")
|
|
168
|
+
return { kind: "limit", reason: "the coordinator refused the pairing device (W §17.4)" };
|
|
169
|
+
return { kind: "refused", code: grant.code ?? grant.state };
|
|
170
|
+
}
|
|
171
|
+
// Every epoch of I to J (same root, W §25.2), before the link is shown.
|
|
172
|
+
const deks = await fetchDeks(o.storage, o.secrets, channel, grant.chain, o.installation);
|
|
173
|
+
const delivery = await deliverKeys({
|
|
174
|
+
channel,
|
|
175
|
+
ledger: new PackageLedger(o.storage),
|
|
176
|
+
chain: grant.chain,
|
|
177
|
+
sender: me,
|
|
178
|
+
recipients: [grant.chain.state.principals.get(toHex(J)) ?? chainJ.leaf.payload.device],
|
|
179
|
+
deks,
|
|
180
|
+
});
|
|
181
|
+
if (delivery.skipped.length > 0)
|
|
182
|
+
throw new IdentityUnavailable(`the packages to the pairing device were not all accepted: ${delivery.skipped.map((s) => s.reason).join("; ")}`);
|
|
183
|
+
// §5.3 step 1, again right before the link is shown: J must hold every
|
|
184
|
+
// epoch of I and the root's unit 1 must be retrievable; a resumed session
|
|
185
|
+
// may meet a newer epoch or a coordinator that lost the unit.
|
|
186
|
+
const now = await syncChain(o.storage, channel, I);
|
|
187
|
+
const missing = await missingMaterial(o, channel, now);
|
|
188
|
+
for (const e of now.state.epochs.values())
|
|
189
|
+
if (!(await new PackageLedger(o.storage).has(I, e.epoch, J)))
|
|
190
|
+
missing.push(`the package of epoch ${e.epoch} to the pairing device`);
|
|
191
|
+
if (missing.length > 0)
|
|
192
|
+
return { kind: "missing-material", missing };
|
|
193
|
+
const bytes = await o.secrets.get(pairingSecretRef(J));
|
|
194
|
+
if (bytes === undefined)
|
|
195
|
+
throw new Error("the pairing device's secret is not in the SecretStore");
|
|
196
|
+
let secret;
|
|
197
|
+
try {
|
|
198
|
+
secret = decodeInviteSecret(bytes);
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
bytes.fill(0);
|
|
202
|
+
}
|
|
203
|
+
const link = assemblePairingUri({
|
|
204
|
+
identityResourceId: I,
|
|
205
|
+
endpoints: op.intent.endpoints,
|
|
206
|
+
pairingDevice: J,
|
|
207
|
+
secret,
|
|
208
|
+
});
|
|
209
|
+
return { kind: "created", session, pairingDevice: J, link: new PairingLinkText(link) };
|
|
210
|
+
}
|
|
211
|
+
const tupleOf = (key, entry, changes) => JSON.stringify([
|
|
212
|
+
changes,
|
|
213
|
+
key,
|
|
214
|
+
entry.descriptor,
|
|
215
|
+
entry.pairing,
|
|
216
|
+
entry.label,
|
|
217
|
+
entry.kind,
|
|
218
|
+
entry.scope ?? [],
|
|
219
|
+
entry.platform ?? null,
|
|
220
|
+
]);
|
|
221
|
+
async function readSession(o, session) {
|
|
222
|
+
const op = await new OperationJournal(o.storage).load(session);
|
|
223
|
+
return {
|
|
224
|
+
op,
|
|
225
|
+
I: resourceId(fromHex(op.intent.identity)),
|
|
226
|
+
R: principalId(fromHex(op.intent.root)),
|
|
227
|
+
J: principalId(fromHex(op.intent.pairingDevice)),
|
|
228
|
+
url: op.intent.url,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
async function openIdentity(o, channel, I) {
|
|
232
|
+
const chain = await syncChain(o.storage, channel, I);
|
|
233
|
+
const doc = o.document(I, genesisOwner(chain));
|
|
234
|
+
const load = await loadIdentityDocument({
|
|
235
|
+
channel,
|
|
236
|
+
chain,
|
|
237
|
+
deks: await fetchDeks(o.storage, o.secrets, channel, chain, o.installation),
|
|
238
|
+
doc,
|
|
239
|
+
});
|
|
240
|
+
return { chain, doc, load };
|
|
241
|
+
}
|
|
242
|
+
function presetsFor(chain, o, R, J, device, kind, scope) {
|
|
243
|
+
const names = kind === 0 ? ["full", "edit", "read"] : ["service"];
|
|
244
|
+
return names.map((preset) => {
|
|
245
|
+
const p = MASK_PRESETS[preset];
|
|
246
|
+
if (kind === 1 && scope.length === 0)
|
|
247
|
+
return { preset, offered: false, reason: "a service scope must keep at least one Resource" };
|
|
248
|
+
const f = checkIssuance({
|
|
249
|
+
chain,
|
|
250
|
+
issuer: o.installation.signer,
|
|
251
|
+
leaf: leafPayload(R, device, p.kind, p.mask, kind === 1 ? scope : []),
|
|
252
|
+
pairing: J,
|
|
253
|
+
});
|
|
254
|
+
return f.feasible ? { preset, offered: true } : { preset, offered: false, reason: f.reason };
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
function reviewOf(o, session, chain, doc, R, J, I, key, entry) {
|
|
258
|
+
// §5.3 step 4: the entry names this session's J, J is an active pairing
|
|
259
|
+
// device of R, and every change that wrote the entry was signed by J.
|
|
260
|
+
if (entry.pairing !== principalRefOf(J))
|
|
261
|
+
return undefined;
|
|
262
|
+
const j = activeDevice(chain.state, J);
|
|
263
|
+
if (j === undefined || j.kind !== DEVICE_KIND.PAIRING || !bytesEqual(j.root, R))
|
|
264
|
+
return undefined;
|
|
265
|
+
const origins = doc.origins("requests", key);
|
|
266
|
+
if (origins.length === 0 || origins.some((w) => !bytesEqual(w.signer, J)))
|
|
267
|
+
return undefined;
|
|
268
|
+
const requestChanges = Object.freeze(origins.map((w) => w.hash));
|
|
269
|
+
const device = principalOfRef(key);
|
|
270
|
+
if (device === undefined)
|
|
271
|
+
return undefined;
|
|
272
|
+
let descriptor;
|
|
273
|
+
try {
|
|
274
|
+
descriptor = decodePrincipalDescriptor(fromBase64url(entry.descriptor));
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
if (!bytesEqual(descriptor.principalId, device))
|
|
280
|
+
return undefined;
|
|
281
|
+
const requestedScope = (entry.scope ?? [])
|
|
282
|
+
.map((ref) => resourceId(fromBase64url(ref)))
|
|
283
|
+
// A service scope never lists the Identity Resource (§5.3 step 4).
|
|
284
|
+
.filter((r) => !bytesEqual(r, I));
|
|
285
|
+
return Object.freeze({
|
|
286
|
+
session,
|
|
287
|
+
requestKey: key,
|
|
288
|
+
device,
|
|
289
|
+
descriptor: entry.descriptor,
|
|
290
|
+
label: entry.label,
|
|
291
|
+
...(entry.platform === undefined ? {} : { platform: entry.platform }),
|
|
292
|
+
kind: entry.kind,
|
|
293
|
+
requestedScope: Object.freeze(requestedScope),
|
|
294
|
+
fingerprint: deviceFingerprint(device),
|
|
295
|
+
presets: Object.freeze(presetsFor(chain, o, R, J, descriptor, entry.kind, requestedScope)),
|
|
296
|
+
requestChanges,
|
|
297
|
+
tuple: tupleOf(key, entry, requestChanges),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
/** §5.3 step 4: the valid requests of a session, each with the presets whose future chain passes. */
|
|
301
|
+
export async function pairingRequests(o) {
|
|
302
|
+
const { I, R, J, url } = await readSession(o, o.session);
|
|
303
|
+
const pool = new ChannelPool(o.network);
|
|
304
|
+
try {
|
|
305
|
+
const channel = await pool.get(o.installation.signer, url);
|
|
306
|
+
const { chain, doc, load } = await openIdentity(o, channel, I);
|
|
307
|
+
const out = [];
|
|
308
|
+
for (const [key, entry] of doc.requests()) {
|
|
309
|
+
const r = reviewOf(o, o.session, chain, doc, R, J, I, key, entry);
|
|
310
|
+
if (r !== undefined)
|
|
311
|
+
out.push(r);
|
|
312
|
+
}
|
|
313
|
+
return Object.freeze(out);
|
|
314
|
+
}
|
|
315
|
+
finally {
|
|
316
|
+
pool.closeAll();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* §5.3 steps 4–7 and 10: approves the confirmed request with a preset
|
|
321
|
+
* (and, for a service device, a scope within the requested one). The
|
|
322
|
+
* complete certificate payload and its digest are journaled before
|
|
323
|
+
* phase 1; phase 2 signs exactly that payload.
|
|
324
|
+
*/
|
|
325
|
+
export async function approvePairing(o) {
|
|
326
|
+
const { I, R, J, url } = await readSession(o, o.review.session);
|
|
327
|
+
const pool = new ChannelPool(o.network);
|
|
328
|
+
const me = o.installation.signer;
|
|
329
|
+
const prepared = await guarded(pool, async () => {
|
|
330
|
+
const channel = await pool.get(me, url);
|
|
331
|
+
const { chain, doc, load } = await openIdentity(o, channel, I);
|
|
332
|
+
const entry = doc.requests().get(o.review.requestKey);
|
|
333
|
+
const review = entry === undefined
|
|
334
|
+
? undefined
|
|
335
|
+
: reviewOf(o, o.review.session, chain, doc, R, J, I, o.review.requestKey, entry);
|
|
336
|
+
if (review === undefined || review.tuple !== o.review.tuple)
|
|
337
|
+
return { kind: "reconfirm", review };
|
|
338
|
+
const preset = MASK_PRESETS[o.preset];
|
|
339
|
+
if ((preset.kind === DEVICE_KIND.SERVICE) !== (review.kind === 1))
|
|
340
|
+
return { kind: "infeasible", reason: "the preset's kind is not the requested kind" };
|
|
341
|
+
const scope = review.kind === 1
|
|
342
|
+
? (o.scope ?? review.requestedScope).filter((r) => !bytesEqual(r, I))
|
|
343
|
+
: [];
|
|
344
|
+
if (scope.some((r) => !review.requestedScope.some((q) => bytesEqual(q, r))))
|
|
345
|
+
return { kind: "infeasible", reason: "the approved scope adds a Resource (§5.3 step 4)" };
|
|
346
|
+
if (review.kind === 1 && scope.length === 0)
|
|
347
|
+
return {
|
|
348
|
+
kind: "infeasible",
|
|
349
|
+
reason: "a service scope trimmed to nothing is not approvable",
|
|
350
|
+
};
|
|
351
|
+
const descriptor = decodePrincipalDescriptor(fromBase64url(review.descriptor));
|
|
352
|
+
const leaf = leafPayload(R, descriptor, preset.kind, preset.mask, scope);
|
|
353
|
+
const f = checkIssuance({ chain, issuer: me, leaf, pairing: J });
|
|
354
|
+
if (!f.feasible)
|
|
355
|
+
return { kind: "infeasible", reason: f.reason };
|
|
356
|
+
const operation = newOperationId("pairing-approval", secureRandom(8));
|
|
357
|
+
await new OperationJournal(o.storage).begin({
|
|
358
|
+
id: operation,
|
|
359
|
+
kind: "pairing-approval",
|
|
360
|
+
createdAt: o.now(),
|
|
361
|
+
intent: {
|
|
362
|
+
session: o.review.session,
|
|
363
|
+
identity: toHex(I),
|
|
364
|
+
url,
|
|
365
|
+
root: toHex(R),
|
|
366
|
+
pairingDevice: toHex(J),
|
|
367
|
+
requestKey: review.requestKey,
|
|
368
|
+
requestChanges: [...review.requestChanges],
|
|
369
|
+
fingerprint: review.fingerprint,
|
|
370
|
+
label: review.label,
|
|
371
|
+
...(review.platform === undefined ? {} : { platform: review.platform }),
|
|
372
|
+
...(o.addedAt === undefined ? {} : { addedAt: o.addedAt }),
|
|
373
|
+
payload: payloadToJson(leaf),
|
|
374
|
+
digest: toHex(deviceCertificatePayloadDigest(leaf)),
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
return operation;
|
|
378
|
+
}, (reason) => ({ kind: "unavailable", reason }), () => ({ kind: "conflict" }));
|
|
379
|
+
if (typeof prepared !== "string")
|
|
380
|
+
return prepared;
|
|
381
|
+
return resumeApproval(o, prepared);
|
|
382
|
+
}
|
|
383
|
+
const payloadToJson = (p) => ({
|
|
384
|
+
root: toHex(p.root),
|
|
385
|
+
descriptor: toBase64url(encodeDescriptor(p.device)),
|
|
386
|
+
kind: p.kind,
|
|
387
|
+
mask: p.mask.map(String),
|
|
388
|
+
scope: p.scope.map(toHex),
|
|
389
|
+
serial: toHex(p.serial),
|
|
390
|
+
});
|
|
391
|
+
function payloadFromJson(j) {
|
|
392
|
+
return Object.freeze({
|
|
393
|
+
root: principalId(fromHex(j.root)),
|
|
394
|
+
device: decodePrincipalDescriptor(fromBase64url(j.descriptor)),
|
|
395
|
+
kind: j.kind,
|
|
396
|
+
mask: Object.freeze(j.mask.map(BigInt)),
|
|
397
|
+
scope: Object.freeze(j.scope.map((s) => resourceId(fromHex(s)))),
|
|
398
|
+
serial: fromHex(j.serial),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
class Infeasible extends Error {
|
|
402
|
+
}
|
|
403
|
+
const next = (chain) => ({
|
|
404
|
+
resourceId: chain.state.resourceId,
|
|
405
|
+
controlSeq: chain.state.seq + 1n,
|
|
406
|
+
prevControlId: chain.state.head,
|
|
407
|
+
});
|
|
408
|
+
/** Phase 1 (§5.3 step 5): `DEVICE_REVOKE {R, J, 3, digest}` on the chain's head. */
|
|
409
|
+
export const consumeRecord = (chain, signer, root, pairing, digest) => signControlRecord(next(chain), { type: "DEVICE_REVOKE", root, device: pairing, reason: 3n, approval: digest }, signer).bytes;
|
|
410
|
+
/** Phase 2 (§5.3 step 6): `DEVICE_GRANT {R, chain_B, 2: J}` on the chain's head. */
|
|
411
|
+
export const approvedGrantRecord = (chain, signer, root, device, pairing) => signControlRecord(next(chain), { type: "DEVICE_GRANT", root, chain: device, pairingDevice: pairing }, signer).bytes;
|
|
412
|
+
/** §5.3 step 9: `DEVICE_REVOKE {R, J, 0}` on the chain's head. */
|
|
413
|
+
export const cancelRecord = (chain, signer, root, pairing) => signControlRecord(next(chain), { type: "DEVICE_REVOKE", root, device: pairing, reason: 0n }, signer).bytes;
|
|
414
|
+
/** Resumes a journaled approval from its next phase (§9.1: "If A crashes after step 5 …"). */
|
|
415
|
+
export async function resumeApproval(o, operation) {
|
|
416
|
+
const journal = new OperationJournal(o.storage);
|
|
417
|
+
const pool = new ChannelPool(o.network);
|
|
418
|
+
const me = o.installation.signer;
|
|
419
|
+
return guarded(pool, async () => {
|
|
420
|
+
let op = await journal.load(operation);
|
|
421
|
+
if (op.status === "superseded")
|
|
422
|
+
return { kind: "superseded", operation };
|
|
423
|
+
const I = resourceId(fromHex(op.intent.identity));
|
|
424
|
+
const R = principalId(fromHex(op.intent.root));
|
|
425
|
+
const J = principalId(fromHex(op.intent.pairingDevice));
|
|
426
|
+
const leaf = payloadFromJson(op.intent.payload);
|
|
427
|
+
const digest = hash32(fromHex(op.intent.digest));
|
|
428
|
+
if (!bytesEqual(deviceCertificatePayloadDigest(leaf), digest))
|
|
429
|
+
throw new Error("the journaled payload does not match its digest");
|
|
430
|
+
const channel = await pool.get(me, op.intent.url);
|
|
431
|
+
// Phase 1: consume J with the digest (§5.3 step 5).
|
|
432
|
+
let consume;
|
|
433
|
+
try {
|
|
434
|
+
consume = await runControlPhase(o.storage, channel, journal, operation, {
|
|
435
|
+
id: "consume",
|
|
436
|
+
kind: "consume",
|
|
437
|
+
resource: I,
|
|
438
|
+
facts: { digest: toHex(digest) },
|
|
439
|
+
classify: (chain) => classifyConsume(chain.state, R, J, digest),
|
|
440
|
+
build: (chain) => {
|
|
441
|
+
// The proposer re-checks its own authority on the new head (§5.3 step 5).
|
|
442
|
+
const f = checkIssuance({ chain, issuer: me, leaf, pairing: J });
|
|
443
|
+
if (!f.feasible)
|
|
444
|
+
throw new Infeasible(f.reason);
|
|
445
|
+
return consumeRecord(chain, me, R, J, digest);
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
catch (e) {
|
|
450
|
+
if (!(e instanceof Infeasible))
|
|
451
|
+
throw e;
|
|
452
|
+
await journal.setStatus(operation, "abandoned");
|
|
453
|
+
return { kind: "infeasible", reason: e.message };
|
|
454
|
+
}
|
|
455
|
+
if (consume.state === "superseded") {
|
|
456
|
+
await journal.setStatus(operation, "superseded");
|
|
457
|
+
return { kind: "superseded", operation };
|
|
458
|
+
}
|
|
459
|
+
if (consume.state === "refused") {
|
|
460
|
+
await journal.setStatus(operation, "abandoned");
|
|
461
|
+
return { kind: "refused", operation, code: consume.code ?? "refused" };
|
|
462
|
+
}
|
|
463
|
+
// Phase 2: sign exactly the journaled payload and register (§5.3 step 6).
|
|
464
|
+
const chainB = signChain(consume.chain.state, me, leaf);
|
|
465
|
+
const register = await runControlPhase(o.storage, channel, journal, operation, {
|
|
466
|
+
id: "register",
|
|
467
|
+
kind: "register",
|
|
468
|
+
resource: I,
|
|
469
|
+
classify: (chain, isOwn) => classifyRegister(chain, {
|
|
470
|
+
root: R,
|
|
471
|
+
chain: chainB,
|
|
472
|
+
pairingPhase2: true,
|
|
473
|
+
isOwnRecord: isOwn,
|
|
474
|
+
}),
|
|
475
|
+
build: (chain) => approvedGrantRecord(chain, me, R, chainB, J),
|
|
476
|
+
});
|
|
477
|
+
if (register.state === "superseded") {
|
|
478
|
+
await journal.setStatus(operation, "superseded");
|
|
479
|
+
return { kind: "superseded", operation };
|
|
480
|
+
}
|
|
481
|
+
if (register.state === "refused") {
|
|
482
|
+
// Rule 8: the approval stays consumed; propose the same chain after a revocation.
|
|
483
|
+
if (register.code === "AUTHORIZATION_FAILED") {
|
|
484
|
+
const p = phaseOf(await journal.load(operation), "register");
|
|
485
|
+
if (p !== undefined)
|
|
486
|
+
await journal.putPhase(operation, { ...p, state: "pending" });
|
|
487
|
+
return { kind: "limit", operation };
|
|
488
|
+
}
|
|
489
|
+
await journal.setStatus(operation, "abandoned");
|
|
490
|
+
return { kind: "refused", operation, code: register.code ?? "refused" };
|
|
491
|
+
}
|
|
492
|
+
// Step 6: the device entry, and the request removed, in one change by A.
|
|
493
|
+
op = await journal.load(operation);
|
|
494
|
+
const key = op.intent.requestKey;
|
|
495
|
+
const deviceEntry = {
|
|
496
|
+
label: op.intent.label,
|
|
497
|
+
kind: (leaf.kind === DEVICE_KIND.SERVICE ? 1 : 0),
|
|
498
|
+
...(op.intent.platform === undefined ? {} : { platform: op.intent.platform }),
|
|
499
|
+
...(op.intent.addedAt === undefined ? {} : { added_at: op.intent.addedAt }),
|
|
500
|
+
};
|
|
501
|
+
// A unit refused as stale is written again in the current epoch (W §19.1);
|
|
502
|
+
// another refusal is reported, and a resumed approval writes it again.
|
|
503
|
+
const document = await runUnitPhase(channel, journal, operation, {
|
|
504
|
+
id: "document",
|
|
505
|
+
resource: I,
|
|
506
|
+
rebuildOn: ["STALE_DATA_EPOCH"],
|
|
507
|
+
rebuildRefused: true,
|
|
508
|
+
build: async () => {
|
|
509
|
+
const { chain, doc, load } = await openIdentity(o, channel, I);
|
|
510
|
+
return sealIdentityUnit({
|
|
511
|
+
storage: o.storage,
|
|
512
|
+
chain,
|
|
513
|
+
deks: await fetchDeks(o.storage, o.secrets, channel, chain, o.installation),
|
|
514
|
+
signer: me,
|
|
515
|
+
doc,
|
|
516
|
+
latest: load.latest,
|
|
517
|
+
intents: [
|
|
518
|
+
{ intent: "device.put", key, entry: deviceEntry },
|
|
519
|
+
...(doc.requests().has(key) ? [{ intent: "request.delete", key }] : []),
|
|
520
|
+
],
|
|
521
|
+
});
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
// Step 7: every epoch of I to a personal B, each recorded (§9.5).
|
|
525
|
+
const chain = await syncChain(o.storage, channel, I);
|
|
526
|
+
if (leaf.kind === DEVICE_KIND.PERSONAL)
|
|
527
|
+
await deliverKeys({
|
|
528
|
+
channel,
|
|
529
|
+
ledger: new PackageLedger(o.storage),
|
|
530
|
+
chain,
|
|
531
|
+
sender: me,
|
|
532
|
+
recipients: [leaf.device],
|
|
533
|
+
deks: await fetchDeks(o.storage, o.secrets, channel, chain, o.installation),
|
|
534
|
+
});
|
|
535
|
+
// Step 9: J, consumed, was a reader of I: the rotation policy of §8.4 applies to I.
|
|
536
|
+
const rotation = await rotateIfExposed({ ...o, channel, operation, resource: I });
|
|
537
|
+
// Step 10: register B in the roster (or the approved scope).
|
|
538
|
+
const { doc } = await openIdentity(o, channel, I);
|
|
539
|
+
const targets = [...doc.roster()]
|
|
540
|
+
.filter(([, e]) => e.archived !== true)
|
|
541
|
+
.map(([ref, e]) => ({ resource: resourceId(fromBase64url(ref)), url: e.coordinator }))
|
|
542
|
+
.filter((t) => !bytesEqual(t.resource, I) &&
|
|
543
|
+
(leaf.kind === DEVICE_KIND.PERSONAL ||
|
|
544
|
+
leaf.scope.some((s) => bytesEqual(s, t.resource))));
|
|
545
|
+
const registered = await registerDevice({ ...o, chain: chainB, targets });
|
|
546
|
+
await journal.updateIntent(operation, { registration: registered.operation });
|
|
547
|
+
// The session is consumed either way; the approval stays open until its document is written.
|
|
548
|
+
await journal.setStatus(op.intent.session, "done");
|
|
549
|
+
const approved = {
|
|
550
|
+
operation,
|
|
551
|
+
device: principalId(leaf.device.principalId),
|
|
552
|
+
chain: chainB,
|
|
553
|
+
registrations: registered.results,
|
|
554
|
+
rotation,
|
|
555
|
+
};
|
|
556
|
+
if (document.state === "refused")
|
|
557
|
+
return { kind: "document-incomplete", ...approved, code: document.code ?? "refused" };
|
|
558
|
+
await journal.setStatus(operation, "done");
|
|
559
|
+
return { kind: "approved", ...approved };
|
|
560
|
+
}, (reason) => ({ kind: "unavailable", operation, reason }), () => ({ kind: "conflict" }));
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* §5.3 step 9 (rejection, timeout, cancel): `DEVICE_REVOKE {R, J, 0}`.
|
|
564
|
+
* With `reject`, the request entry is deleted first (step 4: a request the
|
|
565
|
+
* human does not confirm is rejected).
|
|
566
|
+
*/
|
|
567
|
+
export async function cancelPairing(o) {
|
|
568
|
+
const { I, R, J, url } = await readSession(o, o.session);
|
|
569
|
+
const journal = new OperationJournal(o.storage);
|
|
570
|
+
const pool = new ChannelPool(o.network);
|
|
571
|
+
const me = o.installation.signer;
|
|
572
|
+
return guarded(pool, async () => {
|
|
573
|
+
const channel = await pool.get(me, url);
|
|
574
|
+
let rejected;
|
|
575
|
+
if (o.reject !== undefined) {
|
|
576
|
+
const key = o.reject;
|
|
577
|
+
rejected = await runUnitPhase(channel, journal, o.session, {
|
|
578
|
+
id: `reject:${key}`,
|
|
579
|
+
resource: I,
|
|
580
|
+
rebuildOn: ["STALE_DATA_EPOCH"],
|
|
581
|
+
rebuildRefused: true,
|
|
582
|
+
build: async () => {
|
|
583
|
+
const { chain, doc, load } = await openIdentity(o, channel, I);
|
|
584
|
+
return sealIdentityUnit({
|
|
585
|
+
storage: o.storage,
|
|
586
|
+
chain,
|
|
587
|
+
deks: await fetchDeks(o.storage, o.secrets, channel, chain, o.installation),
|
|
588
|
+
signer: me,
|
|
589
|
+
doc,
|
|
590
|
+
latest: load.latest,
|
|
591
|
+
intents: [{ intent: "request.delete", key }],
|
|
592
|
+
});
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
const cancel = await runControlPhase(o.storage, channel, journal, o.session, {
|
|
597
|
+
id: "cancel",
|
|
598
|
+
kind: "cancel",
|
|
599
|
+
resource: I,
|
|
600
|
+
classify: (chain) => classifyCancel(chain.state, R, J),
|
|
601
|
+
build: (chain) => cancelRecord(chain, me, R, J),
|
|
602
|
+
});
|
|
603
|
+
if (cancel.state === "superseded")
|
|
604
|
+
return { kind: "consumed" };
|
|
605
|
+
if (cancel.state === "refused")
|
|
606
|
+
return { kind: "refused", code: cancel.code ?? "refused" };
|
|
607
|
+
await o.secrets.delete(pairingSecretRef(J));
|
|
608
|
+
// A cancelled J was a reader of I: the rotation policy applies (§5.3 step 9, §8.4).
|
|
609
|
+
const rotation = await rotateIfExposed({ ...o, channel, operation: o.session, resource: I });
|
|
610
|
+
if (rejected?.state === "refused")
|
|
611
|
+
return { kind: "document-incomplete", rotation, code: rejected.code ?? "refused" };
|
|
612
|
+
await journal.setStatus(o.session, "abandoned");
|
|
613
|
+
return { kind: "cancelled", rotation };
|
|
614
|
+
}, (reason) => ({ kind: "unavailable", reason }), () => ({ kind: "conflict" }));
|
|
615
|
+
}
|
|
616
|
+
/** Open pairing sessions of this installation, oldest first. */
|
|
617
|
+
export async function openPairingSessions(o) {
|
|
618
|
+
return (await new OperationJournal(o.storage).list({ kind: "pairing-session", status: "open" })).map((op) => ({
|
|
619
|
+
session: op.id,
|
|
620
|
+
identity: resourceId(fromHex(op.intent.identity)),
|
|
621
|
+
pairingDevice: principalId(fromHex(op.intent.pairingDevice)),
|
|
622
|
+
}));
|
|
623
|
+
}
|
|
624
|
+
//# sourceMappingURL=pairing.js.map
|