@hediet/linkrpc-hub 0.0.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/README.md +101 -0
- package/dist/chunks/config-BCvkg7jv.d.ts +566 -0
- package/dist/chunks/configFile-y6Phntqu.d.ts +9 -0
- package/dist/chunks/connectionTokenBinder.interfaces-B-beCg06.js +40 -0
- package/dist/chunks/connectionTokenBinder.interfaces-B-beCg06.js.map +1 -0
- package/dist/chunks/connectionTokenBinder.interfaces-BhzQ1DTS.d.ts +36 -0
- package/dist/chunks/hubConnectionAcceptor-B5-8JsFY.js +2768 -0
- package/dist/chunks/hubConnectionAcceptor-B5-8JsFY.js.map +1 -0
- package/dist/chunks/hubConnectionAcceptor-BwydvFa4.d.ts +1560 -0
- package/dist/chunks/index-CLIUrV88.d.ts +481 -0
- package/dist/chunks/node-CTXsQ6oa.js +460 -0
- package/dist/chunks/node-CTXsQ6oa.js.map +1 -0
- package/dist/chunks/nodeTransit-CWeFnbwt.js +444 -0
- package/dist/chunks/nodeTransit-CWeFnbwt.js.map +1 -0
- package/dist/chunks/nodeTransit-cmtZgdpW.d.ts +226 -0
- package/dist/chunks/runHub-P3YUwwdv.js +1797 -0
- package/dist/chunks/runHub-P3YUwwdv.js.map +1 -0
- package/dist/chunks/server-BAxchQhy.js +1368 -0
- package/dist/chunks/server-BAxchQhy.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +38 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +305 -0
- package/dist/config.js.map +1 -0
- package/dist/configFile.d.ts +2 -0
- package/dist/configFile.js +27 -0
- package/dist/configFile.js.map +1 -0
- package/dist/engine/runHub.d.ts +42 -0
- package/dist/engine/runHub.js +2 -0
- package/dist/hub/server/client.d.ts +2 -0
- package/dist/hub/server/client.js +2 -0
- package/dist/hub/server/connectionTokenBinder.d.ts +2 -0
- package/dist/hub/server/connectionTokenBinder.js +2 -0
- package/dist/hub/server/index.d.ts +5 -0
- package/dist/hub/server/index.js +5 -0
- package/dist/hub/server/node/index.d.ts +218 -0
- package/dist/hub/server/node/index.js +2 -0
- package/dist/hub/server/transit.d.ts +2 -0
- package/dist/hub/server/transit.js +2 -0
- package/dist/index.d.ts +512 -0
- package/dist/index.js +309 -0
- package/dist/index.js.map +1 -0
- package/dist/serve.d.ts +14 -0
- package/dist/serve.js +31 -0
- package/dist/serve.js.map +1 -0
- package/dist/spawn.d.ts +12 -0
- package/dist/spawn.js +25 -0
- package/dist/spawn.js.map +1 -0
- package/package.json +83 -0
|
@@ -0,0 +1,1368 @@
|
|
|
1
|
+
import "./hubConnectionAcceptor-B5-8JsFY.js";
|
|
2
|
+
import "./nodeTransit-CWeFnbwt.js";
|
|
3
|
+
import "./connectionTokenBinder.interfaces-B-beCg06.js";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { InMemoryManagedIdentity, LinkRpcConnection, base64UrlToBytes, bytesToBase64Url, crypto, jcsCanonicalize, jcsCanonicalizeBytes, keyIdForPrincipal, publicKeyForKeyId, signCapability } from "@hediet/linkrpc";
|
|
6
|
+
import { ROOT_SERVICE_ID, hubAccessInterface, isValidServiceId, mapTransport, walkHubDetailed } from "@hediet/linkrpc/hub/common";
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
+
import * as fs from "node:fs/promises";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
//#region src/hub/server/hubRegisterServiceId.ts
|
|
11
|
+
/**
|
|
12
|
+
* Register an **in-process** service on `hub` under `serviceId`, returning a
|
|
13
|
+
* {@link RegisteredServiceId} whose `connection` serves its interfaces.
|
|
14
|
+
*
|
|
15
|
+
* This is the hubv2 replacement for the v1 `hub.attachParticipant(pair.a)` +
|
|
16
|
+
* `LinkRpcConnection.fromTransport(pair.b)` + `enableReflection({ serviceId })`
|
|
17
|
+
* dance that every built-in extension service performed. It:
|
|
18
|
+
*
|
|
19
|
+
* 1. attaches an in-memory link to the hub and claims `serviceId` on it, so
|
|
20
|
+
* the hub routes every fully-qualified `serviceId::…` call to this service;
|
|
21
|
+
* 2. exposes a {@link LinkRpcConnection} whose registered interfaces answer
|
|
22
|
+
* those calls (register them with `{ serviceId }`); and
|
|
23
|
+
* 3. enables reflection under `serviceId` so the hub's aggregating
|
|
24
|
+
* `directory::list` surfaces this service.
|
|
25
|
+
*
|
|
26
|
+
* Disposing the returned handle (`svc.dispose()`) closes the connection and
|
|
27
|
+
* detaches the link, releasing the claimed prefix.
|
|
28
|
+
*
|
|
29
|
+
* Unlike an accepted *participant* (which gets a {@link RootOverlay} and only
|
|
30
|
+
* reaches the hub through its uplink), an in-process service is attached
|
|
31
|
+
* directly to the hub as a prefix owner. It is fully trusted — no provenance,
|
|
32
|
+
* identity, or forwarded-call gating applies to traffic it receives.
|
|
33
|
+
*/
|
|
34
|
+
function hubRegisterServiceId(hub, serviceId) {
|
|
35
|
+
const link = hub.attachOut();
|
|
36
|
+
link.addPrefixRoute(serviceId);
|
|
37
|
+
const connection = LinkRpcConnection.fromTransport(link.transport);
|
|
38
|
+
connection.enableReflection({ serviceId });
|
|
39
|
+
return new RegisteredServiceId(connection, link);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Handle to an in-process service registered on a {@link Hub} via
|
|
43
|
+
* {@link hubRegisterServiceId}. Owns both the {@link LinkRpcConnection} the
|
|
44
|
+
* service serves its interfaces on and the hub {@link AttachedLink} that routes
|
|
45
|
+
* traffic to it. {@link dispose} closes the connection and detaches the link
|
|
46
|
+
* (releasing the claimed prefix) — wire it into the owning feature's disposal.
|
|
47
|
+
*/
|
|
48
|
+
var RegisteredServiceId = class {
|
|
49
|
+
connection;
|
|
50
|
+
_link;
|
|
51
|
+
constructor(connection, _link) {
|
|
52
|
+
this.connection = connection;
|
|
53
|
+
this._link = _link;
|
|
54
|
+
}
|
|
55
|
+
dispose() {
|
|
56
|
+
this.connection.close();
|
|
57
|
+
this._link.dispose();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/hub/server/accessCandidates.ts
|
|
62
|
+
/**
|
|
63
|
+
* Fetch the full interface directory by recursively walking the hub's
|
|
64
|
+
* **referral** directory graph. {@link walkHubDetailed} follows explicit
|
|
65
|
+
* `hubrpc.directory` listings to collect leaf interfaces (folding transitive
|
|
66
|
+
* root-node-id requirements onto descendants along the way). Routing claims do
|
|
67
|
+
* not create referrals. This is the v2 replacement for the hub-internal
|
|
68
|
+
* `_fullDirectory()`.
|
|
69
|
+
*/
|
|
70
|
+
async function fetchFullDirectory(connection, hubServiceId = "hub") {
|
|
71
|
+
const { listings } = await walkHubDetailed(connection.channel, { rootTarget: hubServiceId });
|
|
72
|
+
return listings.map((it) => ({
|
|
73
|
+
serviceId: it.serviceId,
|
|
74
|
+
interfaceId: it.interfaceId,
|
|
75
|
+
hash: it.hash,
|
|
76
|
+
...it.serviceDescription !== void 0 ? { serviceDescription: it.serviceDescription } : {},
|
|
77
|
+
...it.rootPrincipalSets !== void 0 ? { rootPrincipalSets: it.rootPrincipalSets } : {}
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Resolve every dependency slot against a directory snapshot. Pure port of
|
|
82
|
+
* the v1 hub's per-request candidate resolution loop + `_candidatesForSlot`.
|
|
83
|
+
*/
|
|
84
|
+
function resolveAccessCandidates(slots, directory) {
|
|
85
|
+
const dependencies = {};
|
|
86
|
+
const noCandidateSlots = [];
|
|
87
|
+
for (const [slotId, slot] of Object.entries(slots)) {
|
|
88
|
+
const candidates = candidatesForSlot(slot, directory);
|
|
89
|
+
dependencies[slotId] = {
|
|
90
|
+
request: slot,
|
|
91
|
+
candidates
|
|
92
|
+
};
|
|
93
|
+
if (candidates.length === 0) noCandidateSlots.push(slotId);
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
dependencies,
|
|
97
|
+
noCandidateSlots
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Match one slot against the directory snapshot. A service is a candidate iff
|
|
102
|
+
* it satisfies every `required` interface (by id, and by hash when pinned).
|
|
103
|
+
*/
|
|
104
|
+
function candidatesForSlot(slot, directory) {
|
|
105
|
+
const byPrefix = /* @__PURE__ */ new Map();
|
|
106
|
+
for (const it of directory) {
|
|
107
|
+
let entry = byPrefix.get(it.serviceId);
|
|
108
|
+
if (!entry) {
|
|
109
|
+
entry = { interfaces: [] };
|
|
110
|
+
byPrefix.set(it.serviceId, entry);
|
|
111
|
+
}
|
|
112
|
+
entry.interfaces.push({
|
|
113
|
+
id: it.interfaceId,
|
|
114
|
+
hash: it.hash
|
|
115
|
+
});
|
|
116
|
+
if (entry.serviceDescription === void 0 && it.serviceDescription !== void 0) entry.serviceDescription = it.serviceDescription;
|
|
117
|
+
}
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const [prefix, entry] of byPrefix) {
|
|
120
|
+
const ifaces = entry.interfaces;
|
|
121
|
+
const satisfied = [];
|
|
122
|
+
const unsatisfied = [];
|
|
123
|
+
let missingRequired = false;
|
|
124
|
+
for (const req of slot.interfaces) if (ifaces.some((i) => i.id === req.id && (req.hash === void 0 || i.hash === req.hash))) satisfied.push(req);
|
|
125
|
+
else {
|
|
126
|
+
unsatisfied.push(req);
|
|
127
|
+
if (req.required) missingRequired = true;
|
|
128
|
+
}
|
|
129
|
+
if (missingRequired) continue;
|
|
130
|
+
out.push({
|
|
131
|
+
serviceId: prefix,
|
|
132
|
+
...entry.serviceDescription !== void 0 ? { serviceDescription: entry.serviceDescription } : {},
|
|
133
|
+
satisfiedInterfaces: satisfied,
|
|
134
|
+
unsatisfiedInterfaces: unsatisfied
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/hub/server/mintCapability.ts
|
|
141
|
+
/**
|
|
142
|
+
* Admin-issued capability minting for the v2 consent engine.
|
|
143
|
+
*
|
|
144
|
+
* v1 issued capabilities through the hub (`proposeCapability` +
|
|
145
|
+
* `signProposedCapability`). In v2 the hub no longer signs anything — the
|
|
146
|
+
* consent engine owns an admin {@link SigningIdentity} and mints capabilities
|
|
147
|
+
* directly. A {@link SigningIdentity} signs over arbitrary bytes with its
|
|
148
|
+
* Ed25519 key, which is exactly what {@link signCapability} needs, so this is
|
|
149
|
+
* a thin wrapper that assembles a {@link Capability} and signs it.
|
|
150
|
+
*/
|
|
151
|
+
/**
|
|
152
|
+
* Assemble and sign a {@link Capability} with the admin identity.
|
|
153
|
+
*
|
|
154
|
+
* This replaces the v1 `hub.proposeCapability` / `signProposedCapability`
|
|
155
|
+
* pair: the consent engine decides the permissions (e.g. from
|
|
156
|
+
* {@link resolveAccessCandidates} + the user's consent selection) and mints
|
|
157
|
+
* the capability bound to the consumer's PrincipalId as `audience`.
|
|
158
|
+
*/
|
|
159
|
+
async function mintCapability(options) {
|
|
160
|
+
const capability = {
|
|
161
|
+
issuer: options.issuer.publicSigningIdentity.principal,
|
|
162
|
+
audience: options.audience,
|
|
163
|
+
permissions: [...options.permissions],
|
|
164
|
+
nonce: options.nonce ?? randomNonce(),
|
|
165
|
+
...options.expiresAtMs !== void 0 ? { expiresAtMs: options.expiresAtMs } : {},
|
|
166
|
+
...options.parentHash !== void 0 ? { parentHash: options.parentHash } : {}
|
|
167
|
+
};
|
|
168
|
+
return signCapability(capability, options.issuer);
|
|
169
|
+
}
|
|
170
|
+
function randomNonce() {
|
|
171
|
+
const bytes = /* @__PURE__ */ new Uint8Array(16);
|
|
172
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
173
|
+
return bytesToBase64Url(bytes);
|
|
174
|
+
}
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/hub/server/capabilityProposal.ts
|
|
177
|
+
/**
|
|
178
|
+
* Capability proposal issuer — the byte-equality preview mechanism for the v2
|
|
179
|
+
* consent engine, backed by an admin {@link Identity}.
|
|
180
|
+
*
|
|
181
|
+
* In v1 these were hub-core methods (`Hub.proposeCapability` / `signProposal` /
|
|
182
|
+
* `redeemProposal`). In v2 the hub signs nothing, so the consent engine owns an
|
|
183
|
+
* admin identity and runs the proposal protocol itself. The mechanism lets a
|
|
184
|
+
* separate consent UI (a webview shell, reached over RPC) render the *exact*
|
|
185
|
+
* `Capability` that will be signed, then return it verbatim for redemption —
|
|
186
|
+
* with cryptographic guarantees that the displayed bytes equal the signed
|
|
187
|
+
* bytes and that a proposal cannot be replayed or substituted across prompts.
|
|
188
|
+
*
|
|
189
|
+
* Protocol (all host-side, using the admin key):
|
|
190
|
+
* 1. `propose({ audience, permissions, expiresAtMs? })` → an unsigned
|
|
191
|
+
* `Capability` tagged with an internal marker.
|
|
192
|
+
* 2. `signProposal(cap, { bind? })` → a `CapabilityProposal`: the readable
|
|
193
|
+
* `capability` plus an opaque `$hubData` blob (a domain-separated
|
|
194
|
+
* signature over `canonicalJson({ preview: cap, bind? })`). The UI renders
|
|
195
|
+
* `capability` and, on approval, returns the whole proposal verbatim.
|
|
196
|
+
* 3. `redeemProposal(proposal, { expectedBind? })` → a real
|
|
197
|
+
* `SignedCapability`. Verifies the `$hubData` signature, that the preview
|
|
198
|
+
* deep-equals `proposal.capability`, the optional `bind`, and a one-shot
|
|
199
|
+
* nonce; then signs the bare canonical cap so the result is an ordinary
|
|
200
|
+
* bearer token (verifiable by {@link verifyChain}).
|
|
201
|
+
*/
|
|
202
|
+
function isHubProposalData(v) {
|
|
203
|
+
return v !== null && typeof v === "object" && typeof v.payload === "string" && typeof v.signature === "string";
|
|
204
|
+
}
|
|
205
|
+
const _DURATION_TO_MS = {
|
|
206
|
+
once: 3e5,
|
|
207
|
+
shortLived: 3e5,
|
|
208
|
+
longLived: 864e5
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* Convert an access duration into a Unix-milliseconds expiration timestamp, so
|
|
212
|
+
* handlers compute the same `expiresAtMs` the issuer mints with. Returns
|
|
213
|
+
* `undefined` for `persistent` (and any future never-expiring duration), which
|
|
214
|
+
* callers pass straight to {@link CapabilityProposalIssuer.propose}/`mint` —
|
|
215
|
+
* both omit `expiresAtMs` when it is `undefined`, yielding a non-expiring cap.
|
|
216
|
+
* Defaults to the safe short TTL (`shortLived`) when no duration is supplied.
|
|
217
|
+
*/
|
|
218
|
+
function durationToExp(duration) {
|
|
219
|
+
const d = duration ?? "shortLived";
|
|
220
|
+
if (d === "persistent") return;
|
|
221
|
+
return Date.now() + _DURATION_TO_MS[d];
|
|
222
|
+
}
|
|
223
|
+
function canonicalEqual(a, b) {
|
|
224
|
+
try {
|
|
225
|
+
return jcsCanonicalize(a) === jcsCanonicalize(b);
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** Marker proving a `Capability` came from {@link CapabilityProposalIssuer.propose}. */
|
|
231
|
+
const _PROPOSED = Symbol("linkrpc.proposedCapability");
|
|
232
|
+
/**
|
|
233
|
+
* Issues capabilities and capability proposals signed by an admin
|
|
234
|
+
* {@link SigningIdentity}. One instance per hub host; tracks redeemed nonces
|
|
235
|
+
* for one-shot proposal redemption.
|
|
236
|
+
*/
|
|
237
|
+
var CapabilityProposalIssuer = class {
|
|
238
|
+
_issuer;
|
|
239
|
+
_redeemedNonces = /* @__PURE__ */ new Set();
|
|
240
|
+
_nonceCounter = 0;
|
|
241
|
+
constructor(_issuer) {
|
|
242
|
+
this._issuer = _issuer;
|
|
243
|
+
}
|
|
244
|
+
get issuerPrincipalId() {
|
|
245
|
+
return this._issuer.publicSigningIdentity.principal;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Build an unsigned `Capability` the issuer is willing to sign, tagged with
|
|
249
|
+
* an internal marker. {@link signProposal} refuses to sign anything lacking
|
|
250
|
+
* the marker — a guard against a handler fabricating arbitrary caps. The
|
|
251
|
+
* marker is non-enumerable, so it never affects serialization or signing.
|
|
252
|
+
*/
|
|
253
|
+
propose(args) {
|
|
254
|
+
const cap = {
|
|
255
|
+
issuer: this._issuer.publicSigningIdentity.principal,
|
|
256
|
+
audience: args.audience,
|
|
257
|
+
permissions: [...args.permissions],
|
|
258
|
+
nonce: this._nextNonce(),
|
|
259
|
+
...args.expiresAtMs !== void 0 ? { expiresAtMs: args.expiresAtMs } : {}
|
|
260
|
+
};
|
|
261
|
+
Object.defineProperty(cap, _PROPOSED, {
|
|
262
|
+
value: true,
|
|
263
|
+
enumerable: false,
|
|
264
|
+
configurable: false,
|
|
265
|
+
writable: false
|
|
266
|
+
});
|
|
267
|
+
return cap;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Mint a final `SignedCapability` directly from a proposed cap, bypassing
|
|
271
|
+
* the preview round-trip. Use when no UI needs to render the unsigned form.
|
|
272
|
+
*/
|
|
273
|
+
async mint(args) {
|
|
274
|
+
const cap = this.propose(args);
|
|
275
|
+
return this._sign(cap);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Pair a proposed `Capability` with a domain-separated signature over
|
|
279
|
+
* `canonicalJson({ preview: cap, bind? })`. The result is **not** a bearer
|
|
280
|
+
* token — only this issuer can redeem it.
|
|
281
|
+
*/
|
|
282
|
+
async signProposal(cap, opts) {
|
|
283
|
+
if (!cap[_PROPOSED]) throw new Error("signProposal: capability was not produced by propose()");
|
|
284
|
+
const payload = { preview: cap };
|
|
285
|
+
if (opts?.bind !== void 0) payload.bind = opts.bind;
|
|
286
|
+
const previewBytes = jcsCanonicalizeBytes(payload);
|
|
287
|
+
const sig = await this._issuer.sign(previewBytes);
|
|
288
|
+
return {
|
|
289
|
+
capability: cap,
|
|
290
|
+
$hubData: {
|
|
291
|
+
payload: bytesToBase64Url(previewBytes),
|
|
292
|
+
signature: bytesToBase64Url(sig)
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Verify and redeem a {@link CapabilityProposal}, returning a real
|
|
298
|
+
* {@link SignedCapability}. Checks: `$hubData` signature, preview deep-equal
|
|
299
|
+
* to `proposal.capability`, optional `bind` match, and one-shot nonce.
|
|
300
|
+
*/
|
|
301
|
+
async redeemProposal(proposal, opts) {
|
|
302
|
+
if (!isHubProposalData(proposal.$hubData)) throw new Error("redeemProposal: malformed $hubData");
|
|
303
|
+
const payloadBytes = base64UrlToBytes(proposal.$hubData.payload);
|
|
304
|
+
const sigBytes = base64UrlToBytes(proposal.$hubData.signature);
|
|
305
|
+
const pubKey = publicKeyForKeyId(keyIdForPrincipal(this._issuer.publicSigningIdentity.principal));
|
|
306
|
+
if (!await crypto.verify(pubKey, payloadBytes, sigBytes)) throw new Error("redeemProposal: $hubData.signature does not verify");
|
|
307
|
+
let parsed;
|
|
308
|
+
try {
|
|
309
|
+
parsed = JSON.parse(new TextDecoder().decode(payloadBytes));
|
|
310
|
+
} catch (e) {
|
|
311
|
+
throw new Error(`redeemProposal: malformed payload (${e.message})`);
|
|
312
|
+
}
|
|
313
|
+
if (!canonicalEqual(parsed.preview, proposal.capability)) throw new Error("redeemProposal: proposal.capability does not match the signed preview");
|
|
314
|
+
const hasBind = parsed.bind !== void 0;
|
|
315
|
+
if (hasBind !== (opts?.expectedBind !== void 0)) throw new Error(hasBind ? "redeemProposal: proposal is bound but no expectedBind supplied" : "redeemProposal: expectedBind supplied but proposal has no bind");
|
|
316
|
+
if (hasBind && !canonicalEqual(parsed.bind, opts.expectedBind)) throw new Error("redeemProposal: bind does not match expectedBind");
|
|
317
|
+
const cap = parsed.preview;
|
|
318
|
+
if (this._redeemedNonces.has(cap.nonce)) throw new Error("redeemProposal: proposal already redeemed");
|
|
319
|
+
this._redeemedNonces.add(cap.nonce);
|
|
320
|
+
return this._sign(cap);
|
|
321
|
+
}
|
|
322
|
+
_sign(cap) {
|
|
323
|
+
return signCapability(cap, this._issuer);
|
|
324
|
+
}
|
|
325
|
+
_nextNonce() {
|
|
326
|
+
const bytes = /* @__PURE__ */ new Uint8Array(16);
|
|
327
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
328
|
+
this._nonceCounter += 1;
|
|
329
|
+
return `${bytesToBase64Url(bytes)}-${this._nonceCounter}`;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
//#endregion
|
|
333
|
+
//#region src/hub/server/hubAccessService.ts
|
|
334
|
+
/**
|
|
335
|
+
* Install `hubAccess::{request,extend,requestAccess}` at the **connection
|
|
336
|
+
* root** of a participant's overlay (root form, no serviceId prefix). The root
|
|
337
|
+
* is never forwarded, so the consent front door is reached directly and needs
|
|
338
|
+
* no capability of its own.
|
|
339
|
+
*
|
|
340
|
+
* The capability `audience` is taken **directly from `params.consumer.principal`**
|
|
341
|
+
* — no longer derived from a verified call signer. A cap minted for a nodeId is
|
|
342
|
+
* only usable by the holder of that node's key (enforced at call time by
|
|
343
|
+
* `permits`, which checks `audience === call.signer`), so a self-asserted
|
|
344
|
+
* audience grants no usable authority to a caller who does not hold the key.
|
|
345
|
+
*/
|
|
346
|
+
function registerHubAccessService(connection, options) {
|
|
347
|
+
const { handlers } = options;
|
|
348
|
+
connection.register(hubAccessInterface, {
|
|
349
|
+
request: async (params) => {
|
|
350
|
+
const consumerPrincipalId = params.consumer.principal;
|
|
351
|
+
const slots = {};
|
|
352
|
+
for (const [slotId, raw] of Object.entries(params.dependencies)) slots[slotId] = normalizeSlot(raw);
|
|
353
|
+
const { dependencies, noCandidateSlots } = resolveAccessCandidates(slots, [...await options.fetchDirectory()]);
|
|
354
|
+
if (noCandidateSlots.length > 0) return {
|
|
355
|
+
status: "noCandidates",
|
|
356
|
+
slots: [...noCandidateSlots]
|
|
357
|
+
};
|
|
358
|
+
const decision = await handlers.onAccessRequest({
|
|
359
|
+
consumer: params.consumer,
|
|
360
|
+
consumerPrincipalId,
|
|
361
|
+
dependencies,
|
|
362
|
+
duration: params.duration
|
|
363
|
+
});
|
|
364
|
+
if (!decision.granted) return decision.reason !== void 0 ? {
|
|
365
|
+
status: "denied",
|
|
366
|
+
reason: decision.reason
|
|
367
|
+
} : { status: "denied" };
|
|
368
|
+
const responseSlots = {};
|
|
369
|
+
for (const [slotId, binding] of Object.entries(decision.resolvedSlots)) {
|
|
370
|
+
const resolved = dependencies[slotId];
|
|
371
|
+
if (!resolved) continue;
|
|
372
|
+
if (!resolved.candidates.find((c) => c.serviceId === binding.serviceId)) continue;
|
|
373
|
+
responseSlots[slotId] = {
|
|
374
|
+
serviceId: binding.serviceId,
|
|
375
|
+
satisfiedInterfaces: [...binding.interfaces]
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
status: "granted",
|
|
380
|
+
slots: responseSlots,
|
|
381
|
+
capabilities: [...decision.capabilities]
|
|
382
|
+
};
|
|
383
|
+
},
|
|
384
|
+
extend: async (params) => {
|
|
385
|
+
const consumerPrincipalId = params.consumer.principal;
|
|
386
|
+
const added = params.added.map((a) => ({
|
|
387
|
+
interfaceId: a.interfaceId,
|
|
388
|
+
member: a.member,
|
|
389
|
+
required: a.required !== false
|
|
390
|
+
}));
|
|
391
|
+
const decision = await handlers.onAccessExtend({
|
|
392
|
+
consumer: params.consumer,
|
|
393
|
+
consumerPrincipalId,
|
|
394
|
+
serviceId: params.serviceId,
|
|
395
|
+
added,
|
|
396
|
+
duration: params.duration
|
|
397
|
+
});
|
|
398
|
+
if (!decision.granted) return decision.reason !== void 0 ? {
|
|
399
|
+
status: "denied",
|
|
400
|
+
reason: decision.reason
|
|
401
|
+
} : { status: "denied" };
|
|
402
|
+
const grantedMembers = decision.grantedMembers.map((g) => ({
|
|
403
|
+
interfaceId: g.interfaceId,
|
|
404
|
+
member: g.member
|
|
405
|
+
}));
|
|
406
|
+
return decision.capabilities !== void 0 ? {
|
|
407
|
+
status: "granted",
|
|
408
|
+
serviceId: decision.serviceId,
|
|
409
|
+
granted: grantedMembers,
|
|
410
|
+
capabilities: [...decision.capabilities]
|
|
411
|
+
} : {
|
|
412
|
+
status: "granted",
|
|
413
|
+
serviceId: decision.serviceId,
|
|
414
|
+
granted: grantedMembers
|
|
415
|
+
};
|
|
416
|
+
},
|
|
417
|
+
requestAccess: async (params) => {
|
|
418
|
+
const consumerPrincipalId = params.consumer.principal;
|
|
419
|
+
const decision = await handlers.onAccessRequestDirect({
|
|
420
|
+
consumer: params.consumer,
|
|
421
|
+
consumerPrincipalId,
|
|
422
|
+
permissions: params.permissions,
|
|
423
|
+
duration: params.duration
|
|
424
|
+
});
|
|
425
|
+
if (!decision.granted) return decision.reason !== void 0 ? {
|
|
426
|
+
status: "denied",
|
|
427
|
+
reason: decision.reason
|
|
428
|
+
} : { status: "denied" };
|
|
429
|
+
return {
|
|
430
|
+
status: "granted",
|
|
431
|
+
capabilities: [...decision.capabilities]
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
function normalizeSlot(raw) {
|
|
437
|
+
return {
|
|
438
|
+
interfaces: raw.interfaces.map((i) => ({
|
|
439
|
+
id: i.id,
|
|
440
|
+
required: i.required !== false,
|
|
441
|
+
...i.hash !== void 0 ? { hash: i.hash } : {}
|
|
442
|
+
})),
|
|
443
|
+
members: (raw.members ?? []).map((m) => ({
|
|
444
|
+
interfaceId: m.interfaceId,
|
|
445
|
+
member: m.member,
|
|
446
|
+
required: m.required !== false
|
|
447
|
+
}))
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/hub/server/tokenIdentityStore.ts
|
|
452
|
+
/**
|
|
453
|
+
* The hub's connection-token mint + redemption store, shared between the
|
|
454
|
+
* `connectionTokenBinder::bindConnectionToken` front door (mint) and a
|
|
455
|
+
* `managedIdentity: { mode: "fromToken" }` / `grantedServiceId: { mode:
|
|
456
|
+
* "fromToken" }` listener (redeem).
|
|
457
|
+
*
|
|
458
|
+
* Tokens are random, time-boxed and **single-use**: {@link redeem} consumes the
|
|
459
|
+
* entry, so a token can bind exactly one connection and cannot be replayed once
|
|
460
|
+
* used or expired. Expired entries are pruned lazily on access.
|
|
461
|
+
*/
|
|
462
|
+
var TokenIdentityStore = class {
|
|
463
|
+
_byToken = /* @__PURE__ */ new Map();
|
|
464
|
+
_defaultTtlMs;
|
|
465
|
+
_now;
|
|
466
|
+
_generate;
|
|
467
|
+
constructor(options = {}) {
|
|
468
|
+
this._defaultTtlMs = options.defaultTtlMs ?? 3e4;
|
|
469
|
+
this._now = options.now ?? (() => Date.now());
|
|
470
|
+
this._generate = options.generateToken ?? (() => randomBytes(32).toString("base64url"));
|
|
471
|
+
}
|
|
472
|
+
/** Mint a fresh single-use token bound to `binding`, valid for `ttlMs`. */
|
|
473
|
+
mint(binding = {}, ttlMs) {
|
|
474
|
+
this._prune();
|
|
475
|
+
const token = this._generate();
|
|
476
|
+
const expiresAt = this._now() + (ttlMs ?? this._defaultTtlMs);
|
|
477
|
+
this._byToken.set(token, {
|
|
478
|
+
identitySlot: binding.identitySlot,
|
|
479
|
+
grantedServiceIdNamespace: binding.grantedServiceIdNamespace,
|
|
480
|
+
expiresAt
|
|
481
|
+
});
|
|
482
|
+
return {
|
|
483
|
+
token,
|
|
484
|
+
expiresAt
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Non-consuming existence check for the `hubrpc::initialize` token gate.
|
|
489
|
+
* Returns `true` iff the token is live (known and unexpired). Single-use is
|
|
490
|
+
* enforced separately by {@link redeem}.
|
|
491
|
+
*/
|
|
492
|
+
peek(token) {
|
|
493
|
+
if (token === void 0) return false;
|
|
494
|
+
const entry = this._byToken.get(token);
|
|
495
|
+
if (entry === void 0) return false;
|
|
496
|
+
if (entry.expiresAt <= this._now()) {
|
|
497
|
+
this._byToken.delete(token);
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Consume `token`, returning its binding exactly once. Returns `undefined`
|
|
504
|
+
* for an unknown, expired or already-redeemed token (the caller should drop
|
|
505
|
+
* the connection in that case).
|
|
506
|
+
*/
|
|
507
|
+
redeem(token) {
|
|
508
|
+
if (token === void 0) return void 0;
|
|
509
|
+
const entry = this._byToken.get(token);
|
|
510
|
+
if (entry === void 0) return void 0;
|
|
511
|
+
this._byToken.delete(token);
|
|
512
|
+
if (entry.expiresAt <= this._now()) return void 0;
|
|
513
|
+
const binding = {};
|
|
514
|
+
if (entry.identitySlot !== void 0) binding.identitySlot = entry.identitySlot;
|
|
515
|
+
if (entry.grantedServiceIdNamespace !== void 0) binding.grantedServiceIdNamespace = entry.grantedServiceIdNamespace;
|
|
516
|
+
return binding;
|
|
517
|
+
}
|
|
518
|
+
/** Live (unexpired, unredeemed) token count — primarily for tests/metrics. */
|
|
519
|
+
get size() {
|
|
520
|
+
this._prune();
|
|
521
|
+
return this._byToken.size;
|
|
522
|
+
}
|
|
523
|
+
_prune() {
|
|
524
|
+
const now = this._now();
|
|
525
|
+
for (const [token, entry] of this._byToken) if (entry.expiresAt <= now) this._byToken.delete(token);
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
//#endregion
|
|
529
|
+
//#region src/hub/server/provenance.ts
|
|
530
|
+
/**
|
|
531
|
+
* Annotate every transport from `source` with the provenance `provider`
|
|
532
|
+
* resolves for it. On provider error/timeout — or when the returned
|
|
533
|
+
* `identityKey` does not start with `` `${provider.identityNamespace}/` `` —
|
|
534
|
+
* the transport is forwarded with `provenance: undefined`, unless
|
|
535
|
+
* {@link WithProvenanceOptions.requireProvenance} is set, in which case it is
|
|
536
|
+
* disposed and dropped.
|
|
537
|
+
*
|
|
538
|
+
* This is the one concrete {@link mapTransport} the hub ships: it is where
|
|
539
|
+
* `net.Socket` (or any backend handle) is read for attestation and nowhere
|
|
540
|
+
* else.
|
|
541
|
+
*/
|
|
542
|
+
function withProvenance(source, provider, options = {}) {
|
|
543
|
+
const namespacePrefix = `${provider.identityNamespace}/`;
|
|
544
|
+
return mapTransport(source, async (t) => {
|
|
545
|
+
const controller = new AbortController();
|
|
546
|
+
t.onDidClose(() => controller.abort());
|
|
547
|
+
let timer;
|
|
548
|
+
if (options.resolveTimeoutMs !== void 0) timer = setTimeout(() => controller.abort(), options.resolveTimeoutMs);
|
|
549
|
+
let provenance;
|
|
550
|
+
try {
|
|
551
|
+
const result = await provider.resolve(t, controller.signal);
|
|
552
|
+
if (!("error" in result) && result.identityKey.startsWith(namespacePrefix)) provenance = result;
|
|
553
|
+
} catch {
|
|
554
|
+
provenance = void 0;
|
|
555
|
+
} finally {
|
|
556
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
557
|
+
}
|
|
558
|
+
if (provenance === void 0 && options.requireProvenance) {
|
|
559
|
+
t.dispose();
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
return Object.assign(t, { provenance });
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
//#endregion
|
|
566
|
+
//#region src/hub/server/prefixPolicy.ts
|
|
567
|
+
/**
|
|
568
|
+
* Default {@link PrefixPolicy}: a participant may claim only the prefix(es)
|
|
569
|
+
* {@link PrincipalIdPrefixPolicyOptions.derivePrefixes} grants it. The out-of-box
|
|
570
|
+
* derivation ties the claimable prefix to the peer's attested provenance, so
|
|
571
|
+
* `docker/echo-provider` can serve `docker/echo-provider/*` and nothing can
|
|
572
|
+
* impersonate it.
|
|
573
|
+
*/
|
|
574
|
+
var PrincipalIdPrefixPolicy = class {
|
|
575
|
+
_derive;
|
|
576
|
+
constructor(options = {}) {
|
|
577
|
+
this._derive = options.derivePrefixes ?? defaultDerivePrefixes;
|
|
578
|
+
}
|
|
579
|
+
authorizeClaim(ctx) {
|
|
580
|
+
if (this._derive(ctx).includes(ctx.requestedPrefix)) return { ok: true };
|
|
581
|
+
return {
|
|
582
|
+
ok: false,
|
|
583
|
+
reason: `not authorized to claim '${ctx.requestedPrefix}'`
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
function defaultDerivePrefixes(ctx) {
|
|
588
|
+
const provenance = ctx.transport.provenance;
|
|
589
|
+
if (!provenance) return [];
|
|
590
|
+
const prefix = provenance.identityKey;
|
|
591
|
+
if (prefix === ROOT_SERVICE_ID || !isValidServiceId(prefix)) return [];
|
|
592
|
+
return [prefix];
|
|
593
|
+
}
|
|
594
|
+
//#endregion
|
|
595
|
+
//#region src/hub/server/sqliteIdentityKeystore.ts
|
|
596
|
+
let _DatabaseSync;
|
|
597
|
+
function _getDatabaseSync() {
|
|
598
|
+
if (_DatabaseSync === void 0) _DatabaseSync = createRequire(import.meta.url)("node:sqlite").DatabaseSync;
|
|
599
|
+
return _DatabaseSync;
|
|
600
|
+
}
|
|
601
|
+
const _codec = {
|
|
602
|
+
encode: (_slotId, plaintext) => plaintext,
|
|
603
|
+
decode: (_slotId, stored) => stored
|
|
604
|
+
};
|
|
605
|
+
const _SCHEMA = `
|
|
606
|
+
CREATE TABLE IF NOT EXISTS identity_slots (
|
|
607
|
+
slot_id TEXT PRIMARY KEY,
|
|
608
|
+
ed25519_priv TEXT,
|
|
609
|
+
ed25519_pub TEXT,
|
|
610
|
+
x25519_priv TEXT,
|
|
611
|
+
x25519_pub TEXT,
|
|
612
|
+
identity_created_at INTEGER,
|
|
613
|
+
files_dir TEXT,
|
|
614
|
+
snooze_scope TEXT,
|
|
615
|
+
snooze_expires_at INTEGER,
|
|
616
|
+
created_at INTEGER NOT NULL
|
|
617
|
+
);
|
|
618
|
+
CREATE TABLE IF NOT EXISTS identity_storage (
|
|
619
|
+
slot_id TEXT NOT NULL,
|
|
620
|
+
key TEXT NOT NULL,
|
|
621
|
+
value TEXT NOT NULL,
|
|
622
|
+
PRIMARY KEY (slot_id, key)
|
|
623
|
+
);
|
|
624
|
+
CREATE TABLE IF NOT EXISTS identity_keys (
|
|
625
|
+
slot_id TEXT NOT NULL,
|
|
626
|
+
key_json TEXT NOT NULL,
|
|
627
|
+
PRIMARY KEY (slot_id, key_json)
|
|
628
|
+
);
|
|
629
|
+
`;
|
|
630
|
+
/** Open (creating if needed) a SQLite-backed {@link IdentityKeystore}. */
|
|
631
|
+
function createSqliteIdentityKeystore(opts) {
|
|
632
|
+
const db = opts.db ?? new (_getDatabaseSync())(_requireDbPath(opts));
|
|
633
|
+
db.exec(_SCHEMA);
|
|
634
|
+
return new _SqliteKeystore(db);
|
|
635
|
+
}
|
|
636
|
+
function _requireDbPath(opts) {
|
|
637
|
+
if (opts.dbPath === void 0) throw new Error("createSqliteIdentityKeystore: pass either `dbPath` or `db`");
|
|
638
|
+
return opts.dbPath;
|
|
639
|
+
}
|
|
640
|
+
/** Prepared statements shared by every slot handle on one connection. */
|
|
641
|
+
var _Stmts = class {
|
|
642
|
+
selIdentity;
|
|
643
|
+
upsertIdentity;
|
|
644
|
+
ensureSlot;
|
|
645
|
+
slotExists;
|
|
646
|
+
setFilesDir;
|
|
647
|
+
getFilesDir;
|
|
648
|
+
setSnooze;
|
|
649
|
+
getSnooze;
|
|
650
|
+
clearSnooze;
|
|
651
|
+
listKeys;
|
|
652
|
+
hasKey;
|
|
653
|
+
addKey;
|
|
654
|
+
delKey;
|
|
655
|
+
storGet;
|
|
656
|
+
storSet;
|
|
657
|
+
storDel;
|
|
658
|
+
storKeys;
|
|
659
|
+
storCount;
|
|
660
|
+
delSlot;
|
|
661
|
+
delStorage;
|
|
662
|
+
delKeys;
|
|
663
|
+
constructor(db) {
|
|
664
|
+
this.selIdentity = db.prepare(`SELECT ed25519_priv, ed25519_pub, x25519_priv, x25519_pub
|
|
665
|
+
FROM identity_slots WHERE slot_id = ?`);
|
|
666
|
+
this.upsertIdentity = db.prepare(`INSERT INTO identity_slots
|
|
667
|
+
(slot_id, ed25519_priv, ed25519_pub, x25519_priv, x25519_pub,
|
|
668
|
+
identity_created_at, created_at)
|
|
669
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
670
|
+
ON CONFLICT(slot_id) DO UPDATE SET
|
|
671
|
+
ed25519_priv = excluded.ed25519_priv,
|
|
672
|
+
ed25519_pub = excluded.ed25519_pub,
|
|
673
|
+
x25519_priv = excluded.x25519_priv,
|
|
674
|
+
x25519_pub = excluded.x25519_pub,
|
|
675
|
+
identity_created_at = excluded.identity_created_at`);
|
|
676
|
+
this.ensureSlot = db.prepare(`INSERT OR IGNORE INTO identity_slots (slot_id, created_at) VALUES (?, ?)`);
|
|
677
|
+
this.slotExists = db.prepare(`SELECT 1 FROM identity_slots WHERE slot_id = ?`);
|
|
678
|
+
this.setFilesDir = db.prepare(`UPDATE identity_slots SET files_dir = ? WHERE slot_id = ?`);
|
|
679
|
+
this.getFilesDir = db.prepare(`SELECT files_dir FROM identity_slots WHERE slot_id = ?`);
|
|
680
|
+
this.setSnooze = db.prepare(`UPDATE identity_slots SET snooze_scope = ?, snooze_expires_at = ? WHERE slot_id = ?`);
|
|
681
|
+
this.getSnooze = db.prepare(`SELECT snooze_scope, snooze_expires_at FROM identity_slots WHERE slot_id = ?`);
|
|
682
|
+
this.clearSnooze = db.prepare(`UPDATE identity_slots SET snooze_scope = NULL, snooze_expires_at = NULL WHERE slot_id = ?`);
|
|
683
|
+
this.listKeys = db.prepare(`SELECT key_json FROM identity_keys WHERE slot_id = ?`);
|
|
684
|
+
this.hasKey = db.prepare(`SELECT 1 FROM identity_keys WHERE slot_id = ? AND key_json = ?`);
|
|
685
|
+
this.addKey = db.prepare(`INSERT OR IGNORE INTO identity_keys (slot_id, key_json) VALUES (?, ?)`);
|
|
686
|
+
this.delKey = db.prepare(`DELETE FROM identity_keys WHERE slot_id = ? AND key_json = ?`);
|
|
687
|
+
this.storGet = db.prepare(`SELECT value FROM identity_storage WHERE slot_id = ? AND key = ?`);
|
|
688
|
+
this.storSet = db.prepare(`INSERT OR REPLACE INTO identity_storage (slot_id, key, value) VALUES (?, ?, ?)`);
|
|
689
|
+
this.storDel = db.prepare(`DELETE FROM identity_storage WHERE slot_id = ? AND key = ?`);
|
|
690
|
+
this.storKeys = db.prepare(`SELECT key FROM identity_storage WHERE slot_id = ?`);
|
|
691
|
+
this.storCount = db.prepare(`SELECT COUNT(*) AS n FROM identity_storage WHERE slot_id = ?`);
|
|
692
|
+
this.delSlot = db.prepare(`DELETE FROM identity_slots WHERE slot_id = ?`);
|
|
693
|
+
this.delStorage = db.prepare(`DELETE FROM identity_storage WHERE slot_id = ?`);
|
|
694
|
+
this.delKeys = db.prepare(`DELETE FROM identity_keys WHERE slot_id = ?`);
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
var _SqliteKeystore = class {
|
|
698
|
+
_stmts;
|
|
699
|
+
_slots = /* @__PURE__ */ new Map();
|
|
700
|
+
constructor(db) {
|
|
701
|
+
this._stmts = new _Stmts(db);
|
|
702
|
+
}
|
|
703
|
+
slotById(id) {
|
|
704
|
+
return this._slot(id);
|
|
705
|
+
}
|
|
706
|
+
_slot(id) {
|
|
707
|
+
let existing = this._slots.get(id);
|
|
708
|
+
if (existing === void 0) {
|
|
709
|
+
existing = new _SqliteSlot(id, this._stmts);
|
|
710
|
+
this._slots.set(id, existing);
|
|
711
|
+
}
|
|
712
|
+
return existing;
|
|
713
|
+
}
|
|
714
|
+
async slotByIdAndKey(id, key) {
|
|
715
|
+
const slot = this._slot(id);
|
|
716
|
+
if (!await slot._exists()) return { error: "slotDoesNotExist" };
|
|
717
|
+
if (!await slot.hasKey(key)) return { error: "unknownKey" };
|
|
718
|
+
return slot;
|
|
719
|
+
}
|
|
720
|
+
async slotByIdAndTime(id, nowMs) {
|
|
721
|
+
const slot = this._slot(id);
|
|
722
|
+
if (!await slot.hasState()) return slot;
|
|
723
|
+
const snooze = await slot.getSnooze();
|
|
724
|
+
if (snooze !== void 0 && snooze.expiresAt > nowMs) return slot;
|
|
725
|
+
return { error: "consentRequired" };
|
|
726
|
+
}
|
|
727
|
+
async createSlotWithKey(id, key) {
|
|
728
|
+
const slot = this.slotById(id);
|
|
729
|
+
await slot.addKeys(key);
|
|
730
|
+
return slot;
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
var _SqliteSlot = class {
|
|
734
|
+
id;
|
|
735
|
+
_s;
|
|
736
|
+
_storage;
|
|
737
|
+
constructor(id, _s) {
|
|
738
|
+
this.id = id;
|
|
739
|
+
this._s = _s;
|
|
740
|
+
this._storage = new _SqliteSlotStorage(id, _s);
|
|
741
|
+
}
|
|
742
|
+
get storage() {
|
|
743
|
+
return this._storage;
|
|
744
|
+
}
|
|
745
|
+
async getOrCreateIdentity() {
|
|
746
|
+
const existing = await this.peekIdentity();
|
|
747
|
+
if (existing) return existing;
|
|
748
|
+
const ed = await crypto.generateKeypair();
|
|
749
|
+
const wrap = await crypto.generateX25519Keypair();
|
|
750
|
+
this._writeIdentity(ed, wrap);
|
|
751
|
+
return new InMemoryManagedIdentity(ed, wrap);
|
|
752
|
+
}
|
|
753
|
+
async importIdentity(ed, wrap) {
|
|
754
|
+
const existing = await this.peekIdentity();
|
|
755
|
+
if (existing) return existing;
|
|
756
|
+
this._writeIdentity(ed, wrap);
|
|
757
|
+
return new InMemoryManagedIdentity(ed, wrap);
|
|
758
|
+
}
|
|
759
|
+
async peekIdentity() {
|
|
760
|
+
const row = this._s.selIdentity.get(this.id);
|
|
761
|
+
if (row === void 0 || row.ed25519_priv === null || row.ed25519_pub === null || row.x25519_priv === null || row.x25519_pub === null) return;
|
|
762
|
+
const ed = {
|
|
763
|
+
privateKey: base64UrlToBytes(_codec.decode(this.id, row.ed25519_priv)),
|
|
764
|
+
publicKey: base64UrlToBytes(row.ed25519_pub)
|
|
765
|
+
};
|
|
766
|
+
const wrap = {
|
|
767
|
+
privateKey: base64UrlToBytes(_codec.decode(this.id, row.x25519_priv)),
|
|
768
|
+
publicKey: base64UrlToBytes(row.x25519_pub)
|
|
769
|
+
};
|
|
770
|
+
return new InMemoryManagedIdentity(ed, wrap);
|
|
771
|
+
}
|
|
772
|
+
_writeIdentity(ed, wrap) {
|
|
773
|
+
this._s.upsertIdentity.run(this.id, _codec.encode(this.id, bytesToBase64Url(ed.privateKey)), bytesToBase64Url(ed.publicKey), _codec.encode(this.id, bytesToBase64Url(wrap.privateKey)), bytesToBase64Url(wrap.publicKey), Date.now(), Date.now());
|
|
774
|
+
}
|
|
775
|
+
async listKeys() {
|
|
776
|
+
return this._s.listKeys.all(this.id).map((r) => _fromCanonicalKey(r.key_json));
|
|
777
|
+
}
|
|
778
|
+
async hasKey(key) {
|
|
779
|
+
return this._s.hasKey.get(this.id, _canonicalKey$1(key)) !== void 0;
|
|
780
|
+
}
|
|
781
|
+
async addKeys(...keys) {
|
|
782
|
+
if (keys.length === 0) return;
|
|
783
|
+
this._ensureRow();
|
|
784
|
+
for (const key of keys) this._s.addKey.run(this.id, _canonicalKey$1(key));
|
|
785
|
+
}
|
|
786
|
+
async deleteKey(key) {
|
|
787
|
+
return this._s.delKey.run(this.id, _canonicalKey$1(key)).changes > 0;
|
|
788
|
+
}
|
|
789
|
+
/** Internal: has this slot ever been created? */
|
|
790
|
+
async _exists() {
|
|
791
|
+
return this._s.slotExists.get(this.id) !== void 0;
|
|
792
|
+
}
|
|
793
|
+
async getSnooze() {
|
|
794
|
+
const row = this._s.getSnooze.get(this.id);
|
|
795
|
+
if (row === void 0 || row.snooze_scope === null || row.snooze_expires_at === null) return;
|
|
796
|
+
if (Date.now() >= row.snooze_expires_at) return void 0;
|
|
797
|
+
return {
|
|
798
|
+
scope: row.snooze_scope,
|
|
799
|
+
expiresAt: row.snooze_expires_at
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
async setSnooze(snooze) {
|
|
803
|
+
this._ensureRow();
|
|
804
|
+
this._s.setSnooze.run(snooze.scope, snooze.expiresAt, this.id);
|
|
805
|
+
}
|
|
806
|
+
async clearSnooze() {
|
|
807
|
+
this._s.clearSnooze.run(this.id);
|
|
808
|
+
}
|
|
809
|
+
async getFilesDir() {
|
|
810
|
+
const row = this._s.getFilesDir.get(this.id);
|
|
811
|
+
if (row === void 0 || row.files_dir === null) return void 0;
|
|
812
|
+
return row.files_dir;
|
|
813
|
+
}
|
|
814
|
+
async setFilesDir(absPath) {
|
|
815
|
+
this._ensureRow();
|
|
816
|
+
this._s.setFilesDir.run(absPath, this.id);
|
|
817
|
+
}
|
|
818
|
+
async hasFiles() {
|
|
819
|
+
const dir = await this.getFilesDir();
|
|
820
|
+
if (dir === void 0) return false;
|
|
821
|
+
try {
|
|
822
|
+
return (await fs.readdir(dir)).length > 0;
|
|
823
|
+
} catch (e) {
|
|
824
|
+
if (e.code === "ENOENT") return false;
|
|
825
|
+
throw e;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
async wipeFiles() {
|
|
829
|
+
const dir = await this.getFilesDir();
|
|
830
|
+
if (dir === void 0) return;
|
|
831
|
+
let entries;
|
|
832
|
+
try {
|
|
833
|
+
entries = await fs.readdir(dir);
|
|
834
|
+
} catch (e) {
|
|
835
|
+
if (e.code === "ENOENT") return;
|
|
836
|
+
throw e;
|
|
837
|
+
}
|
|
838
|
+
await Promise.all(entries.map((name) => fs.rm(path.join(dir, name), {
|
|
839
|
+
recursive: true,
|
|
840
|
+
force: true
|
|
841
|
+
})));
|
|
842
|
+
}
|
|
843
|
+
async hasState() {
|
|
844
|
+
if (await this.peekIdentity()) return true;
|
|
845
|
+
const { n } = this._s.storCount.get(this.id);
|
|
846
|
+
if (n > 0) return true;
|
|
847
|
+
return this.hasFiles();
|
|
848
|
+
}
|
|
849
|
+
async delete() {
|
|
850
|
+
const filesDir = await this.getFilesDir().catch(() => void 0);
|
|
851
|
+
if (filesDir !== void 0) await fs.rm(filesDir, {
|
|
852
|
+
recursive: true,
|
|
853
|
+
force: true
|
|
854
|
+
});
|
|
855
|
+
this._s.delStorage.run(this.id);
|
|
856
|
+
this._s.delKeys.run(this.id);
|
|
857
|
+
this._s.delSlot.run(this.id);
|
|
858
|
+
}
|
|
859
|
+
_ensureRow() {
|
|
860
|
+
this._s.ensureSlot.run(this.id, Date.now());
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
/** Per-slot KV backing `identity.storage::*`, one row per key in `identity_storage`. */
|
|
864
|
+
var _SqliteSlotStorage = class {
|
|
865
|
+
_slotId;
|
|
866
|
+
_s;
|
|
867
|
+
constructor(_slotId, _s) {
|
|
868
|
+
this._slotId = _slotId;
|
|
869
|
+
this._s = _s;
|
|
870
|
+
}
|
|
871
|
+
async get(key) {
|
|
872
|
+
const row = this._s.storGet.get(this._slotId, key);
|
|
873
|
+
if (row === void 0) return void 0;
|
|
874
|
+
return JSON.parse(_codec.decode(this._slotId, row.value));
|
|
875
|
+
}
|
|
876
|
+
async set(key, value) {
|
|
877
|
+
this._s.ensureSlot.run(this._slotId, Date.now());
|
|
878
|
+
this._s.storSet.run(this._slotId, key, _codec.encode(this._slotId, JSON.stringify(value)));
|
|
879
|
+
}
|
|
880
|
+
async delete(key) {
|
|
881
|
+
return this._s.storDel.run(this._slotId, key).changes > 0;
|
|
882
|
+
}
|
|
883
|
+
async list(prefix) {
|
|
884
|
+
const keys = this._s.storKeys.all(this._slotId).map((r) => r.key);
|
|
885
|
+
return prefix === void 0 ? keys : keys.filter((k) => k.startsWith(prefix));
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
/** Canonical (sorted-field) JSON for exact access-key equality. */
|
|
889
|
+
function _canonicalKey$1(key) {
|
|
890
|
+
const entries = Object.keys(key).sort().map((k) => [k, key[k]]);
|
|
891
|
+
return JSON.stringify(entries);
|
|
892
|
+
}
|
|
893
|
+
/** Rebuild an access key from its canonical (sorted `[key, value]` pairs) JSON. */
|
|
894
|
+
function _fromCanonicalKey(canonical) {
|
|
895
|
+
const entries = JSON.parse(canonical);
|
|
896
|
+
return Object.fromEntries(entries);
|
|
897
|
+
}
|
|
898
|
+
//#endregion
|
|
899
|
+
//#region src/hub/server/identityKeystore.ts
|
|
900
|
+
const _FILE_SCHEMA_VERSION = 1;
|
|
901
|
+
const _DOMAIN = "hubrpc.identity.keystore.v1";
|
|
902
|
+
const _STORAGE_DOMAIN = "hubrpc.identity.storage.v1";
|
|
903
|
+
const _KEYS_DOMAIN = "hubrpc.identity.keys.v1";
|
|
904
|
+
const _SNOOZE_DOMAIN = "hubrpc.identity.snooze.v1";
|
|
905
|
+
const _FILESDIR_DOMAIN = "hubrpc.identity.filesdir.v1";
|
|
906
|
+
function createIdentityKeystore(opts) {
|
|
907
|
+
if (opts.keystoreSecret.length < 32) throw new Error("createIdentityKeystore: keystoreSecret must be at least 32 bytes");
|
|
908
|
+
return new _IdentityKeystoreImpl(opts);
|
|
909
|
+
}
|
|
910
|
+
var _IdentityKeystoreImpl = class {
|
|
911
|
+
_slots = /* @__PURE__ */ new Map();
|
|
912
|
+
_opts;
|
|
913
|
+
_dirReady = false;
|
|
914
|
+
constructor(opts) {
|
|
915
|
+
this._opts = opts;
|
|
916
|
+
}
|
|
917
|
+
slotById(id) {
|
|
918
|
+
return this._slot(id);
|
|
919
|
+
}
|
|
920
|
+
async slotByIdAndKey(id, key) {
|
|
921
|
+
const slot = this._slot(id);
|
|
922
|
+
if (!await slot._exists()) return { error: "slotDoesNotExist" };
|
|
923
|
+
if (!await slot.hasKey(key)) return { error: "unknownKey" };
|
|
924
|
+
return slot;
|
|
925
|
+
}
|
|
926
|
+
async slotByIdAndTime(id, nowMs) {
|
|
927
|
+
const slot = this._slot(id);
|
|
928
|
+
if (!await slot.hasState()) return slot;
|
|
929
|
+
const snooze = await slot.getSnooze();
|
|
930
|
+
if (snooze !== void 0 && snooze.expiresAt > nowMs) return slot;
|
|
931
|
+
return { error: "consentRequired" };
|
|
932
|
+
}
|
|
933
|
+
async createSlotWithKey(id, key) {
|
|
934
|
+
const slot = this._slot(id);
|
|
935
|
+
await slot.addKeys(key);
|
|
936
|
+
return slot;
|
|
937
|
+
}
|
|
938
|
+
_slot(id) {
|
|
939
|
+
let existing = this._slots.get(id);
|
|
940
|
+
if (existing) return existing;
|
|
941
|
+
const hash = createHash("sha256").update(id).digest("hex").slice(0, 16);
|
|
942
|
+
existing = new _IdentitySlotImpl({
|
|
943
|
+
id,
|
|
944
|
+
identityFile: path.join(this._opts.storageDir, `${hash}.bin`),
|
|
945
|
+
storageFile: path.join(this._opts.storageDir, `${hash}.storage.bin`),
|
|
946
|
+
keysFile: path.join(this._opts.storageDir, `${hash}.keys.bin`),
|
|
947
|
+
snoozeFile: path.join(this._opts.storageDir, `${hash}.snooze.bin`),
|
|
948
|
+
filesDirFile: path.join(this._opts.storageDir, `${hash}.filesdir.bin`),
|
|
949
|
+
keystoreSecret: this._opts.keystoreSecret,
|
|
950
|
+
ensureDir: () => this._ensureDir()
|
|
951
|
+
});
|
|
952
|
+
this._slots.set(id, existing);
|
|
953
|
+
return existing;
|
|
954
|
+
}
|
|
955
|
+
async _ensureDir() {
|
|
956
|
+
if (this._dirReady) return;
|
|
957
|
+
await fs.mkdir(this._opts.storageDir, {
|
|
958
|
+
recursive: true,
|
|
959
|
+
mode: 448
|
|
960
|
+
});
|
|
961
|
+
this._dirReady = true;
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
var _IdentitySlotImpl = class {
|
|
965
|
+
_deps;
|
|
966
|
+
_identity;
|
|
967
|
+
_identityLoaded = false;
|
|
968
|
+
_storage;
|
|
969
|
+
_keys;
|
|
970
|
+
_keysFileExists = false;
|
|
971
|
+
_keysLoaded = false;
|
|
972
|
+
_keysWriteChain = Promise.resolve();
|
|
973
|
+
constructor(_deps) {
|
|
974
|
+
this._deps = _deps;
|
|
975
|
+
this._storage = new _FileBackedStorage({
|
|
976
|
+
slot: _deps.id,
|
|
977
|
+
file: _deps.storageFile,
|
|
978
|
+
keystoreSecret: _deps.keystoreSecret,
|
|
979
|
+
ensureDir: _deps.ensureDir
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
get id() {
|
|
983
|
+
return this._deps.id;
|
|
984
|
+
}
|
|
985
|
+
get storage() {
|
|
986
|
+
return this._storage;
|
|
987
|
+
}
|
|
988
|
+
async getOrCreateIdentity() {
|
|
989
|
+
const existing = await this.peekIdentity();
|
|
990
|
+
if (existing) return existing;
|
|
991
|
+
const ed = await crypto.generateKeypair();
|
|
992
|
+
const wrap = await crypto.generateX25519Keypair();
|
|
993
|
+
await this._writeIdentity(ed, wrap);
|
|
994
|
+
const identity = new InMemoryManagedIdentity(ed, wrap);
|
|
995
|
+
this._identity = identity;
|
|
996
|
+
this._identityLoaded = true;
|
|
997
|
+
return identity;
|
|
998
|
+
}
|
|
999
|
+
async peekIdentity() {
|
|
1000
|
+
if (this._identityLoaded) return this._identity;
|
|
1001
|
+
let raw;
|
|
1002
|
+
try {
|
|
1003
|
+
raw = await fs.readFile(this._deps.identityFile);
|
|
1004
|
+
} catch (e) {
|
|
1005
|
+
if (e.code === "ENOENT") {
|
|
1006
|
+
this._identityLoaded = true;
|
|
1007
|
+
this._identity = void 0;
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
throw e;
|
|
1011
|
+
}
|
|
1012
|
+
const plain = await this._decryptIdentityPayload(raw);
|
|
1013
|
+
const ed = {
|
|
1014
|
+
privateKey: base64UrlToBytes(plain.ed25519.privateKey),
|
|
1015
|
+
publicKey: base64UrlToBytes(plain.ed25519.publicKey)
|
|
1016
|
+
};
|
|
1017
|
+
const wrap = {
|
|
1018
|
+
privateKey: base64UrlToBytes(plain.x25519.privateKey),
|
|
1019
|
+
publicKey: base64UrlToBytes(plain.x25519.publicKey)
|
|
1020
|
+
};
|
|
1021
|
+
const identity = new InMemoryManagedIdentity(ed, wrap);
|
|
1022
|
+
this._identity = identity;
|
|
1023
|
+
this._identityLoaded = true;
|
|
1024
|
+
return identity;
|
|
1025
|
+
}
|
|
1026
|
+
async hasState() {
|
|
1027
|
+
if (await this.peekIdentity()) return true;
|
|
1028
|
+
if ((await this._storage.list()).length > 0) return true;
|
|
1029
|
+
return this.hasFiles();
|
|
1030
|
+
}
|
|
1031
|
+
async listKeys() {
|
|
1032
|
+
return (await this._loadKeys()).map((k) => ({ ...k }));
|
|
1033
|
+
}
|
|
1034
|
+
async hasKey(key) {
|
|
1035
|
+
const keys = await this._loadKeys();
|
|
1036
|
+
const canon = _canonicalKey(key);
|
|
1037
|
+
return keys.some((k) => _canonicalKey(k) === canon);
|
|
1038
|
+
}
|
|
1039
|
+
async addKeys(...keys) {
|
|
1040
|
+
if (keys.length === 0) return;
|
|
1041
|
+
await this._mutateKeys((current) => {
|
|
1042
|
+
const seen = new Set(current.map((k) => _canonicalKey(k)));
|
|
1043
|
+
for (const key of keys) {
|
|
1044
|
+
const canon = _canonicalKey(key);
|
|
1045
|
+
if (!seen.has(canon)) {
|
|
1046
|
+
seen.add(canon);
|
|
1047
|
+
current.push({ ...key });
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
async deleteKey(key) {
|
|
1053
|
+
const canon = _canonicalKey(key);
|
|
1054
|
+
let existed = false;
|
|
1055
|
+
await this._mutateKeys((current) => {
|
|
1056
|
+
const idx = current.findIndex((k) => _canonicalKey(k) === canon);
|
|
1057
|
+
if (idx >= 0) {
|
|
1058
|
+
existed = true;
|
|
1059
|
+
current.splice(idx, 1);
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
return existed;
|
|
1063
|
+
}
|
|
1064
|
+
/** Internal: does this slot exist (i.e. has it ever been created)? */
|
|
1065
|
+
async _exists() {
|
|
1066
|
+
await this._loadKeys();
|
|
1067
|
+
return this._keysFileExists;
|
|
1068
|
+
}
|
|
1069
|
+
async getSnooze() {
|
|
1070
|
+
let raw;
|
|
1071
|
+
try {
|
|
1072
|
+
raw = await fs.readFile(this._deps.snoozeFile);
|
|
1073
|
+
} catch (e) {
|
|
1074
|
+
if (e.code === "ENOENT") return void 0;
|
|
1075
|
+
throw e;
|
|
1076
|
+
}
|
|
1077
|
+
const obj = await _aesDecrypt(this._deps.keystoreSecret, _SNOOZE_DOMAIN, this._deps.id, raw);
|
|
1078
|
+
if (obj.schemaVersion !== _FILE_SCHEMA_VERSION) throw new Error(`identity snooze: unsupported schemaVersion ${obj.schemaVersion}`);
|
|
1079
|
+
if (obj.slot !== this._deps.id) throw new Error("identity snooze: slot mismatch");
|
|
1080
|
+
if (Date.now() >= obj.expiresAt) return void 0;
|
|
1081
|
+
return {
|
|
1082
|
+
scope: obj.scope,
|
|
1083
|
+
expiresAt: obj.expiresAt
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
async setSnooze(snooze) {
|
|
1087
|
+
await this._deps.ensureDir();
|
|
1088
|
+
const payload = {
|
|
1089
|
+
schemaVersion: _FILE_SCHEMA_VERSION,
|
|
1090
|
+
slot: this._deps.id,
|
|
1091
|
+
scope: snooze.scope,
|
|
1092
|
+
expiresAt: snooze.expiresAt
|
|
1093
|
+
};
|
|
1094
|
+
const ct = await _aesEncrypt(this._deps.keystoreSecret, _SNOOZE_DOMAIN, this._deps.id, payload);
|
|
1095
|
+
await fs.writeFile(this._deps.snoozeFile, ct, { mode: 384 });
|
|
1096
|
+
}
|
|
1097
|
+
async clearSnooze() {
|
|
1098
|
+
await _safeUnlink(this._deps.snoozeFile);
|
|
1099
|
+
}
|
|
1100
|
+
async getFilesDir() {
|
|
1101
|
+
let raw;
|
|
1102
|
+
try {
|
|
1103
|
+
raw = await fs.readFile(this._deps.filesDirFile);
|
|
1104
|
+
} catch (e) {
|
|
1105
|
+
if (e.code === "ENOENT") return void 0;
|
|
1106
|
+
throw e;
|
|
1107
|
+
}
|
|
1108
|
+
const obj = await _aesDecrypt(this._deps.keystoreSecret, _FILESDIR_DOMAIN, this._deps.id, raw);
|
|
1109
|
+
if (obj.schemaVersion !== _FILE_SCHEMA_VERSION) throw new Error(`identity filesdir: unsupported schemaVersion ${obj.schemaVersion}`);
|
|
1110
|
+
if (obj.slot !== this._deps.id) throw new Error("identity filesdir: slot mismatch");
|
|
1111
|
+
return obj.dir;
|
|
1112
|
+
}
|
|
1113
|
+
async setFilesDir(absPath) {
|
|
1114
|
+
await this._deps.ensureDir();
|
|
1115
|
+
const payload = {
|
|
1116
|
+
schemaVersion: _FILE_SCHEMA_VERSION,
|
|
1117
|
+
slot: this._deps.id,
|
|
1118
|
+
dir: absPath,
|
|
1119
|
+
updatedAt: Date.now()
|
|
1120
|
+
};
|
|
1121
|
+
const ct = await _aesEncrypt(this._deps.keystoreSecret, _FILESDIR_DOMAIN, this._deps.id, payload);
|
|
1122
|
+
await fs.writeFile(this._deps.filesDirFile, ct, { mode: 384 });
|
|
1123
|
+
}
|
|
1124
|
+
async hasFiles() {
|
|
1125
|
+
const dir = await this.getFilesDir();
|
|
1126
|
+
if (dir === void 0) return false;
|
|
1127
|
+
try {
|
|
1128
|
+
return (await fs.readdir(dir)).length > 0;
|
|
1129
|
+
} catch (e) {
|
|
1130
|
+
if (e.code === "ENOENT") return false;
|
|
1131
|
+
throw e;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
async wipeFiles() {
|
|
1135
|
+
const dir = await this.getFilesDir();
|
|
1136
|
+
if (dir === void 0) return;
|
|
1137
|
+
let entries;
|
|
1138
|
+
try {
|
|
1139
|
+
entries = await fs.readdir(dir);
|
|
1140
|
+
} catch (e) {
|
|
1141
|
+
if (e.code === "ENOENT") return;
|
|
1142
|
+
throw e;
|
|
1143
|
+
}
|
|
1144
|
+
await Promise.all(entries.map((name) => fs.rm(path.join(dir, name), {
|
|
1145
|
+
recursive: true,
|
|
1146
|
+
force: true
|
|
1147
|
+
})));
|
|
1148
|
+
}
|
|
1149
|
+
async delete() {
|
|
1150
|
+
this._identity = void 0;
|
|
1151
|
+
this._identityLoaded = false;
|
|
1152
|
+
this._keys = void 0;
|
|
1153
|
+
this._keysLoaded = false;
|
|
1154
|
+
this._keysFileExists = false;
|
|
1155
|
+
this._storage._invalidate();
|
|
1156
|
+
const filesDir = await this.getFilesDir().catch(() => void 0);
|
|
1157
|
+
if (filesDir !== void 0) await fs.rm(filesDir, {
|
|
1158
|
+
recursive: true,
|
|
1159
|
+
force: true
|
|
1160
|
+
});
|
|
1161
|
+
await _safeUnlink(this._deps.identityFile);
|
|
1162
|
+
await _safeUnlink(this._deps.storageFile);
|
|
1163
|
+
await _safeUnlink(this._deps.keysFile);
|
|
1164
|
+
await _safeUnlink(this._deps.snoozeFile);
|
|
1165
|
+
await _safeUnlink(this._deps.filesDirFile);
|
|
1166
|
+
}
|
|
1167
|
+
async _writeIdentity(ed, wrap) {
|
|
1168
|
+
await this._deps.ensureDir();
|
|
1169
|
+
const payload = {
|
|
1170
|
+
schemaVersion: _FILE_SCHEMA_VERSION,
|
|
1171
|
+
slot: this._deps.id,
|
|
1172
|
+
ed25519: {
|
|
1173
|
+
privateKey: bytesToBase64Url(ed.privateKey),
|
|
1174
|
+
publicKey: bytesToBase64Url(ed.publicKey)
|
|
1175
|
+
},
|
|
1176
|
+
x25519: {
|
|
1177
|
+
privateKey: bytesToBase64Url(wrap.privateKey),
|
|
1178
|
+
publicKey: bytesToBase64Url(wrap.publicKey)
|
|
1179
|
+
},
|
|
1180
|
+
createdAt: Date.now()
|
|
1181
|
+
};
|
|
1182
|
+
const ct = await _aesEncrypt(this._deps.keystoreSecret, _DOMAIN, this._deps.id, payload);
|
|
1183
|
+
await fs.writeFile(this._deps.identityFile, ct, { mode: 384 });
|
|
1184
|
+
}
|
|
1185
|
+
async _decryptIdentityPayload(raw) {
|
|
1186
|
+
const obj = await _aesDecrypt(this._deps.keystoreSecret, _DOMAIN, this._deps.id, raw);
|
|
1187
|
+
if (obj.schemaVersion !== _FILE_SCHEMA_VERSION) throw new Error(`identity keystore: unsupported schemaVersion ${obj.schemaVersion}`);
|
|
1188
|
+
if (obj.slot !== this._deps.id) throw new Error("identity keystore: slot mismatch");
|
|
1189
|
+
return obj;
|
|
1190
|
+
}
|
|
1191
|
+
async _loadKeys() {
|
|
1192
|
+
if (this._keysLoaded) return this._keys;
|
|
1193
|
+
let raw;
|
|
1194
|
+
try {
|
|
1195
|
+
raw = await fs.readFile(this._deps.keysFile);
|
|
1196
|
+
} catch (e) {
|
|
1197
|
+
if (e.code === "ENOENT") {
|
|
1198
|
+
this._keys = [];
|
|
1199
|
+
this._keysFileExists = false;
|
|
1200
|
+
this._keysLoaded = true;
|
|
1201
|
+
return this._keys;
|
|
1202
|
+
}
|
|
1203
|
+
throw e;
|
|
1204
|
+
}
|
|
1205
|
+
const obj = await _aesDecrypt(this._deps.keystoreSecret, _KEYS_DOMAIN, this._deps.id, raw);
|
|
1206
|
+
if (obj.schemaVersion !== _FILE_SCHEMA_VERSION) throw new Error(`identity keys: unsupported schemaVersion ${obj.schemaVersion}`);
|
|
1207
|
+
if (obj.slot !== this._deps.id) throw new Error("identity keys: slot mismatch");
|
|
1208
|
+
this._keys = obj.keys.map((k) => ({ ...k }));
|
|
1209
|
+
this._keysFileExists = true;
|
|
1210
|
+
this._keysLoaded = true;
|
|
1211
|
+
return this._keys;
|
|
1212
|
+
}
|
|
1213
|
+
async _mutateKeys(fn) {
|
|
1214
|
+
const next = this._keysWriteChain.then(async () => {
|
|
1215
|
+
const keys = await this._loadKeys();
|
|
1216
|
+
fn(keys);
|
|
1217
|
+
await this._persistKeys(keys);
|
|
1218
|
+
});
|
|
1219
|
+
this._keysWriteChain = next.catch(() => {});
|
|
1220
|
+
return next;
|
|
1221
|
+
}
|
|
1222
|
+
async _persistKeys(keys) {
|
|
1223
|
+
await this._deps.ensureDir();
|
|
1224
|
+
const payload = {
|
|
1225
|
+
schemaVersion: _FILE_SCHEMA_VERSION,
|
|
1226
|
+
slot: this._deps.id,
|
|
1227
|
+
keys: keys.map((k) => ({ ...k })),
|
|
1228
|
+
updatedAt: Date.now()
|
|
1229
|
+
};
|
|
1230
|
+
const ct = await _aesEncrypt(this._deps.keystoreSecret, _KEYS_DOMAIN, this._deps.id, payload);
|
|
1231
|
+
await fs.writeFile(this._deps.keysFile, ct, { mode: 384 });
|
|
1232
|
+
this._keysFileExists = true;
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
/** Canonical (sorted-field) JSON for exact access-key equality. */
|
|
1236
|
+
function _canonicalKey(key) {
|
|
1237
|
+
const entries = Object.keys(key).sort().map((k) => [k, key[k]]);
|
|
1238
|
+
return JSON.stringify(entries);
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* File-backed per-slot storage. Lazily reads the encrypted file on first
|
|
1242
|
+
* access and caches the decrypted map in memory. Writes serialize
|
|
1243
|
+
* through `_writeChain` so concurrent `set`/`delete` calls don't
|
|
1244
|
+
* overwrite each other.
|
|
1245
|
+
*/
|
|
1246
|
+
var _FileBackedStorage = class {
|
|
1247
|
+
_opts;
|
|
1248
|
+
_data;
|
|
1249
|
+
_loaded = false;
|
|
1250
|
+
_writeChain = Promise.resolve();
|
|
1251
|
+
constructor(_opts) {
|
|
1252
|
+
this._opts = _opts;
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Drop the cached map (called by the slot's `delete()` so the next
|
|
1256
|
+
* `get` re-reads from disk, where the file is now gone).
|
|
1257
|
+
*/
|
|
1258
|
+
_invalidate() {
|
|
1259
|
+
this._data = void 0;
|
|
1260
|
+
this._loaded = false;
|
|
1261
|
+
}
|
|
1262
|
+
async get(key) {
|
|
1263
|
+
const data = await this._load();
|
|
1264
|
+
return Object.prototype.hasOwnProperty.call(data, key) ? data[key] : void 0;
|
|
1265
|
+
}
|
|
1266
|
+
async set(key, value) {
|
|
1267
|
+
await this._mutate((data) => {
|
|
1268
|
+
data[key] = value;
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
async delete(key) {
|
|
1272
|
+
let existed = false;
|
|
1273
|
+
await this._mutate((data) => {
|
|
1274
|
+
existed = Object.prototype.hasOwnProperty.call(data, key);
|
|
1275
|
+
if (existed) delete data[key];
|
|
1276
|
+
});
|
|
1277
|
+
return existed;
|
|
1278
|
+
}
|
|
1279
|
+
async list(prefix) {
|
|
1280
|
+
const data = await this._load();
|
|
1281
|
+
const keys = Object.keys(data);
|
|
1282
|
+
return prefix === void 0 ? keys : keys.filter((k) => k.startsWith(prefix));
|
|
1283
|
+
}
|
|
1284
|
+
async _load() {
|
|
1285
|
+
if (this._loaded) return this._data;
|
|
1286
|
+
let raw;
|
|
1287
|
+
try {
|
|
1288
|
+
raw = await fs.readFile(this._opts.file);
|
|
1289
|
+
} catch (e) {
|
|
1290
|
+
if (e.code === "ENOENT") {
|
|
1291
|
+
this._data = {};
|
|
1292
|
+
this._loaded = true;
|
|
1293
|
+
return this._data;
|
|
1294
|
+
}
|
|
1295
|
+
throw e;
|
|
1296
|
+
}
|
|
1297
|
+
const obj = await _aesDecrypt(this._opts.keystoreSecret, _STORAGE_DOMAIN, this._opts.slot, raw);
|
|
1298
|
+
if (obj.schemaVersion !== _FILE_SCHEMA_VERSION) throw new Error(`identity storage: unsupported schemaVersion ${obj.schemaVersion}`);
|
|
1299
|
+
if (obj.slot !== this._opts.slot) throw new Error("identity storage: slot mismatch");
|
|
1300
|
+
this._data = { ...obj.entries };
|
|
1301
|
+
this._loaded = true;
|
|
1302
|
+
return this._data;
|
|
1303
|
+
}
|
|
1304
|
+
async _mutate(fn) {
|
|
1305
|
+
const next = this._writeChain.then(async () => {
|
|
1306
|
+
const data = await this._load();
|
|
1307
|
+
fn(data);
|
|
1308
|
+
await this._persist(data);
|
|
1309
|
+
});
|
|
1310
|
+
this._writeChain = next.catch(() => {});
|
|
1311
|
+
return next;
|
|
1312
|
+
}
|
|
1313
|
+
async _persist(data) {
|
|
1314
|
+
await this._opts.ensureDir();
|
|
1315
|
+
const payload = {
|
|
1316
|
+
schemaVersion: _FILE_SCHEMA_VERSION,
|
|
1317
|
+
slot: this._opts.slot,
|
|
1318
|
+
entries: data,
|
|
1319
|
+
updatedAt: Date.now()
|
|
1320
|
+
};
|
|
1321
|
+
const ct = await _aesEncrypt(this._opts.keystoreSecret, _STORAGE_DOMAIN, this._opts.slot, payload);
|
|
1322
|
+
await fs.writeFile(this._opts.file, ct, { mode: 384 });
|
|
1323
|
+
}
|
|
1324
|
+
};
|
|
1325
|
+
async function _aesEncrypt(secret, domain, slot, payload) {
|
|
1326
|
+
const pt = new TextEncoder().encode(JSON.stringify(payload));
|
|
1327
|
+
const { createCipheriv, randomBytes } = await import("node:crypto");
|
|
1328
|
+
const key = await _deriveKey(secret, domain);
|
|
1329
|
+
const iv = randomBytes(12);
|
|
1330
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
1331
|
+
cipher.setAAD(new TextEncoder().encode(`${domain}:${slot}`));
|
|
1332
|
+
const enc = Buffer.concat([cipher.update(pt), cipher.final()]);
|
|
1333
|
+
const tag = cipher.getAuthTag();
|
|
1334
|
+
const out = new Uint8Array(28 + enc.length);
|
|
1335
|
+
out.set(iv, 0);
|
|
1336
|
+
out.set(tag, 12);
|
|
1337
|
+
out.set(enc, 28);
|
|
1338
|
+
return out;
|
|
1339
|
+
}
|
|
1340
|
+
async function _aesDecrypt(secret, domain, slot, raw) {
|
|
1341
|
+
if (raw.length < 28) throw new Error(`${domain}: file too short`);
|
|
1342
|
+
const iv = raw.subarray(0, 12);
|
|
1343
|
+
const tag = raw.subarray(12, 28);
|
|
1344
|
+
const ct = raw.subarray(28);
|
|
1345
|
+
const { createDecipheriv } = await import("node:crypto");
|
|
1346
|
+
const decipher = createDecipheriv("aes-256-gcm", await _deriveKey(secret, domain), iv);
|
|
1347
|
+
decipher.setAAD(new TextEncoder().encode(`${domain}:${slot}`));
|
|
1348
|
+
decipher.setAuthTag(tag);
|
|
1349
|
+
const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
1350
|
+
return JSON.parse(pt.toString("utf8"));
|
|
1351
|
+
}
|
|
1352
|
+
async function _deriveKey(secret, domain) {
|
|
1353
|
+
const { createHmac } = await import("node:crypto");
|
|
1354
|
+
const prk = createHmac("sha256", Buffer.alloc(32)).update(secret).digest();
|
|
1355
|
+
const info = Buffer.concat([Buffer.from(domain), Buffer.from([1])]);
|
|
1356
|
+
return createHmac("sha256", prk).update(info).digest();
|
|
1357
|
+
}
|
|
1358
|
+
async function _safeUnlink(file) {
|
|
1359
|
+
try {
|
|
1360
|
+
await fs.unlink(file);
|
|
1361
|
+
} catch (e) {
|
|
1362
|
+
if (e.code !== "ENOENT") throw e;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
//#endregion
|
|
1366
|
+
export { TokenIdentityStore as a, durationToExp as c, fetchFullDirectory as d, resolveAccessCandidates as f, withProvenance as i, mintCapability as l, hubRegisterServiceId as m, createSqliteIdentityKeystore as n, registerHubAccessService as o, RegisteredServiceId as p, PrincipalIdPrefixPolicy as r, CapabilityProposalIssuer as s, createIdentityKeystore as t, candidatesForSlot as u };
|
|
1367
|
+
|
|
1368
|
+
//# sourceMappingURL=server-BAxchQhy.js.map
|