@muhkoo/connect 0.14.0-alpha.9 → 0.15.0-alpha.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 +2 -2
- package/dist/browser/index.js +2422 -538
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +813 -43
- package/dist/connect.workers.d.ts +38 -1
- package/dist/server/index.js +2422 -538
- package/dist/server/index.js.map +1 -1
- package/dist/workers/index.js +111 -22
- package/dist/workers/index.js.map +1 -1
- package/package.json +1 -1
package/dist/browser/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { prove, verify as verify$1 } from '@zk-kit/groth16';
|
|
2
|
+
import { ristretto255_oprf } from '@noble/curves/ed25519.js';
|
|
2
3
|
import { p256 } from '@noble/curves/nist.js';
|
|
3
4
|
import { buildPoseidon } from 'circomlibjs';
|
|
4
5
|
import { scrypt } from '@noble/hashes/scrypt.js';
|
|
5
6
|
import { entropyToMnemonic, validateMnemonic, mnemonicToEntropy } from '@scure/bip39';
|
|
6
7
|
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
|
7
|
-
import { ristretto255_oprf } from '@noble/curves/ed25519.js';
|
|
8
8
|
|
|
9
9
|
const _isTty = (() => {
|
|
10
10
|
try {
|
|
@@ -316,7 +316,7 @@ function fromBase64Url(b64url) {
|
|
|
316
316
|
return fromBase64$2(padded);
|
|
317
317
|
}
|
|
318
318
|
/** Concatenate any number of byte arrays into a single Uint8Array. */
|
|
319
|
-
function concatBytes(...arrays) {
|
|
319
|
+
function concatBytes$1(...arrays) {
|
|
320
320
|
let total = 0;
|
|
321
321
|
for (const a of arrays)
|
|
322
322
|
total += a.byteLength;
|
|
@@ -1243,6 +1243,8 @@ class KeyStore {
|
|
|
1243
1243
|
static instance;
|
|
1244
1244
|
_keys = new Map();
|
|
1245
1245
|
_authKeys = new Map();
|
|
1246
|
+
/** The exact pairs marked vault-backed, so a pair written over them later is never taken for them. */
|
|
1247
|
+
_vaultBacked = new Map();
|
|
1246
1248
|
constructor() { }
|
|
1247
1249
|
static getInstance() {
|
|
1248
1250
|
if (!KeyStore.instance) {
|
|
@@ -1280,7 +1282,28 @@ class KeyStore {
|
|
|
1280
1282
|
const dehydrated = await this.dehydrateKeyPair(id);
|
|
1281
1283
|
appLogger.debug(`Stored remote public keys for ${id}: ecdhPub=${dehydrated.ecdhPub.slice(0, 16)}...`);
|
|
1282
1284
|
}
|
|
1285
|
+
/** Generate an own pair without touching the store; {@link replaceOwnKeyPair} puts it in place. */
|
|
1286
|
+
async generateKeyPair() {
|
|
1287
|
+
const ecdh = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-384' }, true, ['deriveKey', 'deriveBits']);
|
|
1288
|
+
const ecdsa = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign', 'verify']);
|
|
1289
|
+
return {
|
|
1290
|
+
ecdh: { privateKey: ecdh.privateKey, publicKey: ecdh.publicKey },
|
|
1291
|
+
ecdsa: { privateKey: ecdsa.privateKey, publicKey: ecdsa.publicKey },
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1283
1294
|
async hydrateKeyPair(id, dehydrated) {
|
|
1295
|
+
const { ecdh, ecdsa } = await this.importKeyPair(dehydrated);
|
|
1296
|
+
const existing = this._keys.get(id);
|
|
1297
|
+
if (!existing || !existing.privateKey) {
|
|
1298
|
+
this._keys.set(id, ecdh);
|
|
1299
|
+
}
|
|
1300
|
+
const existingAuth = this._authKeys.get(id);
|
|
1301
|
+
if (!existingAuth || !existingAuth.privateKey) {
|
|
1302
|
+
this._authKeys.set(id, ecdsa);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
/** Import a dehydrated pair without touching the store; {@link replaceOwnKeyPair} puts it in place. */
|
|
1306
|
+
async importKeyPair(dehydrated) {
|
|
1284
1307
|
const ecdhPubJwk = JSON.parse(deserialize(dehydrated.ecdhPub));
|
|
1285
1308
|
const ecdhPublicKey = await crypto.subtle.importKey('jwk', ecdhPubJwk, { name: 'ECDH', namedCurve: 'P-384' }, true, []);
|
|
1286
1309
|
const ecdsaPubJwk = JSON.parse(deserialize(dehydrated.ecdsaPub));
|
|
@@ -1295,14 +1318,10 @@ class KeyStore {
|
|
|
1295
1318
|
const ecdsaPrivJwk = JSON.parse(deserialize(dehydrated.ecdsaPriv));
|
|
1296
1319
|
ecdsaPrivateKey = await crypto.subtle.importKey('jwk', ecdsaPrivJwk, { name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign']);
|
|
1297
1320
|
}
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
}
|
|
1302
|
-
const existingAuth = this._authKeys.get(id);
|
|
1303
|
-
if (!existingAuth || !existingAuth.privateKey) {
|
|
1304
|
-
this._authKeys.set(id, { privateKey: ecdsaPrivateKey, publicKey: ecdsaPublicKey });
|
|
1305
|
-
}
|
|
1321
|
+
return {
|
|
1322
|
+
ecdh: { privateKey: ecdhPrivateKey, publicKey: ecdhPublicKey },
|
|
1323
|
+
ecdsa: { privateKey: ecdsaPrivateKey, publicKey: ecdsaPublicKey },
|
|
1324
|
+
};
|
|
1306
1325
|
}
|
|
1307
1326
|
getKeyPair(id) {
|
|
1308
1327
|
return this._keys.get(id) || null;
|
|
@@ -1310,12 +1329,54 @@ class KeyStore {
|
|
|
1310
1329
|
getAuthKeyPair(id) {
|
|
1311
1330
|
return this._authKeys.get(id) || null;
|
|
1312
1331
|
}
|
|
1332
|
+
/** Record that the pair `id` holds now came from the seed-wrapped vault (ChatKeyVault). */
|
|
1333
|
+
markVaultBacked(id) {
|
|
1334
|
+
const ecdh = this._keys.get(id);
|
|
1335
|
+
const ecdsa = this._authKeys.get(id);
|
|
1336
|
+
if (ecdh && ecdsa)
|
|
1337
|
+
this._vaultBacked.set(id, { ecdh, ecdsa });
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Put `pair` in place as `id`'s own pair and mark it vault-backed, in one
|
|
1341
|
+
* synchronous step: whatever `id` held before is dropped, so a pair minted
|
|
1342
|
+
* while the vault pair was being fetched or generated is never kept and
|
|
1343
|
+
* marked in its place (design §3.3).
|
|
1344
|
+
*/
|
|
1345
|
+
replaceOwnKeyPair(id, pair) {
|
|
1346
|
+
this.forget(id);
|
|
1347
|
+
this._keys.set(id, pair.ecdh);
|
|
1348
|
+
this._authKeys.set(id, pair.ecdsa);
|
|
1349
|
+
this._vaultBacked.set(id, { ecdh: pair.ecdh, ecdsa: pair.ecdsa });
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Whether `id`'s own pair was loaded from, or persisted to, the vault in this
|
|
1353
|
+
* tab, and is still the very pair that was marked. A Space identity must be
|
|
1354
|
+
* vault-backed (design §3.3): any other pair, such as one minted by a locked
|
|
1355
|
+
* DM subscription, never takes part, even one that lands over the marked pair.
|
|
1356
|
+
*/
|
|
1357
|
+
isVaultBacked(id) {
|
|
1358
|
+
const marked = this._vaultBacked.get(id);
|
|
1359
|
+
return !!marked && marked.ecdh.privateKey != null
|
|
1360
|
+
&& this._keys.get(id) === marked.ecdh && this._authKeys.get(id) === marked.ecdsa;
|
|
1361
|
+
}
|
|
1362
|
+
/** Drop every key held for `id` (SpecA §6.4 `KeyStore.forget`). */
|
|
1363
|
+
forget(id) {
|
|
1364
|
+
this._keys.delete(id);
|
|
1365
|
+
this._authKeys.delete(id);
|
|
1366
|
+
this._vaultBacked.delete(id);
|
|
1367
|
+
}
|
|
1313
1368
|
async dehydrateKeyPair(id) {
|
|
1314
1369
|
const keyPair = this._keys.get(id);
|
|
1315
1370
|
const authKeyPair = this._authKeys.get(id);
|
|
1316
1371
|
if (!keyPair || !authKeyPair) {
|
|
1317
1372
|
throw new Error(`No key pair for ${id}`);
|
|
1318
1373
|
}
|
|
1374
|
+
return this.exportKeyPair({ ecdh: keyPair, ecdsa: authKeyPair });
|
|
1375
|
+
}
|
|
1376
|
+
/** Dehydrate a pair that is not (yet) in the store, in the same format as {@link dehydrateKeyPair}. */
|
|
1377
|
+
async exportKeyPair(pair) {
|
|
1378
|
+
const keyPair = pair.ecdh;
|
|
1379
|
+
const authKeyPair = pair.ecdsa;
|
|
1319
1380
|
const ecdhPubJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
|
1320
1381
|
const ecdhPrivJwk = keyPair.privateKey ? await crypto.subtle.exportKey('jwk', keyPair.privateKey) : null;
|
|
1321
1382
|
const ecdsaPubJwk = await crypto.subtle.exportKey('jwk', authKeyPair.publicKey);
|
|
@@ -1340,7 +1401,7 @@ class KeyStore {
|
|
|
1340
1401
|
const x = fromBase64Url(jwk.x);
|
|
1341
1402
|
const y = fromBase64Url(jwk.y);
|
|
1342
1403
|
// SEC1 uncompressed point: 0x04 || x || y
|
|
1343
|
-
return concatBytes(new Uint8Array([0x04]), x, y);
|
|
1404
|
+
return concatBytes$1(new Uint8Array([0x04]), x, y);
|
|
1344
1405
|
}
|
|
1345
1406
|
async packDehydratedKeys(id) {
|
|
1346
1407
|
const dehydrated = await this.dehydrateKeyPair(id);
|
|
@@ -1371,7 +1432,6 @@ class DoubleRatchet {
|
|
|
1371
1432
|
sessionType;
|
|
1372
1433
|
maxSkip = 3000;
|
|
1373
1434
|
overlapPeriod = 30000; // ms
|
|
1374
|
-
windowSize = 100; // messages per DH ratchet
|
|
1375
1435
|
constructor(senderId, recipientId, sessionType, isClient = true) {
|
|
1376
1436
|
this.sessionType = sessionType;
|
|
1377
1437
|
const keyStore = KeyStore.getInstance();
|
|
@@ -1423,7 +1483,7 @@ class DoubleRatchet {
|
|
|
1423
1483
|
throw new Error('Missing own private key');
|
|
1424
1484
|
}
|
|
1425
1485
|
const sharedSecret = await crypto.subtle.deriveBits({ name: 'ECDH', public: newPubKey }, ownPriv, 384);
|
|
1426
|
-
const input = concatBytes(this.state.rootKey, new Uint8Array(sharedSecret));
|
|
1486
|
+
const input = concatBytes$1(this.state.rootKey, new Uint8Array(sharedSecret));
|
|
1427
1487
|
const keys = await this.hkdf(input, 'DoubleRatchetDH', 64);
|
|
1428
1488
|
this.state.rootKey = keys.subarray(0, 32);
|
|
1429
1489
|
this.state.recvChainKey = keys.subarray(32);
|
|
@@ -1445,16 +1505,24 @@ class DoubleRatchet {
|
|
|
1445
1505
|
throw new Error(`Session type mismatch: expected ${this.sessionType}, got ${sessionType}`);
|
|
1446
1506
|
}
|
|
1447
1507
|
const keyStore = KeyStore.getInstance();
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1508
|
+
// The DH-ratchet path is disabled, deliberately. It used to fire by
|
|
1509
|
+
// itself once `sendCount` passed a 100-message window, and it was wrong
|
|
1510
|
+
// in three ways at once: it wrote the fresh pair into the PROCESS-GLOBAL
|
|
1511
|
+
// `KeyStore` under our own id (clobbering the long-term ECDH key that
|
|
1512
|
+
// every other ratchet and the Space key-unwrap path read back from the
|
|
1513
|
+
// same entry), it stepped our sending chain without telling the peer (so
|
|
1514
|
+
// message 101 of any conversation stopped decrypting), and it hardcoded
|
|
1515
|
+
// the client role regardless of which half of the session we are.
|
|
1516
|
+
//
|
|
1517
|
+
// No caller in the SDK asks for a rotation — every one passes `false` —
|
|
1518
|
+
// so failing loudly here is strictly better than silently desynchronising
|
|
1519
|
+
// a session. Rotation returns on the standard trigger (receipt of a new
|
|
1520
|
+
// peer `dhPub`) with the key schedule rebuilt; see
|
|
1521
|
+
// docs/superpowers/specs/2026-09-24-dm-protocol-design.md §0 and §3.6.
|
|
1451
1522
|
if (newDhKey && this.sessionType === 'specific') {
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
this.state.clientDhPub = ecdhKeyPair.publicKey;
|
|
1456
|
-
keyStore.keys.set(senderId, { privateKey: ecdhKeyPair.privateKey, publicKey: ecdhKeyPair.publicKey });
|
|
1457
|
-
await this.dhRatchet(this.state.serverDhPub, true);
|
|
1523
|
+
throw new Error('DoubleRatchet: DH key rotation is disabled — the previous implementation ' +
|
|
1524
|
+
'corrupted the process-global KeyStore and desynchronised the peer. ' +
|
|
1525
|
+
'Rotation is reinstated with the key-schedule rewrite (audit H1).');
|
|
1458
1526
|
}
|
|
1459
1527
|
const [messageKey, newChainKey] = await this.symmetricRatchet(this.state.sendChainKey);
|
|
1460
1528
|
this.state.sendChainKey = newChainKey;
|
|
@@ -2209,6 +2277,7 @@ class WSTransport extends EventCore {
|
|
|
2209
2277
|
pongFrame;
|
|
2210
2278
|
heartbeatTimer = null;
|
|
2211
2279
|
pongTimer = null;
|
|
2280
|
+
terminalCloseCodes;
|
|
2212
2281
|
constructor(opts) {
|
|
2213
2282
|
super();
|
|
2214
2283
|
if (!opts || !opts.url) {
|
|
@@ -2225,6 +2294,7 @@ class WSTransport extends EventCore {
|
|
|
2225
2294
|
this.heartbeatTimeout = opts.heartbeatTimeout ?? 10000;
|
|
2226
2295
|
this.pingFrame = opts.pingFrame ?? "ping";
|
|
2227
2296
|
this.pongFrame = opts.pongFrame ?? "pong";
|
|
2297
|
+
this.terminalCloseCodes = opts.terminalCloseCodes ?? [];
|
|
2228
2298
|
}
|
|
2229
2299
|
/** Open the socket. Resolves when CONNECTED fires. Rejects on first error. */
|
|
2230
2300
|
async connect() {
|
|
@@ -2277,12 +2347,17 @@ class WSTransport extends EventCore {
|
|
|
2277
2347
|
reject(err);
|
|
2278
2348
|
}
|
|
2279
2349
|
};
|
|
2280
|
-
socket.onclose = () => {
|
|
2350
|
+
socket.onclose = (ev) => {
|
|
2351
|
+
const code = typeof ev?.code === "number" ? ev.code : undefined;
|
|
2281
2352
|
const wasConnected = this._isConnected;
|
|
2282
2353
|
this._isConnected = false;
|
|
2283
2354
|
this._isConnecting = false;
|
|
2284
2355
|
this.stopHeartbeat();
|
|
2285
|
-
this.emit(EventCoreEvents.DISCONNECTED, undefined);
|
|
2356
|
+
this.emit(EventCoreEvents.DISCONNECTED, code !== undefined ? { code, reason: ev?.reason ?? "" } : undefined);
|
|
2357
|
+
if (code !== undefined && this.terminalCloseCodes.includes(code)) {
|
|
2358
|
+
this.autoReconnect = false;
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
2286
2361
|
if (this.autoReconnect && wasConnected) {
|
|
2287
2362
|
this.scheduleReconnect();
|
|
2288
2363
|
}
|
|
@@ -2312,6 +2387,14 @@ class WSTransport extends EventCore {
|
|
|
2312
2387
|
this._isConnected = false;
|
|
2313
2388
|
this._isConnecting = false;
|
|
2314
2389
|
}
|
|
2390
|
+
/** Restart reconnecting after a terminal close or `disconnect()`; the next URL comes from `urlProvider`. */
|
|
2391
|
+
resume() {
|
|
2392
|
+
// A reconnect already pending is left to run: a second timer would dial on top of it.
|
|
2393
|
+
if (this._isConnected || this._isConnecting || this.reconnectTimeout)
|
|
2394
|
+
return;
|
|
2395
|
+
this.autoReconnect = true;
|
|
2396
|
+
this.scheduleReconnect();
|
|
2397
|
+
}
|
|
2315
2398
|
/**
|
|
2316
2399
|
* Send a raw frame. If currently disconnected, the frame is queued and sent
|
|
2317
2400
|
* after the next successful reconnect (up to `maxQueueSize`).
|
|
@@ -2412,6 +2495,7 @@ class WSTransport extends EventCore {
|
|
|
2412
2495
|
this.reconnectAttempts++;
|
|
2413
2496
|
this.emit(EventCoreEvents.RECONNECTING, { attempt: this.reconnectAttempts });
|
|
2414
2497
|
this.reconnectTimeout = setTimeout(async () => {
|
|
2498
|
+
this.reconnectTimeout = null;
|
|
2415
2499
|
try {
|
|
2416
2500
|
await this.connect();
|
|
2417
2501
|
}
|
|
@@ -2529,8 +2613,9 @@ class BroadcastChannel {
|
|
|
2529
2613
|
void this.announce();
|
|
2530
2614
|
}
|
|
2531
2615
|
});
|
|
2532
|
-
|
|
2533
|
-
|
|
2616
|
+
// `detail` is the close `{code, reason}` when the socket reported one.
|
|
2617
|
+
this.transport.on(EventCoreEvents.DISCONNECTED, (e) => {
|
|
2618
|
+
this.emit(BroadcastChannelEvents.DISCONNECTED, e.detail);
|
|
2534
2619
|
});
|
|
2535
2620
|
this.transport.on(EventCoreEvents.RECONNECTING, (e) => {
|
|
2536
2621
|
this.emit(BroadcastChannelEvents.RECONNECTING, e.detail);
|
|
@@ -2565,6 +2650,10 @@ class BroadcastChannel {
|
|
|
2565
2650
|
disconnect() {
|
|
2566
2651
|
this.transport.disconnect();
|
|
2567
2652
|
}
|
|
2653
|
+
/** Reconnect after a terminal close (see `terminalCloseCodes`) or `disconnect()`, on a fresh `urlProvider` URL. */
|
|
2654
|
+
resume() {
|
|
2655
|
+
this.transport.resume();
|
|
2656
|
+
}
|
|
2568
2657
|
/**
|
|
2569
2658
|
* Send our `keyExchange` frame to the room. Idempotent — only sends on the
|
|
2570
2659
|
* first call. Call this once your higher-level handshake (e.g. sending a
|
|
@@ -3424,6 +3513,79 @@ class DoubleRatchetManager {
|
|
|
3424
3513
|
}
|
|
3425
3514
|
}
|
|
3426
3515
|
|
|
3516
|
+
/**
|
|
3517
|
+
* OPRF (Oblivious PRF) — RFC 9497, ristretto255-SHA512, mode 0x00.
|
|
3518
|
+
*
|
|
3519
|
+
* Used to gate the password/recovery factors of the identity vault
|
|
3520
|
+
* ({@link ./vault}). The wrap key for a password factor is
|
|
3521
|
+
* `HKDF(OPRF(serverKey, scrypt(password)))`, so:
|
|
3522
|
+
* - the client cannot derive the wrap key offline (it needs the server's
|
|
3523
|
+
* secret-keyed evaluation), and
|
|
3524
|
+
* - the server learns nothing — it only ever sees a *blinded* group element,
|
|
3525
|
+
* never the password or the resulting key.
|
|
3526
|
+
*
|
|
3527
|
+
* Net effect: a stolen vault blob is NOT offline-crackable; every guess costs a
|
|
3528
|
+
* round-trip to a rate-limited server endpoint. The same OPRF backs the M2
|
|
3529
|
+
* Google/email factors.
|
|
3530
|
+
*
|
|
3531
|
+
* Thin wrapper over `@noble/curves`' vetted `ristretto255_oprf` so the protocol
|
|
3532
|
+
* (blind / blindEvaluate / finalize) lives in one named place. CLIENT uses
|
|
3533
|
+
* {@link oprfBlind} + {@link oprfFinalize}.
|
|
3534
|
+
*
|
|
3535
|
+
* The `oprfDeriveKey` / `oprfBlindEvaluate` helpers here are the SERVER half,
|
|
3536
|
+
* but note they are used only by this repo's tests. The accelerator does NOT
|
|
3537
|
+
* import them: it has an independent implementation in its own
|
|
3538
|
+
* `src/services/oprf.ts`, also built directly on `@noble/curves`. (An earlier
|
|
3539
|
+
* version of this comment claimed the accelerator's VaultDO imported them; it
|
|
3540
|
+
* does not — it imports `@muhkoo/connect` in exactly two files, both for
|
|
3541
|
+
* Groth16 verification.) Any protocol change therefore has to land in BOTH
|
|
3542
|
+
* repos in lockstep.
|
|
3543
|
+
*
|
|
3544
|
+
* KNOWN WEAKNESS — mode 0x00, tracked as audit finding C4 and NOT fixed here.
|
|
3545
|
+
* This uses RFC 9497 base mode: the server returns a bare evaluation with no
|
|
3546
|
+
* DLEQ proof, and {@link oprfFinalize} verifies nothing. A hostile server can
|
|
3547
|
+
* answer `{evaluated: blinded}` (i.e. k=1); the unblinded point is then just
|
|
3548
|
+
* `H(input)` and the wrap key collapses to a pure function of public strings,
|
|
3549
|
+
* destroying the offline-uncrackability guarantee stated above.
|
|
3550
|
+
*
|
|
3551
|
+
* The fix is `ristretto255_oprf.voprf` (mode 0x01) with the server's public key
|
|
3552
|
+
* pinned as a build-time constant and the proof verified on every finalize.
|
|
3553
|
+
* noble's `voprf` variant is already present and unused. It cannot be done
|
|
3554
|
+
* client-side alone: the wire response (`{evaluated}` / `{evaluated, evaluated2}`)
|
|
3555
|
+
* carries no proof field, and the pinned public keys must correspond to the
|
|
3556
|
+
* accelerator's actual K1/K2 secrets. Changing only this file would break login
|
|
3557
|
+
* against the live server.
|
|
3558
|
+
*/
|
|
3559
|
+
const OPRF = ristretto255_oprf.oprf;
|
|
3560
|
+
// Domain separation for the server key derivation (RFC 9497 `info`).
|
|
3561
|
+
new TextEncoder().encode("muhkoo-oprf-v1");
|
|
3562
|
+
/**
|
|
3563
|
+
* The RFC 9497 mode this client speaks, declared on every evaluation request.
|
|
3564
|
+
*
|
|
3565
|
+
* Declaring it is what lets the server refuse loudly instead of answering in a
|
|
3566
|
+
* mode we did not ask for — the silent substitution that IS audit finding C4.
|
|
3567
|
+
* A server too old to know the field drops it and answers in base mode, which is
|
|
3568
|
+
* what we asked for anyway, so sending it is safe in both directions and imposes
|
|
3569
|
+
* no deploy ordering.
|
|
3570
|
+
*
|
|
3571
|
+
* NOT to be confused with the vault's KEY VERSION, a different axis that also
|
|
3572
|
+
* uses small integers: there, `2` means "per-account key". Here `2` means POPRF.
|
|
3573
|
+
* The server half of these constants is `accelerator/src/services/oprf.ts` and
|
|
3574
|
+
* MUST hold the same numbers.
|
|
3575
|
+
*/
|
|
3576
|
+
const OPRF_PROTOCOL_BASE = 0;
|
|
3577
|
+
/** What this SDK build actually speaks. Bumped to POPRF by the mode migration. */
|
|
3578
|
+
const OPRF_PROTOCOL = OPRF_PROTOCOL_BASE;
|
|
3579
|
+
/** CLIENT: blind `input` (e.g. `scrypt(password)`) before the server eval. */
|
|
3580
|
+
function oprfBlind(input) {
|
|
3581
|
+
const { blind, blinded } = OPRF.blind(input);
|
|
3582
|
+
return { blind, blinded };
|
|
3583
|
+
}
|
|
3584
|
+
/** CLIENT: fold the server's blinded evaluation into the final 64-byte OPRF output. */
|
|
3585
|
+
function oprfFinalize(input, blind, evaluated) {
|
|
3586
|
+
return OPRF.finalize(input, blind, evaluated);
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3427
3589
|
/**
|
|
3428
3590
|
* HTTP client for the accelerator's `/api/auth/*` endpoints.
|
|
3429
3591
|
*
|
|
@@ -3436,6 +3598,27 @@ class DoubleRatchetManager {
|
|
|
3436
3598
|
* a `baseUrl`, optional custom `fetch`, throws typed `Error`s on failure
|
|
3437
3599
|
* (with the worker's message inlined where available).
|
|
3438
3600
|
*/
|
|
3601
|
+
/**
|
|
3602
|
+
* A non-2xx response from `/api/auth/*`, carrying the HTTP status and the
|
|
3603
|
+
* worker's machine-readable `code` alongside its human `error` string.
|
|
3604
|
+
*
|
|
3605
|
+
* Exists because the status used to be discarded: every failure arrived as a
|
|
3606
|
+
* bare `Error` whose only distinguishing feature was its message, so classifying
|
|
3607
|
+
* one meant regex-matching a sentence the server is free to reword. See
|
|
3608
|
+
* `VaultUnavailableError`, which still does exactly that for rate limits.
|
|
3609
|
+
*/
|
|
3610
|
+
class AuthHttpError extends Error {
|
|
3611
|
+
label;
|
|
3612
|
+
status;
|
|
3613
|
+
code;
|
|
3614
|
+
constructor(label, status, code, detail) {
|
|
3615
|
+
super(`AuthClient.${label}: ${detail}`);
|
|
3616
|
+
this.label = label;
|
|
3617
|
+
this.status = status;
|
|
3618
|
+
this.code = code;
|
|
3619
|
+
this.name = "AuthHttpError";
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3439
3622
|
class AuthClient {
|
|
3440
3623
|
baseUrl;
|
|
3441
3624
|
fetchFn;
|
|
@@ -3474,18 +3657,26 @@ class AuthClient {
|
|
|
3474
3657
|
return await this.parse("verify", res);
|
|
3475
3658
|
}
|
|
3476
3659
|
// ---- Identity vault (M1.0) ----------------------------------------------
|
|
3477
|
-
/**
|
|
3478
|
-
|
|
3479
|
-
|
|
3660
|
+
/**
|
|
3661
|
+
* Blinded OPRF evaluation — the server-gated half of a password/recovery wrap key.
|
|
3662
|
+
*
|
|
3663
|
+
* `oprfProtocol` declares the RFC 9497 mode this client speaks so the server
|
|
3664
|
+
* can refuse rather than answer in a different one. A server too old to know
|
|
3665
|
+
* the field drops it and answers in base mode, so this is safe against any
|
|
3666
|
+
* deployment.
|
|
3667
|
+
*/
|
|
3668
|
+
async oprfEvaluate(username, blinded, oprfProtocol = OPRF_PROTOCOL) {
|
|
3669
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/oprf`, this.json("POST", { username, blinded, oprfProtocol }));
|
|
3480
3670
|
return await this.parse("oprfEvaluate", res);
|
|
3481
3671
|
}
|
|
3482
3672
|
/**
|
|
3483
|
-
* Gated
|
|
3484
|
-
*
|
|
3485
|
-
*
|
|
3673
|
+
* Gated OPRF evaluation (M2 — email/google factors). Requires a live
|
|
3674
|
+
* `verifyToken` from {@link emailOtpVerify}; returns BOTH keys'
|
|
3675
|
+
* evaluations (K1 and K2, both operated by Muhkoo). The recover token is
|
|
3676
|
+
* consumed by this call.
|
|
3486
3677
|
*/
|
|
3487
|
-
async oprfEvaluateGated(username, blinded, factorType, purpose, verifyToken) {
|
|
3488
|
-
const res = await this.fetchFn(`${this.baseUrl}/api/auth/oprf`, this.json("POST", { username, blinded, factorType, purpose, verifyToken }));
|
|
3678
|
+
async oprfEvaluateGated(username, blinded, factorType, purpose, verifyToken, oprfProtocol = OPRF_PROTOCOL) {
|
|
3679
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/oprf`, this.json("POST", { username, blinded, factorType, purpose, verifyToken, oprfProtocol }));
|
|
3489
3680
|
return await this.parse("oprfEvaluateGated", res);
|
|
3490
3681
|
}
|
|
3491
3682
|
/**
|
|
@@ -3592,10 +3783,13 @@ class AuthClient {
|
|
|
3592
3783
|
// Leave body null; we'll fall through to the status-only message.
|
|
3593
3784
|
}
|
|
3594
3785
|
if (!res.ok) {
|
|
3595
|
-
const
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3786
|
+
const obj = body && typeof body === "object" ? body : null;
|
|
3787
|
+
const msg = obj && "error" in obj ? String(obj.error) : `${res.status} ${res.statusText}`;
|
|
3788
|
+
// Throws `AuthHttpError` rather than `Error` so callers can classify
|
|
3789
|
+
// by STATUS and CODE instead of regex-matching a human sentence.
|
|
3790
|
+
// `VaultUnavailableError` still does the latter for rate limits; new
|
|
3791
|
+
// code should not copy it — a server-side copy edit breaks it.
|
|
3792
|
+
throw new AuthHttpError(label, res.status, obj && typeof obj.code === "string" ? obj.code : undefined, msg);
|
|
3599
3793
|
}
|
|
3600
3794
|
return body;
|
|
3601
3795
|
}
|
|
@@ -6481,7 +6675,7 @@ async function parseOrThrow(response, op) {
|
|
|
6481
6675
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
6482
6676
|
* `package.json` `version`.
|
|
6483
6677
|
*/
|
|
6484
|
-
const VERSION = "0.
|
|
6678
|
+
const VERSION = "0.15.0-alpha.1";
|
|
6485
6679
|
|
|
6486
6680
|
/**
|
|
6487
6681
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -6597,6 +6791,24 @@ class HttpClient {
|
|
|
6597
6791
|
const tag = `${init?.method ?? "GET"} ${reqPath(input)}`;
|
|
6598
6792
|
return this.fetchFn(input, finalInit).then((res) => { console.info(`[muhkoo:http] ${tag} → ${res.status} ${Math.round(performance.now() - t)}ms`); return res; }, (err) => { console.info(`[muhkoo:http] ${tag} → ERROR ${Math.round(performance.now() - t)}ms`); throw err; });
|
|
6599
6793
|
};
|
|
6794
|
+
/**
|
|
6795
|
+
* {@link fetch} with the JSON helpers' stale-session recovery: a 401 on a
|
|
6796
|
+
* request made while a session token is held runs `onUnauthorized` once
|
|
6797
|
+
* and, if that recovered, replays the request once with the fresh token.
|
|
6798
|
+
* The final response is returned whatever its status, never thrown. The
|
|
6799
|
+
* replay re-sends `init` as given, so its body must be re-sendable (a
|
|
6800
|
+
* string or bytes, not a stream).
|
|
6801
|
+
*/
|
|
6802
|
+
fetchWithRecovery = async (input, init) => {
|
|
6803
|
+
const res = await this.fetch(input, init);
|
|
6804
|
+
// `onUnauthorized` is consulted at most once per call, so a replay that
|
|
6805
|
+
// still 401s (recovery minted a token the endpoint still rejects) can't loop.
|
|
6806
|
+
if (res.status !== 401 || !this.onUnauthorized || !this.getSessionToken())
|
|
6807
|
+
return res;
|
|
6808
|
+
if (!(await this.onUnauthorized()))
|
|
6809
|
+
return res;
|
|
6810
|
+
return this.fetch(input, init);
|
|
6811
|
+
};
|
|
6600
6812
|
/** True when `input` resolves to {@link baseOrigin} (relative URLs always do). */
|
|
6601
6813
|
isSameOrigin(input) {
|
|
6602
6814
|
try {
|
|
@@ -6627,23 +6839,15 @@ class HttpClient {
|
|
|
6627
6839
|
del(path, body) {
|
|
6628
6840
|
return this.request("DELETE", path, body);
|
|
6629
6841
|
}
|
|
6630
|
-
async request(method, path, body
|
|
6842
|
+
async request(method, path, body) {
|
|
6631
6843
|
const url = path.startsWith("http") ? path : `${this.baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
|
|
6632
6844
|
const init = { method };
|
|
6633
6845
|
if (body !== undefined) {
|
|
6634
6846
|
init.headers = { "Content-Type": "application/json" };
|
|
6635
6847
|
init.body = JSON.stringify(body);
|
|
6636
6848
|
}
|
|
6637
|
-
|
|
6638
|
-
|
|
6639
|
-
// `allowRecovery` is cleared on the retry so a still-401 response (e.g.
|
|
6640
|
-
// recovery minted a token the endpoint still rejects) can't loop.
|
|
6641
|
-
if (res.status === 401 && allowRecovery && this.onUnauthorized && this.getSessionToken()) {
|
|
6642
|
-
const recovered = await this.onUnauthorized();
|
|
6643
|
-
if (recovered) {
|
|
6644
|
-
return this.request(method, path, body, false);
|
|
6645
|
-
}
|
|
6646
|
-
}
|
|
6849
|
+
// Stale session: silently re-auth and replay the request once.
|
|
6850
|
+
const res = await this.fetchWithRecovery(url, init);
|
|
6647
6851
|
let parsed = null;
|
|
6648
6852
|
try {
|
|
6649
6853
|
parsed = await res.json();
|
|
@@ -6861,69 +7065,19 @@ function mnemonicToSeed(mnemonic) {
|
|
|
6861
7065
|
}
|
|
6862
7066
|
|
|
6863
7067
|
/**
|
|
6864
|
-
*
|
|
6865
|
-
*
|
|
6866
|
-
* Used to gate the password/recovery factors of the identity vault
|
|
6867
|
-
* ({@link ./vault}). The wrap key for a password factor is
|
|
6868
|
-
* `HKDF(OPRF(serverKey, scrypt(password)))`, so:
|
|
6869
|
-
* - the client cannot derive the wrap key offline (it needs the server's
|
|
6870
|
-
* secret-keyed evaluation), and
|
|
6871
|
-
* - the server learns nothing — it only ever sees a *blinded* group element,
|
|
6872
|
-
* never the password or the resulting key.
|
|
6873
|
-
*
|
|
6874
|
-
* Net effect: a stolen vault blob is NOT offline-crackable; every guess costs a
|
|
6875
|
-
* round-trip to a rate-limited server endpoint. The same OPRF backs the M2
|
|
6876
|
-
* Google/email factors.
|
|
6877
|
-
*
|
|
6878
|
-
* Thin wrapper over `@noble/curves`' vetted `ristretto255_oprf` so the protocol
|
|
6879
|
-
* (blind / blindEvaluate / finalize) lives in one named place. CLIENT uses
|
|
6880
|
-
* {@link oprfBlind} + {@link oprfFinalize}.
|
|
6881
|
-
*
|
|
6882
|
-
* The `oprfDeriveKey` / `oprfBlindEvaluate` helpers here are the SERVER half,
|
|
6883
|
-
* but note they are used only by this repo's tests. The accelerator does NOT
|
|
6884
|
-
* import them: it has an independent implementation in its own
|
|
6885
|
-
* `src/services/oprf.ts`, also built directly on `@noble/curves`. (An earlier
|
|
6886
|
-
* version of this comment claimed the accelerator's VaultDO imported them; it
|
|
6887
|
-
* does not — it imports `@muhkoo/connect` in exactly two files, both for
|
|
6888
|
-
* Groth16 verification.) Any protocol change therefore has to land in BOTH
|
|
6889
|
-
* repos in lockstep.
|
|
6890
|
-
*
|
|
6891
|
-
* KNOWN WEAKNESS — mode 0x00, tracked as audit finding C4 and NOT fixed here.
|
|
6892
|
-
* This uses RFC 9497 base mode: the server returns a bare evaluation with no
|
|
6893
|
-
* DLEQ proof, and {@link oprfFinalize} verifies nothing. A hostile server can
|
|
6894
|
-
* answer `{evaluated: blinded}` (i.e. k=1); the unblinded point is then just
|
|
6895
|
-
* `H(input)` and the wrap key collapses to a pure function of public strings,
|
|
6896
|
-
* destroying the offline-uncrackability guarantee stated above.
|
|
6897
|
-
*
|
|
6898
|
-
* The fix is `ristretto255_oprf.voprf` (mode 0x01) with the server's public key
|
|
6899
|
-
* pinned as a build-time constant and the proof verified on every finalize.
|
|
6900
|
-
* noble's `voprf` variant is already present and unused. It cannot be done
|
|
6901
|
-
* client-side alone: the wire response (`{evaluated}` / `{evaluated, evaluated2}`)
|
|
6902
|
-
* carries no proof field, and the pinned public keys must correspond to the
|
|
6903
|
-
* accelerator's actual K1/K2 secrets. Changing only this file would break login
|
|
6904
|
-
* against the live server.
|
|
6905
|
-
*/
|
|
6906
|
-
const OPRF = ristretto255_oprf.oprf;
|
|
6907
|
-
// Domain separation for the server key derivation (RFC 9497 `info`).
|
|
6908
|
-
new TextEncoder().encode("muhkoo-oprf-v1");
|
|
6909
|
-
/** CLIENT: blind `input` (e.g. `scrypt(password)`) before the server eval. */
|
|
6910
|
-
function oprfBlind(input) {
|
|
6911
|
-
const { blind, blinded } = OPRF.blind(input);
|
|
6912
|
-
return { blind, blinded };
|
|
6913
|
-
}
|
|
6914
|
-
/** CLIENT: fold the server's blinded evaluation into the final 64-byte OPRF output. */
|
|
6915
|
-
function oprfFinalize(input, blind, evaluated) {
|
|
6916
|
-
return OPRF.finalize(input, blind, evaluated);
|
|
6917
|
-
}
|
|
6918
|
-
|
|
6919
|
-
/**
|
|
6920
|
-
* Verification-gated vault factors (M2 — email, later Google).
|
|
7068
|
+
* Verification-gated vault factors (M2 — email and Google).
|
|
6921
7069
|
*
|
|
6922
7070
|
* These factors have NO client-side secret: the user proves *channel control*
|
|
6923
|
-
* (an email OTP) and the server releases a
|
|
6924
|
-
*
|
|
6925
|
-
*
|
|
6926
|
-
*
|
|
7071
|
+
* (an email OTP, or a verified Google ID token) and the server releases a
|
|
7072
|
+
* two-key OPRF evaluation —
|
|
7073
|
+
* `wrapKey = HKDF( OPRF(K1, input) ‖ OPRF(K2, input) )`. Both keys are
|
|
7074
|
+
* operated by Muhkoo (K1 is the accelerator's `OPRF_KEY`), and the accelerator
|
|
7075
|
+
* relays K2's evaluation to the client verbatim, which folds it in with no
|
|
7076
|
+
* verification. So the fold protects a stolen vault blob against outside
|
|
7077
|
+
* attackers only — not against the operator, and not against an accelerator
|
|
7078
|
+
* compromise. Muhkoo can recover the seed of any account with a gated factor;
|
|
7079
|
+
* the hosted sign-in UI discloses this, and custom enroll UIs must too. See
|
|
7080
|
+
* `docs/superpowers/specs/2026-09-25-vault-poprf-design.md` §2.4, and accelerator
|
|
6927
7081
|
* `AUTH_M2_PLAN.md` §1; the canonical reference implementation is
|
|
6928
7082
|
* accelerator `tests/gated-oprf.test.ts` — keep these derivations in
|
|
6929
7083
|
* byte-exact lockstep with it.
|
|
@@ -7001,6 +7155,14 @@ async function gatedWrapKey(input, blind, evaluatedB64, evaluated2B64) {
|
|
|
7001
7155
|
* one-time key delivered in a URL fragment a TV can't receive).
|
|
7002
7156
|
*/
|
|
7003
7157
|
const PKCE_STORE_KEY = "muhkoo.hosted.pkce";
|
|
7158
|
+
/**
|
|
7159
|
+
* A same-origin path the app may be sent back to after sign-in. It starts with
|
|
7160
|
+
* "/", not "//", and has no backslash or control characters (design §4.4.9).
|
|
7161
|
+
*/
|
|
7162
|
+
function isSameOriginPath(p) {
|
|
7163
|
+
return typeof p === "string" && p.startsWith("/") && !p.startsWith("//") && !p.includes("\\")
|
|
7164
|
+
&& ![...p].some((c) => c.charCodeAt(0) < 0x20);
|
|
7165
|
+
}
|
|
7004
7166
|
// ---- device pairing: constants (tv-device-pairing-spec.md §4.5, §9) --------
|
|
7005
7167
|
/** RFC-8628-style backoff step applied on every `slow_down` (spec §9.1). */
|
|
7006
7168
|
const SLOW_DOWN_STEP_S = 5;
|
|
@@ -7084,12 +7246,15 @@ class HostedAuth {
|
|
|
7084
7246
|
* (App side) Begin hosted login: stash a PKCE verifier + state, then send
|
|
7085
7247
|
* the browser to the hosted authorize page. Returns the URL it redirects to
|
|
7086
7248
|
* (also useful for popup mode / tests). `redirectUri` must be registered for
|
|
7087
|
-
* the app in the portal.
|
|
7249
|
+
* the app in the portal. `returnTo`, a same-origin path, rides the stash and
|
|
7250
|
+
* comes back from {@link handleCallback}; anything else is refused here.
|
|
7088
7251
|
*/
|
|
7089
7252
|
async login(opts) {
|
|
7253
|
+
if (opts.returnTo !== undefined && !isSameOriginPath(opts.returnTo))
|
|
7254
|
+
throw new Error('returnTo must be a same-origin path (start with "/", not "//").');
|
|
7090
7255
|
const { codeVerifier, codeChallenge } = await generatePkce();
|
|
7091
7256
|
const state = randomState();
|
|
7092
|
-
this.store({ codeVerifier, state, appId: opts.appId, redirectUri: opts.redirectUri });
|
|
7257
|
+
this.store({ codeVerifier, state, appId: opts.appId, redirectUri: opts.redirectUri, ...(opts.returnTo ? { returnTo: opts.returnTo } : {}) });
|
|
7093
7258
|
const params = {
|
|
7094
7259
|
app_id: opts.appId,
|
|
7095
7260
|
redirect_uri: opts.redirectUri,
|
|
@@ -7135,7 +7300,8 @@ class HostedAuth {
|
|
|
7135
7300
|
/**
|
|
7136
7301
|
* (App side) Complete login on the callback URL: verify state, exchange the
|
|
7137
7302
|
* code (+ PKCE verifier) for the session, unseal the seed from the fragment,
|
|
7138
|
-
* establish the session, and scrub the URL. Returns the signed-in user
|
|
7303
|
+
* establish the session, and scrub the URL. Returns the signed-in user, with
|
|
7304
|
+
* the `returnTo` passed to {@link login} when it is still a same-origin path.
|
|
7139
7305
|
*/
|
|
7140
7306
|
async handleCallback() {
|
|
7141
7307
|
const loc = this.location();
|
|
@@ -7157,7 +7323,8 @@ class HostedAuth {
|
|
|
7157
7323
|
this.deps.session.setIdentity(identity);
|
|
7158
7324
|
this.deps.session.setSeed(seed);
|
|
7159
7325
|
this.scrubUrl();
|
|
7160
|
-
|
|
7326
|
+
const returnTo = isSameOriginPath(stash.returnTo) ? stash.returnTo : undefined;
|
|
7327
|
+
return { username: res.username, commitment: res.commitment, ...(returnTo ? { returnTo } : {}) };
|
|
7161
7328
|
}
|
|
7162
7329
|
/**
|
|
7163
7330
|
* (Hosted-page side) Finish an /authorize request after the user has
|
|
@@ -7902,6 +8069,34 @@ class VaultUnavailableError extends Error {
|
|
|
7902
8069
|
this.cause = cause;
|
|
7903
8070
|
}
|
|
7904
8071
|
}
|
|
8072
|
+
/**
|
|
8073
|
+
* The vault refused the OPRF protocol this client speaks.
|
|
8074
|
+
*
|
|
8075
|
+
* Distinct from {@link VaultUnavailableError} because the remedy is different and
|
|
8076
|
+
* the two used to be conflated: "unavailable" means retry, and a version mismatch
|
|
8077
|
+
* never resolves by retrying — it resolves by updating the app (or the server).
|
|
8078
|
+
*
|
|
8079
|
+
* Critically, this must NOT be caught into the
|
|
8080
|
+
* `vaultSeed ?? deriveMasterSeedFromPassword(...)` fallback in `login`/`unlock`.
|
|
8081
|
+
* That fallback is correct for an account with no password factor, and wrong for
|
|
8082
|
+
* a refusal: it derives a different seed, and on a legacy-migrated account —
|
|
8083
|
+
* where the fallback seed happens to be the right one — it would let
|
|
8084
|
+
* `migrateLegacyPasswordFactor` rewrite the factor as a side effect of a failure
|
|
8085
|
+
* the user was never shown.
|
|
8086
|
+
*/
|
|
8087
|
+
class VaultProtocolMismatchError extends Error {
|
|
8088
|
+
constructor(cause) {
|
|
8089
|
+
super("This app version can't talk to the secure vault. Update the app and try again.");
|
|
8090
|
+
this.name = "VaultProtocolMismatchError";
|
|
8091
|
+
this.cause = cause;
|
|
8092
|
+
}
|
|
8093
|
+
}
|
|
8094
|
+
/** Is `e` the vault refusing our OPRF protocol (HTTP 409)? */
|
|
8095
|
+
function isProtocolRefusal(e) {
|
|
8096
|
+
// Classified by STATUS and CODE, never by matching the server's prose.
|
|
8097
|
+
const err = e;
|
|
8098
|
+
return !!err && err.status === 409 && err.code === "oprf_protocol_unsupported";
|
|
8099
|
+
}
|
|
7905
8100
|
/**
|
|
7906
8101
|
* A stored device pairing that the account has since revoked.
|
|
7907
8102
|
*
|
|
@@ -7996,7 +8191,13 @@ class ZkAuth {
|
|
|
7996
8191
|
try {
|
|
7997
8192
|
await this.enrollPasswordFactor(username, password, seed, token);
|
|
7998
8193
|
}
|
|
7999
|
-
catch {
|
|
8194
|
+
catch (e) {
|
|
8195
|
+
// "Best effort" is right for a transient failure — we retry next
|
|
8196
|
+
// login. It is wrong for a protocol refusal: that will fail every
|
|
8197
|
+
// time, and swallowing it means the user is told nothing while the
|
|
8198
|
+
// account quietly never gets its factor. Surface that one.
|
|
8199
|
+
if (e instanceof VaultProtocolMismatchError)
|
|
8200
|
+
throw e;
|
|
8000
8201
|
/* migrate next time */
|
|
8001
8202
|
}
|
|
8002
8203
|
}
|
|
@@ -8194,15 +8395,16 @@ class ZkAuth {
|
|
|
8194
8395
|
}
|
|
8195
8396
|
/**
|
|
8196
8397
|
* Add an **email** as a recovery + login factor (M2 verification-gated:
|
|
8197
|
-
* the server only releases the
|
|
8398
|
+
* the server only releases the gated OPRF eval after the address is
|
|
8198
8399
|
* OTP-verified). Sends the code, then returns a `confirm` continuation:
|
|
8199
8400
|
*
|
|
8200
8401
|
* const { confirm } = await client.auth.zk.enrollEmailFactor("me@x.com");
|
|
8201
8402
|
* await confirm(codeFromInbox);
|
|
8202
8403
|
*
|
|
8203
8404
|
* Requires being signed in with the seed held (like the other enrolls).
|
|
8204
|
-
* Custody note:
|
|
8205
|
-
*
|
|
8405
|
+
* Custody note: Muhkoo holds the keys behind gated factors, so it can
|
|
8406
|
+
* recover the seed of any account with an email or Google factor. The
|
|
8407
|
+
* hosted sign-in UI discloses this; a custom enroll UI must too.
|
|
8206
8408
|
*/
|
|
8207
8409
|
async enrollEmailFactor(email) {
|
|
8208
8410
|
const seed = this.deps.session.seed;
|
|
@@ -8228,7 +8430,7 @@ class ZkAuth {
|
|
|
8228
8430
|
/**
|
|
8229
8431
|
* Recover an account via its enrolled **email** factor. Sends a code to the
|
|
8230
8432
|
* enrolled address (the response never reveals whether one exists), then
|
|
8231
|
-
* `confirm(code)` completes: gated read +
|
|
8433
|
+
* `confirm(code)` completes: gated read + gated OPRF eval → unwrap seed →
|
|
8232
8434
|
* normal ZK login. After this, call {@link changePassword} to set a new
|
|
8233
8435
|
* password.
|
|
8234
8436
|
*
|
|
@@ -8258,12 +8460,15 @@ class ZkAuth {
|
|
|
8258
8460
|
}
|
|
8259
8461
|
// --- Google factor (M2.3) ------------------------------------------------
|
|
8260
8462
|
// Google is pure authentication UX: its verified ID token gates the
|
|
8261
|
-
//
|
|
8463
|
+
// OPRF eval that releases the master seed. The ZK identity layer
|
|
8262
8464
|
// is unchanged. The app obtains `idToken` via Google Identity Services and
|
|
8263
8465
|
// passes it in; the SDK never renders Google UI.
|
|
8264
8466
|
/**
|
|
8265
8467
|
* Link a Google account as a recovery + login factor. Requires being signed
|
|
8266
8468
|
* in with the seed held. `idToken` is a fresh Google ID token (GIS).
|
|
8469
|
+
* Custody note: as with {@link enrollEmailFactor}, Muhkoo holds the keys
|
|
8470
|
+
* behind this factor and can recover the seed of an account that has it.
|
|
8471
|
+
* The hosted sign-in UI discloses this; a custom enroll UI must too.
|
|
8267
8472
|
*/
|
|
8268
8473
|
async enrollGoogleFactor(idToken) {
|
|
8269
8474
|
const seed = this.deps.session.seed;
|
|
@@ -8281,7 +8486,7 @@ class ZkAuth {
|
|
|
8281
8486
|
}
|
|
8282
8487
|
/**
|
|
8283
8488
|
* Sign in with Google — verify the ID token, unlock the seed via the gated
|
|
8284
|
-
*
|
|
8489
|
+
* OPRF eval, and establish a session. Same machinery serves "Sign in
|
|
8285
8490
|
* with Google" and "recover with Google"; the account must have linked the
|
|
8286
8491
|
* factor first.
|
|
8287
8492
|
*/
|
|
@@ -8304,7 +8509,8 @@ class ZkAuth {
|
|
|
8304
8509
|
* Register a brand-new, **passwordless** account whose only factor is Google.
|
|
8305
8510
|
* A random master seed is generated and wrapped under the Google-gated key;
|
|
8306
8511
|
* the user can add a password / passkey / phrase later. The ZK registration
|
|
8307
|
-
* is identical to a password signup.
|
|
8512
|
+
* is identical to a password signup. Custody note: see
|
|
8513
|
+
* {@link enrollGoogleFactor} — Muhkoo can recover this account's seed.
|
|
8308
8514
|
*/
|
|
8309
8515
|
async registerWithGoogle(username, idToken) {
|
|
8310
8516
|
const seed = randomSeed();
|
|
@@ -8631,7 +8837,16 @@ class ZkAuth {
|
|
|
8631
8837
|
async passwordWrapKey(username, password) {
|
|
8632
8838
|
const pre = passwordPreHash(username, password);
|
|
8633
8839
|
const { blind, blinded } = oprfBlind(pre);
|
|
8634
|
-
const { evaluated } = await this.deps.auth
|
|
8840
|
+
const { evaluated } = await this.deps.auth
|
|
8841
|
+
.oprfEvaluate(username, toBase64$1(blinded), OPRF_PROTOCOL)
|
|
8842
|
+
.catch((e) => {
|
|
8843
|
+
// Raised here, at the one place that knows the call was an OPRF
|
|
8844
|
+
// evaluation, so every caller up the stack sees the typed error
|
|
8845
|
+
// rather than a generic transport failure.
|
|
8846
|
+
if (isProtocolRefusal(e))
|
|
8847
|
+
throw new VaultProtocolMismatchError(e);
|
|
8848
|
+
throw e;
|
|
8849
|
+
});
|
|
8635
8850
|
return wrapKeyFromOprf(oprfFinalize(pre, blind, fromBase64$1(evaluated)));
|
|
8636
8851
|
}
|
|
8637
8852
|
/**
|
|
@@ -8655,7 +8870,14 @@ class ZkAuth {
|
|
|
8655
8870
|
return null; // genuinely no factor → legacy account
|
|
8656
8871
|
const key = await this
|
|
8657
8872
|
.passwordWrapKey(username, password)
|
|
8658
|
-
.catch((e) => {
|
|
8873
|
+
.catch((e) => {
|
|
8874
|
+
// A protocol refusal passes through UNCHANGED. Wrapping it here
|
|
8875
|
+
// would erase the type one line after it was raised, and tell the
|
|
8876
|
+
// user to check their connection about a version mismatch.
|
|
8877
|
+
if (e instanceof VaultProtocolMismatchError)
|
|
8878
|
+
throw e;
|
|
8879
|
+
throw new VaultUnavailableError(e); // OPRF eval unreachable / rate-limited
|
|
8880
|
+
});
|
|
8659
8881
|
try {
|
|
8660
8882
|
return await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
8661
8883
|
}
|
|
@@ -9530,81 +9752,759 @@ class SpacePacketCipher {
|
|
|
9530
9752
|
}
|
|
9531
9753
|
|
|
9532
9754
|
/**
|
|
9533
|
-
*
|
|
9534
|
-
*
|
|
9535
|
-
*
|
|
9536
|
-
* - **realtime transport** over a {@link BroadcastChannel} (group handshake,
|
|
9537
|
-
* roster frames, raw-frame passthrough);
|
|
9538
|
-
* - **fan-out group messaging** — `Message`s sealed once with the space's
|
|
9539
|
-
* group key (via {@link SpacePacketCipher}), wrapped in a cleartext
|
|
9540
|
-
* {@link Packet} envelope, broadcast AND persisted by the server as
|
|
9541
|
-
* history (the `spaceMessage` frame);
|
|
9542
|
-
* - **room-scoped file storage** via {@link FileStorage} over the shard
|
|
9543
|
-
* endpoint;
|
|
9544
|
-
* - **legacy per-peer Double Ratchet messaging** (`send`/`announce`) for the
|
|
9545
|
-
* ephemeral, no-history path — unchanged so existing callers (the chat hook
|
|
9546
|
-
* reached via `client.message.room(name)`) keep working.
|
|
9547
|
-
*
|
|
9548
|
-
* `Space` was formerly `Room`; `Room` remains exported as an alias. The
|
|
9549
|
-
* fan-out methods (`sendMessage`/`onMessage`/`history`/`join`/`admit`/`rotate`)
|
|
9550
|
-
* require a {@link SpaceKeyring} dep; without one only the transport + DR +
|
|
9551
|
-
* file surface is available.
|
|
9755
|
+
* Trust-layer errors shared by Spaces and, later, DMs (design §3.3, §6.9;
|
|
9756
|
+
* SpecA §6.6). Class names are public API and are frozen at their first
|
|
9757
|
+
* release (design Q16).
|
|
9552
9758
|
*/
|
|
9553
|
-
|
|
9554
|
-
|
|
9555
|
-
|
|
9556
|
-
|
|
9557
|
-
|
|
9558
|
-
|
|
9559
|
-
|
|
9560
|
-
/** Per-instance event bus for fan-out events (isolated across spaces). */
|
|
9561
|
-
events = new EventTarget();
|
|
9562
|
-
rawWired = false;
|
|
9563
|
-
catchUpUnsub = null;
|
|
9564
|
-
/** Optional P2P swarm for this Space (block exchange among members). */
|
|
9565
|
-
peerNet = null;
|
|
9566
|
-
constructor(deps) {
|
|
9567
|
-
this.deps = deps;
|
|
9568
|
-
this.name = deps.name;
|
|
9569
|
-
this.historyPolicy = deps.historyPolicy ?? "static";
|
|
9570
|
-
this.cipher = deps.keyring ? new SpacePacketCipher(deps.keyring) : null;
|
|
9759
|
+
/** The session holds no seed, so it has no stable identity and may not take part (design §3.3, T7). */
|
|
9760
|
+
class IdentityLockedError extends Error {
|
|
9761
|
+
code = "identity-locked";
|
|
9762
|
+
constructor(message = "This session is locked. Sign in again (or call client.auth.zk.unlock(password)) to use encrypted spaces. " +
|
|
9763
|
+
"Earlier SDK versions silently used a temporary key here, which hid your earlier messages from other members.") {
|
|
9764
|
+
super(message);
|
|
9765
|
+
this.name = "IdentityLockedError";
|
|
9571
9766
|
}
|
|
9572
|
-
|
|
9573
|
-
|
|
9574
|
-
|
|
9767
|
+
}
|
|
9768
|
+
/**
|
|
9769
|
+
* The stable chat keys could not be loaded (design §3.3). `retryable` says
|
|
9770
|
+
* whether the same call can succeed later. It is true when the vault could not
|
|
9771
|
+
* be reached or written (a 5xx or a network error on `chat-keys`), which the
|
|
9772
|
+
* next open retries (§9.2 I8). It is false when the saved keys were read but do
|
|
9773
|
+
* not unwrap under this session's seed, because they are damaged or were saved
|
|
9774
|
+
* under another seed, and when the client has no vault: retrying fails the same way.
|
|
9775
|
+
*/
|
|
9776
|
+
class IdentityUnavailableError extends Error {
|
|
9777
|
+
reason;
|
|
9778
|
+
retryable;
|
|
9779
|
+
code = "identity-unavailable";
|
|
9780
|
+
constructor(message = "Your chat keys could not be loaded right now. Try again.", reason, retryable = true) {
|
|
9781
|
+
super(message);
|
|
9782
|
+
this.reason = reason;
|
|
9783
|
+
this.retryable = retryable;
|
|
9784
|
+
this.name = "IdentityUnavailableError";
|
|
9575
9785
|
}
|
|
9576
|
-
|
|
9577
|
-
|
|
9578
|
-
|
|
9786
|
+
}
|
|
9787
|
+
/** A membership-log or pin failure. `detail` carries server fields (`reason`, `head`, ...). */
|
|
9788
|
+
class SpaceTrustError extends Error {
|
|
9789
|
+
code;
|
|
9790
|
+
detail;
|
|
9791
|
+
constructor(code, message, detail) {
|
|
9792
|
+
super(message ?? code);
|
|
9793
|
+
this.code = code;
|
|
9794
|
+
this.detail = detail;
|
|
9795
|
+
this.name = "SpaceTrustError";
|
|
9579
9796
|
}
|
|
9580
|
-
|
|
9581
|
-
|
|
9582
|
-
|
|
9583
|
-
|
|
9584
|
-
|
|
9585
|
-
|
|
9586
|
-
|
|
9587
|
-
|
|
9588
|
-
|
|
9589
|
-
|
|
9590
|
-
|
|
9591
|
-
|
|
9592
|
-
|
|
9593
|
-
|
|
9594
|
-
|
|
9595
|
-
|
|
9596
|
-
|
|
9597
|
-
|
|
9598
|
-
|
|
9599
|
-
|
|
9600
|
-
|
|
9601
|
-
|
|
9602
|
-
|
|
9603
|
-
|
|
9797
|
+
}
|
|
9798
|
+
/** This user may not open this Space (design §4.5.12, V25). */
|
|
9799
|
+
class AdmissionDeniedError extends Error {
|
|
9800
|
+
code;
|
|
9801
|
+
constructor(code, message) {
|
|
9802
|
+
super(message ?? code);
|
|
9803
|
+
this.code = code;
|
|
9804
|
+
this.name = "AdmissionDeniedError";
|
|
9805
|
+
}
|
|
9806
|
+
}
|
|
9807
|
+
|
|
9808
|
+
/**
|
|
9809
|
+
* Length-prefixed encoding (SpecA §3.1), the primitive every signed byte string
|
|
9810
|
+
* in `src/spaces/trust` is built from:
|
|
9811
|
+
*
|
|
9812
|
+
* LP(x) := uint32be(len(x)) ‖ x
|
|
9813
|
+
*
|
|
9814
|
+
* Usernames carry no charset rule, so bare concatenation would not be
|
|
9815
|
+
* injective; every variable field is length-prefixed. A string goes in as the
|
|
9816
|
+
* UTF-8 of the exact transmitted string, never a re-encoding of it.
|
|
9817
|
+
*
|
|
9818
|
+
* Ported unchanged from the accelerator's `src/trust/lp.ts`; the two must stay
|
|
9819
|
+
* byte-identical (`tests/fixtures/mlog-v1.json` pins both).
|
|
9820
|
+
*/
|
|
9821
|
+
/** uint32be(len) ‖ bytes */
|
|
9822
|
+
function lp(bytes) {
|
|
9823
|
+
if (bytes.length > 0xffffffff)
|
|
9824
|
+
throw new Error("lp: field too long");
|
|
9825
|
+
const out = new Uint8Array(4 + bytes.length);
|
|
9826
|
+
new DataView(out.buffer).setUint32(0, bytes.length, false);
|
|
9827
|
+
out.set(bytes, 4);
|
|
9828
|
+
return out;
|
|
9829
|
+
}
|
|
9830
|
+
/** lp(utf8(s)) */
|
|
9831
|
+
function lpStr(s) {
|
|
9832
|
+
return lp(utf8Encode(s));
|
|
9833
|
+
}
|
|
9834
|
+
/**
|
|
9835
|
+
* Canonical decimal of a safe integer: `String(n)`, so "-1", "0", "42" (and
|
|
9836
|
+
* -0 is "0"). Throws on a float, NaN, an infinity or an unsafe integer, so no
|
|
9837
|
+
* such value can ever reach a signature.
|
|
9838
|
+
*/
|
|
9839
|
+
function dec(n) {
|
|
9840
|
+
if (!Number.isSafeInteger(n))
|
|
9841
|
+
throw new Error("dec: not a safe integer");
|
|
9842
|
+
return String(n);
|
|
9843
|
+
}
|
|
9844
|
+
function concatBytes(...parts) {
|
|
9845
|
+
let length = 0;
|
|
9846
|
+
for (const p of parts)
|
|
9847
|
+
length += p.length;
|
|
9848
|
+
const out = new Uint8Array(length);
|
|
9849
|
+
let offset = 0;
|
|
9850
|
+
for (const p of parts) {
|
|
9851
|
+
out.set(p, offset);
|
|
9852
|
+
offset += p.length;
|
|
9853
|
+
}
|
|
9854
|
+
return out;
|
|
9855
|
+
}
|
|
9856
|
+
|
|
9857
|
+
/**
|
|
9858
|
+
* The canonical byte strings of the e2e-channels trust layer (spec §6.10).
|
|
9859
|
+
* Every signature and every hash is computed over these bytes, and the
|
|
9860
|
+
* accelerator builds the same bytes; `tests/fixtures/mlog-v1.json` pins both
|
|
9861
|
+
* sides.
|
|
9862
|
+
*
|
|
9863
|
+
* Rules:
|
|
9864
|
+
* - every field is `LP`'d (`lp.ts`); strings as the UTF-8 of the exact
|
|
9865
|
+
* transmitted string (an `ik` is never normalised before signing);
|
|
9866
|
+
* - integers go through `dec`, so "-1" is valid (the enable intent's seq);
|
|
9867
|
+
* - a hash is never over signature bytes: ECDSA is malleable.
|
|
9868
|
+
*
|
|
9869
|
+
* Changing any byte here breaks every stored log. It is a new label, not an edit.
|
|
9870
|
+
*
|
|
9871
|
+
* Ported unchanged from the accelerator's `src/trust/canonical.ts` (the
|
|
9872
|
+
* server-only app roster encoder is left out). No npm dependencies (§6.9).
|
|
9873
|
+
*/
|
|
9874
|
+
const LABEL_CHATKEY = "muhkoo-chatkey-v1";
|
|
9875
|
+
const LABEL_MLOG = "muhkoo-mlog-v1";
|
|
9876
|
+
const LABEL_EPOCH_COMMIT = "muhkoo-epoch-commit-v1";
|
|
9877
|
+
const CANONICAL_DEC = /^(0|[1-9]\d*)$/;
|
|
9878
|
+
/** LP(LABEL_CHATKEY) ‖ LP(lower(memberId)) ‖ LP(ik) ‖ LP(ecdh) ‖ LP(ecdsa) */
|
|
9879
|
+
function certBytes(memberId, cert) {
|
|
9880
|
+
return concatBytes(lpStr(LABEL_CHATKEY), lpStr(memberId.toLowerCase()), lpStr(cert.ik), lpStr(cert.ecdh), lpStr(cert.ecdsa));
|
|
9881
|
+
}
|
|
9882
|
+
/**
|
|
9883
|
+
* Over the integer keys in ascending numeric order: LP(dec(e)) ‖ LP(value).
|
|
9884
|
+
* `{}` gives no bytes. Throws on a key that is not a canonical decimal
|
|
9885
|
+
* ("01", "-1", "1.0") or a value that is not a string.
|
|
9886
|
+
*/
|
|
9887
|
+
function epochMapBytes(map) {
|
|
9888
|
+
const epochs = [];
|
|
9889
|
+
for (const key of Object.keys(map)) {
|
|
9890
|
+
if (!CANONICAL_DEC.test(key))
|
|
9891
|
+
throw new Error("epochMapBytes: key is not a canonical decimal");
|
|
9892
|
+
const e = Number(key);
|
|
9893
|
+
if (!Number.isSafeInteger(e))
|
|
9894
|
+
throw new Error("epochMapBytes: key is not a safe integer");
|
|
9895
|
+
if (typeof map[key] !== "string")
|
|
9896
|
+
throw new Error("epochMapBytes: value is not a string");
|
|
9897
|
+
epochs.push(e);
|
|
9898
|
+
}
|
|
9899
|
+
epochs.sort((a, b) => a - b);
|
|
9900
|
+
return concatBytes(...epochs.map((e) => concatBytes(lpStr(dec(e)), lpStr(map[dec(e)]))));
|
|
9901
|
+
}
|
|
9902
|
+
/** The per-op concatenated LPs of §3.5. `leave` has none. */
|
|
9903
|
+
function opBytes(entry) {
|
|
9904
|
+
switch (entry.op) {
|
|
9905
|
+
case "genesis": {
|
|
9906
|
+
const b = entry.body;
|
|
9907
|
+
return concatBytes(lpStr(b.visibility), lpStr(b.policy), lpStr(b.appId), lpStr(dec(b.baseEpoch)), lp(epochMapBytes(b.legacyCommits)));
|
|
9908
|
+
}
|
|
9909
|
+
case "add":
|
|
9910
|
+
return concatBytes(lpStr(entry.body.member), lpStr(entry.body.memberIk), lpStr(entry.body.role));
|
|
9911
|
+
case "role":
|
|
9912
|
+
return concatBytes(lpStr(entry.body.member), lpStr(entry.body.role));
|
|
9913
|
+
case "remove":
|
|
9914
|
+
return lpStr(entry.body.member);
|
|
9915
|
+
case "leave":
|
|
9916
|
+
return new Uint8Array(0);
|
|
9917
|
+
case "join":
|
|
9918
|
+
return lpStr(entry.body.via);
|
|
9919
|
+
case "link": {
|
|
9920
|
+
const b = entry.body;
|
|
9921
|
+
return concatBytes(lpStr(b.linkId), lpStr(b.role), lpStr(b.standing ? "1" : "0"), lpStr(dec(b.expiresAt)), lpStr(dec(b.maxUses)), lpStr(b.linkEcdhPub), lpStr(b.linkSignPub), lp(epochMapBytes(b.keyCommits)));
|
|
9922
|
+
}
|
|
9923
|
+
case "unlink":
|
|
9924
|
+
return lpStr(entry.body.linkId);
|
|
9925
|
+
case "redeem":
|
|
9926
|
+
return lpStr(entry.body.linkId);
|
|
9927
|
+
case "svc": {
|
|
9928
|
+
const b = entry.body;
|
|
9929
|
+
return concatBytes(lpStr(b.member), lpStr(b.action), lpStr(b.ecdhPub), lpStr(b.ecdsaPub), lpStr(b.mode), lpStr(dec(b.rosterSeq)));
|
|
9930
|
+
}
|
|
9931
|
+
case "retire":
|
|
9932
|
+
return concatBytes(lpStr(entry.body.member), lpStr(entry.body.reason));
|
|
9933
|
+
default: {
|
|
9934
|
+
const unknownOp = entry;
|
|
9935
|
+
throw new Error(`opBytes: unknown op ${String(unknownOp.op)}`);
|
|
9936
|
+
}
|
|
9937
|
+
}
|
|
9938
|
+
}
|
|
9939
|
+
/** LP(LABEL_MLOG) ‖ LP(spaceId) ‖ LP(dec(seq)) ‖ LP(prev) ‖ LP(op) ‖ LP(by) ‖ LP(ik) ‖ LP(dec(at)) ‖ LP(opBytes). seq −1 is valid (enable intent). */
|
|
9940
|
+
function entryBytes(entry) {
|
|
9941
|
+
return concatBytes(lpStr(LABEL_MLOG), lpStr(entry.spaceId), lpStr(dec(entry.seq)), lpStr(entry.prev), lpStr(entry.op), lpStr(entry.by), lpStr(entry.ik), lpStr(dec(entry.at)), lp(opBytes(entry)));
|
|
9942
|
+
}
|
|
9943
|
+
/** SHA-256 over bytes (WebCrypto). */
|
|
9944
|
+
async function sha256(bytes) {
|
|
9945
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
9946
|
+
}
|
|
9947
|
+
/** b64 (standard, padded) of SHA-256(entryBytes). Never over `sig` or `lsig`. */
|
|
9948
|
+
async function entryHash(entry) {
|
|
9949
|
+
return toBase64$2(await sha256(entryBytes(entry)));
|
|
9950
|
+
}
|
|
9951
|
+
/** LP(LABEL_EPOCH_COMMIT) ‖ LP(spaceId) ‖ LP(dec(epoch)) ‖ LP(dec(prevKeyed)) ‖ LP(keyCommit) ‖ LP(dec(headSeq)) ‖ LP(headHash) ‖ LP(sole?"1":"0") ‖ LP(by) ‖ LP(ik) ‖ LP(dec(at)) */
|
|
9952
|
+
function ecBytes(ec) {
|
|
9953
|
+
return concatBytes(lpStr(LABEL_EPOCH_COMMIT), lpStr(ec.spaceId), lpStr(dec(ec.epoch)), lpStr(dec(ec.prevKeyed)), lpStr(ec.keyCommit), lpStr(dec(ec.headSeq)), lpStr(ec.headHash), lpStr("1" ), lpStr(ec.by), lpStr(ec.ik), lpStr(dec(ec.at)));
|
|
9954
|
+
}
|
|
9955
|
+
/**
|
|
9956
|
+
* keyCommit(e, K) := b64url_nopad(HKDF-SHA256(ikm = K, salt = utf8("epoch:" + e),
|
|
9957
|
+
* info = utf8("muhkoo/space/key-commit/v1\n" + spaceId), L = 32)) (design §4.2.4).
|
|
9958
|
+
* The same function as accelerator/scripts/gen-mlog-fixture.mjs `keyCommit`.
|
|
9959
|
+
*/
|
|
9960
|
+
async function keyCommit(epoch, key, spaceId) {
|
|
9961
|
+
const ikm = await crypto.subtle.importKey("raw", key, "HKDF", false, ["deriveBits"]);
|
|
9962
|
+
const bits = await crypto.subtle.deriveBits({
|
|
9963
|
+
name: "HKDF",
|
|
9964
|
+
hash: "SHA-256",
|
|
9965
|
+
salt: utf8Encode(`epoch:${dec(epoch)}`),
|
|
9966
|
+
info: utf8Encode(`muhkoo/space/key-commit/v1\n${spaceId}`),
|
|
9967
|
+
}, ikm, 256);
|
|
9968
|
+
return toBase64Url(new Uint8Array(bits));
|
|
9969
|
+
}
|
|
9970
|
+
|
|
9971
|
+
/**
|
|
9972
|
+
* Identity keys (IK): an account's P-256 ECDSA public key, raw uncompressed
|
|
9973
|
+
* SEC1 (`0x04 ‖ X ‖ Y`, 65 bytes), registered once with the account.
|
|
9974
|
+
*
|
|
9975
|
+
* Stored IKs come in two spellings. Newer clients register base64
|
|
9976
|
+
* (`exportPublicKeyBase64`); older registrations stored 130-char hex. The
|
|
9977
|
+
* decode rule is the accelerator's: 130 hex digits (either case, optional
|
|
9978
|
+
* `0x`) first, otherwise base64.
|
|
9979
|
+
*
|
|
9980
|
+
* Two IKs are compared as decoded bytes, never as strings: a hex-registered
|
|
9981
|
+
* account and a base64 log entry name the same key.
|
|
9982
|
+
*
|
|
9983
|
+
* Verification is P-256 ECDSA with SHA-256 over IEEE P1363 (r ‖ s, 64 bytes)
|
|
9984
|
+
* signatures in standard or url base64. It fails closed and never throws: a
|
|
9985
|
+
* bad key, a bad signature, or a value with no canonical encoding is simply
|
|
9986
|
+
* "not validly signed". Nothing here logs; a refusal is the caller's to report.
|
|
9987
|
+
*
|
|
9988
|
+
* Ported unchanged from the accelerator's `src/lib/ik.ts` and the primitives of
|
|
9989
|
+
* `src/trust/verify.ts`. No npm dependencies (§6.9).
|
|
9990
|
+
*/
|
|
9991
|
+
/** Raw uncompressed P-256 point length. */
|
|
9992
|
+
const RAW_P256_BYTES = 65;
|
|
9993
|
+
/** The longest accepted spelling: `0x` + 130 hex. A base64 IK is 88 chars. */
|
|
9994
|
+
const MAX_IK_CHARS = 132;
|
|
9995
|
+
const HEX_IK = /^[0-9a-fA-F]{130}$/;
|
|
9996
|
+
/** Standard or url alphabet, optional trailing padding. No whitespace: `atob` would skip it. */
|
|
9997
|
+
const B64_CHARS = /^[A-Za-z0-9+/_-]+={0,2}$/;
|
|
9998
|
+
const P256 = { name: "ECDSA", namedCurve: "P-256" };
|
|
9999
|
+
const ECDSA_SHA256 = { name: "ECDSA", hash: "SHA-256" };
|
|
10000
|
+
const P1363_P256_BYTES = 64;
|
|
10001
|
+
/** 64 bytes is 88 base64 chars; anything much longer is not a signature. */
|
|
10002
|
+
const MAX_SIG_CHARS = 128;
|
|
10003
|
+
/**
|
|
10004
|
+
* Raw uncompressed P-256 point (0x04‖X‖Y, 65 bytes) from 130-hex (optional 0x,
|
|
10005
|
+
* either case) or base64 (standard or url alphabet, padded or not). `null` if
|
|
10006
|
+
* malformed, the wrong length, or not 0x04-prefixed. It does not check that
|
|
10007
|
+
* the point is on the curve; `importIk` (below) does.
|
|
10008
|
+
*/
|
|
10009
|
+
function decodeRawP256(s) {
|
|
10010
|
+
if (typeof s !== "string" || s.length === 0 || s.length > MAX_IK_CHARS)
|
|
10011
|
+
return null;
|
|
10012
|
+
const hex = s.startsWith("0x") ? s.slice(2) : s;
|
|
10013
|
+
let raw;
|
|
10014
|
+
if (HEX_IK.test(hex)) {
|
|
10015
|
+
raw = new Uint8Array(RAW_P256_BYTES);
|
|
10016
|
+
for (let i = 0; i < RAW_P256_BYTES; i++)
|
|
10017
|
+
raw[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
10018
|
+
}
|
|
10019
|
+
else {
|
|
10020
|
+
if (!B64_CHARS.test(s))
|
|
10021
|
+
return null;
|
|
10022
|
+
try {
|
|
10023
|
+
raw = fromBase64Url(s);
|
|
10024
|
+
}
|
|
10025
|
+
catch {
|
|
10026
|
+
// Malformed base64 is not a key; null is the "invalid" answer.
|
|
10027
|
+
return null;
|
|
10028
|
+
}
|
|
10029
|
+
}
|
|
10030
|
+
if (raw.length !== RAW_P256_BYTES || raw[0] !== 0x04)
|
|
10031
|
+
return null;
|
|
10032
|
+
return raw;
|
|
10033
|
+
}
|
|
10034
|
+
/** Byte equality of two IK encodings; false if either does not decode. */
|
|
10035
|
+
function ikEquals(a, b) {
|
|
10036
|
+
const x = decodeRawP256(a);
|
|
10037
|
+
const y = decodeRawP256(b);
|
|
10038
|
+
if (!x || !y)
|
|
10039
|
+
return false;
|
|
10040
|
+
let diff = 0;
|
|
10041
|
+
for (let i = 0; i < RAW_P256_BYTES; i++)
|
|
10042
|
+
diff |= x[i] ^ y[i];
|
|
10043
|
+
return diff === 0;
|
|
10044
|
+
}
|
|
10045
|
+
/** `null` if `ik` does not decode, or is not a point on P-256. */
|
|
10046
|
+
async function importIk(ik) {
|
|
10047
|
+
const raw = decodeRawP256(ik);
|
|
10048
|
+
if (!raw)
|
|
10049
|
+
return null;
|
|
10050
|
+
try {
|
|
10051
|
+
return await crypto.subtle.importKey("raw", raw, P256, false, ["verify"]);
|
|
10052
|
+
}
|
|
10053
|
+
catch {
|
|
10054
|
+
// Not on the curve: not an identity key.
|
|
10055
|
+
return null;
|
|
10056
|
+
}
|
|
10057
|
+
}
|
|
10058
|
+
/** `sig`: 64-byte P1363, standard or url base64. Never throws. */
|
|
10059
|
+
async function verifyP256(ik, sigB64, data) {
|
|
10060
|
+
if (typeof sigB64 !== "string" || sigB64 === "" || sigB64.length > MAX_SIG_CHARS)
|
|
10061
|
+
return false;
|
|
10062
|
+
let sig;
|
|
10063
|
+
try {
|
|
10064
|
+
sig = fromBase64Url(sigB64);
|
|
10065
|
+
}
|
|
10066
|
+
catch {
|
|
10067
|
+
return false;
|
|
10068
|
+
}
|
|
10069
|
+
if (sig.length !== P1363_P256_BYTES)
|
|
10070
|
+
return false;
|
|
10071
|
+
const key = await importIk(ik);
|
|
10072
|
+
if (!key)
|
|
10073
|
+
return false;
|
|
10074
|
+
try {
|
|
10075
|
+
return await crypto.subtle.verify(ECDSA_SHA256, key, sig, data);
|
|
10076
|
+
}
|
|
10077
|
+
catch {
|
|
10078
|
+
return false;
|
|
10079
|
+
}
|
|
10080
|
+
}
|
|
10081
|
+
/** fp(IK) = b64(SHA-256(raw 65 bytes)) (design §3.2). Throws if `ik` is not a P-256 point. */
|
|
10082
|
+
async function fingerprint$1(ik) {
|
|
10083
|
+
const raw = decodeRawP256(ik);
|
|
10084
|
+
if (!raw)
|
|
10085
|
+
throw new Error("fingerprint: not a P-256 identity key");
|
|
10086
|
+
return toBase64$2(new Uint8Array(await crypto.subtle.digest("SHA-256", raw)));
|
|
10087
|
+
}
|
|
10088
|
+
|
|
10089
|
+
/**
|
|
10090
|
+
* KeyringClient — HTTP implementation of {@link KeyringTransport} against the
|
|
10091
|
+
* accelerator's per-space keyring endpoints. All blobs are opaque to the
|
|
10092
|
+
* server; this client just moves them.
|
|
10093
|
+
*
|
|
10094
|
+
* POST /api/spaces/:id/keyring/join-request { ...JoinRequest }
|
|
10095
|
+
* GET /api/spaces/:id/keyring/blobs/:member -> WrappedKey[]
|
|
10096
|
+
* POST /api/spaces/:id/keyring/wrap { targetMemberId, fromMemberId, wrapped }
|
|
10097
|
+
* GET /api/spaces/:id/keyring/pending -> JoinRequest[]
|
|
10098
|
+
* GET /api/spaces/:id/keyring/roster -> { memberId, identityEcdhPub }[]
|
|
10099
|
+
* POST /api/spaces/:id/keyring/rotate { nextEpoch } -> { epoch }
|
|
10100
|
+
* GET /api/spaces/:id/keyring/log ?since&limit -> { entries, head }
|
|
10101
|
+
* POST /api/spaces/:id/keyring/log { entry, ec? } -> { ok, head }
|
|
10102
|
+
* GET /api/spaces/:id/keyring/state (status only)
|
|
10103
|
+
* GET /api/spaces/:id/metadata -> SpaceMetadata
|
|
10104
|
+
*/
|
|
10105
|
+
/** A keyring route answered non-2xx; status and JSON body preserved (the existing methods keep their plain Errors). */
|
|
10106
|
+
class KeyringHttpError extends Error {
|
|
10107
|
+
status;
|
|
10108
|
+
body;
|
|
10109
|
+
constructor(status, body, route) {
|
|
10110
|
+
super(`KeyringClient ${route}: ${status}${body && typeof body.error === "string" ? ` ${body.error}` : ""}`);
|
|
10111
|
+
this.status = status;
|
|
10112
|
+
this.body = body;
|
|
10113
|
+
this.name = "KeyringHttpError";
|
|
10114
|
+
}
|
|
10115
|
+
}
|
|
10116
|
+
class KeyringClient {
|
|
10117
|
+
deps;
|
|
10118
|
+
constructor(deps) {
|
|
10119
|
+
this.deps = deps;
|
|
10120
|
+
}
|
|
10121
|
+
url(suffix) {
|
|
10122
|
+
return `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.deps.spaceId)}${suffix}`;
|
|
10123
|
+
}
|
|
10124
|
+
async post(suffix, body) {
|
|
10125
|
+
const res = await this.deps.fetch(this.url(suffix), {
|
|
10126
|
+
method: "POST",
|
|
10127
|
+
headers: { "Content-Type": "application/json" },
|
|
10128
|
+
body: JSON.stringify(body),
|
|
10129
|
+
});
|
|
10130
|
+
if (!res.ok)
|
|
10131
|
+
throw new Error(`KeyringClient POST ${suffix}: ${res.status} ${res.statusText}`);
|
|
10132
|
+
return (await res.json().catch(() => ({})));
|
|
10133
|
+
}
|
|
10134
|
+
async get(suffix) {
|
|
10135
|
+
const res = await this.deps.fetch(this.url(suffix));
|
|
10136
|
+
if (!res.ok)
|
|
10137
|
+
throw new Error(`KeyringClient GET ${suffix}: ${res.status} ${res.statusText}`);
|
|
10138
|
+
return (await res.json());
|
|
10139
|
+
}
|
|
10140
|
+
/**
|
|
10141
|
+
* The log routes. Sent through `fetchWithRecovery` when given, as `probeState` is, so a stale session is
|
|
10142
|
+
* re-established before a 401 reads as locked (design §6.9): a GET replays safely, and a POST body is a string.
|
|
10143
|
+
*/
|
|
10144
|
+
async request(method, suffix, body) {
|
|
10145
|
+
const res = await (this.deps.fetchWithRecovery ?? this.deps.fetch)(this.url(suffix), method === "POST"
|
|
10146
|
+
? { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
|
|
10147
|
+
: undefined);
|
|
10148
|
+
const text = await res.text();
|
|
10149
|
+
let json = null;
|
|
10150
|
+
try {
|
|
10151
|
+
json = text ? JSON.parse(text) : null;
|
|
10152
|
+
}
|
|
10153
|
+
catch {
|
|
10154
|
+
json = null;
|
|
10155
|
+
}
|
|
10156
|
+
if (!res.ok)
|
|
10157
|
+
throw new KeyringHttpError(res.status, json, `${method} ${suffix.split("?")[0]}`);
|
|
10158
|
+
return json;
|
|
10159
|
+
}
|
|
10160
|
+
/** GET /keyring/log (design §3.6): entries with seq > since, ascending, ≤ limit; head null = no log. */
|
|
10161
|
+
async fetchLog(since = -1, limit = 500) {
|
|
10162
|
+
return this.request("GET", `/keyring/log?since=${since}&limit=${limit}`);
|
|
10163
|
+
}
|
|
10164
|
+
/** POST /keyring/log {entry, ec?} → {ok, head}; `409 {error:"Log moved", head}` (no code) on a compare-and-set miss. */
|
|
10165
|
+
async postLogEntry(entry, ec) {
|
|
10166
|
+
return this.request("POST", "/keyring/log", ec ? { entry, ec } : { entry });
|
|
10167
|
+
}
|
|
10168
|
+
/**
|
|
10169
|
+
* Status of GET /keyring/state, or "network" when the request fails: 403 is not-member, 401 is locked,
|
|
10170
|
+
* anything else resumes (design §6.9). Sent through `fetchWithRecovery` when given, so a stale session
|
|
10171
|
+
* is re-established before a 401 is believed.
|
|
10172
|
+
*/
|
|
10173
|
+
async probeState() {
|
|
10174
|
+
try {
|
|
10175
|
+
const res = await (this.deps.fetchWithRecovery ?? this.deps.fetch)(this.url("/keyring/state"));
|
|
10176
|
+
try {
|
|
10177
|
+
await res.text();
|
|
10178
|
+
}
|
|
10179
|
+
catch { /* ignore */ }
|
|
10180
|
+
return res.status;
|
|
10181
|
+
}
|
|
10182
|
+
catch {
|
|
10183
|
+
return "network";
|
|
10184
|
+
}
|
|
10185
|
+
}
|
|
10186
|
+
async postJoinRequest(req) {
|
|
10187
|
+
await this.post("/keyring/join-request", req);
|
|
10188
|
+
}
|
|
10189
|
+
async fetchBlobs(memberId) {
|
|
10190
|
+
const body = await this.get(`/keyring/blobs/${encodeURIComponent(memberId)}`);
|
|
10191
|
+
return body.blobs ?? [];
|
|
10192
|
+
}
|
|
10193
|
+
async postWrappedKey(targetMemberId, wrapped, fromMemberId) {
|
|
10194
|
+
await this.post("/keyring/wrap", { targetMemberId, fromMemberId, wrapped });
|
|
10195
|
+
}
|
|
10196
|
+
async fetchPending() {
|
|
10197
|
+
const body = await this.get("/keyring/pending");
|
|
10198
|
+
return body.pending ?? [];
|
|
10199
|
+
}
|
|
10200
|
+
async fetchRoster() {
|
|
10201
|
+
const body = await this.get("/keyring/roster");
|
|
10202
|
+
return body.roster ?? [];
|
|
10203
|
+
}
|
|
10204
|
+
async rotate(nextEpoch) {
|
|
10205
|
+
return this.post("/keyring/rotate", { nextEpoch });
|
|
10206
|
+
}
|
|
10207
|
+
async invite(username) {
|
|
10208
|
+
await this.post("/keyring/invite", { username });
|
|
10209
|
+
}
|
|
10210
|
+
// -- Shareable invite links ----------------------------------------------
|
|
10211
|
+
// A capability token tied to this space. Redeeming it allowlists the holder
|
|
10212
|
+
// so the keeper admits them — the link IS the invitation. Mint/list/revoke
|
|
10213
|
+
// require the caller to be the creator or an existing member.
|
|
10214
|
+
async createInviteLink(opts = {}) {
|
|
10215
|
+
return this.post("/keyring/link", opts);
|
|
10216
|
+
}
|
|
10217
|
+
async listInviteLinks() {
|
|
10218
|
+
const body = await this.get("/keyring/links");
|
|
10219
|
+
return body.links ?? [];
|
|
10220
|
+
}
|
|
10221
|
+
/** Set a member's role ("viewer" | "editor"). Owner-only on the server. */
|
|
10222
|
+
async setMemberRole(username, role) {
|
|
10223
|
+
await this.post("/keyring/role", { username, role });
|
|
10224
|
+
}
|
|
10225
|
+
/** Roster with roles (owner/members only). */
|
|
10226
|
+
async members() {
|
|
10227
|
+
const body = await this.get("/keyring/members");
|
|
10228
|
+
return body.members ?? [];
|
|
10229
|
+
}
|
|
10230
|
+
async revokeInviteLink(token) {
|
|
10231
|
+
await this.post("/keyring/link-revoke", { token });
|
|
10232
|
+
}
|
|
10233
|
+
/** Redeem a link: the server allowlists this member and asks the keeper to
|
|
10234
|
+
* wrap the group key for their identity. Caller must be authenticated. */
|
|
10235
|
+
async redeemInvite(req) {
|
|
10236
|
+
await this.post("/keyring/redeem", req);
|
|
10237
|
+
}
|
|
10238
|
+
async fetchMetadata() {
|
|
10239
|
+
try {
|
|
10240
|
+
return await this.get("/metadata");
|
|
10241
|
+
}
|
|
10242
|
+
catch {
|
|
10243
|
+
return null;
|
|
10244
|
+
}
|
|
10245
|
+
}
|
|
10246
|
+
}
|
|
10247
|
+
|
|
10248
|
+
/**
|
|
10249
|
+
* `Space` — a client handle for one shared space.
|
|
10250
|
+
*
|
|
10251
|
+
* A space is the unit everything is built on. It carries:
|
|
10252
|
+
* - **realtime transport** over a {@link BroadcastChannel} (group handshake,
|
|
10253
|
+
* roster frames, raw-frame passthrough);
|
|
10254
|
+
* - **fan-out group messaging** — `Message`s sealed once with the space's
|
|
10255
|
+
* group key (via {@link SpacePacketCipher}), wrapped in a cleartext
|
|
10256
|
+
* {@link Packet} envelope, broadcast AND persisted by the server as
|
|
10257
|
+
* history (the `spaceMessage` frame);
|
|
10258
|
+
* - **room-scoped file storage** via {@link FileStorage} over the shard
|
|
10259
|
+
* endpoint;
|
|
10260
|
+
* - **legacy per-peer Double Ratchet messaging** (`send`/`announce`) for the
|
|
10261
|
+
* ephemeral, no-history path — unchanged so existing callers (the chat hook
|
|
10262
|
+
* reached via `client.message.room(name)`) keep working.
|
|
10263
|
+
*
|
|
10264
|
+
* `Space` was formerly `Room`; `Room` remains exported as an alias. The
|
|
10265
|
+
* fan-out methods (`sendMessage`/`onMessage`/`history`/`join`/`admit`/`rotate`)
|
|
10266
|
+
* require a {@link SpaceKeyring} dep; without one only the transport + DR +
|
|
10267
|
+
* file surface is available.
|
|
10268
|
+
*/
|
|
10269
|
+
/** WebSocket close code for a refused private-Space socket (design §3.6; accelerator NOT_MEMBER_CLOSE_CODE). */
|
|
10270
|
+
const NOT_MEMBER_CLOSE_CODE = 4403;
|
|
10271
|
+
/** At most one resume after a 4403 per Space per this interval (design §6.7). */
|
|
10272
|
+
const NOT_MEMBER_RECHECK_MS = 60_000;
|
|
10273
|
+
const NOT_MEMBER_MESSAGE = "You're not a member of this private channel. Ask the owner to add you.";
|
|
10274
|
+
const IDENTITY_MISMATCH_MESSAGE = "The owner added a different identity for you. Ask them to re-add you.";
|
|
10275
|
+
class Space {
|
|
10276
|
+
deps;
|
|
10277
|
+
name;
|
|
10278
|
+
historyPolicy;
|
|
10279
|
+
channel = null;
|
|
10280
|
+
/**
|
|
10281
|
+
* The log turned this user away and closed `channel` (design §3.7). Its
|
|
10282
|
+
* single-use ticket is spent, so it is never dialled again: the next
|
|
10283
|
+
* `connect()` checks the log and opens a new channel on a fresh ticket.
|
|
10284
|
+
*/
|
|
10285
|
+
channelSpent = false;
|
|
10286
|
+
/** Every raw channel listener (`on`), attached to each channel this Space opens. */
|
|
10287
|
+
listeners = [];
|
|
10288
|
+
cipher;
|
|
10289
|
+
/** Per-instance event bus for fan-out events (isolated across spaces). */
|
|
10290
|
+
events = new EventTarget();
|
|
10291
|
+
rawWired = false;
|
|
10292
|
+
catchUpUnsub = null;
|
|
10293
|
+
/** Optional P2P swarm for this Space (block exchange among members). */
|
|
10294
|
+
peerNet = null;
|
|
10295
|
+
_admission;
|
|
10296
|
+
/** Whether admission follows the log's changes yet (subscribed once, on the first open the log admits). */
|
|
10297
|
+
admissionWired = false;
|
|
10298
|
+
/** When the last resume after a 4403 ran (design §6.7: at most one per {@link NOT_MEMBER_RECHECK_MS}). */
|
|
10299
|
+
lastResumeAt = -Infinity;
|
|
10300
|
+
resumeTimer = null;
|
|
10301
|
+
/** Bumped by `disconnect()`: a /keyring/state answer that arrives after it is dropped. */
|
|
10302
|
+
teardowns = 0;
|
|
10303
|
+
constructor(deps) {
|
|
10304
|
+
this.deps = deps;
|
|
10305
|
+
this.name = deps.name;
|
|
10306
|
+
this.historyPolicy = deps.historyPolicy ?? "static";
|
|
10307
|
+
this.cipher = deps.keyring ? new SpacePacketCipher(deps.keyring) : null;
|
|
10308
|
+
this._admission = deps.locked ? "locked" : "requesting";
|
|
10309
|
+
deps.keyring?.onKeysChanged(() => {
|
|
10310
|
+
if (this._admission === "requesting")
|
|
10311
|
+
this.setAdmission("keyed");
|
|
10312
|
+
});
|
|
10313
|
+
}
|
|
10314
|
+
/** The encoded space id (same as the transport name for fan-out spaces). */
|
|
10315
|
+
get id() {
|
|
10316
|
+
return this.name;
|
|
10317
|
+
}
|
|
10318
|
+
/** The keyring backing this space's group keys, if any. */
|
|
10319
|
+
get keyring() {
|
|
10320
|
+
return this.deps.keyring;
|
|
10321
|
+
}
|
|
10322
|
+
/** Where this member stands in this Space (design §4.5.12, as the slice ships it). */
|
|
10323
|
+
get admission() {
|
|
10324
|
+
return this._admission;
|
|
10325
|
+
}
|
|
10326
|
+
/** Subscribe to admission changes. Returns an unsubscribe. */
|
|
10327
|
+
onAdmission(h) {
|
|
10328
|
+
const l = (e) => h(e.detail);
|
|
10329
|
+
this.events.addEventListener("admission", l);
|
|
10330
|
+
return () => this.events.removeEventListener("admission", l);
|
|
10331
|
+
}
|
|
10332
|
+
setAdmission(s) {
|
|
10333
|
+
if (this._admission === s)
|
|
10334
|
+
return;
|
|
10335
|
+
this._admission = s;
|
|
10336
|
+
this.emitEvent("admission", s);
|
|
10337
|
+
}
|
|
10338
|
+
/** The log turned this user away; nothing reconnects until it admits them again. */
|
|
10339
|
+
denied() {
|
|
10340
|
+
return this._admission === "not-member" || this._admission === "identity-mismatch";
|
|
10341
|
+
}
|
|
10342
|
+
/**
|
|
10343
|
+
* Sync the membership log and derive admission from it, before any ticket
|
|
10344
|
+
* (design §3.4, §3.7, §4.5.12). A 403 from the log route is not-member and a
|
|
10345
|
+
* 401 is locked. Any other failure is fatal only where this client has
|
|
10346
|
+
* verified the log before (it holds a high-water mark). Otherwise the Space
|
|
10347
|
+
* opens as unlogged this time, and the next open or `mlog` frame syncs again.
|
|
10348
|
+
*/
|
|
10349
|
+
async admitByLog(ls) {
|
|
10350
|
+
try {
|
|
10351
|
+
await ls.sync();
|
|
10352
|
+
}
|
|
10353
|
+
catch (e) {
|
|
10354
|
+
if (e instanceof KeyringHttpError && e.status === 403) {
|
|
10355
|
+
this.setAdmission("not-member");
|
|
10356
|
+
throw new AdmissionDeniedError("not-member", NOT_MEMBER_MESSAGE);
|
|
10357
|
+
}
|
|
10358
|
+
if (e instanceof KeyringHttpError && e.status === 401) {
|
|
10359
|
+
this.setAdmission("locked");
|
|
10360
|
+
throw new IdentityLockedError();
|
|
10361
|
+
}
|
|
10362
|
+
// A mark that cannot be read counts as held: the failure is not waved through.
|
|
10363
|
+
if (await ls.hasHighWater().catch(() => true))
|
|
10364
|
+
throw e;
|
|
10365
|
+
log.warn("Space: the member list could not be read; opening without it", {
|
|
10366
|
+
spaceId: this.name,
|
|
10367
|
+
code: failureCode(e),
|
|
10368
|
+
message: e instanceof Error ? e.message : String(e),
|
|
10369
|
+
});
|
|
10370
|
+
}
|
|
10371
|
+
await this.evaluateAdmission();
|
|
10372
|
+
if (this._admission === "not-member")
|
|
10373
|
+
throw new AdmissionDeniedError("not-member", NOT_MEMBER_MESSAGE);
|
|
10374
|
+
if (this._admission === "identity-mismatch")
|
|
10375
|
+
throw new AdmissionDeniedError("identity-mismatch", IDENTITY_MISMATCH_MESSAGE);
|
|
10376
|
+
if (!this.admissionWired) {
|
|
10377
|
+
this.admissionWired = true;
|
|
10378
|
+
ls.onChange(() => {
|
|
10379
|
+
void this.evaluateAdmission().catch((e) => {
|
|
10380
|
+
log.warn("Space: admission could not be re-derived from the member list", {
|
|
10381
|
+
spaceId: this.name,
|
|
10382
|
+
code: failureCode(e),
|
|
10383
|
+
message: e instanceof Error ? e.message : String(e),
|
|
10384
|
+
});
|
|
10385
|
+
});
|
|
10386
|
+
});
|
|
10387
|
+
}
|
|
10388
|
+
}
|
|
10389
|
+
/** Re-derive admission from the verified log (design §3.4 rows, §3.7, §4.5.12). */
|
|
10390
|
+
async evaluateAdmission() {
|
|
10391
|
+
const ls = this.deps.logSync;
|
|
10392
|
+
if (!ls)
|
|
10393
|
+
return;
|
|
10394
|
+
// Before the logEnabled check: a log that went missing leaves the Space unlogged, and broken.
|
|
10395
|
+
if (ls.integrity.broken) {
|
|
10396
|
+
this.setAdmission("integrity");
|
|
10397
|
+
return;
|
|
10398
|
+
}
|
|
10399
|
+
if (!ls.logEnabled)
|
|
10400
|
+
return;
|
|
10401
|
+
const m = ls.member(this.deps.myId());
|
|
10402
|
+
if (!m) {
|
|
10403
|
+
this.setAdmission("not-member");
|
|
10404
|
+
this.dropChannel();
|
|
10405
|
+
return;
|
|
10406
|
+
}
|
|
10407
|
+
if (!ikEquals(m.ik, await ls.signer.identityKeyRaw())) {
|
|
10408
|
+
this.setAdmission("identity-mismatch");
|
|
10409
|
+
this.dropChannel();
|
|
10410
|
+
return;
|
|
10411
|
+
}
|
|
10412
|
+
if (this._admission !== "keyed")
|
|
10413
|
+
this.setAdmission(this.deps.keyring?.hasAnyKey() ? "keyed" : "requesting");
|
|
10414
|
+
}
|
|
10415
|
+
/**
|
|
10416
|
+
* A 4403 alone never shows not-member: ask /keyring/state first (design §6.9, AV12).
|
|
10417
|
+
* 403 is not-member and 401, which `probeState` returns only once a silent re-auth has failed, is
|
|
10418
|
+
* locked; either closes the channel for good, like a log refusal. Any
|
|
10419
|
+
* other answer (404 while step I has no state route, 2xx, 5xx, a network error) resumes, at most
|
|
10420
|
+
* once per {@link NOT_MEMBER_RECHECK_MS}.
|
|
10421
|
+
*/
|
|
10422
|
+
async onNotMemberClose() {
|
|
10423
|
+
const teardowns = this.teardowns;
|
|
10424
|
+
const status = this.deps.probeState
|
|
10425
|
+
? await this.deps.probeState().catch(() => "network")
|
|
10426
|
+
: "network";
|
|
10427
|
+
if (teardowns !== this.teardowns)
|
|
10428
|
+
return; // disconnected while asking: nothing to resume
|
|
10429
|
+
if (status === 403) {
|
|
10430
|
+
this.setAdmission("not-member");
|
|
10431
|
+
this.dropChannel();
|
|
10432
|
+
return;
|
|
10433
|
+
}
|
|
10434
|
+
if (status === 401) {
|
|
10435
|
+
this.setAdmission("locked");
|
|
10436
|
+
this.dropChannel();
|
|
10437
|
+
return;
|
|
10438
|
+
}
|
|
10439
|
+
if (this.resumeTimer)
|
|
10440
|
+
return;
|
|
10441
|
+
const wait = Math.max(0, this.lastResumeAt + NOT_MEMBER_RECHECK_MS - Date.now());
|
|
10442
|
+
this.resumeTimer = setTimeout(() => {
|
|
10443
|
+
this.resumeTimer = null;
|
|
10444
|
+
this.lastResumeAt = Date.now();
|
|
10445
|
+
this.channel?.resume?.();
|
|
10446
|
+
}, wait);
|
|
10447
|
+
}
|
|
10448
|
+
/** Close the socket of a user the log turned away, and never dial that channel again (design §3.7). */
|
|
10449
|
+
dropChannel() {
|
|
10450
|
+
if (!this.channel || this.channelSpent)
|
|
10451
|
+
return;
|
|
10452
|
+
this.channelSpent = true;
|
|
10453
|
+
this.disconnect();
|
|
10454
|
+
}
|
|
10455
|
+
// -- transport -------------------------------------------------------------
|
|
10456
|
+
/**
|
|
10457
|
+
* Open the space websocket (resolves the ticket + identity lazily). On a
|
|
10458
|
+
* Space with a membership log, the log is checked first: a user it does not
|
|
10459
|
+
* list, or lists under another identity key, is refused with
|
|
10460
|
+
* {@link AdmissionDeniedError} before any ticket is fetched (design §4.5.12).
|
|
10461
|
+
*/
|
|
10462
|
+
async connect() {
|
|
10463
|
+
if (this.deps.locked)
|
|
10464
|
+
throw new IdentityLockedError();
|
|
10465
|
+
const ls = this.deps.logSync;
|
|
10466
|
+
// Checked on the first open, and again on a Space the log turned away, so a later call never reopens its socket unchecked.
|
|
10467
|
+
// A channel the log closed is replaced, not redialled: its ticket was single-use.
|
|
10468
|
+
if (ls && (!this.channel || this.channelSpent || this.denied()))
|
|
10469
|
+
await this.admitByLog(ls);
|
|
10470
|
+
if (!this.channel || this.channelSpent) {
|
|
10471
|
+
const spent = this.channel;
|
|
10472
|
+
// The WS upgrade ticket is single-use + short-TTL, so a reconnect
|
|
10473
|
+
// must mint a FRESH one — otherwise the socket drops (idle timeout,
|
|
10474
|
+
// network blip) and never comes back. `buildUrl` is handed to the
|
|
10475
|
+
// transport as a `urlProvider` it calls before each reconnect.
|
|
10476
|
+
const buildUrl = async (reconnect = false) => {
|
|
10477
|
+
const ticket = await this.deps.fetchTicket();
|
|
10478
|
+
if (!ticket && reconnect && this.deps.keyring && this.deps.hasSession?.()) {
|
|
10479
|
+
// A reconnect with a live session never goes ticketless (anonymous): let the transport retry.
|
|
10480
|
+
throw new Error("Space: no WebSocket ticket yet; retrying");
|
|
10481
|
+
}
|
|
10482
|
+
const q = new URLSearchParams();
|
|
10483
|
+
if (ticket)
|
|
10484
|
+
q.set("ticket", ticket);
|
|
10485
|
+
q.set("refuse", "close"); // be refused with the 4403 close, which this SDK treats as terminal (design §3.6)
|
|
10486
|
+
return `${this.deps.wsBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/websocket?${q.toString()}`;
|
|
10487
|
+
};
|
|
10488
|
+
const url = await buildUrl();
|
|
10489
|
+
this.channel = this.deps.createChannel
|
|
10490
|
+
? this.deps.createChannel(url, this.deps.myId())
|
|
10491
|
+
: new BroadcastChannel({
|
|
10492
|
+
url,
|
|
10493
|
+
myId: this.deps.myId(),
|
|
10494
|
+
autoAnnounce: false,
|
|
10495
|
+
urlProvider: () => buildUrl(true),
|
|
10496
|
+
// Keep recovering across network flaps (each attempt mints a
|
|
10497
|
+
// fresh ticket); teardown calls disconnect() which stops it.
|
|
10498
|
+
maxReconnectAttempts: 0,
|
|
10499
|
+
// A 4403 stops the transport; the Space decides whether to resume.
|
|
10500
|
+
terminalCloseCodes: [NOT_MEMBER_CLOSE_CODE],
|
|
9604
10501
|
});
|
|
9605
|
-
|
|
10502
|
+
this.channelSpent = false;
|
|
10503
|
+
this.rawWired = false;
|
|
10504
|
+
for (const [event, handler] of this.listeners) {
|
|
10505
|
+
spent?.off(event, handler);
|
|
9606
10506
|
this.channel.on(event, handler);
|
|
9607
|
-
|
|
10507
|
+
}
|
|
9608
10508
|
// Fan-out spaces drive their own `{name}` handshake + frame routing.
|
|
9609
10509
|
// Legacy (keyring-less) callers wire these themselves, so leave that
|
|
9610
10510
|
// path byte-for-byte unchanged.
|
|
@@ -9615,6 +10515,11 @@ class Space {
|
|
|
9615
10515
|
// (re-runs on each reconnect to re-form the mesh).
|
|
9616
10516
|
this.peerNet?.start();
|
|
9617
10517
|
});
|
|
10518
|
+
this.channel.on(BroadcastChannelEvents.DISCONNECTED, (e) => {
|
|
10519
|
+
const detail = e.detail;
|
|
10520
|
+
if (detail?.code === NOT_MEMBER_CLOSE_CODE)
|
|
10521
|
+
void this.onNotMemberClose();
|
|
10522
|
+
});
|
|
9618
10523
|
this.wireFanout();
|
|
9619
10524
|
// Register offline catch-up once: on each reconnect the sync
|
|
9620
10525
|
// engine pages history forward to fill any gap we missed.
|
|
@@ -9626,6 +10531,11 @@ class Space {
|
|
|
9626
10531
|
await this.channel.connect();
|
|
9627
10532
|
}
|
|
9628
10533
|
disconnect() {
|
|
10534
|
+
this.teardowns++;
|
|
10535
|
+
if (this.resumeTimer) {
|
|
10536
|
+
clearTimeout(this.resumeTimer);
|
|
10537
|
+
this.resumeTimer = null;
|
|
10538
|
+
}
|
|
9629
10539
|
this.channel?.disconnect();
|
|
9630
10540
|
this.catchUpUnsub?.();
|
|
9631
10541
|
this.catchUpUnsub = null;
|
|
@@ -9651,21 +10561,16 @@ class Space {
|
|
|
9651
10561
|
onPeerGossip(handler) {
|
|
9652
10562
|
return this.peerNet?.onGossip(handler) ?? (() => { });
|
|
9653
10563
|
}
|
|
9654
|
-
/** Subscribe to a raw channel event. Buffered until `connect()` if early. */
|
|
10564
|
+
/** Subscribe to a raw channel event. Buffered until `connect()` if early, and kept across a new channel. */
|
|
9655
10565
|
on(event, handler) {
|
|
9656
|
-
|
|
9657
|
-
|
|
9658
|
-
else
|
|
9659
|
-
this.pending.push([event, handler]);
|
|
10566
|
+
this.listeners.push([event, handler]);
|
|
10567
|
+
this.channel?.on(event, handler);
|
|
9660
10568
|
}
|
|
9661
10569
|
off(event, handler) {
|
|
9662
|
-
|
|
9663
|
-
|
|
9664
|
-
|
|
9665
|
-
|
|
9666
|
-
if (i >= 0)
|
|
9667
|
-
this.pending.splice(i, 1);
|
|
9668
|
-
}
|
|
10570
|
+
const i = this.listeners.findIndex(([e, h]) => e === event && h === handler);
|
|
10571
|
+
if (i >= 0)
|
|
10572
|
+
this.listeners.splice(i, 1);
|
|
10573
|
+
this.channel?.off(event, handler);
|
|
9669
10574
|
}
|
|
9670
10575
|
// -- legacy Double Ratchet path (unchanged) --------------------------------
|
|
9671
10576
|
/** Advertise our keyExchange to the room (idempotent per connection). */
|
|
@@ -9905,6 +10810,8 @@ class Space {
|
|
|
9905
10810
|
* hold nothing, request a key and poll until one arrives (or timeout).
|
|
9906
10811
|
*/
|
|
9907
10812
|
async join(opts = {}) {
|
|
10813
|
+
if (this.deps.locked)
|
|
10814
|
+
return false;
|
|
9908
10815
|
const keyring = this.requireKeyring();
|
|
9909
10816
|
await this.connect();
|
|
9910
10817
|
if (await keyring.loadFromCache())
|
|
@@ -9922,10 +10829,27 @@ class Space {
|
|
|
9922
10829
|
}
|
|
9923
10830
|
return false;
|
|
9924
10831
|
}
|
|
9925
|
-
/** Creator path: mint
|
|
10832
|
+
/** Creator path: mint epoch 0; for a private Space, sign the genesis + ec(0) BEFORE connecting (design §4.5.10). */
|
|
9926
10833
|
async create() {
|
|
10834
|
+
if (this.deps.locked)
|
|
10835
|
+
throw new IdentityLockedError();
|
|
9927
10836
|
const keyring = this.requireKeyring();
|
|
9928
10837
|
await keyring.bootstrapNew();
|
|
10838
|
+
const ls = this.deps.logSync;
|
|
10839
|
+
if (this.deps.visibility === "private" && ls) {
|
|
10840
|
+
if (!this.deps.appId)
|
|
10841
|
+
throw new Error("Space: a private Space's genesis needs the app id.");
|
|
10842
|
+
const appId = await this.deps.appId();
|
|
10843
|
+
const k0 = keyring.rawKey(0);
|
|
10844
|
+
await ls.append("genesis", { visibility: "private", policy: this.historyPolicy, appId, baseEpoch: -1, legacyCommits: {} }, async (hash, entry) => {
|
|
10845
|
+
const ec = {
|
|
10846
|
+
v: 1, spaceId: this.name, epoch: 0, prevKeyed: -1,
|
|
10847
|
+
keyCommit: await keyCommit(0, k0, this.name),
|
|
10848
|
+
headSeq: 0, headHash: hash, sole: true, by: entry.by, ik: entry.ik, at: entry.at,
|
|
10849
|
+
};
|
|
10850
|
+
return { ...ec, sig: await ls.signer.sign(ecBytes(ec)) };
|
|
10851
|
+
});
|
|
10852
|
+
}
|
|
9929
10853
|
await this.connect();
|
|
9930
10854
|
// Publish our identity in the member directory so peers can verify our
|
|
9931
10855
|
// message signatures (the creator never "joins", so register here).
|
|
@@ -9945,8 +10869,45 @@ class Space {
|
|
|
9945
10869
|
}
|
|
9946
10870
|
/** Rotate to a fresh epoch and re-wrap to the roster (rotate spaces). */
|
|
9947
10871
|
async rotate(roster) {
|
|
10872
|
+
if (this.deps.logSync?.logEnabled)
|
|
10873
|
+
throw new SpaceTrustError("log-refused", "Key rotation for signed spaces arrives with a later release.");
|
|
9948
10874
|
return this.requireKeyring().rotate(roster);
|
|
9949
10875
|
}
|
|
10876
|
+
// -- signed member list (design §3.5–§3.8) ---------------------------------
|
|
10877
|
+
/** Whether this Space has a verified membership log. */
|
|
10878
|
+
get logEnabled() {
|
|
10879
|
+
return !!this.deps.logSync?.logEnabled;
|
|
10880
|
+
}
|
|
10881
|
+
/** The verified member list, or null when this Space has no log. */
|
|
10882
|
+
membership() {
|
|
10883
|
+
return this.deps.logSync?.logEnabled ? this.deps.logSync.members() : null;
|
|
10884
|
+
}
|
|
10885
|
+
/** Called with the member list (null when unlogged) each time the log is re-synced. Returns an unsubscribe. */
|
|
10886
|
+
onMembership(h) {
|
|
10887
|
+
return this.deps.logSync ? this.deps.logSync.onChange(() => h(this.membership())) : () => { };
|
|
10888
|
+
}
|
|
10889
|
+
requireLog() {
|
|
10890
|
+
const ls = this.deps.logSync;
|
|
10891
|
+
if (!ls?.logEnabled)
|
|
10892
|
+
throw new SpaceTrustError("log-refused", "This space has no signed member list.");
|
|
10893
|
+
return ls;
|
|
10894
|
+
}
|
|
10895
|
+
/** Add by exact username (design §3.8; the owner, or an editor for viewers). Looks the IK up in the directory. */
|
|
10896
|
+
async add(username, opts = {}) {
|
|
10897
|
+
const ls = this.requireLog();
|
|
10898
|
+
const memberIk = await ls.directory.lookup(username);
|
|
10899
|
+
return ls.append("add", { member: username, memberIk, role: opts.role ?? "viewer" });
|
|
10900
|
+
}
|
|
10901
|
+
/** Remove a member. In this build it cuts them off at the relay; it does not rotate the key. */
|
|
10902
|
+
async remove(memberId) {
|
|
10903
|
+
return this.requireLog().append("remove", { member: memberId });
|
|
10904
|
+
}
|
|
10905
|
+
async setRole(memberId, role) {
|
|
10906
|
+
return this.requireLog().append("role", { member: memberId, role });
|
|
10907
|
+
}
|
|
10908
|
+
async leave() {
|
|
10909
|
+
return this.requireLog().append("leave", {});
|
|
10910
|
+
}
|
|
9950
10911
|
// -- room-scoped files (no websocket needed) -------------------------------
|
|
9951
10912
|
async putFile(file, metadata, opts = {}) {
|
|
9952
10913
|
const result = await this.fileStorage(opts.backgroundUpload)
|
|
@@ -10033,6 +10994,13 @@ class Space {
|
|
|
10033
10994
|
const f = frame;
|
|
10034
10995
|
if (!f || typeof f !== "object")
|
|
10035
10996
|
return;
|
|
10997
|
+
if (f.mlog && typeof f.mlog === "object") {
|
|
10998
|
+
this.deps.logSync?.scheduleSync();
|
|
10999
|
+
return;
|
|
11000
|
+
}
|
|
11001
|
+
if (f.rotateDue) {
|
|
11002
|
+
return;
|
|
11003
|
+
} // no client can answer a rotation request yet: key rotation for signed spaces comes later
|
|
10036
11004
|
if (typeof f.spaceMessage === "string" && this.cipher) {
|
|
10037
11005
|
const handle = Number(f.timestamp ?? 0);
|
|
10038
11006
|
const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, handle);
|
|
@@ -10083,7 +11051,10 @@ class Space {
|
|
|
10083
11051
|
// A new member appeared — refresh the directory so their future
|
|
10084
11052
|
// messages verify, and (if we hold the key) auto-admit them.
|
|
10085
11053
|
void this.deps.keyring?.refreshDirectory({ force: true });
|
|
10086
|
-
if (this.deps.autoAdmit !== false &&
|
|
11054
|
+
if (this.deps.autoAdmit !== false &&
|
|
11055
|
+
this.deps.keyring?.hasAnyKey() &&
|
|
11056
|
+
req.memberId !== this.deps.myId() &&
|
|
11057
|
+
this.deps.keyring.canWrapTo(req.memberId)) {
|
|
10087
11058
|
void this.deps.keyring.admit(req.memberId, req.identityEcdhPub).catch(() => { });
|
|
10088
11059
|
}
|
|
10089
11060
|
return;
|
|
@@ -10099,6 +11070,9 @@ class Space {
|
|
|
10099
11070
|
* (replacement content for an existing handle). Requires a keyring.
|
|
10100
11071
|
*/
|
|
10101
11072
|
async sealMessage(payload, channel, contentType, clientId) {
|
|
11073
|
+
if (this.deps.logSync?.integrity.broken) {
|
|
11074
|
+
throw new SpaceTrustError("integrity", "This space's member list couldn't be verified. You can still read messages you already have; sending is paused.");
|
|
11075
|
+
}
|
|
10102
11076
|
const cipher = this.requireCipher(contentType);
|
|
10103
11077
|
const message = new Message(payload);
|
|
10104
11078
|
const headers = await cipher.seal(message.serialize());
|
|
@@ -10278,6 +11252,10 @@ class Space {
|
|
|
10278
11252
|
function delay(ms) {
|
|
10279
11253
|
return new Promise((r) => setTimeout(r, ms));
|
|
10280
11254
|
}
|
|
11255
|
+
/** A failure's HTTP status or error code, for a log line. */
|
|
11256
|
+
function failureCode(e) {
|
|
11257
|
+
return (e instanceof KeyringHttpError ? e.status : e?.code) ?? "unknown";
|
|
11258
|
+
}
|
|
10281
11259
|
/**
|
|
10282
11260
|
* Zero-pad a numeric server handle for use as a cache key, so string-ordered
|
|
10283
11261
|
* keys sort in handle order. Must match the offline layer's `padHandle`.
|
|
@@ -10494,7 +11472,7 @@ function parseMaybeJson(text) {
|
|
|
10494
11472
|
* at channel creation hands the key to the keeper. MUST match the accelerator's
|
|
10495
11473
|
* `KEEPER_MEMBER_ID`.
|
|
10496
11474
|
*/
|
|
10497
|
-
const KEEPER_MEMBER_ID = "__keeper__";
|
|
11475
|
+
const KEEPER_MEMBER_ID$1 = "__keeper__";
|
|
10498
11476
|
/** Staging-only keyring diagnostics (group-key cache hit/miss, persist, pulls). */
|
|
10499
11477
|
const KEYRING_DEBUG = (() => {
|
|
10500
11478
|
try {
|
|
@@ -10523,10 +11501,24 @@ class SpaceKeyring {
|
|
|
10523
11501
|
* this window won't re-fetch (the roster hasn't changed that fast); genuine
|
|
10524
11502
|
* membership changes use `{ force: true }` (join requests / history loads). */
|
|
10525
11503
|
static DIR_COOLDOWN_MS = 5000;
|
|
11504
|
+
keyListeners = new Set();
|
|
10526
11505
|
constructor(deps) {
|
|
10527
11506
|
this.deps = deps;
|
|
10528
11507
|
this.policy = deps.historyPolicy ?? "static";
|
|
10529
11508
|
}
|
|
11509
|
+
/** Notified whenever this keyring gains a key (bootstrap, cache hit, pull). Returns an unsubscribe. */
|
|
11510
|
+
onKeysChanged(h) {
|
|
11511
|
+
this.keyListeners.add(h);
|
|
11512
|
+
return () => this.keyListeners.delete(h);
|
|
11513
|
+
}
|
|
11514
|
+
notifyKeys() {
|
|
11515
|
+
for (const h of this.keyListeners) {
|
|
11516
|
+
try {
|
|
11517
|
+
h();
|
|
11518
|
+
}
|
|
11519
|
+
catch { /* a listener's failure never breaks the keyring */ }
|
|
11520
|
+
}
|
|
11521
|
+
}
|
|
10530
11522
|
// -- member directory (for verifying sender signatures) -------------------
|
|
10531
11523
|
/** The cached ECDSA verify key for a member, if known. */
|
|
10532
11524
|
ecdsaKeyFor(memberId) {
|
|
@@ -10581,6 +11573,14 @@ class SpaceKeyring {
|
|
|
10581
11573
|
hasAnyKey() {
|
|
10582
11574
|
return this.keys.size > 0;
|
|
10583
11575
|
}
|
|
11576
|
+
/** The raw 32-byte group key for `epoch`, for its key commitment (design §4.2.4). */
|
|
11577
|
+
rawKey(epoch) {
|
|
11578
|
+
return this.keys.get(epoch);
|
|
11579
|
+
}
|
|
11580
|
+
/** Whether a key may be wrapped to `memberId` (design §4.5.1: on a signed Space, log members and the keeper only). */
|
|
11581
|
+
canWrapTo(memberId) {
|
|
11582
|
+
return this.deps.canWrapTo ? this.deps.canWrapTo(memberId) : true;
|
|
11583
|
+
}
|
|
10584
11584
|
// -- lifecycle -------------------------------------------------------------
|
|
10585
11585
|
/** Hydrate keys from the PersonalSpace cache, if available. Best-effort —
|
|
10586
11586
|
* a cache miss/failure (e.g. not unlocked) must not block joining. */
|
|
@@ -10622,6 +11622,7 @@ class SpaceKeyring {
|
|
|
10622
11622
|
}
|
|
10623
11623
|
this.epoch = Math.max(0, ...Array.from(this.keys.keys()));
|
|
10624
11624
|
klog(`loadFromCache: HIT ${this.keys.size} epoch(s), current=${this.epoch}`);
|
|
11625
|
+
this.notifyKeys();
|
|
10625
11626
|
return true;
|
|
10626
11627
|
}
|
|
10627
11628
|
async persist() {
|
|
@@ -10651,19 +11652,29 @@ class SpaceKeyring {
|
|
|
10651
11652
|
this.keys.set(0, generateSpaceKey());
|
|
10652
11653
|
this.epoch = 0;
|
|
10653
11654
|
await this.persist();
|
|
11655
|
+
this.notifyKeys();
|
|
10654
11656
|
}
|
|
10655
11657
|
// -- newcomer: request + pull ---------------------------------------------
|
|
10656
11658
|
/**
|
|
10657
11659
|
* Post a join request so an online key-holder can wrap a key for us. Also
|
|
10658
11660
|
* publishes our identity keys in the member directory (so others can verify
|
|
10659
|
-
* our message signatures, and the keeper can wrap to us).
|
|
11661
|
+
* our message signatures, and the keeper can wrap to us). With a `keyCert`
|
|
11662
|
+
* dep it also carries the IK certificate over exactly those two strings.
|
|
10660
11663
|
*/
|
|
10661
11664
|
async requestKey(desiredEpoch) {
|
|
11665
|
+
let keyCert;
|
|
11666
|
+
try {
|
|
11667
|
+
keyCert = this.deps.keyCert ? await this.deps.keyCert() : undefined;
|
|
11668
|
+
}
|
|
11669
|
+
catch {
|
|
11670
|
+
keyCert = undefined; // step I never requires a certificate; the join still goes out (server brief §3)
|
|
11671
|
+
}
|
|
10662
11672
|
await this.deps.transport.postJoinRequest({
|
|
10663
11673
|
memberId: this.deps.memberId,
|
|
10664
11674
|
identityEcdhPub: this.deps.identityEcdhPub,
|
|
10665
11675
|
identityEcdsaPub: this.deps.identityEcdsaPub,
|
|
10666
11676
|
desiredEpoch,
|
|
11677
|
+
...(keyCert ? { keyCert } : {}),
|
|
10667
11678
|
});
|
|
10668
11679
|
}
|
|
10669
11680
|
/**
|
|
@@ -10691,6 +11702,7 @@ class SpaceKeyring {
|
|
|
10691
11702
|
if (added > 0) {
|
|
10692
11703
|
this.epoch = Math.max(this.epoch, ...Array.from(this.keys.keys()));
|
|
10693
11704
|
await this.persist();
|
|
11705
|
+
this.notifyKeys();
|
|
10694
11706
|
}
|
|
10695
11707
|
klog(`pullKeys: +${added} epoch(s) (now hold ${this.keys.size}, current=${this.epoch})`);
|
|
10696
11708
|
return added;
|
|
@@ -10717,6 +11729,8 @@ class SpaceKeyring {
|
|
|
10717
11729
|
* `rotate` space they receive only the current epoch.
|
|
10718
11730
|
*/
|
|
10719
11731
|
async admit(targetMemberId, identityEcdhPub) {
|
|
11732
|
+
if (!this.canWrapTo(targetMemberId))
|
|
11733
|
+
throw new Error(`SpaceKeyring: ${targetMemberId} is not a member of this space`);
|
|
10720
11734
|
const recipient = await importEcdhPublicKey(identityEcdhPub);
|
|
10721
11735
|
const epochs = this.policy === "static"
|
|
10722
11736
|
? Array.from(this.keys.keys())
|
|
@@ -10724,148 +11738,55 @@ class SpaceKeyring {
|
|
|
10724
11738
|
for (const epoch of epochs) {
|
|
10725
11739
|
const key = this.keys.get(epoch);
|
|
10726
11740
|
if (!key)
|
|
10727
|
-
continue;
|
|
10728
|
-
const wrapped = await wrapSpaceKey(key, epoch, recipient);
|
|
10729
|
-
await this.deps.transport.postWrappedKey(targetMemberId, wrapped, this.deps.memberId);
|
|
10730
|
-
}
|
|
10731
|
-
}
|
|
10732
|
-
/**
|
|
10733
|
-
* Admit every outstanding join request (an online key-holder fulfilling
|
|
10734
|
-
* pending newcomers). Returns the ids admitted.
|
|
10735
|
-
*/
|
|
10736
|
-
async admitPending() {
|
|
10737
|
-
const pending = await this.deps.transport.fetchPending();
|
|
10738
|
-
const admitted = [];
|
|
10739
|
-
for (const req of pending) {
|
|
10740
|
-
if (req.memberId === this.deps.memberId)
|
|
10741
|
-
continue;
|
|
10742
|
-
await this.admit(req.memberId, req.identityEcdhPub);
|
|
10743
|
-
admitted.push(req.memberId);
|
|
10744
|
-
}
|
|
10745
|
-
return admitted;
|
|
10746
|
-
}
|
|
10747
|
-
/**
|
|
10748
|
-
* Rotate to a fresh epoch (rotate spaces). Mints a new group key, advances
|
|
10749
|
-
* the epoch, and re-wraps it to every member in `roster` (plus ourselves).
|
|
10750
|
-
*/
|
|
10751
|
-
async rotate(roster) {
|
|
10752
|
-
const members = roster ?? (await this.deps.transport.fetchRoster());
|
|
10753
|
-
const { epoch: nextEpoch } = await this.deps.transport.rotate(this.epoch + 1);
|
|
10754
|
-
const newKey = generateSpaceKey();
|
|
10755
|
-
this.keys.set(nextEpoch, newKey);
|
|
10756
|
-
this.epoch = nextEpoch;
|
|
10757
|
-
for (const m of members) {
|
|
10758
|
-
if (m.memberId === this.deps.memberId)
|
|
10759
|
-
continue;
|
|
10760
|
-
const recipient = await importEcdhPublicKey(m.identityEcdhPub);
|
|
10761
|
-
const wrapped = await wrapSpaceKey(newKey, nextEpoch, recipient);
|
|
10762
|
-
await this.deps.transport.postWrappedKey(m.memberId, wrapped, this.deps.memberId);
|
|
10763
|
-
}
|
|
10764
|
-
await this.persist();
|
|
10765
|
-
return nextEpoch;
|
|
10766
|
-
}
|
|
10767
|
-
/** Add `username` to a private channel's membership allowlist (member-only). */
|
|
10768
|
-
async invite(username) {
|
|
10769
|
-
await this.deps.transport.invite(username);
|
|
10770
|
-
}
|
|
10771
|
-
}
|
|
10772
|
-
|
|
10773
|
-
/**
|
|
10774
|
-
* KeyringClient — HTTP implementation of {@link KeyringTransport} against the
|
|
10775
|
-
* accelerator's per-space keyring endpoints. All blobs are opaque to the
|
|
10776
|
-
* server; this client just moves them.
|
|
10777
|
-
*
|
|
10778
|
-
* POST /api/spaces/:id/keyring/join-request { ...JoinRequest }
|
|
10779
|
-
* GET /api/spaces/:id/keyring/blobs/:member -> WrappedKey[]
|
|
10780
|
-
* POST /api/spaces/:id/keyring/wrap { targetMemberId, fromMemberId, wrapped }
|
|
10781
|
-
* GET /api/spaces/:id/keyring/pending -> JoinRequest[]
|
|
10782
|
-
* GET /api/spaces/:id/keyring/roster -> { memberId, identityEcdhPub }[]
|
|
10783
|
-
* POST /api/spaces/:id/keyring/rotate { nextEpoch } -> { epoch }
|
|
10784
|
-
* GET /api/spaces/:id/metadata -> SpaceMetadata
|
|
10785
|
-
*/
|
|
10786
|
-
class KeyringClient {
|
|
10787
|
-
deps;
|
|
10788
|
-
constructor(deps) {
|
|
10789
|
-
this.deps = deps;
|
|
10790
|
-
}
|
|
10791
|
-
url(suffix) {
|
|
10792
|
-
return `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.deps.spaceId)}${suffix}`;
|
|
10793
|
-
}
|
|
10794
|
-
async post(suffix, body) {
|
|
10795
|
-
const res = await this.deps.fetch(this.url(suffix), {
|
|
10796
|
-
method: "POST",
|
|
10797
|
-
headers: { "Content-Type": "application/json" },
|
|
10798
|
-
body: JSON.stringify(body),
|
|
10799
|
-
});
|
|
10800
|
-
if (!res.ok)
|
|
10801
|
-
throw new Error(`KeyringClient POST ${suffix}: ${res.status} ${res.statusText}`);
|
|
10802
|
-
return (await res.json().catch(() => ({})));
|
|
10803
|
-
}
|
|
10804
|
-
async get(suffix) {
|
|
10805
|
-
const res = await this.deps.fetch(this.url(suffix));
|
|
10806
|
-
if (!res.ok)
|
|
10807
|
-
throw new Error(`KeyringClient GET ${suffix}: ${res.status} ${res.statusText}`);
|
|
10808
|
-
return (await res.json());
|
|
10809
|
-
}
|
|
10810
|
-
async postJoinRequest(req) {
|
|
10811
|
-
await this.post("/keyring/join-request", req);
|
|
10812
|
-
}
|
|
10813
|
-
async fetchBlobs(memberId) {
|
|
10814
|
-
const body = await this.get(`/keyring/blobs/${encodeURIComponent(memberId)}`);
|
|
10815
|
-
return body.blobs ?? [];
|
|
10816
|
-
}
|
|
10817
|
-
async postWrappedKey(targetMemberId, wrapped, fromMemberId) {
|
|
10818
|
-
await this.post("/keyring/wrap", { targetMemberId, fromMemberId, wrapped });
|
|
10819
|
-
}
|
|
10820
|
-
async fetchPending() {
|
|
10821
|
-
const body = await this.get("/keyring/pending");
|
|
10822
|
-
return body.pending ?? [];
|
|
10823
|
-
}
|
|
10824
|
-
async fetchRoster() {
|
|
10825
|
-
const body = await this.get("/keyring/roster");
|
|
10826
|
-
return body.roster ?? [];
|
|
10827
|
-
}
|
|
10828
|
-
async rotate(nextEpoch) {
|
|
10829
|
-
return this.post("/keyring/rotate", { nextEpoch });
|
|
10830
|
-
}
|
|
10831
|
-
async invite(username) {
|
|
10832
|
-
await this.post("/keyring/invite", { username });
|
|
10833
|
-
}
|
|
10834
|
-
// -- Shareable invite links ----------------------------------------------
|
|
10835
|
-
// A capability token tied to this space. Redeeming it allowlists the holder
|
|
10836
|
-
// so the keeper admits them — the link IS the invitation. Mint/list/revoke
|
|
10837
|
-
// require the caller to be the creator or an existing member.
|
|
10838
|
-
async createInviteLink(opts = {}) {
|
|
10839
|
-
return this.post("/keyring/link", opts);
|
|
10840
|
-
}
|
|
10841
|
-
async listInviteLinks() {
|
|
10842
|
-
const body = await this.get("/keyring/links");
|
|
10843
|
-
return body.links ?? [];
|
|
10844
|
-
}
|
|
10845
|
-
/** Set a member's role ("viewer" | "editor"). Owner-only on the server. */
|
|
10846
|
-
async setMemberRole(username, role) {
|
|
10847
|
-
await this.post("/keyring/role", { username, role });
|
|
10848
|
-
}
|
|
10849
|
-
/** Roster with roles (owner/members only). */
|
|
10850
|
-
async members() {
|
|
10851
|
-
const body = await this.get("/keyring/members");
|
|
10852
|
-
return body.members ?? [];
|
|
10853
|
-
}
|
|
10854
|
-
async revokeInviteLink(token) {
|
|
10855
|
-
await this.post("/keyring/link-revoke", { token });
|
|
10856
|
-
}
|
|
10857
|
-
/** Redeem a link: the server allowlists this member and asks the keeper to
|
|
10858
|
-
* wrap the group key for their identity. Caller must be authenticated. */
|
|
10859
|
-
async redeemInvite(req) {
|
|
10860
|
-
await this.post("/keyring/redeem", req);
|
|
11741
|
+
continue;
|
|
11742
|
+
const wrapped = await wrapSpaceKey(key, epoch, recipient);
|
|
11743
|
+
await this.deps.transport.postWrappedKey(targetMemberId, wrapped, this.deps.memberId);
|
|
11744
|
+
}
|
|
10861
11745
|
}
|
|
10862
|
-
|
|
10863
|
-
|
|
10864
|
-
|
|
11746
|
+
/**
|
|
11747
|
+
* Admit every outstanding join request (an online key-holder fulfilling
|
|
11748
|
+
* pending newcomers). Returns the ids admitted.
|
|
11749
|
+
*/
|
|
11750
|
+
async admitPending() {
|
|
11751
|
+
const pending = await this.deps.transport.fetchPending();
|
|
11752
|
+
const admitted = [];
|
|
11753
|
+
for (const req of pending) {
|
|
11754
|
+
if (req.memberId === this.deps.memberId)
|
|
11755
|
+
continue;
|
|
11756
|
+
// The server's pending list still holds ex-members after a remove.
|
|
11757
|
+
if (!this.canWrapTo(req.memberId))
|
|
11758
|
+
continue;
|
|
11759
|
+
await this.admit(req.memberId, req.identityEcdhPub);
|
|
11760
|
+
admitted.push(req.memberId);
|
|
10865
11761
|
}
|
|
10866
|
-
|
|
10867
|
-
|
|
11762
|
+
return admitted;
|
|
11763
|
+
}
|
|
11764
|
+
/**
|
|
11765
|
+
* Rotate to a fresh epoch (rotate spaces). Mints a new group key, advances
|
|
11766
|
+
* the epoch, and re-wraps it to every member in `roster` (plus ourselves).
|
|
11767
|
+
*/
|
|
11768
|
+
async rotate(roster) {
|
|
11769
|
+
const members = roster ?? (await this.deps.transport.fetchRoster());
|
|
11770
|
+
const { epoch: nextEpoch } = await this.deps.transport.rotate(this.epoch + 1);
|
|
11771
|
+
const newKey = generateSpaceKey();
|
|
11772
|
+
this.keys.set(nextEpoch, newKey);
|
|
11773
|
+
this.epoch = nextEpoch;
|
|
11774
|
+
for (const m of members) {
|
|
11775
|
+
if (m.memberId === this.deps.memberId)
|
|
11776
|
+
continue;
|
|
11777
|
+
// The roster keeps ex-members too; the same rule as admit applies to every wrap.
|
|
11778
|
+
if (!this.canWrapTo(m.memberId))
|
|
11779
|
+
continue;
|
|
11780
|
+
const recipient = await importEcdhPublicKey(m.identityEcdhPub);
|
|
11781
|
+
const wrapped = await wrapSpaceKey(newKey, nextEpoch, recipient);
|
|
11782
|
+
await this.deps.transport.postWrappedKey(m.memberId, wrapped, this.deps.memberId);
|
|
10868
11783
|
}
|
|
11784
|
+
await this.persist();
|
|
11785
|
+
return nextEpoch;
|
|
11786
|
+
}
|
|
11787
|
+
/** Add `username` to a private channel's membership allowlist (member-only). */
|
|
11788
|
+
async invite(username) {
|
|
11789
|
+
await this.deps.transport.invite(username);
|
|
10869
11790
|
}
|
|
10870
11791
|
}
|
|
10871
11792
|
|
|
@@ -11899,25 +12820,640 @@ class PeerNetwork {
|
|
|
11899
12820
|
try {
|
|
11900
12821
|
this.signaler.broadcast("bye");
|
|
11901
12822
|
}
|
|
11902
|
-
catch { /* socket may be gone */ }
|
|
11903
|
-
for (const off of this.offs)
|
|
11904
|
-
off();
|
|
11905
|
-
this.transport.close();
|
|
11906
|
-
this.host.close();
|
|
11907
|
-
}
|
|
11908
|
-
}
|
|
11909
|
-
|
|
11910
|
-
/**
|
|
11911
|
-
* Whether this environment can do WebRTC peering. `RTCPeerConnection` is
|
|
11912
|
-
* Window-only (absent in Node/Workers/SSR), so this gates whether a
|
|
11913
|
-
* {@link ./PeerNetwork} can be built at all.
|
|
11914
|
-
*/
|
|
11915
|
-
function isP2pCapable() {
|
|
11916
|
-
try {
|
|
11917
|
-
return typeof RTCPeerConnection !== "undefined";
|
|
12823
|
+
catch { /* socket may be gone */ }
|
|
12824
|
+
for (const off of this.offs)
|
|
12825
|
+
off();
|
|
12826
|
+
this.transport.close();
|
|
12827
|
+
this.host.close();
|
|
12828
|
+
}
|
|
12829
|
+
}
|
|
12830
|
+
|
|
12831
|
+
/**
|
|
12832
|
+
* Whether this environment can do WebRTC peering. `RTCPeerConnection` is
|
|
12833
|
+
* Window-only (absent in Node/Workers/SSR), so this gates whether a
|
|
12834
|
+
* {@link ./PeerNetwork} can be built at all.
|
|
12835
|
+
*/
|
|
12836
|
+
function isP2pCapable() {
|
|
12837
|
+
try {
|
|
12838
|
+
return typeof RTCPeerConnection !== "undefined";
|
|
12839
|
+
}
|
|
12840
|
+
catch {
|
|
12841
|
+
return false;
|
|
12842
|
+
}
|
|
12843
|
+
}
|
|
12844
|
+
|
|
12845
|
+
/**
|
|
12846
|
+
* `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
|
|
12847
|
+
* keypair (the connect `KeyStore` entry keyed by the member id / username).
|
|
12848
|
+
*
|
|
12849
|
+
* Historically each app hand-rolled this (see the web app's old
|
|
12850
|
+
* `personal/spaceLoader.ts`): generate a keypair at register, seed-wrap it, and
|
|
12851
|
+
* PUT it to the personal space; on login GET + unwrap + hydrate the `KeyStore`.
|
|
12852
|
+
* That scaffolding now lives here so ANY app gets a **stable** member keypair
|
|
12853
|
+
* across reloads for free — without which the member re-admits to every space on
|
|
12854
|
+
* every load and the group-key cache can never round-trip.
|
|
12855
|
+
*
|
|
12856
|
+
* Security invariant: the wrapping secret is the **master seed** (not the
|
|
12857
|
+
* password), so the blob survives password changes and unlocks under a passkey.
|
|
12858
|
+
* The seed is only ever held in memory (never persisted), so this vault can only
|
|
12859
|
+
* provision/rehydrate while the client is unlocked.
|
|
12860
|
+
*/
|
|
12861
|
+
/** Staging-only diagnostics: is the member keypair stable across reloads? */
|
|
12862
|
+
const VAULT_DEBUG = (() => {
|
|
12863
|
+
try {
|
|
12864
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
12865
|
+
}
|
|
12866
|
+
catch {
|
|
12867
|
+
return false;
|
|
12868
|
+
}
|
|
12869
|
+
})();
|
|
12870
|
+
function vlog(msg) {
|
|
12871
|
+
if (VAULT_DEBUG)
|
|
12872
|
+
console.info(`[muhkoo:vault] ${msg}`);
|
|
12873
|
+
}
|
|
12874
|
+
/** A short, stable fingerprint of the member's current keypair (ecdsa pub). */
|
|
12875
|
+
async function fingerprint(memberId) {
|
|
12876
|
+
try {
|
|
12877
|
+
const raw = await KeyStore.getInstance().getRawEcdsaPublicKey(memberId);
|
|
12878
|
+
if (!raw)
|
|
12879
|
+
return "none";
|
|
12880
|
+
let s = 0;
|
|
12881
|
+
for (const b of raw)
|
|
12882
|
+
s = (s * 31 + b) >>> 0;
|
|
12883
|
+
return s.toString(16).padStart(8, "0");
|
|
12884
|
+
}
|
|
12885
|
+
catch {
|
|
12886
|
+
return "err";
|
|
12887
|
+
}
|
|
12888
|
+
}
|
|
12889
|
+
/** Personal-space key holding the seed-wrapped, dehydrated ratchet keypair. */
|
|
12890
|
+
const CHAT_KEYS_KEY = "chat-keys";
|
|
12891
|
+
/**
|
|
12892
|
+
* The saved blob was read but does not unwrap, parse or import under this
|
|
12893
|
+
* seed: it is damaged, or was saved under another seed. Unlike a store
|
|
12894
|
+
* failure, retrying with the same seed fails the same way (design §9.2 I8).
|
|
12895
|
+
*/
|
|
12896
|
+
class ChatKeysUnreadableError extends Error {
|
|
12897
|
+
reason;
|
|
12898
|
+
constructor(reason) {
|
|
12899
|
+
super("The saved chat keys do not unwrap under this seed.");
|
|
12900
|
+
this.reason = reason;
|
|
12901
|
+
this.name = "ChatKeysUnreadableError";
|
|
12902
|
+
}
|
|
12903
|
+
}
|
|
12904
|
+
class ChatKeyVault {
|
|
12905
|
+
store;
|
|
12906
|
+
constructor(store) {
|
|
12907
|
+
this.store = store;
|
|
12908
|
+
}
|
|
12909
|
+
/**
|
|
12910
|
+
* Register-time: ensure a vault-backed keypair exists for `memberId`,
|
|
12911
|
+
* seed-wrap it and persist it. Idempotent for a pair that is already
|
|
12912
|
+
* vault-backed. Otherwise a fresh pair is generated, persisted, and only
|
|
12913
|
+
* then swapped in, replacing whatever the tab held: a throwaway key is never
|
|
12914
|
+
* persisted as the stable one, and a failed persist leaves the tab's keys
|
|
12915
|
+
* as they were, with nothing marked.
|
|
12916
|
+
*/
|
|
12917
|
+
async provision(memberId, seedSecret) {
|
|
12918
|
+
const ks = KeyStore.getInstance();
|
|
12919
|
+
const fresh = ks.isVaultBacked(memberId) ? null : await ks.generateKeyPair();
|
|
12920
|
+
const dehydrated = fresh ? await ks.exportKeyPair(fresh) : await ks.dehydrateKeyPair(memberId);
|
|
12921
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(dehydrated));
|
|
12922
|
+
await this.store.put(CHAT_KEYS_KEY, await wrapWithPassphrase(seedSecret, plaintext));
|
|
12923
|
+
if (fresh)
|
|
12924
|
+
ks.replaceOwnKeyPair(memberId, fresh);
|
|
12925
|
+
vlog(`provision: minted+persisted keypair fp=${await fingerprint(memberId)}`);
|
|
12926
|
+
}
|
|
12927
|
+
/**
|
|
12928
|
+
* Sign-in / unlock time: fetch the wrapped blob, unwrap it with the seed, and
|
|
12929
|
+
* hydrate the `KeyStore`, restoring the SAME keypair the member had before.
|
|
12930
|
+
* Returns `true` when a vault-backed keypair is now in hand, and `false` when
|
|
12931
|
+
* there is no blob yet (a brand-new member; the caller provisions). A store
|
|
12932
|
+
* failure propagates as thrown; a blob that does not unwrap, parse or import
|
|
12933
|
+
* throws `ChatKeysUnreadableError`. Nothing in the `KeyStore` changes until
|
|
12934
|
+
* the saved pair is imported; it then replaces whatever pair the tab held,
|
|
12935
|
+
* in one step, so a pair minted meanwhile is never kept in its place.
|
|
12936
|
+
*/
|
|
12937
|
+
async rehydrate(memberId, seedSecret) {
|
|
12938
|
+
const ks = KeyStore.getInstance();
|
|
12939
|
+
if (ks.isVaultBacked(memberId))
|
|
12940
|
+
return true;
|
|
12941
|
+
const wrapped = await this.store.get(CHAT_KEYS_KEY);
|
|
12942
|
+
if (!wrapped) {
|
|
12943
|
+
vlog("rehydrate: no chat-keys blob (new member → will provision)");
|
|
12944
|
+
return false;
|
|
12945
|
+
}
|
|
12946
|
+
ks.replaceOwnKeyPair(memberId, await this.unwrapPair(seedSecret, wrapped));
|
|
12947
|
+
vlog(`rehydrate: HIT — restored stable keypair fp=${await fingerprint(memberId)}`);
|
|
12948
|
+
return true;
|
|
12949
|
+
}
|
|
12950
|
+
/** Unwrap, parse and import a saved pair, outside the `KeyStore`. */
|
|
12951
|
+
async unwrapPair(seedSecret, wrapped) {
|
|
12952
|
+
try {
|
|
12953
|
+
const bytes = await unwrapWithPassphrase(seedSecret, wrapped);
|
|
12954
|
+
return await KeyStore.getInstance().importKeyPair(JSON.parse(new TextDecoder().decode(bytes)));
|
|
12955
|
+
}
|
|
12956
|
+
catch (e) {
|
|
12957
|
+
throw new ChatKeysUnreadableError(e);
|
|
12958
|
+
}
|
|
12959
|
+
}
|
|
12960
|
+
/**
|
|
12961
|
+
* Ensure a stable keypair is in hand: rehydrate from the vault if a blob
|
|
12962
|
+
* exists, otherwise mint one and persist it (so it's stable next session).
|
|
12963
|
+
* Used at register and lazily when a space is first opened.
|
|
12964
|
+
*/
|
|
12965
|
+
async ensure(memberId, seedSecret) {
|
|
12966
|
+
if (await this.rehydrate(memberId, seedSecret))
|
|
12967
|
+
return;
|
|
12968
|
+
await this.provision(memberId, seedSecret);
|
|
12969
|
+
}
|
|
12970
|
+
}
|
|
12971
|
+
|
|
12972
|
+
/**
|
|
12973
|
+
* A certificate by the account IK over the member's P-384 chat keys (design §3.2).
|
|
12974
|
+
* `ecdh`/`ecdsa` must be the EXACT strings posted as identityEcdhPub/identityEcdsaPub.
|
|
12975
|
+
* Minted in memory, never stored client-side.
|
|
12976
|
+
*/
|
|
12977
|
+
async function mintKeyCert(signer, memberId, ecdh, ecdsa) {
|
|
12978
|
+
const ik = await signer.identityKeyRaw();
|
|
12979
|
+
const sig = await signer.sign(certBytes(memberId, { ik, ecdh, ecdsa }));
|
|
12980
|
+
return { v: 1, ik, ecdh, ecdsa, sig };
|
|
12981
|
+
}
|
|
12982
|
+
|
|
12983
|
+
/** Ids the platform reserves; never a member (server brief §2.8). */
|
|
12984
|
+
const RESERVED = /^(__keeper__$|__log__(:|$)|__agent__:|__fn__:|__link__:|__machine__:)/;
|
|
12985
|
+
/**
|
|
12986
|
+
* Ops this client does not interpret yet: links and redeems arrive with the
|
|
12987
|
+
* link routes (step A0a), and log-enabled channels carry no agents or
|
|
12988
|
+
* functions, so `svc` and `retire` change nothing here.
|
|
12989
|
+
*/
|
|
12990
|
+
const UNSUPPORTED = new Set(["join", "link", "unlink", "redeem", "svc", "retire"]);
|
|
12991
|
+
/**
|
|
12992
|
+
* A verified private membership log (design §3.5–§3.7). Pure: it fetches nothing.
|
|
12993
|
+
* An entry that chains correctly but fails its rule, signature or signer
|
|
12994
|
+
* binding is a no-op (SEC4). A chain break, a bad or missing genesis, a genesis
|
|
12995
|
+
* for another app, or an entry that cannot be encoded is integrity, and
|
|
12996
|
+
* nothing after it is applied. `apply` never throws on an entry a relay sends,
|
|
12997
|
+
* and it returns ok only once a genesis has been applied, so after an ok result
|
|
12998
|
+
* `head` is never null (a private log also has its `owner`).
|
|
12999
|
+
*/
|
|
13000
|
+
class MembershipLog {
|
|
13001
|
+
opts;
|
|
13002
|
+
byId = new Map();
|
|
13003
|
+
hashes = new Map();
|
|
13004
|
+
invalidList = [];
|
|
13005
|
+
ownerId = null;
|
|
13006
|
+
headVal = null;
|
|
13007
|
+
humanRemovalSeq = -1;
|
|
13008
|
+
publicLog = false;
|
|
13009
|
+
constructor(opts) {
|
|
13010
|
+
this.opts = opts;
|
|
13011
|
+
}
|
|
13012
|
+
get owner() { return this.ownerId; }
|
|
13013
|
+
get head() { return this.headVal; }
|
|
13014
|
+
get members() { return [...this.byId.values()]; }
|
|
13015
|
+
get lastHumanRemovalSeq() { return this.humanRemovalSeq; }
|
|
13016
|
+
get invalid() { return this.invalidList; }
|
|
13017
|
+
/** A public log: this client treats the Space as unlogged (a public genesis is step A0a). */
|
|
13018
|
+
get unsupported() { return this.publicLog; }
|
|
13019
|
+
member(id) { return this.byId.get(id); }
|
|
13020
|
+
isMember(id) { return this.byId.has(id); }
|
|
13021
|
+
hashAt(seq) { return this.hashes.get(seq); }
|
|
13022
|
+
async apply(entries) {
|
|
13023
|
+
for (const e of entries) {
|
|
13024
|
+
if (typeof e !== "object" || e === null)
|
|
13025
|
+
return { ok: false, integrity: "malformed" };
|
|
13026
|
+
const expectSeq = (this.headVal?.seq ?? -1) + 1;
|
|
13027
|
+
if (e.seq !== expectSeq || e.prev !== (this.headVal?.hash ?? "") || e.spaceId !== this.opts.spaceId) {
|
|
13028
|
+
return { ok: false, integrity: "chain" };
|
|
13029
|
+
}
|
|
13030
|
+
const { sig, lsig: _lsig, ...unsigned } = e;
|
|
13031
|
+
let hash;
|
|
13032
|
+
try {
|
|
13033
|
+
hash = await entryHash(unsigned);
|
|
13034
|
+
}
|
|
13035
|
+
catch {
|
|
13036
|
+
// An unknown op, a non-integer `at`, a missing body or a bad epoch map:
|
|
13037
|
+
// an honest server never stores one, and its hash cannot be computed.
|
|
13038
|
+
return { ok: false, integrity: "malformed" };
|
|
13039
|
+
}
|
|
13040
|
+
if (e.seq === 0) {
|
|
13041
|
+
const bad = await this.checkGenesis(e, sig);
|
|
13042
|
+
if (bad)
|
|
13043
|
+
return { ok: false, integrity: bad };
|
|
13044
|
+
}
|
|
13045
|
+
else {
|
|
13046
|
+
const reason = await this.check(e, sig);
|
|
13047
|
+
if (reason)
|
|
13048
|
+
this.invalidList.push({ seq: e.seq, reason });
|
|
13049
|
+
else
|
|
13050
|
+
this.mutate(e);
|
|
13051
|
+
}
|
|
13052
|
+
this.headVal = { seq: e.seq, hash };
|
|
13053
|
+
this.hashes.set(e.seq, hash);
|
|
13054
|
+
}
|
|
13055
|
+
// A server that reports a head but sends no entries has withheld the genesis.
|
|
13056
|
+
if (!this.headVal)
|
|
13057
|
+
return { ok: false, integrity: "missing-genesis" };
|
|
13058
|
+
return { ok: true };
|
|
13059
|
+
}
|
|
13060
|
+
async checkGenesis(e, sig) {
|
|
13061
|
+
if (e.op !== "genesis")
|
|
13062
|
+
return "chain";
|
|
13063
|
+
const b = e.body;
|
|
13064
|
+
if (!(await verifyP256(e.ik, sig, entryBytes(e))))
|
|
13065
|
+
return "genesis";
|
|
13066
|
+
if (b.appId !== this.opts.appId)
|
|
13067
|
+
return "wrong-app";
|
|
13068
|
+
if (b.visibility !== "private") {
|
|
13069
|
+
this.publicLog = true;
|
|
13070
|
+
return null;
|
|
13071
|
+
}
|
|
13072
|
+
this.ownerId = e.by;
|
|
13073
|
+
this.byId.set(e.by, Object.freeze({ id: e.by, ik: e.ik, role: "owner", since: 0 }));
|
|
13074
|
+
return null;
|
|
13075
|
+
}
|
|
13076
|
+
async check(e, sig) {
|
|
13077
|
+
if (this.publicLog)
|
|
13078
|
+
return "unsupported-op";
|
|
13079
|
+
if (e.op === "genesis")
|
|
13080
|
+
return "genesis-seq";
|
|
13081
|
+
if (UNSUPPORTED.has(e.op))
|
|
13082
|
+
return "unsupported-op";
|
|
13083
|
+
const signer = this.byId.get(e.by);
|
|
13084
|
+
if (!signer)
|
|
13085
|
+
return "not-member";
|
|
13086
|
+
if (!ikEquals(signer.ik, e.ik))
|
|
13087
|
+
return "identity-bound";
|
|
13088
|
+
if (!(await verifyP256(e.ik, sig, entryBytes(e))))
|
|
13089
|
+
return "bad-signature";
|
|
13090
|
+
const b = e.body;
|
|
13091
|
+
switch (e.op) {
|
|
13092
|
+
case "add": {
|
|
13093
|
+
if (signer.role === "viewer")
|
|
13094
|
+
return "not-owner";
|
|
13095
|
+
if (signer.role === "editor" && b.role !== "viewer")
|
|
13096
|
+
return "editor-role";
|
|
13097
|
+
if (!b.member || RESERVED.test(b.member))
|
|
13098
|
+
return "target-reserved";
|
|
13099
|
+
if (this.byId.has(b.member))
|
|
13100
|
+
return "target-member";
|
|
13101
|
+
if (!decodeRawP256(b.memberIk))
|
|
13102
|
+
return "bad-member-ik";
|
|
13103
|
+
if (b.role !== "viewer" && b.role !== "editor")
|
|
13104
|
+
return "malformed";
|
|
13105
|
+
return null;
|
|
13106
|
+
}
|
|
13107
|
+
case "role": {
|
|
13108
|
+
if (signer.role !== "owner")
|
|
13109
|
+
return "not-owner";
|
|
13110
|
+
if (!b.member || RESERVED.test(b.member))
|
|
13111
|
+
return "target-reserved";
|
|
13112
|
+
if (b.member === this.ownerId)
|
|
13113
|
+
return "target-owner";
|
|
13114
|
+
if (!this.byId.has(b.member))
|
|
13115
|
+
return "target-not-member";
|
|
13116
|
+
if (b.role !== "viewer" && b.role !== "editor")
|
|
13117
|
+
return "malformed";
|
|
13118
|
+
return null;
|
|
13119
|
+
}
|
|
13120
|
+
case "remove": {
|
|
13121
|
+
if (signer.role === "viewer")
|
|
13122
|
+
return "not-owner";
|
|
13123
|
+
if (!b.member || RESERVED.test(b.member))
|
|
13124
|
+
return "target-reserved";
|
|
13125
|
+
if (b.member === this.ownerId)
|
|
13126
|
+
return "target-owner";
|
|
13127
|
+
const target = this.byId.get(b.member);
|
|
13128
|
+
if (!target)
|
|
13129
|
+
return "target-not-member";
|
|
13130
|
+
if (signer.role === "editor" && target.role === "editor")
|
|
13131
|
+
return "target-editor";
|
|
13132
|
+
return null;
|
|
13133
|
+
}
|
|
13134
|
+
case "leave":
|
|
13135
|
+
return e.by === this.ownerId ? "owner-leave" : null;
|
|
13136
|
+
default:
|
|
13137
|
+
return "unsupported-op";
|
|
13138
|
+
}
|
|
13139
|
+
}
|
|
13140
|
+
/**
|
|
13141
|
+
* Applies an entry `check` accepted. A member record is never changed in
|
|
13142
|
+
* place: a role change stores a new record, so a `LogMember` a caller
|
|
13143
|
+
* already holds keeps the state it was read at.
|
|
13144
|
+
*/
|
|
13145
|
+
mutate(e) {
|
|
13146
|
+
const b = e.body;
|
|
13147
|
+
switch (e.op) {
|
|
13148
|
+
case "add":
|
|
13149
|
+
this.byId.set(b.member, Object.freeze({ id: b.member, ik: b.memberIk, role: b.role, since: e.seq }));
|
|
13150
|
+
return;
|
|
13151
|
+
case "role":
|
|
13152
|
+
this.byId.set(b.member, Object.freeze({ ...this.byId.get(b.member), role: b.role }));
|
|
13153
|
+
return;
|
|
13154
|
+
case "remove":
|
|
13155
|
+
this.byId.delete(b.member);
|
|
13156
|
+
this.humanRemovalSeq = e.seq;
|
|
13157
|
+
return;
|
|
13158
|
+
case "leave":
|
|
13159
|
+
this.byId.delete(e.by);
|
|
13160
|
+
this.humanRemovalSeq = e.seq;
|
|
13161
|
+
return;
|
|
13162
|
+
}
|
|
13163
|
+
}
|
|
13164
|
+
}
|
|
13165
|
+
|
|
13166
|
+
const MAX_APPEND_RETRIES = 3;
|
|
13167
|
+
const SYNC_DEBOUNCE_MS = 1000;
|
|
13168
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
13169
|
+
/** Must equal SpaceKeyring's KEEPER_MEMBER_ID (and the accelerator's); duplicated so this module does not import SpaceKeyring. */
|
|
13170
|
+
const KEEPER_MEMBER_ID = "__keeper__";
|
|
13171
|
+
/**
|
|
13172
|
+
* Who a client may wrap a Space key to (design §4.5.1): the keeper always; while
|
|
13173
|
+
* the log's integrity is broken, nobody else; on a log-enabled Space, current
|
|
13174
|
+
* log members only, whatever the server's roster or pending list still holds
|
|
13175
|
+
* after a remove; on a Space without a log, anyone, as before.
|
|
13176
|
+
*/
|
|
13177
|
+
function logCanWrapTo(ls) {
|
|
13178
|
+
return (id) => id === KEEPER_MEMBER_ID || (!ls.integrity.broken && (!ls.logEnabled || ls.isMember(id)));
|
|
13179
|
+
}
|
|
13180
|
+
/**
|
|
13181
|
+
* One Space's membership log, as this client has verified it (design §3.6–§3.7).
|
|
13182
|
+
* Syncs the whole log from −1 on each call (slice logs are tiny, so no entry
|
|
13183
|
+
* cache), checks the head against the high-water mark kept in `muhkoo.trust`,
|
|
13184
|
+
* checks the genesis against the pins (TOFU against the directory the first
|
|
13185
|
+
* time), and pins every IK a verified `add` names (design §3.4).
|
|
13186
|
+
*/
|
|
13187
|
+
class LogSync {
|
|
13188
|
+
deps;
|
|
13189
|
+
log = null;
|
|
13190
|
+
enabled = false;
|
|
13191
|
+
integrityState = { broken: false };
|
|
13192
|
+
changed = new Set();
|
|
13193
|
+
fps = new Map();
|
|
13194
|
+
listeners = new Set();
|
|
13195
|
+
timer = null;
|
|
13196
|
+
running = null;
|
|
13197
|
+
followUp = null;
|
|
13198
|
+
constructor(deps) {
|
|
13199
|
+
this.deps = deps;
|
|
13200
|
+
}
|
|
13201
|
+
get logEnabled() { return this.enabled; }
|
|
13202
|
+
get integrity() { return this.integrityState; }
|
|
13203
|
+
get signer() { return this.deps.signer; }
|
|
13204
|
+
get directory() { return this.deps.directory; }
|
|
13205
|
+
member(id) { return this.log?.member(id); }
|
|
13206
|
+
isMember(id) { return !!this.log?.isMember(id); }
|
|
13207
|
+
members() {
|
|
13208
|
+
return (this.log?.members ?? []).map((m) => ({
|
|
13209
|
+
id: m.id, role: m.role, ik: m.ik,
|
|
13210
|
+
fingerprint: this.fps.get(m.id) ?? "",
|
|
13211
|
+
identityChanged: this.changed.has(m.id.toLowerCase()),
|
|
13212
|
+
}));
|
|
13213
|
+
}
|
|
13214
|
+
onChange(h) {
|
|
13215
|
+
this.listeners.add(h);
|
|
13216
|
+
return () => this.listeners.delete(h);
|
|
13217
|
+
}
|
|
13218
|
+
/**
|
|
13219
|
+
* An `mlog` frame marks the log stale; sync once a burst of frames settles.
|
|
13220
|
+
* A failure is logged with its code and reported through `onChange`, never
|
|
13221
|
+
* thrown; integrity is left as it was, and the next frame or open retries.
|
|
13222
|
+
*/
|
|
13223
|
+
scheduleSync() {
|
|
13224
|
+
if (this.timer)
|
|
13225
|
+
return;
|
|
13226
|
+
this.timer = setTimeout(() => {
|
|
13227
|
+
this.timer = null;
|
|
13228
|
+
void this.sync().catch((e) => {
|
|
13229
|
+
const code = e instanceof KeyringHttpError ? e.status : e?.code;
|
|
13230
|
+
log.warn("LogSync: background sync failed", {
|
|
13231
|
+
spaceId: this.deps.spaceId,
|
|
13232
|
+
code: code ?? "unknown",
|
|
13233
|
+
message: e instanceof Error ? e.message : String(e),
|
|
13234
|
+
});
|
|
13235
|
+
this.emit();
|
|
13236
|
+
});
|
|
13237
|
+
}, SYNC_DEBOUNCE_MS);
|
|
13238
|
+
}
|
|
13239
|
+
hwKey() {
|
|
13240
|
+
return `loghw:${this.deps.commitment()}:${this.deps.spaceId}`;
|
|
13241
|
+
}
|
|
13242
|
+
/** Whether this client holds a high-water mark for this Space, i.e. has verified its log before (design §3.7 item 3). */
|
|
13243
|
+
async hasHighWater() {
|
|
13244
|
+
return (await (await this.deps.store()).get(this.hwKey())) !== undefined;
|
|
13245
|
+
}
|
|
13246
|
+
/**
|
|
13247
|
+
* Fetch and verify the whole log. Single-flight: a call made while a sync
|
|
13248
|
+
* runs does not start a second one alongside it. It waits for exactly one
|
|
13249
|
+
* follow-up run, which starts once the running one ends, so it sees whatever
|
|
13250
|
+
* prompted the call; every call made in the meantime shares that follow-up.
|
|
13251
|
+
*/
|
|
13252
|
+
sync() {
|
|
13253
|
+
if (this.followUp)
|
|
13254
|
+
return this.followUp;
|
|
13255
|
+
if (!this.running)
|
|
13256
|
+
return this.startSync();
|
|
13257
|
+
const followUp = this.running.catch(() => undefined).then(() => {
|
|
13258
|
+
this.followUp = null;
|
|
13259
|
+
return this.startSync();
|
|
13260
|
+
});
|
|
13261
|
+
this.followUp = followUp;
|
|
13262
|
+
return followUp;
|
|
13263
|
+
}
|
|
13264
|
+
startSync() {
|
|
13265
|
+
const run = this.runSync().finally(() => {
|
|
13266
|
+
if (this.running === run)
|
|
13267
|
+
this.running = null;
|
|
13268
|
+
});
|
|
13269
|
+
this.running = run;
|
|
13270
|
+
return run;
|
|
13271
|
+
}
|
|
13272
|
+
async runSync() {
|
|
13273
|
+
const store = await this.deps.store();
|
|
13274
|
+
const hw = await store.get(this.hwKey()); // read BEFORE the request (design §3.7 item 3)
|
|
13275
|
+
const all = [];
|
|
13276
|
+
let since = -1;
|
|
13277
|
+
let head = null;
|
|
13278
|
+
for (let page = 0; page < 10_000; page++) {
|
|
13279
|
+
const r = await this.deps.transport.fetchLog(since, 500);
|
|
13280
|
+
head = r.head;
|
|
13281
|
+
const fresh = r.entries.filter((e) => e.seq > since);
|
|
13282
|
+
all.push(...fresh);
|
|
13283
|
+
if (!r.head || fresh.length === 0 || all[all.length - 1].seq >= r.head.seq)
|
|
13284
|
+
break;
|
|
13285
|
+
since = all[all.length - 1].seq;
|
|
13286
|
+
}
|
|
13287
|
+
if (!head) {
|
|
13288
|
+
this.log = null;
|
|
13289
|
+
this.enabled = false;
|
|
13290
|
+
this.integrityState = hw ? { broken: true, reason: "log-missing" } : { broken: false };
|
|
13291
|
+
this.emit();
|
|
13292
|
+
return;
|
|
13293
|
+
}
|
|
13294
|
+
// Only a Space that has a log needs the app id, so an unlogged Space never waits on it.
|
|
13295
|
+
const appId = await this.deps.appId();
|
|
13296
|
+
const log = new MembershipLog({ spaceId: this.deps.spaceId, appId });
|
|
13297
|
+
const res = await log.apply(all);
|
|
13298
|
+
if (log.unsupported) {
|
|
13299
|
+
// A public genesis is unlogged only where this client never recorded a private
|
|
13300
|
+
// chain: a mark or a genesis pin means the log this client verified was replaced.
|
|
13301
|
+
let swapped = { broken: false };
|
|
13302
|
+
if (hw)
|
|
13303
|
+
swapped = { broken: true, reason: "fork" };
|
|
13304
|
+
else {
|
|
13305
|
+
const pinned = await this.deps.pins.get("spaces", await this.spacePinKey());
|
|
13306
|
+
if (pinned !== undefined && pinned !== log.hashAt(0))
|
|
13307
|
+
swapped = { broken: true, reason: "genesis-pin" };
|
|
13308
|
+
}
|
|
13309
|
+
this.log = null;
|
|
13310
|
+
this.enabled = false;
|
|
13311
|
+
this.integrityState = swapped;
|
|
13312
|
+
this.emit();
|
|
13313
|
+
return;
|
|
13314
|
+
}
|
|
13315
|
+
let integrity = res.ok ? { broken: false } : { broken: true, reason: res.integrity };
|
|
13316
|
+
if (!integrity.broken && hw) {
|
|
13317
|
+
if (log.head.seq < hw.seq)
|
|
13318
|
+
integrity = { broken: true, reason: "rollback" };
|
|
13319
|
+
else if (log.hashAt(hw.seq) !== hw.hash)
|
|
13320
|
+
integrity = { broken: true, reason: "fork" };
|
|
13321
|
+
}
|
|
13322
|
+
// The view this client already holds is a high-water mark too: a lower head is never adopted.
|
|
13323
|
+
const prior = this.log?.head ?? null;
|
|
13324
|
+
const lower = prior !== null && (log.head?.seq ?? -1) < prior.seq;
|
|
13325
|
+
if (!integrity.broken && lower)
|
|
13326
|
+
integrity = { broken: true, reason: "rollback" };
|
|
13327
|
+
if (!integrity.broken)
|
|
13328
|
+
integrity = await this.checkGenesis(log);
|
|
13329
|
+
if (!integrity.broken)
|
|
13330
|
+
await this.pinMembers(log);
|
|
13331
|
+
if (!integrity.broken) {
|
|
13332
|
+
// Re-read: another tab may have recorded a newer head while this sync ran. The mark never goes down.
|
|
13333
|
+
const cur = await store.get(this.hwKey());
|
|
13334
|
+
if (!cur || cur.seq <= log.head.seq) {
|
|
13335
|
+
await store.put(this.hwKey(), {
|
|
13336
|
+
seq: log.head.seq,
|
|
13337
|
+
hash: log.head.hash,
|
|
13338
|
+
humanRemovalSeq: Math.max(hw?.humanRemovalSeq ?? -1, cur?.humanRemovalSeq ?? -1, log.lastHumanRemovalSeq),
|
|
13339
|
+
});
|
|
13340
|
+
}
|
|
13341
|
+
}
|
|
13342
|
+
if (!lower)
|
|
13343
|
+
this.log = log;
|
|
13344
|
+
this.enabled = true;
|
|
13345
|
+
this.integrityState = integrity;
|
|
13346
|
+
this.emit();
|
|
13347
|
+
}
|
|
13348
|
+
/** Genesis trust (design §3.7 item 2): the pinned genesis hash, else TOFU with the owner's IK checked against the directory. */
|
|
13349
|
+
async checkGenesis(log) {
|
|
13350
|
+
const owner = log.owner;
|
|
13351
|
+
// A signed genesis can still name no usable owner (`by` null or ""): an honest server never stores one.
|
|
13352
|
+
if (typeof owner !== "string" || owner === "")
|
|
13353
|
+
return { broken: true, reason: "malformed" };
|
|
13354
|
+
const ownerIk = log.member(owner).ik;
|
|
13355
|
+
const genesisHash = log.hashAt(0);
|
|
13356
|
+
const sKey = await this.spacePinKey();
|
|
13357
|
+
const pinned = await this.deps.pins.get("spaces", sKey);
|
|
13358
|
+
if (pinned !== undefined && pinned !== genesisHash)
|
|
13359
|
+
return { broken: true, reason: "genesis-pin" };
|
|
13360
|
+
if (pinned === undefined) {
|
|
13361
|
+
if (!ikEquals(await this.deps.directory.lookup(owner), ownerIk))
|
|
13362
|
+
return { broken: true, reason: "owner-identity" };
|
|
13363
|
+
// A conflict here means another tab pinned a different genesis after our read.
|
|
13364
|
+
const { conflicts } = await this.deps.pins.pin("spaces", { [sKey]: genesisHash });
|
|
13365
|
+
if (conflicts.length)
|
|
13366
|
+
return { broken: true, reason: "genesis-pin" };
|
|
13367
|
+
}
|
|
13368
|
+
const { conflicts } = await this.deps.pins.pin("identity", { [owner.toLowerCase()]: await fingerprint$1(ownerIk) });
|
|
13369
|
+
return conflicts.length ? { broken: true, reason: "owner-identity" } : { broken: false };
|
|
13370
|
+
}
|
|
13371
|
+
/** "s:" + b64url(SHA-256(utf8(spaceId))), the spaces-v1 key; computed exactly as PinStore.spacePinKey, which this layer may not import. */
|
|
13372
|
+
async spacePinKey() {
|
|
13373
|
+
return "s:" + toBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8Encode(this.deps.spaceId))));
|
|
13374
|
+
}
|
|
13375
|
+
/** Pin every IK a verified add names (design §3.4 row 2); a different pinned IK flags the member (identity-changed). */
|
|
13376
|
+
async pinMembers(log) {
|
|
13377
|
+
const entries = {};
|
|
13378
|
+
for (const m of log.members) {
|
|
13379
|
+
const fp = await fingerprint$1(m.ik);
|
|
13380
|
+
this.fps.set(m.id, fp);
|
|
13381
|
+
if (m.id !== log.owner)
|
|
13382
|
+
entries[m.id.toLowerCase()] = fp;
|
|
13383
|
+
}
|
|
13384
|
+
if (!Object.keys(entries).length)
|
|
13385
|
+
return;
|
|
13386
|
+
const { conflicts } = await this.deps.pins.pin("identity", entries);
|
|
13387
|
+
this.changed = new Set(conflicts);
|
|
13388
|
+
}
|
|
13389
|
+
/**
|
|
13390
|
+
* Sign and append one entry. On `409 Log moved`: re-sync, re-sign against the
|
|
13391
|
+
* new head and retry, at most 3 times with jitter (design §9.2 I9). Nothing is
|
|
13392
|
+
* signed while integrity is broken, including after a re-sync that breaks it.
|
|
13393
|
+
* Any other refusal → SpaceTrustError("log-refused") carrying the server's code and reason.
|
|
13394
|
+
* Once the server has stored the entry, its `{seq}` is returned even if the sync confirming it fails.
|
|
13395
|
+
*/
|
|
13396
|
+
async append(op, body, ec) {
|
|
13397
|
+
for (let attempt = 0; attempt <= MAX_APPEND_RETRIES; attempt++) {
|
|
13398
|
+
if (attempt > 0 || (op !== "genesis" && !this.log))
|
|
13399
|
+
await this.sync();
|
|
13400
|
+
if (this.integrityState.broken)
|
|
13401
|
+
throw new SpaceTrustError("integrity", "This space's member list couldn't be verified. You can still read messages you already have; sending is paused.");
|
|
13402
|
+
const head = this.log?.head ?? null;
|
|
13403
|
+
const unsigned = {
|
|
13404
|
+
v: 1,
|
|
13405
|
+
spaceId: this.deps.spaceId,
|
|
13406
|
+
seq: head ? head.seq + 1 : 0,
|
|
13407
|
+
prev: head?.hash ?? "",
|
|
13408
|
+
op,
|
|
13409
|
+
by: this.deps.me(),
|
|
13410
|
+
ik: await this.deps.signer.identityKeyRaw(),
|
|
13411
|
+
at: Date.now(),
|
|
13412
|
+
body,
|
|
13413
|
+
};
|
|
13414
|
+
const sig = await this.deps.signer.sign(entryBytes(unsigned));
|
|
13415
|
+
const entry = { ...unsigned, sig };
|
|
13416
|
+
const commit = ec ? await ec(await entryHash(unsigned), unsigned) : undefined;
|
|
13417
|
+
let r;
|
|
13418
|
+
try {
|
|
13419
|
+
r = await this.deps.transport.postLogEntry(entry, commit);
|
|
13420
|
+
}
|
|
13421
|
+
catch (e) {
|
|
13422
|
+
if (e instanceof KeyringHttpError && e.status === 409 && !e.body?.code) {
|
|
13423
|
+
await sleep(50 + Math.floor(Math.random() * 200));
|
|
13424
|
+
continue;
|
|
13425
|
+
}
|
|
13426
|
+
if (e instanceof KeyringHttpError) {
|
|
13427
|
+
throw new SpaceTrustError("log-refused", String(e.body?.error ?? e.message), { status: e.status, ...(e.body ?? {}) });
|
|
13428
|
+
}
|
|
13429
|
+
throw e;
|
|
13430
|
+
}
|
|
13431
|
+
// The entry is stored: a failure of this sync is not a refusal, and the entry is never re-sent.
|
|
13432
|
+
// It is logged, a background sync applies the entry later, and the stored seq is still returned.
|
|
13433
|
+
try {
|
|
13434
|
+
await this.sync();
|
|
13435
|
+
}
|
|
13436
|
+
catch (e) {
|
|
13437
|
+
const code = e instanceof KeyringHttpError ? e.status : e?.code;
|
|
13438
|
+
log.warn("LogSync: the entry was stored, but the sync confirming it failed", {
|
|
13439
|
+
spaceId: this.deps.spaceId,
|
|
13440
|
+
seq: r.head.seq,
|
|
13441
|
+
code: code ?? "unknown",
|
|
13442
|
+
message: e instanceof Error ? e.message : String(e),
|
|
13443
|
+
});
|
|
13444
|
+
this.scheduleSync();
|
|
13445
|
+
}
|
|
13446
|
+
return { seq: r.head.seq };
|
|
13447
|
+
}
|
|
13448
|
+
throw new SpaceTrustError("log-conflict", "The member list changed while saving. Try again.");
|
|
11918
13449
|
}
|
|
11919
|
-
|
|
11920
|
-
|
|
13450
|
+
emit() {
|
|
13451
|
+
for (const h of this.listeners) {
|
|
13452
|
+
try {
|
|
13453
|
+
h();
|
|
13454
|
+
}
|
|
13455
|
+
catch { /* a listener's failure never breaks sync */ }
|
|
13456
|
+
}
|
|
11921
13457
|
}
|
|
11922
13458
|
}
|
|
11923
13459
|
|
|
@@ -11959,6 +13495,10 @@ class SpaceNamespace {
|
|
|
11959
13495
|
spaceCache;
|
|
11960
13496
|
/** Spaces built this session, by id — used to route offline send replays. */
|
|
11961
13497
|
openSpaces = new Map();
|
|
13498
|
+
/** In-flight identity loads, by member id; each entry is deleted once it settles. */
|
|
13499
|
+
identityInFlight = new Map();
|
|
13500
|
+
/** Chat-key certificates, by member and the exact key strings; a failed mint is dropped so the next join retries. */
|
|
13501
|
+
certs = new Map();
|
|
11962
13502
|
/** Unique id for THIS device/session — the P2P peer identity. A user's own
|
|
11963
13503
|
* devices share a member id, so the mesh MUST distinguish them by this or a
|
|
11964
13504
|
* device treats its siblings' signals as its own echo and never connects. */
|
|
@@ -11972,7 +13512,8 @@ class SpaceNamespace {
|
|
|
11972
13512
|
deps.offline.registerReplayer("space", async (entry) => {
|
|
11973
13513
|
const { spaceId, frame } = entry.args;
|
|
11974
13514
|
let space = this.openSpaces.get(spaceId);
|
|
11975
|
-
|
|
13515
|
+
// A Space built while the session was locked never connects: build it again now (design §3.3).
|
|
13516
|
+
if (!space || space.admission === "locked")
|
|
11976
13517
|
space = await this.get(spaceId);
|
|
11977
13518
|
await space.connect();
|
|
11978
13519
|
if (!space.isConnected())
|
|
@@ -11996,13 +13537,15 @@ class SpaceNamespace {
|
|
|
11996
13537
|
* the first VFS write hang forever instead of failing.
|
|
11997
13538
|
*/
|
|
11998
13539
|
async createSpace(opts = {}) {
|
|
13540
|
+
if (opts.messaging !== false && !this.deps.session.seed)
|
|
13541
|
+
throw new IdentityLockedError();
|
|
11999
13542
|
const historyPolicy = opts.historyPolicy ?? "static";
|
|
12000
13543
|
const identity = await generateSpaceIdentity();
|
|
12001
13544
|
// Register the space (server records pubkey + policy; the DO is lazy).
|
|
12002
13545
|
// For a private space the server also records the creator (this user) as
|
|
12003
13546
|
// its first member — so the metadata POST must carry the user session.
|
|
12004
13547
|
await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy, visibility: opts.private ? "private" : "public" });
|
|
12005
|
-
const space = await this.build(identity.id, historyPolicy);
|
|
13548
|
+
const space = await this.build(identity.id, historyPolicy, opts.private ? "private" : "public", { messaging: opts.messaging });
|
|
12006
13549
|
if (opts.messaging !== false)
|
|
12007
13550
|
await space.create();
|
|
12008
13551
|
return space;
|
|
@@ -12014,9 +13557,13 @@ class SpaceNamespace {
|
|
|
12014
13557
|
await space.join({ timeoutMs: opts.timeoutMs });
|
|
12015
13558
|
return space;
|
|
12016
13559
|
}
|
|
12017
|
-
/**
|
|
12018
|
-
|
|
12019
|
-
|
|
13560
|
+
/**
|
|
13561
|
+
* A handle to a space without auto-connecting (uses cached keys on demand).
|
|
13562
|
+
* `messaging: false` opens a files-only space, as {@link createSpace} makes:
|
|
13563
|
+
* no keyring and no identity, so it opens whether or not the session is locked.
|
|
13564
|
+
*/
|
|
13565
|
+
async get(spaceId, historyPolicy = "static", opts = {}) {
|
|
13566
|
+
return this.build(spaceId, historyPolicy, undefined, opts);
|
|
12020
13567
|
}
|
|
12021
13568
|
// -------------------------------------------------------------------------
|
|
12022
13569
|
// Channels — named, app-scoped pointers to spaces. The name → space-id
|
|
@@ -12063,7 +13610,7 @@ class SpaceNamespace {
|
|
|
12063
13610
|
try {
|
|
12064
13611
|
const { pubkey } = await this.deps.http.get("/api/app/keeper");
|
|
12065
13612
|
if (pubkey && space.keyring)
|
|
12066
|
-
await space.keyring.admit(KEEPER_MEMBER_ID, pubkey);
|
|
13613
|
+
await space.keyring.admit(KEEPER_MEMBER_ID$1, pubkey);
|
|
12067
13614
|
}
|
|
12068
13615
|
catch (err) {
|
|
12069
13616
|
console.warn("[client.space] keeper admit failed (channel still usable):", err);
|
|
@@ -12114,6 +13661,8 @@ class SpaceNamespace {
|
|
|
12114
13661
|
* Returns the open {@link Space}.
|
|
12115
13662
|
*/
|
|
12116
13663
|
async joinByInvite(spaceId, token, opts = {}) {
|
|
13664
|
+
if (!this.deps.session.seed)
|
|
13665
|
+
throw new IdentityLockedError();
|
|
12117
13666
|
const memberId = this.myId();
|
|
12118
13667
|
const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
|
|
12119
13668
|
await this.keyringFor(spaceId).redeemInvite({ token, identityEcdhPub: ecdhPub, identityEcdsaPub: ecdsaPub });
|
|
@@ -12128,19 +13677,64 @@ class SpaceNamespace {
|
|
|
12128
13677
|
spaceId,
|
|
12129
13678
|
httpBaseUrl: this.deps.http.baseUrl,
|
|
12130
13679
|
fetch: this.deps.http.fetch,
|
|
13680
|
+
fetchWithRecovery: this.deps.http.fetchWithRecovery,
|
|
12131
13681
|
});
|
|
12132
13682
|
}
|
|
12133
13683
|
// -------------------------------------------------------------------------
|
|
12134
13684
|
// Internals
|
|
12135
13685
|
// -------------------------------------------------------------------------
|
|
12136
|
-
async build(spaceId, historyPolicy) {
|
|
13686
|
+
async build(spaceId, historyPolicy, visibility, opts = {}) {
|
|
12137
13687
|
const memberId = this.myId();
|
|
13688
|
+
const filesOnly = opts.messaging === false;
|
|
13689
|
+
if (filesOnly || !this.deps.session.seed) {
|
|
13690
|
+
// Locked (design §3.3): no identity, no keyring, no join request. The
|
|
13691
|
+
// Space reports admission "locked"; the app re-opens it after sign-in.
|
|
13692
|
+
// A files-only Space (`messaging: false`) is built the same way whether
|
|
13693
|
+
// or not the session is locked, and is never marked locked: its file
|
|
13694
|
+
// chunks carry their own keys, so it needs no identity at all.
|
|
13695
|
+
const keyless = new Space({
|
|
13696
|
+
name: spaceId,
|
|
13697
|
+
wsBaseUrl: this.deps.wsBaseUrl,
|
|
13698
|
+
httpBaseUrl: this.deps.http.baseUrl,
|
|
13699
|
+
fetch: this.deps.http.fetch,
|
|
13700
|
+
myId: () => memberId,
|
|
13701
|
+
fetchTicket: () => this.fetchTicket(),
|
|
13702
|
+
historyPolicy,
|
|
13703
|
+
locked: !filesOnly,
|
|
13704
|
+
shardCache: this.deps.offline?.fileCache,
|
|
13705
|
+
deferShardUpload: this.deps.offline
|
|
13706
|
+
? (hash) => this.deps.offline.deferShardUpload(hash)
|
|
13707
|
+
: undefined,
|
|
13708
|
+
});
|
|
13709
|
+
this.openSpaces.set(spaceId, keyless);
|
|
13710
|
+
return keyless;
|
|
13711
|
+
}
|
|
12138
13712
|
const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
|
|
12139
13713
|
const transport = new KeyringClient({
|
|
12140
13714
|
spaceId,
|
|
12141
13715
|
httpBaseUrl: this.deps.http.baseUrl,
|
|
12142
13716
|
fetch: this.deps.http.fetch,
|
|
13717
|
+
// The 4403 state check runs after the HttpClient's 401 recovery (design §6.9).
|
|
13718
|
+
fetchWithRecovery: this.deps.http.fetchWithRecovery,
|
|
12143
13719
|
});
|
|
13720
|
+
// The Space's membership log (design §3.6–§3.7), when this client has trust wired.
|
|
13721
|
+
const trust = this.deps.trust;
|
|
13722
|
+
const logSync = trust
|
|
13723
|
+
? new LogSync({
|
|
13724
|
+
spaceId,
|
|
13725
|
+
me: () => memberId,
|
|
13726
|
+
appId: () => trust.appId(),
|
|
13727
|
+
commitment: () => this.deps.session.commitment,
|
|
13728
|
+
transport: {
|
|
13729
|
+
fetchLog: (s, l) => transport.fetchLog(s, l),
|
|
13730
|
+
postLogEntry: (e, ec) => transport.postLogEntry(e, ec),
|
|
13731
|
+
},
|
|
13732
|
+
signer: trust.signer,
|
|
13733
|
+
directory: trust.directory,
|
|
13734
|
+
pins: trust.pins,
|
|
13735
|
+
store: () => trust.store(),
|
|
13736
|
+
})
|
|
13737
|
+
: undefined;
|
|
12144
13738
|
const keyring = new SpaceKeyring({
|
|
12145
13739
|
spaceId,
|
|
12146
13740
|
memberId,
|
|
@@ -12150,6 +13744,8 @@ class SpaceNamespace {
|
|
|
12150
13744
|
transport,
|
|
12151
13745
|
cache: this.deps.cache,
|
|
12152
13746
|
historyPolicy,
|
|
13747
|
+
keyCert: this.deps.trust ? () => this.certFor(memberId, ecdhPub, ecdsaPub) : undefined,
|
|
13748
|
+
canWrapTo: logSync ? logCanWrapTo(logSync) : undefined,
|
|
12153
13749
|
});
|
|
12154
13750
|
const space = new Space({
|
|
12155
13751
|
name: spaceId,
|
|
@@ -12160,6 +13756,11 @@ class SpaceNamespace {
|
|
|
12160
13756
|
fetchTicket: () => this.fetchTicket(),
|
|
12161
13757
|
keyring,
|
|
12162
13758
|
historyPolicy,
|
|
13759
|
+
logSync,
|
|
13760
|
+
visibility,
|
|
13761
|
+
appId: trust ? () => trust.appId() : undefined,
|
|
13762
|
+
probeState: () => transport.probeState(),
|
|
13763
|
+
hasSession: () => !!this.deps.session.token,
|
|
12163
13764
|
offline: this.spaceCache,
|
|
12164
13765
|
shardCache: this.deps.offline?.fileCache,
|
|
12165
13766
|
deferShardUpload: this.deps.offline
|
|
@@ -12181,43 +13782,63 @@ class SpaceNamespace {
|
|
|
12181
13782
|
}
|
|
12182
13783
|
return space;
|
|
12183
13784
|
}
|
|
12184
|
-
/**
|
|
12185
|
-
|
|
13785
|
+
/**
|
|
13786
|
+
* The member's stable chat identity (design §3.3). A locked session (no
|
|
13787
|
+
* seed) throws `IdentityLockedError`. A vault failure throws
|
|
13788
|
+
* `IdentityUnavailableError`: `retryable` when the vault could not be
|
|
13789
|
+
* reached (§9.2 I8), and not when the saved keys do not unwrap under this
|
|
13790
|
+
* seed. A throwaway pair is never minted: doing so is what hid a reloaded
|
|
13791
|
+
* member's earlier messages from everyone else.
|
|
13792
|
+
*
|
|
13793
|
+
* Concurrent calls for one member share a single load, so two first opens
|
|
13794
|
+
* can never interleave their forget/generate steps and persist a second or
|
|
13795
|
+
* mixed pair.
|
|
13796
|
+
*/
|
|
13797
|
+
ensureIdentity(memberId) {
|
|
13798
|
+
let pending = this.identityInFlight.get(memberId);
|
|
13799
|
+
if (!pending) {
|
|
13800
|
+
pending = this.loadIdentity(memberId).finally(() => this.identityInFlight.delete(memberId));
|
|
13801
|
+
this.identityInFlight.set(memberId, pending);
|
|
13802
|
+
}
|
|
13803
|
+
return pending;
|
|
13804
|
+
}
|
|
13805
|
+
async loadIdentity(memberId) {
|
|
12186
13806
|
const store = KeyStore.getInstance();
|
|
12187
|
-
|
|
12188
|
-
|
|
12189
|
-
|
|
12190
|
-
|
|
12191
|
-
|
|
12192
|
-
|
|
12193
|
-
const seed = this.deps.session.seed;
|
|
12194
|
-
const staging = (() => { try {
|
|
12195
|
-
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
13807
|
+
const seed = this.deps.session.seed;
|
|
13808
|
+
if (!seed)
|
|
13809
|
+
throw new IdentityLockedError();
|
|
13810
|
+
if (!store.isVaultBacked(memberId)) {
|
|
13811
|
+
if (!this.deps.chatKeys) {
|
|
13812
|
+
throw new IdentityUnavailableError("This client has no chat-key vault; sign in with a session first.", undefined, false);
|
|
12196
13813
|
}
|
|
12197
|
-
|
|
12198
|
-
|
|
12199
|
-
} })();
|
|
12200
|
-
if (staging)
|
|
12201
|
-
console.info(`[muhkoo:vault] ensureIdentity: unlocked=${!!seed} vault=${!!this.deps.chatKeys}`);
|
|
12202
|
-
if (this.deps.chatKeys && seed) {
|
|
12203
|
-
try {
|
|
12204
|
-
await this.deps.chatKeys.ensure(memberId, toBase64$2(seed));
|
|
12205
|
-
}
|
|
12206
|
-
catch (e) {
|
|
12207
|
-
if (staging)
|
|
12208
|
-
console.info(`[muhkoo:vault] ensureIdentity: vault.ensure failed — ${e instanceof Error ? e.message : e}`);
|
|
12209
|
-
}
|
|
13814
|
+
try {
|
|
13815
|
+
await this.deps.chatKeys.ensure(memberId, toBase64$2(seed));
|
|
12210
13816
|
}
|
|
12211
|
-
|
|
12212
|
-
if (
|
|
12213
|
-
|
|
12214
|
-
|
|
13817
|
+
catch (e) {
|
|
13818
|
+
if (e instanceof ChatKeysUnreadableError) {
|
|
13819
|
+
throw new IdentityUnavailableError("Your saved chat keys could not be unlocked with this session's key: they are damaged, " +
|
|
13820
|
+
"or were saved under a different key. Trying again will not fix this.", e, false);
|
|
13821
|
+
}
|
|
13822
|
+
throw new IdentityUnavailableError(undefined, e);
|
|
12215
13823
|
}
|
|
13824
|
+
if (!store.isVaultBacked(memberId))
|
|
13825
|
+
throw new IdentityUnavailableError();
|
|
12216
13826
|
}
|
|
12217
13827
|
const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
|
|
12218
13828
|
const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
|
|
12219
13829
|
return { ecdhPub, ecdsaPub };
|
|
12220
13830
|
}
|
|
13831
|
+
/** One certificate per (member, chat keys) for the life of the Client (design §3.2: global, not per Space). */
|
|
13832
|
+
certFor(memberId, ecdh, ecdsa) {
|
|
13833
|
+
const k = `${memberId}\n${ecdh}\n${ecdsa}`;
|
|
13834
|
+
let p = this.certs.get(k);
|
|
13835
|
+
if (!p) {
|
|
13836
|
+
p = mintKeyCert(this.deps.trust.signer, memberId, ecdh, ecdsa);
|
|
13837
|
+
p.catch(() => this.certs.delete(k));
|
|
13838
|
+
this.certs.set(k, p);
|
|
13839
|
+
}
|
|
13840
|
+
return p;
|
|
13841
|
+
}
|
|
12221
13842
|
async fetchPolicy(spaceId) {
|
|
12222
13843
|
const meta = await new KeyringClient({
|
|
12223
13844
|
spaceId,
|
|
@@ -12506,6 +14127,328 @@ class AccessTokensNamespace {
|
|
|
12506
14127
|
}
|
|
12507
14128
|
}
|
|
12508
14129
|
|
|
14130
|
+
/**
|
|
14131
|
+
* POST /api/auth/identity (SpecA §4.1 as amended by CS-7). An unknown name
|
|
14132
|
+
* answers a deterministic decoy, so a lookup never says whether an account
|
|
14133
|
+
* exists. Responses are cached for the life of the Client. Any failure,
|
|
14134
|
+
* including a 429, fails closed (SpecA §4.3).
|
|
14135
|
+
*/
|
|
14136
|
+
class IdentityDirectory {
|
|
14137
|
+
http;
|
|
14138
|
+
cache = new Map();
|
|
14139
|
+
constructor(http) {
|
|
14140
|
+
this.http = http;
|
|
14141
|
+
}
|
|
14142
|
+
async lookup(username) {
|
|
14143
|
+
const l = username.toLowerCase();
|
|
14144
|
+
return (await this.lookupMany([username]))[l];
|
|
14145
|
+
}
|
|
14146
|
+
async lookupMany(usernames) {
|
|
14147
|
+
const out = {};
|
|
14148
|
+
const missing = [];
|
|
14149
|
+
for (const u of usernames) {
|
|
14150
|
+
const l = u.toLowerCase();
|
|
14151
|
+
const hit = this.cache.get(l);
|
|
14152
|
+
if (hit)
|
|
14153
|
+
out[l] = hit;
|
|
14154
|
+
else if (!missing.includes(l))
|
|
14155
|
+
missing.push(l);
|
|
14156
|
+
}
|
|
14157
|
+
for (let i = 0; i < missing.length; i += 64) {
|
|
14158
|
+
const batch = missing.slice(i, i + 64);
|
|
14159
|
+
let keys;
|
|
14160
|
+
try {
|
|
14161
|
+
keys = (await this.http.post("/api/auth/identity", { usernames: batch }))?.keys;
|
|
14162
|
+
}
|
|
14163
|
+
catch (e) {
|
|
14164
|
+
throw new SpaceTrustError("lookup-failed", "Couldn't verify members right now; retrying.", {
|
|
14165
|
+
status: e instanceof HttpError ? e.status : undefined,
|
|
14166
|
+
});
|
|
14167
|
+
}
|
|
14168
|
+
for (const l of batch) {
|
|
14169
|
+
const k = keys?.[l];
|
|
14170
|
+
if (typeof k !== "string")
|
|
14171
|
+
throw new SpaceTrustError("lookup-failed", "Couldn't verify members right now; retrying.");
|
|
14172
|
+
this.cache.set(l, k);
|
|
14173
|
+
out[l] = k;
|
|
14174
|
+
}
|
|
14175
|
+
}
|
|
14176
|
+
return out;
|
|
14177
|
+
}
|
|
14178
|
+
}
|
|
14179
|
+
|
|
14180
|
+
/**
|
|
14181
|
+
* Derives the IK from the in-memory seed (HKDF(seed,"ecdsa-priv"), non-extractable),
|
|
14182
|
+
* and checks it against the account's registered IK before its first use, because
|
|
14183
|
+
* `unlockWithSeed`/`unlockWithDevice` set a seed without a commitment check.
|
|
14184
|
+
* Locked or mismatched → IdentityLockedError.
|
|
14185
|
+
*/
|
|
14186
|
+
class SeedIdentitySigner {
|
|
14187
|
+
deps;
|
|
14188
|
+
cached = null;
|
|
14189
|
+
constructor(deps) {
|
|
14190
|
+
this.deps = deps;
|
|
14191
|
+
}
|
|
14192
|
+
async key() {
|
|
14193
|
+
const { session } = this.deps;
|
|
14194
|
+
const seed = session.seed;
|
|
14195
|
+
const username = session.username;
|
|
14196
|
+
if (!seed || !username)
|
|
14197
|
+
throw new IdentityLockedError();
|
|
14198
|
+
if (this.cached && this.cached.seed === seed)
|
|
14199
|
+
return this.cached;
|
|
14200
|
+
const identity = session.identity ?? (await deriveIdentityFromSeed(seed));
|
|
14201
|
+
const ik = await exportPublicKeyBase64(identity.ecdsaKeyPair.publicKey);
|
|
14202
|
+
const registered = await this.deps.directory.lookup(username);
|
|
14203
|
+
if (!ikEquals(registered, ik)) {
|
|
14204
|
+
throw new IdentityLockedError("This session's key is not your account's registered identity. Sign in again.");
|
|
14205
|
+
}
|
|
14206
|
+
this.cached = { seed, priv: identity.ecdsaKeyPair.privateKey, ik };
|
|
14207
|
+
return this.cached;
|
|
14208
|
+
}
|
|
14209
|
+
async identityKeyRaw() {
|
|
14210
|
+
return (await this.key()).ik;
|
|
14211
|
+
}
|
|
14212
|
+
async sign(bytes) {
|
|
14213
|
+
const { priv } = await this.key();
|
|
14214
|
+
return toBase64$2(new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, priv, bytes)));
|
|
14215
|
+
}
|
|
14216
|
+
}
|
|
14217
|
+
|
|
14218
|
+
/** Reserved personal-KV keys and signing labels (SpecA §4.2; design §3.4, §6.6a). Frozen formats. */
|
|
14219
|
+
const DOCS = {
|
|
14220
|
+
identity: { key: "muhkoo.pins/identity-v1", label: "muhkoo-pinmap-v1" },
|
|
14221
|
+
spaces: { key: "muhkoo.pins/spaces-v1", label: "muhkoo-spacepins-v1" },
|
|
14222
|
+
};
|
|
14223
|
+
/** `hlc` for a pin write: dec(counter) zero-padded to 16 digits, because the server compares strings (CP9). */
|
|
14224
|
+
const pad16 = (n) => dec(n).padStart(16, "0");
|
|
14225
|
+
/** LP(label) ‖ LP(ownerCommitment) ‖ LP(dec(counter)) ‖ LP(concat over keys sorted (code units) of LP(k) ‖ LP(v)). */
|
|
14226
|
+
function pinDocBytes(label, ownerCommitment, counter, entries) {
|
|
14227
|
+
const keys = Object.keys(entries).sort();
|
|
14228
|
+
return concatBytes(lpStr(label), lpStr(ownerCommitment), lpStr(dec(counter)), lp(concatBytes(...keys.map((k) => concatBytes(lpStr(k), lpStr(entries[k]))))));
|
|
14229
|
+
}
|
|
14230
|
+
/** The `spaces-v1` key for a Space's genesis pin: "s:" + b64url(SHA-256(utf8(spaceId))). */
|
|
14231
|
+
async function spacePinKey(spaceId) {
|
|
14232
|
+
return "s:" + toBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8Encode(spaceId))));
|
|
14233
|
+
}
|
|
14234
|
+
class PinStore {
|
|
14235
|
+
deps;
|
|
14236
|
+
constructor(deps) {
|
|
14237
|
+
this.deps = deps;
|
|
14238
|
+
}
|
|
14239
|
+
commitment() {
|
|
14240
|
+
const c = this.deps.commitment();
|
|
14241
|
+
if (!c)
|
|
14242
|
+
throw new SpaceTrustError("pins-invalid", "Sign in first.");
|
|
14243
|
+
return c;
|
|
14244
|
+
}
|
|
14245
|
+
markKey(doc) {
|
|
14246
|
+
return `pinhw:${DOCS[doc].key}:${this.commitment()}`;
|
|
14247
|
+
}
|
|
14248
|
+
kvPath(doc, suffix = "") {
|
|
14249
|
+
return `/api/personal/${encodeURIComponent(this.commitment())}/kv/${encodeURIComponent(DOCS[doc].key)}${suffix}`;
|
|
14250
|
+
}
|
|
14251
|
+
async fetchDoc(doc) {
|
|
14252
|
+
try {
|
|
14253
|
+
const res = await this.deps.http.post(this.kvPath(doc, "/get"), {});
|
|
14254
|
+
return res?.value ?? null;
|
|
14255
|
+
}
|
|
14256
|
+
catch (e) {
|
|
14257
|
+
if (e instanceof HttpError && e.status === 404)
|
|
14258
|
+
return null;
|
|
14259
|
+
throw new SpaceTrustError("pins-stale", "Couldn't verify members right now; retrying.");
|
|
14260
|
+
}
|
|
14261
|
+
}
|
|
14262
|
+
async verify(doc, d) {
|
|
14263
|
+
const ok = d && d.v === 1 && d.ownerCommitment === this.commitment() &&
|
|
14264
|
+
Number.isSafeInteger(d.counter) && d.counter >= 1 &&
|
|
14265
|
+
d.entries && typeof d.entries === "object" &&
|
|
14266
|
+
Object.values(d.entries).every((v) => typeof v === "string") &&
|
|
14267
|
+
(await verifyP256(await this.deps.signer.identityKeyRaw(), d.sig, pinDocBytes(DOCS[doc].label, d.ownerCommitment, d.counter, d.entries)));
|
|
14268
|
+
if (!ok)
|
|
14269
|
+
throw new SpaceTrustError("pins-invalid", "Your saved trust records could not be verified.");
|
|
14270
|
+
}
|
|
14271
|
+
/** The current verified document; an empty bootstrap at counter 0 when none exists and no mark does either. */
|
|
14272
|
+
async read(doc) {
|
|
14273
|
+
const store = await this.deps.store();
|
|
14274
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
14275
|
+
const before = await store.get(this.markKey(doc));
|
|
14276
|
+
const d = await this.fetchDoc(doc);
|
|
14277
|
+
const now = await store.get(this.markKey(doc));
|
|
14278
|
+
if (!d) {
|
|
14279
|
+
if (before !== undefined)
|
|
14280
|
+
throw new SpaceTrustError("pins-missing", "Your saved trust records are missing.");
|
|
14281
|
+
return { v: 1, ownerCommitment: this.commitment(), counter: 0, entries: {}, sig: "" };
|
|
14282
|
+
}
|
|
14283
|
+
await this.verify(doc, d);
|
|
14284
|
+
if (before !== undefined && d.counter < before)
|
|
14285
|
+
throw new SpaceTrustError("pins-rollback", "Your saved trust records were rolled back.");
|
|
14286
|
+
if (now !== undefined && d.counter < now)
|
|
14287
|
+
continue; // stale, not a rollback (KI6): re-read once
|
|
14288
|
+
await store.put(this.markKey(doc), Math.max(d.counter, now ?? 0));
|
|
14289
|
+
return d;
|
|
14290
|
+
}
|
|
14291
|
+
throw new SpaceTrustError("pins-stale", "Couldn't verify members right now; retrying.");
|
|
14292
|
+
}
|
|
14293
|
+
async get(doc, key) {
|
|
14294
|
+
return (await this.read(doc)).entries[key];
|
|
14295
|
+
}
|
|
14296
|
+
/** Pin new keys. A key already pinned to a different value is reported, never overwritten (design §3.4). */
|
|
14297
|
+
async pin(doc, entries) {
|
|
14298
|
+
return this.mutate(doc, (cur) => {
|
|
14299
|
+
const next = { ...cur };
|
|
14300
|
+
const conflicts = [];
|
|
14301
|
+
for (const [k, v] of Object.entries(entries)) {
|
|
14302
|
+
if (next[k] === undefined)
|
|
14303
|
+
next[k] = v;
|
|
14304
|
+
else if (next[k] !== v)
|
|
14305
|
+
conflicts.push(k);
|
|
14306
|
+
}
|
|
14307
|
+
return { next, conflicts };
|
|
14308
|
+
});
|
|
14309
|
+
}
|
|
14310
|
+
async unpin(doc, keys) {
|
|
14311
|
+
await this.mutate(doc, (cur) => {
|
|
14312
|
+
const next = { ...cur };
|
|
14313
|
+
for (const k of keys)
|
|
14314
|
+
delete next[k];
|
|
14315
|
+
return { next, conflicts: [] };
|
|
14316
|
+
});
|
|
14317
|
+
}
|
|
14318
|
+
/** User-confirmed recovery (design §9.1 "Your saved trust records are missing."): forget the high-water mark. */
|
|
14319
|
+
async resetMark(doc) {
|
|
14320
|
+
await (await this.deps.store()).del(this.markKey(doc));
|
|
14321
|
+
}
|
|
14322
|
+
async mutate(doc, change) {
|
|
14323
|
+
const store = await this.deps.store();
|
|
14324
|
+
for (let attempt = 0; attempt <= 3; attempt++) {
|
|
14325
|
+
const cur = await this.read(doc);
|
|
14326
|
+
const { next, conflicts } = change(cur.entries);
|
|
14327
|
+
const same = Object.keys(next).length === Object.keys(cur.entries).length &&
|
|
14328
|
+
Object.entries(next).every(([k, v]) => cur.entries[k] === v);
|
|
14329
|
+
if (same)
|
|
14330
|
+
return { conflicts };
|
|
14331
|
+
const counter = cur.counter + 1;
|
|
14332
|
+
const c = this.commitment();
|
|
14333
|
+
const sig = await this.deps.signer.sign(pinDocBytes(DOCS[doc].label, c, counter, next));
|
|
14334
|
+
const value = { v: 1, ownerCommitment: c, counter, entries: next, sig };
|
|
14335
|
+
const res = await this.deps.http.post(this.kvPath(doc), { value, hlc: pad16(counter) });
|
|
14336
|
+
if (res?.applied === false)
|
|
14337
|
+
continue; // a concurrent writer won: re-read, merge, retry
|
|
14338
|
+
await store.put(this.markKey(doc), counter);
|
|
14339
|
+
return { conflicts };
|
|
14340
|
+
}
|
|
14341
|
+
throw new SpaceTrustError("pins-write-failed", "Couldn't save trust records right now.");
|
|
14342
|
+
}
|
|
14343
|
+
}
|
|
14344
|
+
|
|
14345
|
+
const TRUST_OPEN_TIMEOUT_MS = 2000;
|
|
14346
|
+
const DB_NAME$1 = "muhkoo.trust";
|
|
14347
|
+
const STORE = "kv";
|
|
14348
|
+
function memoryTrustStore() {
|
|
14349
|
+
const m = new Map();
|
|
14350
|
+
return {
|
|
14351
|
+
persistent: false,
|
|
14352
|
+
async get(k) { return m.get(k); },
|
|
14353
|
+
async put(k, v) { m.set(k, structuredClone(v)); },
|
|
14354
|
+
async del(k) { m.delete(k); },
|
|
14355
|
+
};
|
|
14356
|
+
}
|
|
14357
|
+
async function openTrustStore(opts = {}) {
|
|
14358
|
+
const idb = opts.idb === undefined ? globalThis.indexedDB : opts.idb;
|
|
14359
|
+
if (!idb)
|
|
14360
|
+
return memoryTrustStore();
|
|
14361
|
+
const db = await new Promise((resolve) => {
|
|
14362
|
+
let settled = false;
|
|
14363
|
+
const done = (v) => { if (!settled) {
|
|
14364
|
+
settled = true;
|
|
14365
|
+
resolve(v);
|
|
14366
|
+
} };
|
|
14367
|
+
const timer = setTimeout(() => done(null), opts.timeoutMs ?? TRUST_OPEN_TIMEOUT_MS);
|
|
14368
|
+
try {
|
|
14369
|
+
const req = idb.open(DB_NAME$1, 1);
|
|
14370
|
+
req.onupgradeneeded = () => {
|
|
14371
|
+
if (!req.result.objectStoreNames.contains(STORE))
|
|
14372
|
+
req.result.createObjectStore(STORE);
|
|
14373
|
+
};
|
|
14374
|
+
req.onsuccess = () => { clearTimeout(timer); done(req.result); };
|
|
14375
|
+
req.onerror = () => { clearTimeout(timer); done(null); };
|
|
14376
|
+
}
|
|
14377
|
+
catch {
|
|
14378
|
+
clearTimeout(timer);
|
|
14379
|
+
done(null);
|
|
14380
|
+
}
|
|
14381
|
+
});
|
|
14382
|
+
if (!db)
|
|
14383
|
+
return memoryTrustStore();
|
|
14384
|
+
const run = (mode, fn) => new Promise((resolve, reject) => {
|
|
14385
|
+
const r = fn(db.transaction(STORE, mode).objectStore(STORE));
|
|
14386
|
+
r.onsuccess = () => resolve(r.result);
|
|
14387
|
+
r.onerror = () => reject(r.error);
|
|
14388
|
+
});
|
|
14389
|
+
return {
|
|
14390
|
+
persistent: true,
|
|
14391
|
+
get: (k) => run("readonly", (s) => s.get(k)),
|
|
14392
|
+
put: async (k, v) => { await run("readwrite", (s) => s.put(v, k)); },
|
|
14393
|
+
del: async (k) => { await run("readwrite", (s) => s.delete(k)); },
|
|
14394
|
+
};
|
|
14395
|
+
}
|
|
14396
|
+
|
|
14397
|
+
/** One per Client: shared by every Space (and, in sub-project 4, DMs). */
|
|
14398
|
+
class TrustContext {
|
|
14399
|
+
deps;
|
|
14400
|
+
directory;
|
|
14401
|
+
signer;
|
|
14402
|
+
pins;
|
|
14403
|
+
storeP = null;
|
|
14404
|
+
opened = null;
|
|
14405
|
+
appIdP = null;
|
|
14406
|
+
constructor(deps) {
|
|
14407
|
+
this.deps = deps;
|
|
14408
|
+
this.directory = new IdentityDirectory(deps.http);
|
|
14409
|
+
this.signer = new SeedIdentitySigner({ session: deps.session, directory: this.directory });
|
|
14410
|
+
this.pins = new PinStore({
|
|
14411
|
+
http: deps.http,
|
|
14412
|
+
commitment: () => deps.session.commitment,
|
|
14413
|
+
signer: this.signer,
|
|
14414
|
+
store: () => this.store(),
|
|
14415
|
+
});
|
|
14416
|
+
// Start opening muhkoo.trust now, so `persistent` has settled by the time an app asks (KI1).
|
|
14417
|
+
// A failed open reaches whoever awaits store(); it is not an unhandled rejection here.
|
|
14418
|
+
this.store().catch(() => undefined);
|
|
14419
|
+
}
|
|
14420
|
+
/** Whether muhkoo.trust survives a restart (KI1): false until the open settles, and false for an in-memory store. */
|
|
14421
|
+
get persistent() {
|
|
14422
|
+
return this.opened?.persistent ?? false;
|
|
14423
|
+
}
|
|
14424
|
+
store() {
|
|
14425
|
+
return (this.storeP ??= (this.deps.openStore ?? openTrustStore)().then((s) => (this.opened = s)));
|
|
14426
|
+
}
|
|
14427
|
+
/** This app's id, from GET /api/app/trust (a genesis must name it, D4). Cached; a failure is retried next call. */
|
|
14428
|
+
appId() {
|
|
14429
|
+
return (this.appIdP ??= this.deps.http
|
|
14430
|
+
.get("/api/app/trust")
|
|
14431
|
+
.then((r) => {
|
|
14432
|
+
if (!r?.appId)
|
|
14433
|
+
throw new Error("app trust: no appId");
|
|
14434
|
+
return r.appId;
|
|
14435
|
+
})
|
|
14436
|
+
.catch((e) => {
|
|
14437
|
+
this.appIdP = null;
|
|
14438
|
+
throw e;
|
|
14439
|
+
}));
|
|
14440
|
+
}
|
|
14441
|
+
/** User-confirmed "Reset trust for this space" (design §9.1): drop its genesis pin and log high-water. */
|
|
14442
|
+
async resetSpacePins(spaceId) {
|
|
14443
|
+
await this.pins.unpin("spaces", [await spacePinKey(spaceId)]);
|
|
14444
|
+
await (await this.store()).del(`loghw:${this.deps.session.commitment}:${spaceId}`);
|
|
14445
|
+
}
|
|
14446
|
+
/** `client.trust.unpin(username)`: forget a member's pinned identity after comparing safety numbers (design §3.4). */
|
|
14447
|
+
async unpinUser(username) {
|
|
14448
|
+
await this.pins.unpin("identity", [username.toLowerCase()]);
|
|
14449
|
+
}
|
|
14450
|
+
}
|
|
14451
|
+
|
|
12509
14452
|
/**
|
|
12510
14453
|
* Absolute POSIX paths for the VFS.
|
|
12511
14454
|
*
|
|
@@ -13515,6 +15458,18 @@ class VfsNamespace {
|
|
|
13515
15458
|
* than by TTL — a stale directory listing shows files that are not there.
|
|
13516
15459
|
*/
|
|
13517
15460
|
cache = new Map();
|
|
15461
|
+
/**
|
|
15462
|
+
* The write of each directory record still in flight, by id.
|
|
15463
|
+
*
|
|
15464
|
+
* Two writes of one record must land in the order they were made. The
|
|
15465
|
+
* personal space has no compare-and-swap: of two writes of one key that
|
|
15466
|
+
* are in flight together it keeps whichever request finishes last, and
|
|
15467
|
+
* that is often the one sent first. Then the earlier write, sealed before
|
|
15468
|
+
* the later edit was made, undoes it. That is how a file deleted forever
|
|
15469
|
+
* from the Trash, while the move that put it there was still being
|
|
15470
|
+
* written, came back. So each write waits for the one before it.
|
|
15471
|
+
*/
|
|
15472
|
+
writing = new Map();
|
|
13518
15473
|
historyLimit;
|
|
13519
15474
|
maxEntries;
|
|
13520
15475
|
maxPathChars;
|
|
@@ -13978,10 +15933,7 @@ class VfsNamespace {
|
|
|
13978
15933
|
const parentPath = dirname(target);
|
|
13979
15934
|
await this.mkdir(parentPath, { recursive: true });
|
|
13980
15935
|
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
13981
|
-
const { manifest, size } = await this.deps.content.write(bytes, {
|
|
13982
|
-
name,
|
|
13983
|
-
type: opts.type ?? contentTypeFor(name),
|
|
13984
|
-
});
|
|
15936
|
+
const { manifest, size } = await this.deps.content.write(bytes, { name, type: opts.type ?? contentTypeFor(name) }, { onProgress: opts.onProgress });
|
|
13985
15937
|
const parent = await this.openDir(parentPath);
|
|
13986
15938
|
const prior = entryIn(parent.node, name);
|
|
13987
15939
|
if (prior?.kind === "dir")
|
|
@@ -14539,24 +16491,14 @@ class VfsNamespace {
|
|
|
14539
16491
|
if (Object.hasOwn(dir.node.entries, name))
|
|
14540
16492
|
delete dir.hidden[name];
|
|
14541
16493
|
}
|
|
14542
|
-
|
|
14543
|
-
|
|
14544
|
-
|
|
16494
|
+
// Not sent until any earlier write of this record has landed: see
|
|
16495
|
+
// `writing`. Whether that one worked or not, this one still goes.
|
|
16496
|
+
const before = this.writing.get(dir.id);
|
|
16497
|
+
const send = () => this.writeRecord(dir);
|
|
16498
|
+
const write = before ? before.then(send, send) : send();
|
|
16499
|
+
this.writing.set(dir.id, write);
|
|
14545
16500
|
try {
|
|
14546
|
-
|
|
14547
|
-
try {
|
|
14548
|
-
sealed = await seal(dir.key, stored);
|
|
14549
|
-
}
|
|
14550
|
-
catch (err) {
|
|
14551
|
-
// JSON.stringify's own failures: a value too deeply nested to
|
|
14552
|
-
// write. Reading drops those from a stored record, so this is
|
|
14553
|
-
// what the write itself was given.
|
|
14554
|
-
if (!(err instanceof RangeError || err instanceof TypeError))
|
|
14555
|
-
throw err;
|
|
14556
|
-
const where = dir.path ?? `the directory stored as record ${printable(dir.id, 40)}`;
|
|
14557
|
-
throw new VfsConflictError(`cannot write to ${where}: it would hold a value nested too deeply to store`);
|
|
14558
|
-
}
|
|
14559
|
-
await this.deps.store.put(dirKey(dir.id), sealed);
|
|
16501
|
+
await write;
|
|
14560
16502
|
}
|
|
14561
16503
|
catch (err) {
|
|
14562
16504
|
// The edit above was made in place, on the very object the cache
|
|
@@ -14567,8 +16509,38 @@ class VfsNamespace {
|
|
|
14567
16509
|
this.cache.delete(dir.id);
|
|
14568
16510
|
throw err;
|
|
14569
16511
|
}
|
|
16512
|
+
finally {
|
|
16513
|
+
if (this.writing.get(dir.id) === write)
|
|
16514
|
+
this.writing.delete(dir.id);
|
|
16515
|
+
}
|
|
14570
16516
|
this.cache.set(dir.id, Promise.resolve(dir));
|
|
14571
16517
|
}
|
|
16518
|
+
/**
|
|
16519
|
+
* Seal a directory record as it stands NOW and put it in the store.
|
|
16520
|
+
*
|
|
16521
|
+
* Read at the moment of sending, not when the edit was made, so a write
|
|
16522
|
+
* that waited behind another carries every edit made to the record by
|
|
16523
|
+
* then, its own included.
|
|
16524
|
+
*/
|
|
16525
|
+
async writeRecord(dir) {
|
|
16526
|
+
const stored = Object.keys(dir.hidden).length
|
|
16527
|
+
? { ...dir.node, entries: Object.assign(Object.create(null), dir.hidden, dir.node.entries) }
|
|
16528
|
+
: dir.node;
|
|
16529
|
+
let sealed;
|
|
16530
|
+
try {
|
|
16531
|
+
sealed = await seal(dir.key, stored);
|
|
16532
|
+
}
|
|
16533
|
+
catch (err) {
|
|
16534
|
+
// JSON.stringify's own failures: a value too deeply nested to
|
|
16535
|
+
// write. Reading drops those from a stored record, so this is
|
|
16536
|
+
// what the write itself was given.
|
|
16537
|
+
if (!(err instanceof RangeError || err instanceof TypeError))
|
|
16538
|
+
throw err;
|
|
16539
|
+
const where = dir.path ?? `the directory stored as record ${printable(dir.id, 40)}`;
|
|
16540
|
+
throw new VfsConflictError(`cannot write to ${where}: it would hold a value nested too deeply to store`);
|
|
16541
|
+
}
|
|
16542
|
+
await this.deps.store.put(dirKey(dir.id), sealed);
|
|
16543
|
+
}
|
|
14572
16544
|
async createDir(parentPath, name) {
|
|
14573
16545
|
assertValidName(name);
|
|
14574
16546
|
const parent = await this.openDir(parentPath);
|
|
@@ -16229,112 +18201,6 @@ class VcsNamespace {
|
|
|
16229
18201
|
}
|
|
16230
18202
|
}
|
|
16231
18203
|
|
|
16232
|
-
/**
|
|
16233
|
-
* `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
|
|
16234
|
-
* keypair (the connect `KeyStore` entry keyed by the member id / username).
|
|
16235
|
-
*
|
|
16236
|
-
* Historically each app hand-rolled this (see the web app's old
|
|
16237
|
-
* `personal/spaceLoader.ts`): generate a keypair at register, seed-wrap it, and
|
|
16238
|
-
* PUT it to the personal space; on login GET + unwrap + hydrate the `KeyStore`.
|
|
16239
|
-
* That scaffolding now lives here so ANY app gets a **stable** member keypair
|
|
16240
|
-
* across reloads for free — without which the member re-admits to every space on
|
|
16241
|
-
* every load and the group-key cache can never round-trip.
|
|
16242
|
-
*
|
|
16243
|
-
* Security invariant: the wrapping secret is the **master seed** (not the
|
|
16244
|
-
* password), so the blob survives password changes and unlocks under a passkey.
|
|
16245
|
-
* The seed is only ever held in memory (never persisted), so this vault can only
|
|
16246
|
-
* provision/rehydrate while the client is unlocked.
|
|
16247
|
-
*/
|
|
16248
|
-
/** Staging-only diagnostics: is the member keypair stable across reloads? */
|
|
16249
|
-
const VAULT_DEBUG = (() => {
|
|
16250
|
-
try {
|
|
16251
|
-
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
16252
|
-
}
|
|
16253
|
-
catch {
|
|
16254
|
-
return false;
|
|
16255
|
-
}
|
|
16256
|
-
})();
|
|
16257
|
-
function vlog(msg) {
|
|
16258
|
-
if (VAULT_DEBUG)
|
|
16259
|
-
console.info(`[muhkoo:vault] ${msg}`);
|
|
16260
|
-
}
|
|
16261
|
-
/** A short, stable fingerprint of the member's current keypair (ecdsa pub). */
|
|
16262
|
-
async function fingerprint(memberId) {
|
|
16263
|
-
try {
|
|
16264
|
-
const raw = await KeyStore.getInstance().getRawEcdsaPublicKey(memberId);
|
|
16265
|
-
if (!raw)
|
|
16266
|
-
return "none";
|
|
16267
|
-
let s = 0;
|
|
16268
|
-
for (const b of raw)
|
|
16269
|
-
s = (s * 31 + b) >>> 0;
|
|
16270
|
-
return s.toString(16).padStart(8, "0");
|
|
16271
|
-
}
|
|
16272
|
-
catch {
|
|
16273
|
-
return "err";
|
|
16274
|
-
}
|
|
16275
|
-
}
|
|
16276
|
-
/** Personal-space key holding the seed-wrapped, dehydrated ratchet keypair. */
|
|
16277
|
-
const CHAT_KEYS_KEY = "chat-keys";
|
|
16278
|
-
class ChatKeyVault {
|
|
16279
|
-
store;
|
|
16280
|
-
constructor(store) {
|
|
16281
|
-
this.store = store;
|
|
16282
|
-
}
|
|
16283
|
-
/**
|
|
16284
|
-
* Register-time: ensure a keypair exists for `memberId`, seed-wrap the
|
|
16285
|
-
* dehydrated form, and persist it to the personal space. Idempotent — reuses
|
|
16286
|
-
* an existing in-tab keypair rather than minting a second one.
|
|
16287
|
-
*/
|
|
16288
|
-
async provision(memberId, seedSecret) {
|
|
16289
|
-
const ks = KeyStore.getInstance();
|
|
16290
|
-
if (!ks.getKeyPair(memberId)) {
|
|
16291
|
-
try {
|
|
16292
|
-
await ks.generateOwnKeyPair(memberId);
|
|
16293
|
-
}
|
|
16294
|
-
catch (e) {
|
|
16295
|
-
if (!String(e?.message ?? e).includes("already exists"))
|
|
16296
|
-
throw e;
|
|
16297
|
-
}
|
|
16298
|
-
}
|
|
16299
|
-
const dehydrated = await ks.dehydrateKeyPair(memberId);
|
|
16300
|
-
const plaintext = new TextEncoder().encode(JSON.stringify(dehydrated));
|
|
16301
|
-
await this.store.put(CHAT_KEYS_KEY, await wrapWithPassphrase(seedSecret, plaintext));
|
|
16302
|
-
vlog(`provision: minted+persisted keypair fp=${await fingerprint(memberId)}`);
|
|
16303
|
-
}
|
|
16304
|
-
/**
|
|
16305
|
-
* Sign-in / unlock time: fetch the wrapped blob, unwrap with the seed, and
|
|
16306
|
-
* hydrate the `KeyStore` — restoring the SAME keypair the member had before.
|
|
16307
|
-
* Returns `true` when a keypair is now in hand (already present or hydrated),
|
|
16308
|
-
* `false` when there's no blob yet (a brand-new member → caller provisions).
|
|
16309
|
-
* Throws only on a genuine unwrap failure (wrong secret / tampered blob).
|
|
16310
|
-
*/
|
|
16311
|
-
async rehydrate(memberId, seedSecret) {
|
|
16312
|
-
// Already unlocked in this tab — nothing to do.
|
|
16313
|
-
if (KeyStore.getInstance().getKeyPair(memberId)?.privateKey)
|
|
16314
|
-
return true;
|
|
16315
|
-
const wrapped = await this.store.get(CHAT_KEYS_KEY);
|
|
16316
|
-
if (!wrapped) {
|
|
16317
|
-
vlog("rehydrate: no chat-keys blob (new member → will provision)");
|
|
16318
|
-
return false;
|
|
16319
|
-
}
|
|
16320
|
-
const bytes = await unwrapWithPassphrase(seedSecret, wrapped);
|
|
16321
|
-
const dehydrated = JSON.parse(new TextDecoder().decode(bytes));
|
|
16322
|
-
await KeyStore.getInstance().hydrateKeyPair(memberId, dehydrated);
|
|
16323
|
-
vlog(`rehydrate: HIT — restored stable keypair fp=${await fingerprint(memberId)}`);
|
|
16324
|
-
return true;
|
|
16325
|
-
}
|
|
16326
|
-
/**
|
|
16327
|
-
* Ensure a stable keypair is in hand: rehydrate from the vault if a blob
|
|
16328
|
-
* exists, otherwise mint one and persist it (so it's stable next session).
|
|
16329
|
-
* Used at register and lazily when a space is first opened.
|
|
16330
|
-
*/
|
|
16331
|
-
async ensure(memberId, seedSecret) {
|
|
16332
|
-
if (await this.rehydrate(memberId, seedSecret))
|
|
16333
|
-
return;
|
|
16334
|
-
await this.provision(memberId, seedSecret);
|
|
16335
|
-
}
|
|
16336
|
-
}
|
|
16337
|
-
|
|
16338
18204
|
/**
|
|
16339
18205
|
* `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
|
|
16340
18206
|
* lexicographically-sortable string so the whole offline layer (and the
|
|
@@ -16612,8 +18478,9 @@ class ConnectivityManager {
|
|
|
16612
18478
|
* mutation by re-invoking the real namespace method (with an `isReplay` flag so
|
|
16613
18479
|
* it doesn't re-enqueue). `drain()` replays entries in HLC order:
|
|
16614
18480
|
* - success → remove the entry;
|
|
16615
|
-
* - permanent failure (4xx
|
|
16616
|
-
*
|
|
18481
|
+
* - permanent failure (4xx, or a Space whose member list refuses this user)
|
|
18482
|
+
* → drop it and surface a `SYNC_ERROR` (a write the server will never
|
|
18483
|
+
* accept must not wedge the queue forever);
|
|
16617
18484
|
* - transient/network failure → stop draining and leave the rest for the next
|
|
16618
18485
|
* reconnect.
|
|
16619
18486
|
*
|
|
@@ -16688,9 +18555,13 @@ class OutboundQueue {
|
|
|
16688
18555
|
}
|
|
16689
18556
|
}
|
|
16690
18557
|
}
|
|
16691
|
-
/** A 4xx is the server permanently refusing the write
|
|
16692
|
-
*
|
|
18558
|
+
/** A 4xx is the server permanently refusing the write, and so is a Space whose
|
|
18559
|
+
* member list does not admit this user (design §4.5.12): the relay refuses
|
|
18560
|
+
* their frames. Anything else, a locked session included, is worth retrying
|
|
18561
|
+
* on the next reconnect. */
|
|
16693
18562
|
function isPermanent(err) {
|
|
18563
|
+
if (err instanceof AdmissionDeniedError)
|
|
18564
|
+
return true;
|
|
16694
18565
|
return err instanceof HttpError && err.status >= 400 && err.status < 500;
|
|
16695
18566
|
}
|
|
16696
18567
|
|
|
@@ -17478,6 +19349,8 @@ class Client {
|
|
|
17478
19349
|
message;
|
|
17479
19350
|
/** Fan-out group spaces — `client.space.createSpace(...)`, etc. */
|
|
17480
19351
|
space;
|
|
19352
|
+
/** Trust records shared by every Space: pins and per-Space log heads (design §3.4, §6.9). */
|
|
19353
|
+
trust;
|
|
17481
19354
|
/** Programmable Agents — `client.agents.create(appId, …)`, etc. */
|
|
17482
19355
|
agents;
|
|
17483
19356
|
/** Serverless functions — `client.functions.deploy(appId, …)`, etc. */
|
|
@@ -17617,7 +19490,7 @@ class Client {
|
|
|
17617
19490
|
contentSpace = (async () => {
|
|
17618
19491
|
const existing = await personalStore.get(VFS_SPACE_KEY);
|
|
17619
19492
|
if (typeof existing === "string" && existing)
|
|
17620
|
-
return this.space.get(existing);
|
|
19493
|
+
return this.space.get(existing, "static", { messaging: false });
|
|
17621
19494
|
// `messaging: false` — this space only ever holds files, and
|
|
17622
19495
|
// file chunks carry their own keys, so it needs no group
|
|
17623
19496
|
// keyring and no socket. Creating it the normal way opens a
|
|
@@ -17644,9 +19517,9 @@ class Client {
|
|
|
17644
19517
|
// GC. `storage.writeFile` would additionally index the manifest
|
|
17645
19518
|
// in the space — a second, diverging source of truth for a file
|
|
17646
19519
|
// whose handle the VFS already holds.
|
|
17647
|
-
write: async (data, meta) => {
|
|
19520
|
+
write: async (data, meta, opts) => {
|
|
17648
19521
|
const space = await resolveContentSpace();
|
|
17649
|
-
const { manifest, stat } = await space.putFile(data, { name: meta.name, type: meta.type });
|
|
19522
|
+
const { manifest, stat } = await space.putFile(data, { name: meta.name, type: meta.type }, { onProgress: opts?.onProgress });
|
|
17650
19523
|
return { manifest, size: stat.size };
|
|
17651
19524
|
},
|
|
17652
19525
|
// Reads go through the manifest alone — no space needed. That is
|
|
@@ -17704,6 +19577,16 @@ class Client {
|
|
|
17704
19577
|
},
|
|
17705
19578
|
};
|
|
17706
19579
|
const chatKeys = new ChatKeyVault(chatKeyStore);
|
|
19580
|
+
// Shared by every Space (and, in sub-project 4, DMs): the account's
|
|
19581
|
+
// identity signer, the identity directory, and the pin/log-head store
|
|
19582
|
+
// (design §3.4, §6.9). Built before `SpaceNamespace` so it can hand the
|
|
19583
|
+
// namespace a reference to wire into each Space it builds.
|
|
19584
|
+
const trustContext = new TrustContext({ http: this.http, session: this.session });
|
|
19585
|
+
this.trust = {
|
|
19586
|
+
get persistent() { return trustContext.persistent; },
|
|
19587
|
+
unpin: (u) => trustContext.unpinUser(u),
|
|
19588
|
+
resetSpacePins: (id) => trustContext.resetSpacePins(id),
|
|
19589
|
+
};
|
|
17707
19590
|
this.space = new SpaceNamespace({
|
|
17708
19591
|
http: this.http,
|
|
17709
19592
|
session: this.session,
|
|
@@ -17712,6 +19595,7 @@ class Client {
|
|
|
17712
19595
|
offline: this.offline,
|
|
17713
19596
|
p2p: options.p2p,
|
|
17714
19597
|
chatKeys,
|
|
19598
|
+
trust: trustContext,
|
|
17715
19599
|
});
|
|
17716
19600
|
this.agents = new AgentsNamespace({ http: this.http });
|
|
17717
19601
|
this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
|
|
@@ -18099,7 +19983,7 @@ class DoubleRatchetCipher {
|
|
|
18099
19983
|
if (!sessionId) {
|
|
18100
19984
|
throw new Error("DoubleRatchetCipher: no active session to seal under");
|
|
18101
19985
|
}
|
|
18102
|
-
const cipherMessage = await this.deps.ratchetManager.encrypt(this.deps.clientId, this.deps.serverId, sessionId, serializedMessage, false, // newDhKey —
|
|
19986
|
+
const cipherMessage = await this.deps.ratchetManager.encrypt(this.deps.clientId, this.deps.serverId, sessionId, serializedMessage, false, // newDhKey — rotation is disabled until the key-schedule rewrite (audit H1)
|
|
18103
19987
|
this.deps.sessionType);
|
|
18104
19988
|
return { encrypted: true, cipherMessage: JSON.stringify(cipherMessage) };
|
|
18105
19989
|
}
|
|
@@ -18135,5 +20019,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
18135
20019
|
default: loadRsWasm
|
|
18136
20020
|
});
|
|
18137
20021
|
|
|
18138
|
-
export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES$1 as AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, CircuitIntegrityError, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_BRANCH, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceRevokedError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PINNED_CIRCUIT_DIGESTS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ROOT_ID, Ready, ReauthRequiredError, ReedSolomonCodec, Repo, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VFS_ROOT_INFO, VaultUnavailableError, VcsError, VcsNamespace, VfsConflictError, VfsCorruptRecordError, VfsLimitError, VfsLockedError, VfsNamespace, VfsNotFoundError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, dirKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, headKey, historyKey, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, loadDeviceIdentity, log, manifestToStat, materializeRow, merge3, merge3Text, mergeKey, mergeMap, mergeMessage, mergeRegister, messageKey, objectKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, refKey, refsIndexKey, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
20022
|
+
export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES$1 as AES_GCM_TAG_BYTES, AccessTokensNamespace, AdmissionDeniedError, AgentsNamespace, AuthClient, AuthHttpError, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, CircuitIntegrityError, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_BRANCH, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceRevokedError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IdentityLockedError, IdentityUnavailableError, IndexedDbStore, KEEPER_MEMBER_ID$1 as KEEPER_MEMBER_ID, KeyStore, KeyringClient, KeyringHttpError, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NOT_MEMBER_CLOSE_CODE, NOT_MEMBER_RECHECK_MS, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PINNED_CIRCUIT_DIGESTS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ROOT_ID, Ready, ReauthRequiredError, ReedSolomonCodec, Repo, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, SpaceTrustError, StorageNamespace, SyncEngine, VERSION, VFS_ROOT_INFO, VaultProtocolMismatchError, VaultUnavailableError, VcsError, VcsNamespace, VfsConflictError, VfsCorruptRecordError, VfsLimitError, VfsLockedError, VfsNamespace, VfsNotFoundError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes$1 as concatBytes, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, dirKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, headKey, historyKey, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, isSameOriginPath, loadDeviceIdentity, log, manifestToStat, materializeRow, merge3, merge3Text, mergeKey, mergeMap, mergeMessage, mergeRegister, messageKey, objectKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, refKey, refsIndexKey, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
18139
20023
|
//# sourceMappingURL=index.js.map
|