@muhkoo/connect 0.2.0-alpha.1 → 0.3.0-alpha.0
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 +26 -18
- package/dist/browser/index.js +719 -277
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +377 -131
- package/dist/server/index.js +1904 -1462
- package/dist/server/index.js.map +1 -1
- package/dist/workers/index.js +118 -3
- package/dist/workers/index.js.map +1 -1
- package/package.json +1 -1
package/dist/server/index.js
CHANGED
|
@@ -2562,7 +2562,7 @@ async function poseidonHash(inputs) {
|
|
|
2562
2562
|
* worker depending on `VITE_WORKER_URL`.
|
|
2563
2563
|
*/
|
|
2564
2564
|
let _snarkjs$1 = null;
|
|
2565
|
-
async function loadSnarkjs$
|
|
2565
|
+
async function loadSnarkjs$1() {
|
|
2566
2566
|
if (_snarkjs$1)
|
|
2567
2567
|
return _snarkjs$1;
|
|
2568
2568
|
_snarkjs$1 = (await import('snarkjs'));
|
|
@@ -2584,7 +2584,7 @@ function defaultCircuitUrls(baseUrl) {
|
|
|
2584
2584
|
};
|
|
2585
2585
|
}
|
|
2586
2586
|
/** BN254 scalar field modulus — the prime the circuit's signals live in. */
|
|
2587
|
-
const FIELD_SIZE$
|
|
2587
|
+
const FIELD_SIZE$1 = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
|
|
2588
2588
|
/**
|
|
2589
2589
|
* Reduce any hex/decimal/byte input to a decimal-string field element in
|
|
2590
2590
|
* `[0, FIELD_SIZE)`. snarkjs and the `preimagePoK` circuit both want decimal
|
|
@@ -2625,9 +2625,9 @@ function toField(value) {
|
|
|
2625
2625
|
hex += b.toString(16).padStart(2, "0");
|
|
2626
2626
|
v = BigInt("0x" + hex);
|
|
2627
2627
|
}
|
|
2628
|
-
v = v % FIELD_SIZE$
|
|
2628
|
+
v = v % FIELD_SIZE$1;
|
|
2629
2629
|
if (v < 0n)
|
|
2630
|
-
v += FIELD_SIZE$
|
|
2630
|
+
v += FIELD_SIZE$1;
|
|
2631
2631
|
return v.toString();
|
|
2632
2632
|
}
|
|
2633
2633
|
/**
|
|
@@ -2667,7 +2667,7 @@ async function generateAuthProof(args) {
|
|
|
2667
2667
|
salt: saltField,
|
|
2668
2668
|
ecdsaPub: ecdsaPubField,
|
|
2669
2669
|
};
|
|
2670
|
-
const snarkjs = await loadSnarkjs$
|
|
2670
|
+
const snarkjs = await loadSnarkjs$1();
|
|
2671
2671
|
const { proof, publicSignals } = await snarkjs.groth16.fullProve(circuitInput, args.circuits.wasmUrl, args.circuits.zkeyUrl);
|
|
2672
2672
|
return {
|
|
2673
2673
|
proof: proof,
|
|
@@ -2752,6 +2752,7 @@ class HttpClient {
|
|
|
2752
2752
|
baseUrl;
|
|
2753
2753
|
apiKey;
|
|
2754
2754
|
getSessionToken;
|
|
2755
|
+
onUnauthorized;
|
|
2755
2756
|
fetchFn;
|
|
2756
2757
|
constructor(opts) {
|
|
2757
2758
|
if (!opts?.baseUrl)
|
|
@@ -2759,6 +2760,7 @@ class HttpClient {
|
|
|
2759
2760
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
2760
2761
|
this.apiKey = opts.apiKey ?? null;
|
|
2761
2762
|
this.getSessionToken = opts.getSessionToken ?? (() => null);
|
|
2763
|
+
this.onUnauthorized = opts.onUnauthorized ?? null;
|
|
2762
2764
|
const f = opts.fetch ?? globalThis.fetch;
|
|
2763
2765
|
if (typeof f !== "function") {
|
|
2764
2766
|
throw new Error("HttpClient: `globalThis.fetch` is unavailable; pass an explicit `fetch`.");
|
|
@@ -2791,7 +2793,7 @@ class HttpClient {
|
|
|
2791
2793
|
del(path, body) {
|
|
2792
2794
|
return this.request("DELETE", path, body);
|
|
2793
2795
|
}
|
|
2794
|
-
async request(method, path, body) {
|
|
2796
|
+
async request(method, path, body, allowRecovery = true) {
|
|
2795
2797
|
const url = path.startsWith("http") ? path : `${this.baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
|
|
2796
2798
|
const init = { method };
|
|
2797
2799
|
if (body !== undefined) {
|
|
@@ -2799,6 +2801,15 @@ class HttpClient {
|
|
|
2799
2801
|
init.body = JSON.stringify(body);
|
|
2800
2802
|
}
|
|
2801
2803
|
const res = await this.fetch(url, init);
|
|
2804
|
+
// Stale session: try to silently re-auth and replay the request once.
|
|
2805
|
+
// `allowRecovery` is cleared on the retry so a still-401 response (e.g.
|
|
2806
|
+
// recovery minted a token the endpoint still rejects) can't loop.
|
|
2807
|
+
if (res.status === 401 && allowRecovery && this.onUnauthorized && this.getSessionToken()) {
|
|
2808
|
+
const recovered = await this.onUnauthorized();
|
|
2809
|
+
if (recovered) {
|
|
2810
|
+
return this.request(method, path, body, false);
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2802
2813
|
let parsed = null;
|
|
2803
2814
|
try {
|
|
2804
2815
|
parsed = await res.json();
|
|
@@ -3028,6 +3039,44 @@ class ZkAuth {
|
|
|
3028
3039
|
*/
|
|
3029
3040
|
async login(username, password, opts = {}) {
|
|
3030
3041
|
const identity = await deriveIdentity(username, password);
|
|
3042
|
+
return this.proveAndStore(username, identity, opts);
|
|
3043
|
+
}
|
|
3044
|
+
/**
|
|
3045
|
+
* Silently re-authenticate the current user without prompting for a
|
|
3046
|
+
* password. Only possible while the client is **unlocked** — i.e. the
|
|
3047
|
+
* derived {@link ZkIdentity} (secret + keypairs) is still in memory from a
|
|
3048
|
+
* prior `login()`/`unlock()`. Re-runs the full challenge→proof→authenticate
|
|
3049
|
+
* dance with that in-memory identity and swaps in the fresh token.
|
|
3050
|
+
*
|
|
3051
|
+
* Returns `true` if a new session was minted, `false` if recovery isn't
|
|
3052
|
+
* possible (no active user, or locked — the identity was never derived or
|
|
3053
|
+
* was lost on reload). A `false` result is the app's cue to send the user
|
|
3054
|
+
* back to the login screen.
|
|
3055
|
+
*
|
|
3056
|
+
* Used automatically by the {@link Client} when a token-gated request comes
|
|
3057
|
+
* back `401` (stale/expired token), so transient session expiry self-heals
|
|
3058
|
+
* instead of surfacing errors mid-session.
|
|
3059
|
+
*/
|
|
3060
|
+
async recover() {
|
|
3061
|
+
const username = this.deps.session.username;
|
|
3062
|
+
const identity = this.deps.session.identity;
|
|
3063
|
+
if (!username || !identity)
|
|
3064
|
+
return false;
|
|
3065
|
+
try {
|
|
3066
|
+
await this.proveAndStore(username, identity, {});
|
|
3067
|
+
return true;
|
|
3068
|
+
}
|
|
3069
|
+
catch {
|
|
3070
|
+
return false;
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
/**
|
|
3074
|
+
* Shared sign-in core: prove knowledge of `identity` against a fresh
|
|
3075
|
+
* challenge and persist the resulting session + identity. Used by both
|
|
3076
|
+
* `login()` (identity derived from a password) and `recover()` (identity
|
|
3077
|
+
* already in memory).
|
|
3078
|
+
*/
|
|
3079
|
+
async proveAndStore(username, identity, opts) {
|
|
3031
3080
|
const ecdsaPubHex = await exportPublicKeyHex(identity.ecdsaKeyPair.publicKey);
|
|
3032
3081
|
const challenge = await this.deps.auth.getChallenge(username);
|
|
3033
3082
|
const { proof, publicSignals, commitment } = await generateAuthProof({
|
|
@@ -3184,144 +3233,6 @@ class StorageCipher {
|
|
|
3184
3233
|
}
|
|
3185
3234
|
}
|
|
3186
3235
|
|
|
3187
|
-
/**
|
|
3188
|
-
* `client.storage` — per-user persistent storage backed by the accelerator's
|
|
3189
|
-
* personal space.
|
|
3190
|
-
*
|
|
3191
|
-
* await client.storage.set('todos', id, { title, completed })
|
|
3192
|
-
* const todo = await client.storage.get('todos', id)
|
|
3193
|
-
* const ids = await client.storage.list('todos')
|
|
3194
|
-
* await client.storage.delete('todos', id)
|
|
3195
|
-
* const off = client.storage.on('change', e => …) // realtime, cross-device
|
|
3196
|
-
*
|
|
3197
|
-
* A *collection* is a key namespace within the user's space; the stored key is
|
|
3198
|
-
* `${collection}/${id}`. Operations are authorized by the user's session token
|
|
3199
|
-
* (stamped on every request by {@link HttpClient}) — no per-op Groth16 proof.
|
|
3200
|
-
*
|
|
3201
|
-
* Values are encrypted at rest by default ({@link StorageCipher}, an AES-GCM
|
|
3202
|
-
* key derived from the ZK identity); pass `{ encrypt: false }` to store
|
|
3203
|
-
* plaintext. The realtime feed rides the personal space's own websocket, so no
|
|
3204
|
-
* separate transport is involved.
|
|
3205
|
-
*
|
|
3206
|
-
* `query()` is intentionally deferred — see the SDK overhaul plan.
|
|
3207
|
-
*/
|
|
3208
|
-
class StorageNamespace {
|
|
3209
|
-
deps;
|
|
3210
|
-
cipher = null;
|
|
3211
|
-
cipherIdentity = null;
|
|
3212
|
-
constructor(deps) {
|
|
3213
|
-
this.deps = deps;
|
|
3214
|
-
}
|
|
3215
|
-
/** Store `value` under `collection`/`id` (encrypted at rest by default). */
|
|
3216
|
-
async set(collection, id, value, opts) {
|
|
3217
|
-
const encrypt = opts?.encrypt !== false;
|
|
3218
|
-
const stored = encrypt ? await this.getCipher().encrypt(value) : value;
|
|
3219
|
-
await this.deps.http.post(this.kvPath(collection, id), { value: stored });
|
|
3220
|
-
}
|
|
3221
|
-
/** Fetch the value at `collection`/`id`, or `null` if absent. */
|
|
3222
|
-
async get(collection, id) {
|
|
3223
|
-
const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
|
|
3224
|
-
return this.decode(res.value);
|
|
3225
|
-
}
|
|
3226
|
-
/** Delete `collection`/`id`. Returns whether the key existed. */
|
|
3227
|
-
async delete(collection, id) {
|
|
3228
|
-
const res = await this.deps.http.del(this.kvPath(collection, id), {});
|
|
3229
|
-
return Boolean(res.existed);
|
|
3230
|
-
}
|
|
3231
|
-
/** List the ids present in `collection`. */
|
|
3232
|
-
async list(collection) {
|
|
3233
|
-
const res = await this.deps.http.post(this.spacePath("/list"), {});
|
|
3234
|
-
const prefix = `${collection}/`;
|
|
3235
|
-
return (res.keys ?? [])
|
|
3236
|
-
.filter((k) => k.startsWith(prefix))
|
|
3237
|
-
.map((k) => k.slice(prefix.length));
|
|
3238
|
-
}
|
|
3239
|
-
/** Reserved — server-side query is deferred (see the overhaul plan). */
|
|
3240
|
-
async query(_collection, _filter) {
|
|
3241
|
-
throw new Error("client.storage.query is not implemented yet (deferred in the SDK overhaul).");
|
|
3242
|
-
}
|
|
3243
|
-
/**
|
|
3244
|
-
* Subscribe to realtime changes to this user's data across devices. Opens
|
|
3245
|
-
* the personal space's websocket and decrypts each change before handing
|
|
3246
|
-
* it to `handler`. Returns an unsubscribe function.
|
|
3247
|
-
*
|
|
3248
|
-
* Requires a `WebSocket` global (browsers; Node 22+ or a `ws` shim).
|
|
3249
|
-
*/
|
|
3250
|
-
on(event, handler) {
|
|
3251
|
-
if (event !== "change")
|
|
3252
|
-
throw new Error(`client.storage: unknown event "${event}"`);
|
|
3253
|
-
const WS = globalThis.WebSocket;
|
|
3254
|
-
if (typeof WS !== "function") {
|
|
3255
|
-
throw new Error("client.storage.on: no `WebSocket` global available in this runtime.");
|
|
3256
|
-
}
|
|
3257
|
-
const token = this.deps.session.token;
|
|
3258
|
-
if (!token)
|
|
3259
|
-
throw new Error("client.storage.on: not signed in.");
|
|
3260
|
-
const url = `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(this.commitment())}/websocket?session=${encodeURIComponent(token)}`;
|
|
3261
|
-
const socket = new WS(url);
|
|
3262
|
-
socket.addEventListener("message", (ev) => {
|
|
3263
|
-
void this.dispatchChange(ev.data, handler);
|
|
3264
|
-
});
|
|
3265
|
-
return () => {
|
|
3266
|
-
try {
|
|
3267
|
-
socket.close();
|
|
3268
|
-
}
|
|
3269
|
-
catch { /* already closed */ }
|
|
3270
|
-
};
|
|
3271
|
-
}
|
|
3272
|
-
// -------------------------------------------------------------------------
|
|
3273
|
-
// Internals
|
|
3274
|
-
// -------------------------------------------------------------------------
|
|
3275
|
-
async dispatchChange(raw, handler) {
|
|
3276
|
-
let frame;
|
|
3277
|
-
try {
|
|
3278
|
-
frame = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
3279
|
-
}
|
|
3280
|
-
catch {
|
|
3281
|
-
return;
|
|
3282
|
-
}
|
|
3283
|
-
if (!frame || frame._t !== "change" || typeof frame.key !== "string")
|
|
3284
|
-
return;
|
|
3285
|
-
const slash = frame.key.indexOf("/");
|
|
3286
|
-
if (slash < 0)
|
|
3287
|
-
return;
|
|
3288
|
-
const collection = frame.key.slice(0, slash);
|
|
3289
|
-
const id = frame.key.slice(slash + 1);
|
|
3290
|
-
const data = frame.op === "set" ? await this.decode(frame.value) : null;
|
|
3291
|
-
handler({ collection, id, type: frame.op, data });
|
|
3292
|
-
}
|
|
3293
|
-
/** Decode a stored value: decrypt envelopes, pass plaintext through. */
|
|
3294
|
-
async decode(value) {
|
|
3295
|
-
if (value == null)
|
|
3296
|
-
return null;
|
|
3297
|
-
if (StorageCipher.isEnvelope(value))
|
|
3298
|
-
return this.getCipher().decrypt(value);
|
|
3299
|
-
return value;
|
|
3300
|
-
}
|
|
3301
|
-
commitment() {
|
|
3302
|
-
const c = this.deps.session.commitment;
|
|
3303
|
-
if (!c)
|
|
3304
|
-
throw new Error("client.storage: not signed in — call client.auth.zk.login() first.");
|
|
3305
|
-
return c;
|
|
3306
|
-
}
|
|
3307
|
-
spacePath(suffix) {
|
|
3308
|
-
return `/api/personal/${encodeURIComponent(this.commitment())}${suffix}`;
|
|
3309
|
-
}
|
|
3310
|
-
kvPath(collection, id) {
|
|
3311
|
-
const key = `${collection}/${id}`;
|
|
3312
|
-
return this.spacePath(`/kv/${encodeURIComponent(key)}`);
|
|
3313
|
-
}
|
|
3314
|
-
/** The at-rest cipher for the current identity (re-derived if it changes). */
|
|
3315
|
-
getCipher() {
|
|
3316
|
-
const identity = this.deps.session.requireIdentity();
|
|
3317
|
-
if (this.cipher && this.cipherIdentity === identity)
|
|
3318
|
-
return this.cipher;
|
|
3319
|
-
this.cipher = new StorageCipher(identity.secretHex, identity.saltHex);
|
|
3320
|
-
this.cipherIdentity = identity;
|
|
3321
|
-
return this.cipher;
|
|
3322
|
-
}
|
|
3323
|
-
}
|
|
3324
|
-
|
|
3325
3236
|
/**
|
|
3326
3237
|
* Pure WebSocket transport — owns the socket lifecycle and nothing else.
|
|
3327
3238
|
*
|
|
@@ -3337,10 +3248,29 @@ class StorageNamespace {
|
|
|
3337
3248
|
* - Emit lifecycle events (CONNECTED / DISCONNECTED / RECONNECTING / ERROR)
|
|
3338
3249
|
* plus raw inbound MESSAGE events
|
|
3339
3250
|
*/
|
|
3251
|
+
/** Monotonic source of unique transport ids when a caller doesn't supply one. */
|
|
3252
|
+
let __wsInstanceSeq = 0;
|
|
3340
3253
|
class WSTransport extends EventCore {
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3254
|
+
/**
|
|
3255
|
+
* Per-instance event scope. `EventCore` is a process-global static emitter,
|
|
3256
|
+
* so without namespacing every WSTransport would hear every other one's
|
|
3257
|
+
* frames. We prefix each event with this transport's `id` and expose
|
|
3258
|
+
* `on`/`off`/`emit` that scope transparently — callers still use the plain
|
|
3259
|
+
* `EventCoreEvents` enum and only ever see their own connection's events.
|
|
3260
|
+
*/
|
|
3261
|
+
id;
|
|
3262
|
+
emit = (event, data) => {
|
|
3263
|
+
EventCore.emit(this.scopedEvent(event), data);
|
|
3264
|
+
};
|
|
3265
|
+
on = (event, handler) => {
|
|
3266
|
+
EventCore.on(this.scopedEvent(event), handler);
|
|
3267
|
+
};
|
|
3268
|
+
off = (event, handler) => {
|
|
3269
|
+
EventCore.off(this.scopedEvent(event), handler);
|
|
3270
|
+
};
|
|
3271
|
+
scopedEvent(event) {
|
|
3272
|
+
return `${this.id}:${event}`;
|
|
3273
|
+
}
|
|
3344
3274
|
url;
|
|
3345
3275
|
socket = null;
|
|
3346
3276
|
_isConnected = false;
|
|
@@ -3354,16 +3284,28 @@ class WSTransport extends EventCore {
|
|
|
3354
3284
|
// Outbound queue (raw frame strings — already serialized by caller)
|
|
3355
3285
|
outboundQueue = [];
|
|
3356
3286
|
maxQueueSize;
|
|
3287
|
+
// Heartbeat / liveness state
|
|
3288
|
+
heartbeatInterval;
|
|
3289
|
+
heartbeatTimeout;
|
|
3290
|
+
pingFrame;
|
|
3291
|
+
pongFrame;
|
|
3292
|
+
heartbeatTimer = null;
|
|
3293
|
+
pongTimer = null;
|
|
3357
3294
|
constructor(opts) {
|
|
3358
3295
|
super();
|
|
3359
3296
|
if (!opts || !opts.url) {
|
|
3360
3297
|
throw new Error("WSTransport: `url` is required");
|
|
3361
3298
|
}
|
|
3362
3299
|
this.url = opts.url;
|
|
3300
|
+
this.id = opts.id ?? `ws${++__wsInstanceSeq}`;
|
|
3363
3301
|
this.autoReconnect = opts.autoReconnect ?? true;
|
|
3364
3302
|
this.reconnectDelay = opts.reconnectDelay ?? 3000;
|
|
3365
3303
|
this.maxReconnectAttempts = opts.maxReconnectAttempts ?? 5;
|
|
3366
3304
|
this.maxQueueSize = opts.maxQueueSize ?? 100;
|
|
3305
|
+
this.heartbeatInterval = opts.heartbeatInterval ?? 30000;
|
|
3306
|
+
this.heartbeatTimeout = opts.heartbeatTimeout ?? 10000;
|
|
3307
|
+
this.pingFrame = opts.pingFrame ?? "ping";
|
|
3308
|
+
this.pongFrame = opts.pongFrame ?? "pong";
|
|
3367
3309
|
}
|
|
3368
3310
|
/** Open the socket. Resolves when CONNECTED fires. Rejects on first error. */
|
|
3369
3311
|
async connect() {
|
|
@@ -3381,9 +3323,19 @@ class WSTransport extends EventCore {
|
|
|
3381
3323
|
this.reconnectAttempts = 0;
|
|
3382
3324
|
this.emit(EventCoreEvents.CONNECTED, undefined);
|
|
3383
3325
|
this.flushQueue();
|
|
3326
|
+
this.startHeartbeat();
|
|
3384
3327
|
resolve();
|
|
3385
3328
|
};
|
|
3386
3329
|
socket.onmessage = (event) => {
|
|
3330
|
+
// Heartbeat ack — swallow it (don't surface as a MESSAGE) and clear
|
|
3331
|
+
// the liveness deadline. The layer above never sees keep-alive frames.
|
|
3332
|
+
if (event.data === this.pongFrame) {
|
|
3333
|
+
if (this.pongTimer) {
|
|
3334
|
+
clearTimeout(this.pongTimer);
|
|
3335
|
+
this.pongTimer = null;
|
|
3336
|
+
}
|
|
3337
|
+
return;
|
|
3338
|
+
}
|
|
3387
3339
|
// Raw inbound frame — layer above is responsible for parsing.
|
|
3388
3340
|
this.emit(EventCoreEvents.MESSAGE, event.data);
|
|
3389
3341
|
};
|
|
@@ -3398,6 +3350,7 @@ class WSTransport extends EventCore {
|
|
|
3398
3350
|
const wasConnected = this._isConnected;
|
|
3399
3351
|
this._isConnected = false;
|
|
3400
3352
|
this._isConnecting = false;
|
|
3353
|
+
this.stopHeartbeat();
|
|
3401
3354
|
this.emit(EventCoreEvents.DISCONNECTED, undefined);
|
|
3402
3355
|
if (this.autoReconnect && wasConnected) {
|
|
3403
3356
|
this.scheduleReconnect();
|
|
@@ -3416,6 +3369,7 @@ class WSTransport extends EventCore {
|
|
|
3416
3369
|
clearTimeout(this.reconnectTimeout);
|
|
3417
3370
|
this.reconnectTimeout = null;
|
|
3418
3371
|
}
|
|
3372
|
+
this.stopHeartbeat();
|
|
3419
3373
|
this.autoReconnect = false;
|
|
3420
3374
|
if (this.socket) {
|
|
3421
3375
|
try {
|
|
@@ -3446,6 +3400,78 @@ class WSTransport extends EventCore {
|
|
|
3446
3400
|
queuedFrames() { return this.outboundQueue.length; }
|
|
3447
3401
|
reconnectAttemptCount() { return this.reconnectAttempts; }
|
|
3448
3402
|
// -- internals -------------------------------------------------------------
|
|
3403
|
+
/** Begin the keep-alive loop (no-op when disabled). */
|
|
3404
|
+
startHeartbeat() {
|
|
3405
|
+
if (this.heartbeatInterval <= 0)
|
|
3406
|
+
return;
|
|
3407
|
+
this.stopHeartbeat();
|
|
3408
|
+
this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.heartbeatInterval);
|
|
3409
|
+
}
|
|
3410
|
+
/** Stop the keep-alive loop and clear any pending liveness deadline. */
|
|
3411
|
+
stopHeartbeat() {
|
|
3412
|
+
if (this.heartbeatTimer) {
|
|
3413
|
+
clearInterval(this.heartbeatTimer);
|
|
3414
|
+
this.heartbeatTimer = null;
|
|
3415
|
+
}
|
|
3416
|
+
if (this.pongTimer) {
|
|
3417
|
+
clearTimeout(this.pongTimer);
|
|
3418
|
+
this.pongTimer = null;
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
/**
|
|
3422
|
+
* Send one ping and arm a liveness deadline. If no `pongFrame` arrives before
|
|
3423
|
+
* `heartbeatTimeout`, the connection is considered dead and we force a
|
|
3424
|
+
* reconnect — this is what catches a silently-dropped TCP connection that
|
|
3425
|
+
* never fired `onclose` (the failure mode that "loses connection with the
|
|
3426
|
+
* Accelerator" on long sessions).
|
|
3427
|
+
*/
|
|
3428
|
+
sendHeartbeat() {
|
|
3429
|
+
if (!this._isConnected || !this.socket)
|
|
3430
|
+
return;
|
|
3431
|
+
try {
|
|
3432
|
+
this.socket.send(this.pingFrame);
|
|
3433
|
+
}
|
|
3434
|
+
catch {
|
|
3435
|
+
this.forceReconnect();
|
|
3436
|
+
return;
|
|
3437
|
+
}
|
|
3438
|
+
// A still-pending deadline means the previous pong never came back.
|
|
3439
|
+
if (this.pongTimer)
|
|
3440
|
+
return;
|
|
3441
|
+
this.pongTimer = setTimeout(() => {
|
|
3442
|
+
this.pongTimer = null;
|
|
3443
|
+
this.forceReconnect();
|
|
3444
|
+
}, this.heartbeatTimeout);
|
|
3445
|
+
}
|
|
3446
|
+
/**
|
|
3447
|
+
* Tear down a connection we believe is dead and kick off reconnect, without
|
|
3448
|
+
* waiting on the OS/browser to eventually fire `onclose` (which a half-open
|
|
3449
|
+
* socket may never do). We detach the handlers first so the normal close path
|
|
3450
|
+
* can't double-fire the reconnect.
|
|
3451
|
+
*/
|
|
3452
|
+
forceReconnect() {
|
|
3453
|
+
this.stopHeartbeat();
|
|
3454
|
+
const sock = this.socket;
|
|
3455
|
+
const wasConnected = this._isConnected;
|
|
3456
|
+
this.socket = null;
|
|
3457
|
+
this._isConnected = false;
|
|
3458
|
+
this._isConnecting = false;
|
|
3459
|
+
if (sock) {
|
|
3460
|
+
sock.onopen = null;
|
|
3461
|
+
sock.onmessage = null;
|
|
3462
|
+
sock.onerror = null;
|
|
3463
|
+
sock.onclose = null;
|
|
3464
|
+
try {
|
|
3465
|
+
sock.close();
|
|
3466
|
+
}
|
|
3467
|
+
catch { /* already gone */ }
|
|
3468
|
+
}
|
|
3469
|
+
this.emit(EventCoreEvents.ERROR, new Error("WSTransport: heartbeat timeout"));
|
|
3470
|
+
this.emit(EventCoreEvents.DISCONNECTED, undefined);
|
|
3471
|
+
if (this.autoReconnect && wasConnected) {
|
|
3472
|
+
this.scheduleReconnect();
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3449
3475
|
scheduleReconnect() {
|
|
3450
3476
|
if (this.maxReconnectAttempts > 0 &&
|
|
3451
3477
|
this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
@@ -3481,473 +3507,160 @@ class WSTransport extends EventCore {
|
|
|
3481
3507
|
}
|
|
3482
3508
|
|
|
3483
3509
|
/**
|
|
3484
|
-
*
|
|
3485
|
-
*
|
|
3486
|
-
* per-pair `DoubleRatchet` instances and owns the protocol bookkeeping
|
|
3487
|
-
* (handshake reciprocation, dedup, role assignment, broadcast fan-out,
|
|
3488
|
-
* recipient filtering) so application code doesn't have to.
|
|
3510
|
+
* `client.kv` — per-user key/value storage backed by the accelerator's personal
|
|
3511
|
+
* space.
|
|
3489
3512
|
*
|
|
3490
|
-
*
|
|
3491
|
-
*
|
|
3492
|
-
*
|
|
3493
|
-
*
|
|
3494
|
-
*
|
|
3495
|
-
* the typed result (plaintext, handshake-done, ignored).
|
|
3513
|
+
* await client.kv.set('todos', id, { title, completed })
|
|
3514
|
+
* const todo = await client.kv.get('todos', id)
|
|
3515
|
+
* const ids = await client.kv.list('todos')
|
|
3516
|
+
* await client.kv.delete('todos', id)
|
|
3517
|
+
* const off = client.kv.on('change', e => …) // realtime, cross-device
|
|
3496
3518
|
*
|
|
3497
|
-
*
|
|
3498
|
-
*
|
|
3499
|
-
*
|
|
3519
|
+
* A *collection* is a key namespace within the user's space; the stored key is
|
|
3520
|
+
* `${collection}/${id}`. Operations are authorized by the user's session token
|
|
3521
|
+
* (stamped on every request by {@link HttpClient}) — no per-op Groth16 proof.
|
|
3522
|
+
*
|
|
3523
|
+
* Values are encrypted at rest by default ({@link StorageCipher}, an AES-GCM
|
|
3524
|
+
* key derived from the ZK identity); pass `{ encrypt: false }` to store
|
|
3525
|
+
* plaintext. The realtime feed rides the personal space's websocket over a
|
|
3526
|
+
* {@link WSTransport} (heartbeat keep-alive + auto-reconnect, event-namespaced
|
|
3527
|
+
* so it doesn't cross wires with chat/space transports).
|
|
3528
|
+
*
|
|
3529
|
+
* For **files** (blobs), use `client.storage` instead — `client.kv` is for
|
|
3530
|
+
* small structured values. `query()` is intentionally deferred.
|
|
3500
3531
|
*/
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
/** One DoubleRatchet per peer pair. */
|
|
3508
|
-
ratchets = new Map();
|
|
3509
|
-
/** Peers we've already sent our keyExchange to in this Session. */
|
|
3510
|
-
sentHandshakeTo = new Set();
|
|
3511
|
-
/** Resolved when own keys are generated and ready to use. */
|
|
3512
|
-
_ready;
|
|
3513
|
-
_resolveReady;
|
|
3514
|
-
_initialized = false;
|
|
3515
|
-
constructor(opts) {
|
|
3516
|
-
if (!opts || !opts.myId) {
|
|
3517
|
-
throw new Error("EncryptedSession: `myId` is required");
|
|
3518
|
-
}
|
|
3519
|
-
this.myId = opts.myId;
|
|
3520
|
-
this.keyStore = KeyStore.getInstance();
|
|
3521
|
-
this._ready = new Promise((resolve) => {
|
|
3522
|
-
this._resolveReady = resolve;
|
|
3523
|
-
});
|
|
3532
|
+
class KvNamespace {
|
|
3533
|
+
deps;
|
|
3534
|
+
cipher = null;
|
|
3535
|
+
cipherIdentity = null;
|
|
3536
|
+
constructor(deps) {
|
|
3537
|
+
this.deps = deps;
|
|
3524
3538
|
}
|
|
3525
|
-
/**
|
|
3526
|
-
|
|
3527
|
-
|
|
3539
|
+
/** Store `value` under `collection`/`id` (encrypted at rest by default). */
|
|
3540
|
+
async set(collection, id, value, opts) {
|
|
3541
|
+
const encrypt = opts?.encrypt !== false;
|
|
3542
|
+
const stored = encrypt ? await this.getCipher().encrypt(value) : value;
|
|
3543
|
+
await this.deps.http.post(this.kvPath(collection, id), { value: stored });
|
|
3528
3544
|
}
|
|
3529
|
-
/**
|
|
3530
|
-
get id
|
|
3531
|
-
|
|
3545
|
+
/** Fetch the value at `collection`/`id`, or `null` if absent. */
|
|
3546
|
+
async get(collection, id) {
|
|
3547
|
+
const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
|
|
3548
|
+
return this.decode(res.value);
|
|
3549
|
+
}
|
|
3550
|
+
/** Delete `collection`/`id`. Returns whether the key existed. */
|
|
3551
|
+
async delete(collection, id) {
|
|
3552
|
+
const res = await this.deps.http.del(this.kvPath(collection, id), {});
|
|
3553
|
+
return Boolean(res.existed);
|
|
3554
|
+
}
|
|
3555
|
+
/** List the ids present in `collection`. */
|
|
3556
|
+
async list(collection) {
|
|
3557
|
+
const res = await this.deps.http.post(this.spacePath("/list"), {});
|
|
3558
|
+
const prefix = `${collection}/`;
|
|
3559
|
+
return (res.keys ?? [])
|
|
3560
|
+
.filter((k) => k.startsWith(prefix))
|
|
3561
|
+
.map((k) => k.slice(prefix.length));
|
|
3562
|
+
}
|
|
3563
|
+
/** Reserved — server-side query is deferred (see the overhaul plan). */
|
|
3564
|
+
async query(_collection, _filter) {
|
|
3565
|
+
throw new Error("client.kv.query is not implemented yet (deferred in the SDK overhaul).");
|
|
3532
3566
|
}
|
|
3533
3567
|
/**
|
|
3534
|
-
*
|
|
3535
|
-
*
|
|
3568
|
+
* Subscribe to realtime changes to this user's data across devices. Opens
|
|
3569
|
+
* the personal space's websocket and decrypts each change before handing
|
|
3570
|
+
* it to `handler`. Returns an unsubscribe function.
|
|
3571
|
+
*
|
|
3572
|
+
* Requires a `WebSocket` global (browsers; Node 22+ or a `ws` shim).
|
|
3536
3573
|
*/
|
|
3537
|
-
|
|
3538
|
-
if (
|
|
3539
|
-
|
|
3540
|
-
if (
|
|
3541
|
-
|
|
3542
|
-
}
|
|
3543
|
-
this._initialized = true;
|
|
3544
|
-
this._resolveReady();
|
|
3545
|
-
}
|
|
3546
|
-
/**
|
|
3547
|
-
* Build the keyExchange frame this client should send to peers when it
|
|
3548
|
-
* comes online. Application is responsible for actually transmitting it
|
|
3549
|
-
* (e.g. ws.send(JSON.stringify(frame))).
|
|
3550
|
-
*/
|
|
3551
|
-
async getOwnKeyExchange() {
|
|
3552
|
-
await this._ready;
|
|
3553
|
-
const ownEcdh = this.keyStore.getKeyPair(this.myId);
|
|
3554
|
-
const ownEcdsa = this.keyStore.getAuthKeyPair(this.myId);
|
|
3555
|
-
if (!ownEcdh || !ownEcdsa) {
|
|
3556
|
-
throw new Error("EncryptedSession.getOwnKeyExchange: keys not initialized");
|
|
3574
|
+
on(event, handler) {
|
|
3575
|
+
if (event !== "change")
|
|
3576
|
+
throw new Error(`client.kv: unknown event "${event}"`);
|
|
3577
|
+
if (typeof globalThis.WebSocket !== "function") {
|
|
3578
|
+
throw new Error("client.kv.on: no `WebSocket` global available in this runtime.");
|
|
3557
3579
|
}
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3580
|
+
const token = this.deps.session.token;
|
|
3581
|
+
if (!token)
|
|
3582
|
+
throw new Error("client.kv.on: not signed in.");
|
|
3583
|
+
const commitment = this.commitment();
|
|
3584
|
+
const url = `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(commitment)}/websocket?session=${encodeURIComponent(token)}`;
|
|
3585
|
+
// Ride a WSTransport rather than a bare socket: it keeps the (otherwise
|
|
3586
|
+
// idle) personal-space feed warm with heartbeat pings, auto-reconnects
|
|
3587
|
+
// if it drops, and namespaces its events by `id` so this feed never
|
|
3588
|
+
// crosses wires with chat/space transports on the shared EventCore bus.
|
|
3589
|
+
const transport = new WSTransport({ url, id: `personal:${commitment}` });
|
|
3590
|
+
const onMessage = (e) => {
|
|
3591
|
+
void this.dispatchChange(e.detail, handler);
|
|
3592
|
+
};
|
|
3593
|
+
transport.on(EventCoreEvents.MESSAGE, onMessage);
|
|
3594
|
+
// Fire-and-forget: the realtime feed is best-effort, and the transport
|
|
3595
|
+
// queues nothing it needs before CONNECTED (server→client only).
|
|
3596
|
+
void transport.connect();
|
|
3597
|
+
return () => {
|
|
3598
|
+
transport.off(EventCoreEvents.MESSAGE, onMessage);
|
|
3599
|
+
transport.disconnect();
|
|
3565
3600
|
};
|
|
3566
3601
|
}
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
* outgoing message locally in that case (since there's no one to send
|
|
3575
|
-
* it to yet).
|
|
3576
|
-
*/
|
|
3577
|
-
async encrypt(plaintext) {
|
|
3578
|
-
await this._ready;
|
|
3579
|
-
const frames = [];
|
|
3580
|
-
for (const peerId of this.ratchets.keys()) {
|
|
3581
|
-
const ratchet = this.ratchets.get(peerId);
|
|
3582
|
-
const sessionId = this._sessionIdFor(peerId);
|
|
3583
|
-
const cipherMessage = await ratchet.encrypt(plaintext,
|
|
3584
|
-
/* newDhKey */ false, this.myId, peerId, sessionId, "specific");
|
|
3585
|
-
frames.push({ cipherMessage });
|
|
3586
|
-
}
|
|
3587
|
-
return frames;
|
|
3588
|
-
}
|
|
3589
|
-
/**
|
|
3590
|
-
* Hand an inbound frame to the session. Returns a typed result the
|
|
3591
|
-
* application acts on.
|
|
3592
|
-
*
|
|
3593
|
-
* - `kind: 'plaintext'` — decrypted; render `result.text`
|
|
3594
|
-
* - `kind: 'handshake'` — a peer's keys are now stored, and if
|
|
3595
|
-
* `result.outbound` is set, send it
|
|
3596
|
-
* - `kind: 'ignored'` — not for us, malformed, duplicate, etc.
|
|
3597
|
-
*
|
|
3598
|
-
* Calling code should always handle `'ignored'` (no UI side effect).
|
|
3599
|
-
*/
|
|
3600
|
-
async receive(frame) {
|
|
3601
|
-
await this._ready;
|
|
3602
|
-
// -- handshake frame -----------------------------------------------------
|
|
3603
|
-
if (isKeyExchangeFrame(frame)) {
|
|
3604
|
-
const kx = frame.keyExchange;
|
|
3605
|
-
const peerId = kx.userId;
|
|
3606
|
-
if (peerId === this.myId) {
|
|
3607
|
-
return { kind: "ignored", reason: "own keyExchange echo" };
|
|
3608
|
-
}
|
|
3609
|
-
const isNew = !this.ratchets.has(peerId);
|
|
3610
|
-
// (Re-)store the peer's pubkeys + (re-)build the ratchet if needed.
|
|
3611
|
-
const peerEcdh = await importJwkBase64(kx.ecdhPublicKey, "ECDH");
|
|
3612
|
-
const peerEcdsa = await importJwkBase64(kx.ecdsaPublicKey, "ECDSA");
|
|
3613
|
-
await this.keyStore.storeRemotePublicKeys(peerId, peerEcdh, peerEcdsa);
|
|
3614
|
-
if (isNew) {
|
|
3615
|
-
const isClient = this.myId < peerId;
|
|
3616
|
-
const ratchet = new DoubleRatchet(this.myId, peerId, "specific", isClient);
|
|
3617
|
-
await ratchet.initializeSession(isClient);
|
|
3618
|
-
this.ratchets.set(peerId, ratchet);
|
|
3619
|
-
}
|
|
3620
|
-
// Reciprocate exactly once per peer per session lifetime.
|
|
3621
|
-
let outbound = null;
|
|
3622
|
-
if (isNew && !this.sentHandshakeTo.has(peerId)) {
|
|
3623
|
-
outbound = await this.getOwnKeyExchange();
|
|
3624
|
-
this.sentHandshakeTo.add(peerId);
|
|
3625
|
-
}
|
|
3626
|
-
return { kind: "handshake", peerId, outbound };
|
|
3602
|
+
// -------------------------------------------------------------------------
|
|
3603
|
+
// Internals
|
|
3604
|
+
// -------------------------------------------------------------------------
|
|
3605
|
+
async dispatchChange(raw, handler) {
|
|
3606
|
+
let frame;
|
|
3607
|
+
try {
|
|
3608
|
+
frame = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
3627
3609
|
}
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
const cipherMessage = frame.cipherMessage;
|
|
3631
|
-
const header = cipherMessage?.header;
|
|
3632
|
-
if (!header) {
|
|
3633
|
-
return { kind: "ignored", reason: "cipherMessage has no header" };
|
|
3634
|
-
}
|
|
3635
|
-
if (header.senderId === this.myId) {
|
|
3636
|
-
return { kind: "ignored", reason: "own outbound echo" };
|
|
3637
|
-
}
|
|
3638
|
-
if (header.recipientId && header.recipientId !== this.myId) {
|
|
3639
|
-
return { kind: "ignored", reason: "addressed to a different peer" };
|
|
3640
|
-
}
|
|
3641
|
-
const ratchet = this.ratchets.get(header.senderId);
|
|
3642
|
-
if (!ratchet) {
|
|
3643
|
-
return {
|
|
3644
|
-
kind: "ignored",
|
|
3645
|
-
reason: `no ratchet with sender ${header.senderId} (handshake hasn't completed)`,
|
|
3646
|
-
};
|
|
3647
|
-
}
|
|
3648
|
-
const isClient = this.myId < header.senderId;
|
|
3649
|
-
let text;
|
|
3650
|
-
try {
|
|
3651
|
-
text = await ratchet.decrypt(cipherMessage, isClient);
|
|
3652
|
-
}
|
|
3653
|
-
catch (err) {
|
|
3654
|
-
// DoubleRatchet's wallclock-based replay-protection check throws
|
|
3655
|
-
// "CipherMessage too old, possible replay attack" for any frame
|
|
3656
|
-
// older than 5 minutes. Real replays still fail downstream because
|
|
3657
|
-
// the message key has been consumed; the wallclock check is a
|
|
3658
|
-
// defense-in-depth heuristic, not a security boundary. Treating it
|
|
3659
|
-
// as `ignored` lets the channel survive legacy backlog replays and
|
|
3660
|
-
// CF WS-idle-timeout reconnect cycles without spamming the
|
|
3661
|
-
// channel's ERROR stream.
|
|
3662
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
3663
|
-
if (msg.includes("too old") || msg.includes("replay attack")) {
|
|
3664
|
-
return { kind: "ignored", reason: `stale cipherMessage rejected: ${msg}` };
|
|
3665
|
-
}
|
|
3666
|
-
throw err;
|
|
3667
|
-
}
|
|
3668
|
-
return { kind: "plaintext", from: header.senderId, text, cipherMessage };
|
|
3610
|
+
catch {
|
|
3611
|
+
return;
|
|
3669
3612
|
}
|
|
3670
|
-
|
|
3613
|
+
if (!frame || frame._t !== "change" || typeof frame.key !== "string")
|
|
3614
|
+
return;
|
|
3615
|
+
const slash = frame.key.indexOf("/");
|
|
3616
|
+
if (slash < 0)
|
|
3617
|
+
return;
|
|
3618
|
+
const collection = frame.key.slice(0, slash);
|
|
3619
|
+
const id = frame.key.slice(slash + 1);
|
|
3620
|
+
const data = frame.op === "set" ? await this.decode(frame.value) : null;
|
|
3621
|
+
handler({ collection, id, type: frame.op, data });
|
|
3671
3622
|
}
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3623
|
+
/** Decode a stored value: decrypt envelopes, pass plaintext through. */
|
|
3624
|
+
async decode(value) {
|
|
3625
|
+
if (value == null)
|
|
3626
|
+
return null;
|
|
3627
|
+
if (StorageCipher.isEnvelope(value))
|
|
3628
|
+
return this.getCipher().decrypt(value);
|
|
3629
|
+
return value;
|
|
3676
3630
|
}
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3631
|
+
commitment() {
|
|
3632
|
+
const c = this.deps.session.commitment;
|
|
3633
|
+
if (!c)
|
|
3634
|
+
throw new Error("client.kv: not signed in — call client.auth.zk.login() first.");
|
|
3635
|
+
return c;
|
|
3680
3636
|
}
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
* stored keys remain in `KeyStore` so a fresh handshake can be set up
|
|
3684
|
-
* later without re-fetching them, but the ratchet state is dropped.
|
|
3685
|
-
*/
|
|
3686
|
-
forgetPeer(peerId) {
|
|
3687
|
-
this.ratchets.delete(peerId);
|
|
3688
|
-
this.sentHandshakeTo.delete(peerId);
|
|
3637
|
+
spacePath(suffix) {
|
|
3638
|
+
return `/api/personal/${encodeURIComponent(this.commitment())}${suffix}`;
|
|
3689
3639
|
}
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3640
|
+
kvPath(collection, id) {
|
|
3641
|
+
const key = `${collection}/${id}`;
|
|
3642
|
+
return this.spacePath(`/kv/${encodeURIComponent(key)}`);
|
|
3643
|
+
}
|
|
3644
|
+
/** The at-rest cipher for the current identity (re-derived if it changes). */
|
|
3645
|
+
getCipher() {
|
|
3646
|
+
const identity = this.deps.session.requireIdentity();
|
|
3647
|
+
if (this.cipher && this.cipherIdentity === identity)
|
|
3648
|
+
return this.cipher;
|
|
3649
|
+
this.cipher = new StorageCipher(identity.secretHex, identity.saltHex);
|
|
3650
|
+
this.cipherIdentity = identity;
|
|
3651
|
+
return this.cipher;
|
|
3694
3652
|
}
|
|
3695
|
-
}
|
|
3696
|
-
// ---------------------------------------------------------------------------
|
|
3697
|
-
// Helpers — JWK <-> base64 string for key transport
|
|
3698
|
-
// ---------------------------------------------------------------------------
|
|
3699
|
-
async function exportJwkBase64(key) {
|
|
3700
|
-
const jwk = await crypto.subtle.exportKey("jwk", key);
|
|
3701
|
-
return btoa(JSON.stringify(jwk));
|
|
3702
|
-
}
|
|
3703
|
-
async function importJwkBase64(b64, type) {
|
|
3704
|
-
const jwk = JSON.parse(atob(b64));
|
|
3705
|
-
const algorithm = type === "ECDH"
|
|
3706
|
-
? { name: "ECDH", namedCurve: "P-384" }
|
|
3707
|
-
: { name: "ECDSA", namedCurve: "P-384" };
|
|
3708
|
-
const usages = type === "ECDH" ? [] : ["verify"];
|
|
3709
|
-
return await crypto.subtle.importKey("jwk", jwk, algorithm, true, usages);
|
|
3710
|
-
}
|
|
3711
|
-
// ---------------------------------------------------------------------------
|
|
3712
|
-
// Type guards
|
|
3713
|
-
// ---------------------------------------------------------------------------
|
|
3714
|
-
function isKeyExchangeFrame(frame) {
|
|
3715
|
-
const kx = frame.keyExchange;
|
|
3716
|
-
if (!kx || typeof kx !== "object")
|
|
3717
|
-
return false;
|
|
3718
|
-
const k = kx;
|
|
3719
|
-
return (typeof k.userId === "string" &&
|
|
3720
|
-
typeof k.ecdhPublicKey === "string" &&
|
|
3721
|
-
typeof k.ecdsaPublicKey === "string");
|
|
3722
|
-
}
|
|
3723
|
-
function isCipherFrame(frame) {
|
|
3724
|
-
const cm = frame.cipherMessage;
|
|
3725
|
-
if (!cm || typeof cm !== "object")
|
|
3726
|
-
return false;
|
|
3727
|
-
const m = cm;
|
|
3728
|
-
return Boolean(m.header && m.ciphertext && m.nonce);
|
|
3729
3653
|
}
|
|
3730
3654
|
|
|
3731
3655
|
/**
|
|
3732
|
-
*
|
|
3733
|
-
* "room"-style apps (e.g. a chat). Wires together:
|
|
3734
|
-
*
|
|
3735
|
-
* - `WSTransport` for socket lifecycle, reconnect, and offline frame queue
|
|
3736
|
-
* - `EncryptedSession` for per-peer Double Ratchet state, handshake
|
|
3737
|
-
* reciprocation, fan-out encryption, and recipient filtering
|
|
3738
|
-
*
|
|
3739
|
-
* The host app only deals with high-level events and method calls; it never
|
|
3740
|
-
* touches `new WebSocket(...)` or builds cipherMessage frames by hand.
|
|
3741
|
-
*
|
|
3742
|
-
* Wire protocol (kept compatible with the existing chat protocol so this
|
|
3743
|
-
* drops into the deployed Accelerator without server changes):
|
|
3656
|
+
* Wire types for the storage layer.
|
|
3744
3657
|
*
|
|
3745
|
-
*
|
|
3746
|
-
*
|
|
3747
|
-
*
|
|
3748
|
-
*
|
|
3749
|
-
*
|
|
3750
|
-
*
|
|
3751
|
-
* emits `peer_handshake`
|
|
3752
|
-
* `{ cipherMessage: {...} }` — decrypted; emits `message`
|
|
3753
|
-
* anything else — passes through as `raw_frame`
|
|
3754
|
-
* (the app decides what to do)
|
|
3755
|
-
*
|
|
3756
|
-
* **Event scoping.** Unlike `Network`, BroadcastChannel uses a per-instance
|
|
3757
|
-
* `EventTarget`, so two channels (e.g. two rooms in the same app) don't
|
|
3758
|
-
* cross-contaminate each other's events.
|
|
3759
|
-
*
|
|
3760
|
-
* Note: this class shadows the `BroadcastChannel` Web API global (cross-tab
|
|
3761
|
-
* messaging) only when imported by name. They're unrelated.
|
|
3762
|
-
*/
|
|
3763
|
-
// ---------------------------------------------------------------------------
|
|
3764
|
-
// Event names
|
|
3765
|
-
// ---------------------------------------------------------------------------
|
|
3766
|
-
/**
|
|
3767
|
-
* Event names BroadcastChannel emits. Lifecycle events reuse the
|
|
3768
|
-
* `EventCoreEvents` string values (`connected`, `disconnected`, etc.) so
|
|
3769
|
-
* apps can use shared constants if they want. Channel-specific events are
|
|
3770
|
-
* prefixed `channel:` to avoid colliding with other event systems in the
|
|
3771
|
-
* same app.
|
|
3772
|
-
*/
|
|
3773
|
-
const BroadcastChannelEvents = {
|
|
3774
|
-
CONNECTED: EventCoreEvents.CONNECTED,
|
|
3775
|
-
DISCONNECTED: EventCoreEvents.DISCONNECTED,
|
|
3776
|
-
RECONNECTING: EventCoreEvents.RECONNECTING,
|
|
3777
|
-
ERROR: EventCoreEvents.ERROR,
|
|
3778
|
-
/** A peer's ratchet is now ready. `detail.peerId` is the peer's id. */
|
|
3779
|
-
PEER_HANDSHAKE: "channel:peer_handshake",
|
|
3780
|
-
/** Decrypted message. `detail` is `{ from, text, cipherMessage }`. */
|
|
3781
|
-
MESSAGE: "channel:message",
|
|
3782
|
-
/** Inbound frame BroadcastChannel didn't recognize. App handles it. */
|
|
3783
|
-
RAW_FRAME: "channel:raw_frame",
|
|
3784
|
-
};
|
|
3785
|
-
// ---------------------------------------------------------------------------
|
|
3786
|
-
// BroadcastChannel
|
|
3787
|
-
// ---------------------------------------------------------------------------
|
|
3788
|
-
class BroadcastChannel {
|
|
3789
|
-
transport;
|
|
3790
|
-
session;
|
|
3791
|
-
myId;
|
|
3792
|
-
autoAnnounce;
|
|
3793
|
-
announced = false;
|
|
3794
|
-
/** Per-instance EventTarget — events do NOT leak across BroadcastChannels. */
|
|
3795
|
-
events = new EventTarget();
|
|
3796
|
-
constructor(opts) {
|
|
3797
|
-
if (!opts || !opts.myId) {
|
|
3798
|
-
throw new Error("BroadcastChannel: `myId` is required");
|
|
3799
|
-
}
|
|
3800
|
-
this.myId = opts.myId;
|
|
3801
|
-
this.autoAnnounce = opts.autoAnnounce ?? false;
|
|
3802
|
-
this.transport = new WSTransport(opts);
|
|
3803
|
-
this.session = new EncryptedSession({ myId: opts.myId });
|
|
3804
|
-
// Pass-through lifecycle events from the transport. `EventCore.on` is
|
|
3805
|
-
// static so it routes back through the shared singleton; we bridge it
|
|
3806
|
-
// into our instance-scoped event target.
|
|
3807
|
-
this.transport.on(EventCoreEvents.CONNECTED, () => {
|
|
3808
|
-
// Reset the announce flag on every (re)connect. CF Workers WebSockets
|
|
3809
|
-
// hit an idle timeout and force reconnects; without resetting, the
|
|
3810
|
-
// SDK would short-circuit announce() and any peer who joined the
|
|
3811
|
-
// room after our last announce would never see our pubkey.
|
|
3812
|
-
this.announced = false;
|
|
3813
|
-
this.emit(BroadcastChannelEvents.CONNECTED);
|
|
3814
|
-
if (this.autoAnnounce) {
|
|
3815
|
-
void this.announce();
|
|
3816
|
-
}
|
|
3817
|
-
});
|
|
3818
|
-
this.transport.on(EventCoreEvents.DISCONNECTED, () => {
|
|
3819
|
-
this.emit(BroadcastChannelEvents.DISCONNECTED);
|
|
3820
|
-
});
|
|
3821
|
-
this.transport.on(EventCoreEvents.RECONNECTING, (e) => {
|
|
3822
|
-
this.emit(BroadcastChannelEvents.RECONNECTING, e.detail);
|
|
3823
|
-
});
|
|
3824
|
-
this.transport.on(EventCoreEvents.ERROR, (e) => {
|
|
3825
|
-
this.emit(BroadcastChannelEvents.ERROR, e.detail);
|
|
3826
|
-
});
|
|
3827
|
-
// Route inbound raw frames through our framing/decryption logic.
|
|
3828
|
-
this.transport.on(EventCoreEvents.MESSAGE, (e) => {
|
|
3829
|
-
void this.handleInboundFrame(e.detail);
|
|
3830
|
-
});
|
|
3831
|
-
}
|
|
3832
|
-
// -- event API -------------------------------------------------------------
|
|
3833
|
-
/** Subscribe to a channel event. Handler receives a CustomEvent. */
|
|
3834
|
-
on(event, handler) {
|
|
3835
|
-
this.events.addEventListener(event, handler);
|
|
3836
|
-
}
|
|
3837
|
-
/** Unsubscribe. */
|
|
3838
|
-
off(event, handler) {
|
|
3839
|
-
this.events.removeEventListener(event, handler);
|
|
3840
|
-
}
|
|
3841
|
-
emit(event, detail) {
|
|
3842
|
-
this.events.dispatchEvent(new CustomEvent(event, { detail }));
|
|
3843
|
-
}
|
|
3844
|
-
// -- lifecycle -------------------------------------------------------------
|
|
3845
|
-
/** Generate our keys (idempotent) and open the WebSocket. */
|
|
3846
|
-
async connect() {
|
|
3847
|
-
await this.session.initialize();
|
|
3848
|
-
await this.transport.connect();
|
|
3849
|
-
}
|
|
3850
|
-
/** Close the WebSocket and stop reconnecting. */
|
|
3851
|
-
disconnect() {
|
|
3852
|
-
this.transport.disconnect();
|
|
3853
|
-
}
|
|
3854
|
-
/**
|
|
3855
|
-
* Send our `keyExchange` frame to the room. Idempotent — only sends on the
|
|
3856
|
-
* first call. Call this once your higher-level handshake (e.g. sending a
|
|
3857
|
-
* `{name}` identification frame) has completed.
|
|
3858
|
-
*/
|
|
3859
|
-
async announce() {
|
|
3860
|
-
if (this.announced)
|
|
3861
|
-
return;
|
|
3862
|
-
this.announced = true;
|
|
3863
|
-
const frame = await this.session.getOwnKeyExchange();
|
|
3864
|
-
this.transport.send(JSON.stringify(frame));
|
|
3865
|
-
}
|
|
3866
|
-
/**
|
|
3867
|
-
* Encrypt `plaintext` to every peer we have a ratchet with, and send each
|
|
3868
|
-
* cipherMessage as its own WS frame. Returns the number of peers we sent to
|
|
3869
|
-
* (0 if no peers are handshaken yet — in which case the caller should
|
|
3870
|
-
* typically render the message locally for the sender and try again later).
|
|
3871
|
-
*/
|
|
3872
|
-
async send(plaintext) {
|
|
3873
|
-
const frames = await this.session.encrypt(plaintext);
|
|
3874
|
-
for (const frame of frames) {
|
|
3875
|
-
this.transport.send(JSON.stringify(frame));
|
|
3876
|
-
}
|
|
3877
|
-
return frames.length;
|
|
3878
|
-
}
|
|
3879
|
-
/**
|
|
3880
|
-
* Send an arbitrary JSON-serializable frame as-is. The app uses this for
|
|
3881
|
-
* any frame that isn't channel-managed (e.g. a chat's `{name}`, `{file}`,
|
|
3882
|
-
* or unencrypted `{message}`).
|
|
3883
|
-
*/
|
|
3884
|
-
sendRaw(frame) {
|
|
3885
|
-
this.transport.send(JSON.stringify(frame));
|
|
3886
|
-
}
|
|
3887
|
-
// -- introspection ---------------------------------------------------------
|
|
3888
|
-
/** List of peer ids we have an established ratchet for. */
|
|
3889
|
-
peers() {
|
|
3890
|
-
return this.session.peers();
|
|
3891
|
-
}
|
|
3892
|
-
/** Drop a peer's ratchet (e.g. when they leave the room). */
|
|
3893
|
-
forgetPeer(peerId) {
|
|
3894
|
-
this.session.forgetPeer(peerId);
|
|
3895
|
-
}
|
|
3896
|
-
isConnected() {
|
|
3897
|
-
return this.transport.isConnected();
|
|
3898
|
-
}
|
|
3899
|
-
// -- internals -------------------------------------------------------------
|
|
3900
|
-
async handleInboundFrame(raw) {
|
|
3901
|
-
let parsed;
|
|
3902
|
-
try {
|
|
3903
|
-
parsed = JSON.parse(raw);
|
|
3904
|
-
}
|
|
3905
|
-
catch (err) {
|
|
3906
|
-
this.emit(BroadcastChannelEvents.ERROR, err);
|
|
3907
|
-
return;
|
|
3908
|
-
}
|
|
3909
|
-
if (!parsed || typeof parsed !== "object") {
|
|
3910
|
-
this.emit(BroadcastChannelEvents.RAW_FRAME, parsed);
|
|
3911
|
-
return;
|
|
3912
|
-
}
|
|
3913
|
-
const obj = parsed;
|
|
3914
|
-
// Channel-managed: keyExchange & cipherMessage. Anything else is RAW_FRAME.
|
|
3915
|
-
if ("keyExchange" in obj || "cipherMessage" in obj) {
|
|
3916
|
-
try {
|
|
3917
|
-
const result = await this.session.receive(obj);
|
|
3918
|
-
if (result.kind === "handshake") {
|
|
3919
|
-
if (result.outbound) {
|
|
3920
|
-
this.transport.send(JSON.stringify(result.outbound));
|
|
3921
|
-
}
|
|
3922
|
-
this.emit(BroadcastChannelEvents.PEER_HANDSHAKE, { peerId: result.peerId });
|
|
3923
|
-
}
|
|
3924
|
-
else if (result.kind === "plaintext") {
|
|
3925
|
-
this.emit(BroadcastChannelEvents.MESSAGE, {
|
|
3926
|
-
from: result.from,
|
|
3927
|
-
text: result.text,
|
|
3928
|
-
cipherMessage: result.cipherMessage,
|
|
3929
|
-
});
|
|
3930
|
-
}
|
|
3931
|
-
// `ignored` is intentionally swallowed; subscribe to ERROR for bad frames.
|
|
3932
|
-
}
|
|
3933
|
-
catch (err) {
|
|
3934
|
-
this.emit(BroadcastChannelEvents.ERROR, err);
|
|
3935
|
-
}
|
|
3936
|
-
return;
|
|
3937
|
-
}
|
|
3938
|
-
this.emit(BroadcastChannelEvents.RAW_FRAME, obj);
|
|
3939
|
-
}
|
|
3940
|
-
}
|
|
3941
|
-
|
|
3942
|
-
/**
|
|
3943
|
-
* Wire types for the storage layer.
|
|
3944
|
-
*
|
|
3945
|
-
* The `FileManifest` is the single piece of metadata the system needs to
|
|
3946
|
-
* reassemble a file: per-chunk encryption material, RS parameters, and the
|
|
3947
|
-
* SHA-256 content hashes of every shard that belongs to the chunk. With the
|
|
3948
|
-
* manifest in hand, a reader can pull each shard from the open content-
|
|
3949
|
-
* addressed shard store, decode, decrypt, and concatenate to recover the
|
|
3950
|
-
* original bytes.
|
|
3658
|
+
* The `FileManifest` is the single piece of metadata the system needs to
|
|
3659
|
+
* reassemble a file: per-chunk encryption material, RS parameters, and the
|
|
3660
|
+
* SHA-256 content hashes of every shard that belongs to the chunk. With the
|
|
3661
|
+
* manifest in hand, a reader can pull each shard from the open content-
|
|
3662
|
+
* addressed shard store, decode, decrypt, and concatenate to recover the
|
|
3663
|
+
* original bytes.
|
|
3951
3664
|
*
|
|
3952
3665
|
* The manifest itself lives inside a `SharedSpaceDO` (Phase B) which gates
|
|
3953
3666
|
* access by ZK proof + ACL. Shards are stored in the open content-addressed
|
|
@@ -4091,662 +3804,1352 @@ function getArrayJsValueFromWasm(ptr, len) {
|
|
|
4091
3804
|
for (let i = 0; i < slice.length; i++) {
|
|
4092
3805
|
result.push(takeObject(slice[i]));
|
|
4093
3806
|
}
|
|
4094
|
-
return result;
|
|
4095
|
-
}
|
|
4096
|
-
function getStringFromWasm(ptr, len) {
|
|
4097
|
-
return textDecoder.decode(u8().subarray(ptr, ptr + len));
|
|
3807
|
+
return result;
|
|
3808
|
+
}
|
|
3809
|
+
function getStringFromWasm(ptr, len) {
|
|
3810
|
+
return textDecoder.decode(u8().subarray(ptr, ptr + len));
|
|
3811
|
+
}
|
|
3812
|
+
/**
|
|
3813
|
+
* The wbindgen import object — every callback Rust expects from JS. Names are
|
|
3814
|
+
* hard-coded by wasm-bindgen based on the Rust function signatures; do not
|
|
3815
|
+
* rename. If the upstream package is regenerated, these may drift and need
|
|
3816
|
+
* resyncing with the new `wasm_reed_solomon_erasure.js`.
|
|
3817
|
+
*/
|
|
3818
|
+
function buildImports() {
|
|
3819
|
+
const wbindgen = {
|
|
3820
|
+
__wbindgen_object_drop_ref: (arg0) => {
|
|
3821
|
+
takeObject(arg0);
|
|
3822
|
+
},
|
|
3823
|
+
__wbg_buffer_7af23f65f6c64548: (arg0) => {
|
|
3824
|
+
const ret = getObject(arg0).buffer;
|
|
3825
|
+
return addHeapObject(ret);
|
|
3826
|
+
},
|
|
3827
|
+
__wbg_newwithbyteoffsetandlength_ce1e75f0ce5f7974: (arg0, arg1, arg2) => {
|
|
3828
|
+
const ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0);
|
|
3829
|
+
return addHeapObject(ret);
|
|
3830
|
+
},
|
|
3831
|
+
__wbg_new_cc9018bd6f283b6f: (arg0) => {
|
|
3832
|
+
const ret = new Uint8Array(getObject(arg0));
|
|
3833
|
+
return addHeapObject(ret);
|
|
3834
|
+
},
|
|
3835
|
+
__wbg_set_f25e869e4565d2a2: (arg0, arg1, arg2) => {
|
|
3836
|
+
getObject(arg0).set(getObject(arg1), arg2 >>> 0);
|
|
3837
|
+
},
|
|
3838
|
+
__wbg_length_0acb1cf9bbaf8519: (arg0) => {
|
|
3839
|
+
return getObject(arg0).length;
|
|
3840
|
+
},
|
|
3841
|
+
__wbg_new_e8c5277c0f9e2cfc: (arg0) => {
|
|
3842
|
+
const ret = new Uint32Array(getObject(arg0));
|
|
3843
|
+
return addHeapObject(ret);
|
|
3844
|
+
},
|
|
3845
|
+
__wbg_set_03c7d8c063f469ef: (arg0, arg1, arg2) => {
|
|
3846
|
+
getObject(arg0).set(getObject(arg1), arg2 >>> 0);
|
|
3847
|
+
},
|
|
3848
|
+
__wbg_length_f98ca60981480796: (arg0) => {
|
|
3849
|
+
return getObject(arg0).length;
|
|
3850
|
+
},
|
|
3851
|
+
__wbindgen_throw: (arg0, arg1) => {
|
|
3852
|
+
throw new Error(getStringFromWasm(arg0, arg1));
|
|
3853
|
+
},
|
|
3854
|
+
__wbindgen_memory: () => {
|
|
3855
|
+
return addHeapObject(wasm.memory);
|
|
3856
|
+
},
|
|
3857
|
+
};
|
|
3858
|
+
return { __wbindgen_placeholder__: wbindgen };
|
|
3859
|
+
}
|
|
3860
|
+
let _readyPromise = null;
|
|
3861
|
+
/**
|
|
3862
|
+
* Load and instantiate the WASM module. Idempotent — repeated calls await the
|
|
3863
|
+
* same one-shot promise.
|
|
3864
|
+
*
|
|
3865
|
+
* Two ways to initialize, matching the pattern used by `workers/groth16-verifier`:
|
|
3866
|
+
* 1. `await initRsWasm()` — uses the bundled .wasm (base64-inlined at build
|
|
3867
|
+
* time by `@rollup/plugin-wasm`). Works in any runtime that allows
|
|
3868
|
+
* runtime `WebAssembly.compile()`.
|
|
3869
|
+
* 2. `await initRsWasm(myWasmModule)` — accepts a pre-compiled
|
|
3870
|
+
* `WebAssembly.Module`. Useful when the bundled loader path isn't
|
|
3871
|
+
* available (vitest, CF Workers wanting deploy-time precompile, etc.).
|
|
3872
|
+
*/
|
|
3873
|
+
async function initRsWasm(wasmModule) {
|
|
3874
|
+
if (_readyPromise)
|
|
3875
|
+
return _readyPromise;
|
|
3876
|
+
_readyPromise = (async () => {
|
|
3877
|
+
const imports = buildImports();
|
|
3878
|
+
let instance;
|
|
3879
|
+
if (wasmModule) {
|
|
3880
|
+
// `instantiate(module, imports)` returns a bare Instance.
|
|
3881
|
+
instance = await WebAssembly.instantiate(wasmModule, imports);
|
|
3882
|
+
}
|
|
3883
|
+
else {
|
|
3884
|
+
// Defer the `.wasm` import to a separate module so vitest can load
|
|
3885
|
+
// `rs.ts` without it. Production callers either don't pass a
|
|
3886
|
+
// module (and hit the bundled loader here) or pass one explicitly.
|
|
3887
|
+
const bundled = (await Promise.resolve().then(function () { return bundledLoader; })).default;
|
|
3888
|
+
const result = await bundled(imports);
|
|
3889
|
+
// The bundled loader's actual return type is
|
|
3890
|
+
// `{ module: WebAssembly.Module, instance: WebAssembly.Instance }`
|
|
3891
|
+
// — the rollup-plugin-wasm shape from `WebAssembly.instantiate(buffer, imports)`.
|
|
3892
|
+
// Older versions (or future changes) may return a bare Instance,
|
|
3893
|
+
// so accept either shape.
|
|
3894
|
+
instance = unwrapInstance(result);
|
|
3895
|
+
}
|
|
3896
|
+
wasm = instance.exports;
|
|
3897
|
+
})();
|
|
3898
|
+
return _readyPromise;
|
|
3899
|
+
}
|
|
3900
|
+
/**
|
|
3901
|
+
* Normalize the value returned by `WebAssembly.instantiate` / the
|
|
3902
|
+
* rollup-plugin-wasm loader into a bare `WebAssembly.Instance`. Handles
|
|
3903
|
+
* both the `(module, imports)` overload (returns Instance) and the
|
|
3904
|
+
* `(buffer, imports)` overload (returns `{ module, instance }`).
|
|
3905
|
+
*/
|
|
3906
|
+
function unwrapInstance(result) {
|
|
3907
|
+
if (result && typeof result === "object") {
|
|
3908
|
+
const candidate = result;
|
|
3909
|
+
if (candidate.instance && typeof candidate.instance === "object" && "exports" in candidate.instance) {
|
|
3910
|
+
return candidate.instance;
|
|
3911
|
+
}
|
|
3912
|
+
if ("exports" in candidate) {
|
|
3913
|
+
return result;
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
throw new Error("rs.ts: bundled WASM loader returned an unrecognized shape — expected WebAssembly.Instance or { module, instance }");
|
|
3917
|
+
}
|
|
3918
|
+
function ensureReady() {
|
|
3919
|
+
if (!wasm) {
|
|
3920
|
+
throw new Error("rs.ts: WASM not initialized. Call `await initRsWasm()` first.");
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
// ---------------------------------------------------------------------------
|
|
3924
|
+
// Public API — encode + reconstruct
|
|
3925
|
+
// ---------------------------------------------------------------------------
|
|
3926
|
+
/**
|
|
3927
|
+
* Encode the input data shards with `parityShard` parity shards. All input
|
|
3928
|
+
* shards must be Uint8Arrays of equal length; output contains the input shards
|
|
3929
|
+
* unchanged plus `parityShard` newly computed parity shards.
|
|
3930
|
+
*/
|
|
3931
|
+
function encode(dataShards, parityShard) {
|
|
3932
|
+
ensureReady();
|
|
3933
|
+
const w = wasm;
|
|
3934
|
+
try {
|
|
3935
|
+
const retptr = w.__wbindgen_add_to_stack_pointer(-16);
|
|
3936
|
+
const ptr0 = passArrayJsValueToWasm(dataShards, w.__wbindgen_malloc);
|
|
3937
|
+
const len0 = WASM_VECTOR_LEN;
|
|
3938
|
+
w.encode(retptr, ptr0, len0, parityShard);
|
|
3939
|
+
const r0 = i32()[retptr / 4 + 0];
|
|
3940
|
+
const r1 = i32()[retptr / 4 + 1];
|
|
3941
|
+
const v1 = getArrayJsValueFromWasm(r0, r1).slice();
|
|
3942
|
+
w.__wbindgen_free(r0, r1 * 4);
|
|
3943
|
+
return v1;
|
|
3944
|
+
}
|
|
3945
|
+
finally {
|
|
3946
|
+
w.__wbindgen_add_to_stack_pointer(16);
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
/**
|
|
3950
|
+
* Reconstruct the original full shard array. `corruptedShards` is the full
|
|
3951
|
+
* list (data + parity) with placeholder Uint8Arrays at positions listed in
|
|
3952
|
+
* `deadShardIndexes`. Returns the recovered, complete list of shards.
|
|
3953
|
+
*
|
|
3954
|
+
* Up to `parityShard` shards may be missing; more than that is uncorrectable
|
|
3955
|
+
* and the underlying WASM throws.
|
|
3956
|
+
*/
|
|
3957
|
+
function reconstruct(corruptedShards, parityShard, deadShardIndexes) {
|
|
3958
|
+
ensureReady();
|
|
3959
|
+
const w = wasm;
|
|
3960
|
+
try {
|
|
3961
|
+
const retptr = w.__wbindgen_add_to_stack_pointer(-16);
|
|
3962
|
+
const ptr0 = passArrayJsValueToWasm(corruptedShards, w.__wbindgen_malloc);
|
|
3963
|
+
const len0 = WASM_VECTOR_LEN;
|
|
3964
|
+
w.reconstruct(retptr, ptr0, len0, parityShard, addHeapObject(deadShardIndexes));
|
|
3965
|
+
const r0 = i32()[retptr / 4 + 0];
|
|
3966
|
+
const r1 = i32()[retptr / 4 + 1];
|
|
3967
|
+
const v1 = getArrayJsValueFromWasm(r0, r1).slice();
|
|
3968
|
+
w.__wbindgen_free(r0, r1 * 4);
|
|
3969
|
+
return v1;
|
|
3970
|
+
}
|
|
3971
|
+
finally {
|
|
3972
|
+
w.__wbindgen_add_to_stack_pointer(16);
|
|
3973
|
+
}
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3976
|
+
/**
|
|
3977
|
+
* Reed–Solomon erasure-coding codec.
|
|
3978
|
+
*
|
|
3979
|
+
* Wraps the WASM-backed `encode` / `reconstruct` primitives in `wasm/rs.ts`
|
|
3980
|
+
* with shard-padding bookkeeping so callers can hand it any-length input and
|
|
3981
|
+
* get back uniform-size shards.
|
|
3982
|
+
*
|
|
3983
|
+
* Usage:
|
|
3984
|
+
* const codec = new ReedSolomonCodec();
|
|
3985
|
+
* await codec.ready();
|
|
3986
|
+
* const { shards, shardSize, originalSize } = codec.encode(plaintext, 4, 2);
|
|
3987
|
+
* // 6 shards (4 data + 2 parity), each `shardSize` bytes
|
|
3988
|
+
* // …ship them, lose up to 2, then …
|
|
3989
|
+
* const recovered = codec.decode(shards, 2, deadIndexes, originalSize);
|
|
3990
|
+
*
|
|
3991
|
+
* Why the explicit `originalSize`: RS pads input to make every data shard the
|
|
3992
|
+
* same length. The decoder must trim that padding back off after recovery.
|
|
3993
|
+
* The caller is responsible for stashing `originalSize` somewhere durable
|
|
3994
|
+
* (the file manifest, in this codebase).
|
|
3995
|
+
*/
|
|
3996
|
+
class ReedSolomonCodec {
|
|
3997
|
+
/**
|
|
3998
|
+
* Resolves once the WASM module is loaded; safe to call repeatedly.
|
|
3999
|
+
*
|
|
4000
|
+
* Pass `wasmModule` to use a pre-compiled module (e.g. in tests where the
|
|
4001
|
+
* rollup-inlined loader path isn't available, or in CF Workers where you
|
|
4002
|
+
* want to load the module at deploy time). Default path uses the bundled
|
|
4003
|
+
* base64-inlined wasm — works in Node, browsers, and Workers.
|
|
4004
|
+
*/
|
|
4005
|
+
ready(wasmModule) {
|
|
4006
|
+
return initRsWasm(wasmModule);
|
|
4007
|
+
}
|
|
4008
|
+
/**
|
|
4009
|
+
* Split `data` into `dataShards` equal-size chunks (zero-padded to fit),
|
|
4010
|
+
* compute `parityShards` parity shards, and return all of them.
|
|
4011
|
+
*/
|
|
4012
|
+
encode(data, dataShards, parityShards) {
|
|
4013
|
+
if (dataShards < 1)
|
|
4014
|
+
throw new Error("ReedSolomonCodec.encode: dataShards must be >= 1");
|
|
4015
|
+
if (parityShards < 1)
|
|
4016
|
+
throw new Error("ReedSolomonCodec.encode: parityShards must be >= 1");
|
|
4017
|
+
if (data.length === 0)
|
|
4018
|
+
throw new Error("ReedSolomonCodec.encode: data is empty");
|
|
4019
|
+
// The wbindgen-generated WASM in `wasm-reed-solomon-erasure@0.2.2` takes
|
|
4020
|
+
// the `parity_shard` count separately and infers `total = data.length +
|
|
4021
|
+
// parity_shard`. Every shard must be the same length, so we pad the
|
|
4022
|
+
// input to `dataShards * shardSize` with trailing zeros.
|
|
4023
|
+
const shardSize = Math.ceil(data.length / dataShards);
|
|
4024
|
+
const splitInputs = [];
|
|
4025
|
+
for (let i = 0; i < dataShards; i++) {
|
|
4026
|
+
// Each shard is its own backing ArrayBuffer so the WASM side
|
|
4027
|
+
// doesn't observe overlapping views (which would confuse the
|
|
4028
|
+
// wbindgen length lookups).
|
|
4029
|
+
const shard = new Uint8Array(shardSize);
|
|
4030
|
+
const start = i * shardSize;
|
|
4031
|
+
const end = Math.min(start + shardSize, data.length);
|
|
4032
|
+
if (start < data.length) {
|
|
4033
|
+
shard.set(data.subarray(start, end));
|
|
4034
|
+
}
|
|
4035
|
+
splitInputs.push(shard);
|
|
4036
|
+
}
|
|
4037
|
+
const shards = encode(splitInputs, parityShards);
|
|
4038
|
+
// The underlying call returns N+M shards; first N are the (padded)
|
|
4039
|
+
// data shards we just sent in, last M are the parity shards.
|
|
4040
|
+
return {
|
|
4041
|
+
shards,
|
|
4042
|
+
shardSize,
|
|
4043
|
+
originalSize: data.length,
|
|
4044
|
+
dataShards,
|
|
4045
|
+
parityShards,
|
|
4046
|
+
};
|
|
4047
|
+
}
|
|
4048
|
+
/**
|
|
4049
|
+
* Reconstruct the original `data` from `shards`. Up to `parityShards`
|
|
4050
|
+
* shards may be missing; for each missing shard, pass an empty
|
|
4051
|
+
* `Uint8Array` placeholder at its position and list the position in
|
|
4052
|
+
* `deadShardIndexes`.
|
|
4053
|
+
*
|
|
4054
|
+
* `originalSize` is required so the trailing zero-padding can be trimmed.
|
|
4055
|
+
*/
|
|
4056
|
+
decode(shards, parityShards, deadShardIndexes, originalSize) {
|
|
4057
|
+
if (parityShards < 1)
|
|
4058
|
+
throw new Error("ReedSolomonCodec.decode: parityShards must be >= 1");
|
|
4059
|
+
if (shards.length <= parityShards) {
|
|
4060
|
+
throw new Error("ReedSolomonCodec.decode: shards.length must be > parityShards");
|
|
4061
|
+
}
|
|
4062
|
+
if (deadShardIndexes.length > parityShards) {
|
|
4063
|
+
throw new Error(`ReedSolomonCodec.decode: too many missing shards (${deadShardIndexes.length} > ${parityShards})`);
|
|
4064
|
+
}
|
|
4065
|
+
const dataShards = shards.length - parityShards;
|
|
4066
|
+
const recovered = deadShardIndexes.length === 0
|
|
4067
|
+
? shards
|
|
4068
|
+
: reconstruct(shards, parityShards, new Uint32Array(deadShardIndexes));
|
|
4069
|
+
// Concatenate the leading `dataShards`, then trim padding back off.
|
|
4070
|
+
const shardSize = recovered[0]?.length ?? 0;
|
|
4071
|
+
const out = new Uint8Array(dataShards * shardSize);
|
|
4072
|
+
for (let i = 0; i < dataShards; i++) {
|
|
4073
|
+
out.set(recovered[i], i * shardSize);
|
|
4074
|
+
}
|
|
4075
|
+
return out.subarray(0, originalSize);
|
|
4076
|
+
}
|
|
4077
|
+
}
|
|
4078
|
+
|
|
4079
|
+
/**
|
|
4080
|
+
* HTTP client for the open content-addressed shard store.
|
|
4081
|
+
*
|
|
4082
|
+
* Each shard is named by the lowercase hex SHA-256 of its ciphertext bytes.
|
|
4083
|
+
* The server verifies the hash on PUT, so the address authenticates the
|
|
4084
|
+
* contents — there's no benefit to ACLing PUT/GET, and making them open
|
|
4085
|
+
* enables cross-user dedup down the line.
|
|
4086
|
+
*
|
|
4087
|
+
* Wire protocol:
|
|
4088
|
+
* PUT /api/shards/:hash body: shard bytes (application/octet-stream)
|
|
4089
|
+
* 200 { ok: true, hash, size, dedup: boolean }
|
|
4090
|
+
* 400 if hash doesn't match body
|
|
4091
|
+
*
|
|
4092
|
+
* GET /api/shards/:hash 200 with shard bytes (application/octet-stream)
|
|
4093
|
+
* 404 if absent
|
|
4094
|
+
*
|
|
4095
|
+
* HEAD /api/shards/:hash 200 if present (no body) — used by callers to
|
|
4096
|
+
* skip a PUT they'd otherwise dedup-rewrite. The
|
|
4097
|
+
* server can ignore the optimization if it wants.
|
|
4098
|
+
*/
|
|
4099
|
+
/**
|
|
4100
|
+
* Compute the lowercase hex SHA-256 of `bytes`. Available via WebCrypto in
|
|
4101
|
+
* every runtime this SDK targets.
|
|
4102
|
+
*/
|
|
4103
|
+
async function shardHash(bytes) {
|
|
4104
|
+
const subtle = globalThis.crypto?.subtle;
|
|
4105
|
+
if (!subtle) {
|
|
4106
|
+
throw new Error("shardHash: `globalThis.crypto.subtle` is unavailable in this runtime.");
|
|
4107
|
+
}
|
|
4108
|
+
const digest = await subtle.digest("SHA-256", bytes);
|
|
4109
|
+
return toHex(new Uint8Array(digest));
|
|
4110
|
+
}
|
|
4111
|
+
class ShardClient {
|
|
4112
|
+
baseUrl;
|
|
4113
|
+
pathPrefix;
|
|
4114
|
+
fetchFn;
|
|
4115
|
+
constructor(opts) {
|
|
4116
|
+
if (!opts?.baseUrl)
|
|
4117
|
+
throw new Error("ShardClient: `baseUrl` is required");
|
|
4118
|
+
// Strip trailing slash so url concatenation stays clean.
|
|
4119
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
4120
|
+
// Normalize prefix to leading-slash + no-trailing-slash form so the
|
|
4121
|
+
// URL builder can blindly append `/{hash}`.
|
|
4122
|
+
const rawPrefix = opts.pathPrefix ?? "/api/shards";
|
|
4123
|
+
const withSlash = rawPrefix.startsWith("/") ? rawPrefix : `/${rawPrefix}`;
|
|
4124
|
+
this.pathPrefix = withSlash.replace(/\/+$/, "");
|
|
4125
|
+
const f = opts.fetch ?? globalThis.fetch;
|
|
4126
|
+
if (typeof f !== "function") {
|
|
4127
|
+
throw new Error("ShardClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
|
|
4128
|
+
}
|
|
4129
|
+
this.fetchFn = f.bind(globalThis);
|
|
4130
|
+
}
|
|
4131
|
+
/** Build the URL for a given shard hash — exposed for callers that want to log or pre-resolve. */
|
|
4132
|
+
urlForHash(hash) {
|
|
4133
|
+
return `${this.baseUrl}${this.pathPrefix}/${encodeURIComponent(hash)}`;
|
|
4134
|
+
}
|
|
4135
|
+
/**
|
|
4136
|
+
* Upload a shard. The server verifies SHA-256(body) matches `:hash` and
|
|
4137
|
+
* rejects with 400 if not, so the caller is the source of truth on
|
|
4138
|
+
* naming. Re-uploading an existing shard is idempotent — the server
|
|
4139
|
+
* returns `dedup: true` in that case.
|
|
4140
|
+
*/
|
|
4141
|
+
async putShard(hash, bytes) {
|
|
4142
|
+
const url = this.urlForHash(hash);
|
|
4143
|
+
const response = await this.fetchFn(url, {
|
|
4144
|
+
method: "PUT",
|
|
4145
|
+
headers: { "content-type": "application/octet-stream" },
|
|
4146
|
+
// BufferSource is what fetch's body expects; a Uint8Array satisfies it.
|
|
4147
|
+
body: bytes,
|
|
4148
|
+
});
|
|
4149
|
+
if (!response.ok) {
|
|
4150
|
+
const text = await safeText(response);
|
|
4151
|
+
throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
|
|
4152
|
+
}
|
|
4153
|
+
let dedup = false;
|
|
4154
|
+
try {
|
|
4155
|
+
const json = (await response.json());
|
|
4156
|
+
dedup = Boolean(json?.dedup);
|
|
4157
|
+
}
|
|
4158
|
+
catch {
|
|
4159
|
+
// Server returned 200 with no body — treat as fresh upload.
|
|
4160
|
+
}
|
|
4161
|
+
return { dedup };
|
|
4162
|
+
}
|
|
4163
|
+
/**
|
|
4164
|
+
* Fetch a shard by hash. Throws if the server returns a non-200; returns
|
|
4165
|
+
* `null` for 404 so callers can distinguish "missing" from "errored".
|
|
4166
|
+
*/
|
|
4167
|
+
async getShard(hash) {
|
|
4168
|
+
const url = this.urlForHash(hash);
|
|
4169
|
+
const response = await this.fetchFn(url, { method: "GET" });
|
|
4170
|
+
if (response.status === 404)
|
|
4171
|
+
return null;
|
|
4172
|
+
if (!response.ok) {
|
|
4173
|
+
const text = await safeText(response);
|
|
4174
|
+
throw new Error(`ShardClient.getShard(${hash}) failed (${response.status}): ${text}`);
|
|
4175
|
+
}
|
|
4176
|
+
const buf = await response.arrayBuffer();
|
|
4177
|
+
return new Uint8Array(buf);
|
|
4178
|
+
}
|
|
4179
|
+
/**
|
|
4180
|
+
* Check whether a shard exists. Lets the caller skip a PUT they'd otherwise
|
|
4181
|
+
* dedup-rewrite. The server can implement this as a HEAD or return a small
|
|
4182
|
+
* JSON body — we don't depend on either.
|
|
4183
|
+
*/
|
|
4184
|
+
async hasShard(hash) {
|
|
4185
|
+
const url = this.urlForHash(hash);
|
|
4186
|
+
const response = await this.fetchFn(url, { method: "HEAD" });
|
|
4187
|
+
return response.status === 200;
|
|
4188
|
+
}
|
|
4189
|
+
}
|
|
4190
|
+
async function safeText(response) {
|
|
4191
|
+
try {
|
|
4192
|
+
return await response.text();
|
|
4193
|
+
}
|
|
4194
|
+
catch {
|
|
4195
|
+
return "";
|
|
4196
|
+
}
|
|
4197
|
+
}
|
|
4198
|
+
|
|
4199
|
+
/**
|
|
4200
|
+
* `FileStorage` — top-level entry point for chunked, encrypted, erasure-coded
|
|
4201
|
+
* file storage.
|
|
4202
|
+
*
|
|
4203
|
+
* Pipeline for writing a file:
|
|
4204
|
+
* 1. Split the plaintext into fixed-size chunks (default 4 MiB).
|
|
4205
|
+
* 2. For each chunk:
|
|
4206
|
+
* a. Generate a fresh AES-256-GCM key + 12-byte IV (`ChunkCipher`).
|
|
4207
|
+
* b. Encrypt the chunk → ciphertext (plaintext.length + 16 bytes tag).
|
|
4208
|
+
* c. Reed–Solomon encode the ciphertext into `dataShards + parityShards`
|
|
4209
|
+
* equal-length shards (`ReedSolomonCodec`).
|
|
4210
|
+
* d. PUT every shard to the content-addressed shard store, keyed by
|
|
4211
|
+
* SHA-256 of the shard bytes (`ShardClient`).
|
|
4212
|
+
* 3. Build a `FileManifest` listing every chunk's keys, IVs, sizes, and
|
|
4213
|
+
* the SHA-256 of every shard in order.
|
|
4214
|
+
*
|
|
4215
|
+
* Two ways to use the manifest:
|
|
4216
|
+
* - {@link FileStorage.writeFileToShards} returns it to the caller, who is
|
|
4217
|
+
* then responsible for delivering it to readers (e.g. by E2E-encrypting
|
|
4218
|
+
* it as a chat message). The chat application takes this path.
|
|
4219
|
+
* - {@link FileStorage.writeFile} additionally POSTs the manifest to a
|
|
4220
|
+
* gated multi-user space (`SharedSpaceClient`) so any participant of the
|
|
4221
|
+
* space can read the file by manifest id later.
|
|
4222
|
+
*
|
|
4223
|
+
* Reading is the inverse: {@link FileStorage.readFileFromShards} takes a
|
|
4224
|
+
* manifest and reassembles the plaintext; {@link FileStorage.readFile} first
|
|
4225
|
+
* fetches the manifest from the space.
|
|
4226
|
+
*/
|
|
4227
|
+
class FileStorage {
|
|
4228
|
+
space;
|
|
4229
|
+
shards;
|
|
4230
|
+
chunkSize;
|
|
4231
|
+
dataShards;
|
|
4232
|
+
parityShards;
|
|
4233
|
+
concurrency;
|
|
4234
|
+
cipher = new ChunkCipher();
|
|
4235
|
+
codec = new ReedSolomonCodec();
|
|
4236
|
+
rsWasmModule;
|
|
4237
|
+
constructor(opts) {
|
|
4238
|
+
if (!opts?.shards)
|
|
4239
|
+
throw new Error("FileStorage: `shards` (ShardClient) is required");
|
|
4240
|
+
this.space = opts.space;
|
|
4241
|
+
this.shards = opts.shards;
|
|
4242
|
+
this.chunkSize = opts.chunkSize ?? 4 * 1024 * 1024;
|
|
4243
|
+
this.dataShards = opts.dataShards ?? 4;
|
|
4244
|
+
this.parityShards = opts.parityShards ?? 2;
|
|
4245
|
+
this.concurrency = Math.max(1, opts.concurrency ?? 8);
|
|
4246
|
+
this.rsWasmModule = opts.rsWasmModule;
|
|
4247
|
+
if (this.chunkSize < 1)
|
|
4248
|
+
throw new Error("FileStorage: chunkSize must be >= 1 byte");
|
|
4249
|
+
if (this.dataShards < 1)
|
|
4250
|
+
throw new Error("FileStorage: dataShards must be >= 1");
|
|
4251
|
+
if (this.parityShards < 1)
|
|
4252
|
+
throw new Error("FileStorage: parityShards must be >= 1");
|
|
4253
|
+
}
|
|
4254
|
+
// -------------------------------------------------------------------------
|
|
4255
|
+
// Shard-only pipeline — the caller owns the manifest. Used by chat file
|
|
4256
|
+
// sharing, where the manifest rides as an E2E-encrypted chat message.
|
|
4257
|
+
// -------------------------------------------------------------------------
|
|
4258
|
+
/**
|
|
4259
|
+
* Chunk → encrypt → encode → upload-shards. Returns the resulting manifest
|
|
4260
|
+
* along with a `FileStat` summary. The caller is responsible for storing
|
|
4261
|
+
* or transmitting the manifest however it likes.
|
|
4262
|
+
*/
|
|
4263
|
+
async writeFileToShards(input) {
|
|
4264
|
+
await this.codec.ready(this.rsWasmModule);
|
|
4265
|
+
const data = await toUint8Array(input.data);
|
|
4266
|
+
const fileId = input.fileId ?? generateId();
|
|
4267
|
+
const createdAt = Date.now();
|
|
4268
|
+
const chunks = [];
|
|
4269
|
+
// Slicing + encrypting + encoding + uploading is done per-chunk so
|
|
4270
|
+
// the peak memory footprint stays at ~chunkSize even for huge files.
|
|
4271
|
+
// Within a chunk, the shard uploads are parallelized up to
|
|
4272
|
+
// `this.concurrency`.
|
|
4273
|
+
for (let chunkIndex = 0; chunkIndex * this.chunkSize < Math.max(1, data.length); chunkIndex++) {
|
|
4274
|
+
const start = chunkIndex * this.chunkSize;
|
|
4275
|
+
const end = Math.min(start + this.chunkSize, data.length);
|
|
4276
|
+
const plaintext = data.subarray(start, end);
|
|
4277
|
+
chunks.push(await this.writeChunk(plaintext, chunkIndex));
|
|
4278
|
+
// Stop after one iteration if the file is empty — we still emit a
|
|
4279
|
+
// single zero-length chunk so the manifest has a stable shape.
|
|
4280
|
+
if (data.length === 0)
|
|
4281
|
+
break;
|
|
4282
|
+
}
|
|
4283
|
+
const manifest = {
|
|
4284
|
+
id: fileId,
|
|
4285
|
+
name: input.metadata.name,
|
|
4286
|
+
size: data.length,
|
|
4287
|
+
type: input.metadata.type,
|
|
4288
|
+
path: input.metadata.path,
|
|
4289
|
+
lastModified: input.metadata.lastModified ?? createdAt,
|
|
4290
|
+
createdAt,
|
|
4291
|
+
chunks,
|
|
4292
|
+
version: 1,
|
|
4293
|
+
};
|
|
4294
|
+
return { manifest, stat: manifestToStat(manifest) };
|
|
4295
|
+
}
|
|
4296
|
+
/**
|
|
4297
|
+
* Inverse of {@link writeFileToShards}. Takes a manifest the caller
|
|
4298
|
+
* obtained somehow (chat message, share link, etc.) and reassembles the
|
|
4299
|
+
* plaintext. Throws if too many shards are missing or any chunk's auth
|
|
4300
|
+
* tag fails.
|
|
4301
|
+
*/
|
|
4302
|
+
async readFileFromShards(manifest) {
|
|
4303
|
+
await this.codec.ready(this.rsWasmModule);
|
|
4304
|
+
// Materialize each chunk into plaintext, then concatenate. We don't
|
|
4305
|
+
// stream across chunks here because the consuming surface returns
|
|
4306
|
+
// `Uint8Array`; a streaming variant would reuse `readChunk` unchanged.
|
|
4307
|
+
const plaintextChunks = [];
|
|
4308
|
+
for (const chunkManifest of [...manifest.chunks].sort((a, b) => a.chunkIndex - b.chunkIndex)) {
|
|
4309
|
+
plaintextChunks.push(await this.readChunk(chunkManifest));
|
|
4310
|
+
}
|
|
4311
|
+
let total = 0;
|
|
4312
|
+
for (const c of plaintextChunks)
|
|
4313
|
+
total += c.length;
|
|
4314
|
+
const out = new Uint8Array(total);
|
|
4315
|
+
let offset = 0;
|
|
4316
|
+
for (const c of plaintextChunks) {
|
|
4317
|
+
out.set(c, offset);
|
|
4318
|
+
offset += c.length;
|
|
4319
|
+
}
|
|
4320
|
+
return { data: out, stat: manifestToStat(manifest) };
|
|
4321
|
+
}
|
|
4322
|
+
// -------------------------------------------------------------------------
|
|
4323
|
+
// Space-backed convenience wrappers — encrypt + upload + write manifest.
|
|
4324
|
+
// Require a `SharedSpaceClient` to have been provided at construction.
|
|
4325
|
+
// -------------------------------------------------------------------------
|
|
4326
|
+
/** As {@link writeFileToShards}, plus POSTs the manifest to `input.spaceId`. */
|
|
4327
|
+
async writeFile(input) {
|
|
4328
|
+
const space = this.requireSpace("writeFile");
|
|
4329
|
+
const { manifest, stat } = await this.writeFileToShards(input);
|
|
4330
|
+
await space.writeFileManifest(input.spaceId, manifest);
|
|
4331
|
+
return stat;
|
|
4332
|
+
}
|
|
4333
|
+
/** As {@link readFileFromShards}, but fetches the manifest from a space first. */
|
|
4334
|
+
async readFile(spaceId, fileId) {
|
|
4335
|
+
const space = this.requireSpace("readFile");
|
|
4336
|
+
const manifest = await space.readFileManifest(spaceId, fileId);
|
|
4337
|
+
return await this.readFileFromShards(manifest);
|
|
4338
|
+
}
|
|
4339
|
+
/**
|
|
4340
|
+
* Delete the manifest from a space. **Does not GC the shards** — those
|
|
4341
|
+
* are content-addressed and may be referenced by other manifests.
|
|
4342
|
+
* Garbage collection is a separate server-side concern (Phase D / D-ish).
|
|
4343
|
+
*/
|
|
4344
|
+
async deleteFile(spaceId, fileId) {
|
|
4345
|
+
const space = this.requireSpace("deleteFile");
|
|
4346
|
+
return space.deleteFileManifest(spaceId, fileId);
|
|
4347
|
+
}
|
|
4348
|
+
/** Convenience pass-through. */
|
|
4349
|
+
async listFiles(spaceId) {
|
|
4350
|
+
const space = this.requireSpace("listFiles");
|
|
4351
|
+
return space.listFiles(spaceId);
|
|
4352
|
+
}
|
|
4353
|
+
/** Read a manifest verbatim — useful for "share file" / export flows. */
|
|
4354
|
+
async readManifest(spaceId, fileId) {
|
|
4355
|
+
const space = this.requireSpace("readManifest");
|
|
4356
|
+
return space.readFileManifest(spaceId, fileId);
|
|
4357
|
+
}
|
|
4358
|
+
// -------------------------------------------------------------------------
|
|
4359
|
+
// Internals
|
|
4360
|
+
// -------------------------------------------------------------------------
|
|
4361
|
+
requireSpace(operation) {
|
|
4362
|
+
if (!this.space) {
|
|
4363
|
+
throw new Error(`FileStorage.${operation}: a SharedSpaceClient is required for this operation ` +
|
|
4364
|
+
"(constructor was called without `space`)");
|
|
4365
|
+
}
|
|
4366
|
+
return this.space;
|
|
4367
|
+
}
|
|
4368
|
+
/** Encrypt + encode + upload one chunk; return its manifest entry. */
|
|
4369
|
+
async writeChunk(plaintext, chunkIndex) {
|
|
4370
|
+
const keyMaterial = this.cipher.generateKeyMaterial();
|
|
4371
|
+
const ciphertext = plaintext.length === 0
|
|
4372
|
+
? new Uint8Array(0)
|
|
4373
|
+
: await this.cipher.encryptChunk(plaintext, keyMaterial);
|
|
4374
|
+
// An empty chunk encodes to nothing — emit a manifest entry with no
|
|
4375
|
+
// shards so the reader can re-emit an empty plaintext and skip the
|
|
4376
|
+
// shard round-trip.
|
|
4377
|
+
if (ciphertext.length === 0) {
|
|
4378
|
+
return {
|
|
4379
|
+
id: generateId(),
|
|
4380
|
+
chunkIndex,
|
|
4381
|
+
originalSize: 0,
|
|
4382
|
+
ciphertextSize: 0,
|
|
4383
|
+
dataShards: this.dataShards,
|
|
4384
|
+
parityShards: this.parityShards,
|
|
4385
|
+
shardSize: 0,
|
|
4386
|
+
shardHashes: [],
|
|
4387
|
+
cipher: keyMaterial,
|
|
4388
|
+
};
|
|
4389
|
+
}
|
|
4390
|
+
const encoded = this.codec.encode(ciphertext, this.dataShards, this.parityShards);
|
|
4391
|
+
const hashes = await Promise.all(encoded.shards.map((s) => shardHash(s)));
|
|
4392
|
+
await this.runWithConcurrency(encoded.shards.map((bytes, i) => async () => {
|
|
4393
|
+
await this.shards.putShard(hashes[i], bytes);
|
|
4394
|
+
}));
|
|
4395
|
+
return {
|
|
4396
|
+
id: generateId(),
|
|
4397
|
+
chunkIndex,
|
|
4398
|
+
originalSize: plaintext.length,
|
|
4399
|
+
ciphertextSize: ciphertext.length,
|
|
4400
|
+
dataShards: encoded.dataShards,
|
|
4401
|
+
parityShards: encoded.parityShards,
|
|
4402
|
+
shardSize: encoded.shardSize,
|
|
4403
|
+
shardHashes: hashes,
|
|
4404
|
+
cipher: keyMaterial,
|
|
4405
|
+
};
|
|
4406
|
+
}
|
|
4407
|
+
/** Inverse of `writeChunk`. */
|
|
4408
|
+
async readChunk(manifest) {
|
|
4409
|
+
if (manifest.shardHashes.length === 0) {
|
|
4410
|
+
// Empty chunk fast path.
|
|
4411
|
+
return new Uint8Array(0);
|
|
4412
|
+
}
|
|
4413
|
+
// Fetch shards in parallel. Anything missing becomes a placeholder so
|
|
4414
|
+
// the decoder can fill it in from the parity shards (up to
|
|
4415
|
+
// `parityShards` allowed).
|
|
4416
|
+
const fetched = new Array(manifest.shardHashes.length);
|
|
4417
|
+
await this.runWithConcurrency(manifest.shardHashes.map((hash, i) => async () => {
|
|
4418
|
+
fetched[i] = await this.shards.getShard(hash);
|
|
4419
|
+
}));
|
|
4420
|
+
const deadIndexes = [];
|
|
4421
|
+
const shards = fetched.map((bytes, i) => {
|
|
4422
|
+
if (bytes === null) {
|
|
4423
|
+
deadIndexes.push(i);
|
|
4424
|
+
return new Uint8Array(manifest.shardSize);
|
|
4425
|
+
}
|
|
4426
|
+
if (bytes.length !== manifest.shardSize) {
|
|
4427
|
+
throw new Error(`FileStorage: shard ${manifest.shardHashes[i]} has wrong size ` +
|
|
4428
|
+
`(got ${bytes.length}, expected ${manifest.shardSize})`);
|
|
4429
|
+
}
|
|
4430
|
+
return bytes;
|
|
4431
|
+
});
|
|
4432
|
+
if (deadIndexes.length > manifest.parityShards) {
|
|
4433
|
+
throw new Error(`FileStorage: chunk ${manifest.id} unrecoverable — ${deadIndexes.length} shards missing, ` +
|
|
4434
|
+
`only ${manifest.parityShards} tolerated`);
|
|
4435
|
+
}
|
|
4436
|
+
const ciphertextPadded = this.codec.decode(shards, manifest.parityShards, deadIndexes, manifest.ciphertextSize);
|
|
4437
|
+
return await this.cipher.decryptChunk(ciphertextPadded, manifest.cipher);
|
|
4438
|
+
}
|
|
4439
|
+
/** Run `tasks` with at most `this.concurrency` in flight. */
|
|
4440
|
+
async runWithConcurrency(tasks) {
|
|
4441
|
+
let cursor = 0;
|
|
4442
|
+
const concurrency = this.concurrency;
|
|
4443
|
+
async function nextWorker() {
|
|
4444
|
+
while (cursor < tasks.length) {
|
|
4445
|
+
const idx = cursor++;
|
|
4446
|
+
await tasks[idx]();
|
|
4447
|
+
}
|
|
4448
|
+
}
|
|
4449
|
+
const workers = new Array(Math.min(concurrency, tasks.length))
|
|
4450
|
+
.fill(null)
|
|
4451
|
+
.map(() => nextWorker());
|
|
4452
|
+
await Promise.all(workers);
|
|
4453
|
+
}
|
|
4098
4454
|
}
|
|
4099
|
-
/**
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
const wbindgen = {
|
|
4107
|
-
__wbindgen_object_drop_ref: (arg0) => {
|
|
4108
|
-
takeObject(arg0);
|
|
4109
|
-
},
|
|
4110
|
-
__wbg_buffer_7af23f65f6c64548: (arg0) => {
|
|
4111
|
-
const ret = getObject(arg0).buffer;
|
|
4112
|
-
return addHeapObject(ret);
|
|
4113
|
-
},
|
|
4114
|
-
__wbg_newwithbyteoffsetandlength_ce1e75f0ce5f7974: (arg0, arg1, arg2) => {
|
|
4115
|
-
const ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0);
|
|
4116
|
-
return addHeapObject(ret);
|
|
4117
|
-
},
|
|
4118
|
-
__wbg_new_cc9018bd6f283b6f: (arg0) => {
|
|
4119
|
-
const ret = new Uint8Array(getObject(arg0));
|
|
4120
|
-
return addHeapObject(ret);
|
|
4121
|
-
},
|
|
4122
|
-
__wbg_set_f25e869e4565d2a2: (arg0, arg1, arg2) => {
|
|
4123
|
-
getObject(arg0).set(getObject(arg1), arg2 >>> 0);
|
|
4124
|
-
},
|
|
4125
|
-
__wbg_length_0acb1cf9bbaf8519: (arg0) => {
|
|
4126
|
-
return getObject(arg0).length;
|
|
4127
|
-
},
|
|
4128
|
-
__wbg_new_e8c5277c0f9e2cfc: (arg0) => {
|
|
4129
|
-
const ret = new Uint32Array(getObject(arg0));
|
|
4130
|
-
return addHeapObject(ret);
|
|
4131
|
-
},
|
|
4132
|
-
__wbg_set_03c7d8c063f469ef: (arg0, arg1, arg2) => {
|
|
4133
|
-
getObject(arg0).set(getObject(arg1), arg2 >>> 0);
|
|
4134
|
-
},
|
|
4135
|
-
__wbg_length_f98ca60981480796: (arg0) => {
|
|
4136
|
-
return getObject(arg0).length;
|
|
4137
|
-
},
|
|
4138
|
-
__wbindgen_throw: (arg0, arg1) => {
|
|
4139
|
-
throw new Error(getStringFromWasm(arg0, arg1));
|
|
4140
|
-
},
|
|
4141
|
-
__wbindgen_memory: () => {
|
|
4142
|
-
return addHeapObject(wasm.memory);
|
|
4143
|
-
},
|
|
4144
|
-
};
|
|
4145
|
-
return { __wbindgen_placeholder__: wbindgen };
|
|
4455
|
+
/** Normalize `data` to a flat `Uint8Array`. */
|
|
4456
|
+
async function toUint8Array(data) {
|
|
4457
|
+
if (data instanceof Uint8Array)
|
|
4458
|
+
return data;
|
|
4459
|
+
// Blob covers File too (File extends Blob in every runtime we target).
|
|
4460
|
+
const ab = await data.arrayBuffer();
|
|
4461
|
+
return new Uint8Array(ab);
|
|
4146
4462
|
}
|
|
4147
|
-
|
|
4463
|
+
|
|
4148
4464
|
/**
|
|
4149
|
-
*
|
|
4150
|
-
*
|
|
4151
|
-
*
|
|
4152
|
-
*
|
|
4153
|
-
*
|
|
4154
|
-
*
|
|
4155
|
-
*
|
|
4156
|
-
*
|
|
4157
|
-
*
|
|
4158
|
-
*
|
|
4465
|
+
* HTTP client for the gated file-manifest store on a SharedSpace
|
|
4466
|
+
* (`/api/spaces/:spaceId/files`).
|
|
4467
|
+
*
|
|
4468
|
+
* A "file" in a space is its **manifest** (per-chunk keys + the SHA-256 of each
|
|
4469
|
+
* global shard). The shard bytes live in the open `/api/shards` store; the
|
|
4470
|
+
* manifest is the capability. This client only moves manifests in/out of the
|
|
4471
|
+
* space's gated store.
|
|
4472
|
+
*
|
|
4473
|
+
* Authorization is **session-based**, consistent with the rest of the
|
|
4474
|
+
* SharedSpace (keyring, websocket): the SDK's credential-stamping `fetch`
|
|
4475
|
+
* (from {@link HttpClient}) attaches the app key + session token, the platform
|
|
4476
|
+
* validates the session and injects a trusted `X-Muhkoo-User-Context`, and the
|
|
4477
|
+
* DO authorizes on that + channel membership. There is no per-op Groth16 proof.
|
|
4478
|
+
*
|
|
4479
|
+
* Wire protocol (server: `SharedSpaceDO`):
|
|
4480
|
+
* POST /api/spaces/:spaceId/files body: { manifest } → { ok, fileId }
|
|
4481
|
+
* GET /api/spaces/:spaceId/files → { files: FileStat[] }
|
|
4482
|
+
* GET /api/spaces/:spaceId/files/:fileId → { manifest }
|
|
4483
|
+
* DELETE /api/spaces/:spaceId/files/:fileId → { ok, existed }
|
|
4159
4484
|
*/
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4485
|
+
class SharedSpaceClient {
|
|
4486
|
+
baseUrl;
|
|
4487
|
+
fetchFn;
|
|
4488
|
+
constructor(opts) {
|
|
4489
|
+
if (!opts?.baseUrl)
|
|
4490
|
+
throw new Error("SharedSpaceClient: `baseUrl` is required");
|
|
4491
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
4492
|
+
const f = opts.fetch ?? globalThis.fetch;
|
|
4493
|
+
if (typeof f !== "function") {
|
|
4494
|
+
throw new Error("SharedSpaceClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
|
|
4169
4495
|
}
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4496
|
+
this.fetchFn = f.bind(globalThis);
|
|
4497
|
+
}
|
|
4498
|
+
/** Write a file manifest into the space. Requires space membership. */
|
|
4499
|
+
async writeFileManifest(spaceId, manifest) {
|
|
4500
|
+
const url = `${this.filesBase(spaceId)}`;
|
|
4501
|
+
const res = await this.fetchFn(url, this.jsonRequest("POST", { manifest }));
|
|
4502
|
+
await parseOrThrow$1(res, `writeFileManifest(${manifest.id})`);
|
|
4503
|
+
}
|
|
4504
|
+
/** Read a file manifest by id. Requires space membership. */
|
|
4505
|
+
async readFileManifest(spaceId, fileId) {
|
|
4506
|
+
const url = `${this.filesBase(spaceId)}/${encodeURIComponent(fileId)}`;
|
|
4507
|
+
const res = await this.fetchFn(url, this.jsonRequest("GET"));
|
|
4508
|
+
const data = await parseOrThrow$1(res, `readFileManifest(${fileId})`);
|
|
4509
|
+
return data.manifest;
|
|
4510
|
+
}
|
|
4511
|
+
/** Delete a file manifest by id. Requires space membership. */
|
|
4512
|
+
async deleteFileManifest(spaceId, fileId) {
|
|
4513
|
+
const url = `${this.filesBase(spaceId)}/${encodeURIComponent(fileId)}`;
|
|
4514
|
+
const res = await this.fetchFn(url, this.jsonRequest("DELETE"));
|
|
4515
|
+
const data = await parseOrThrow$1(res, `deleteFileManifest(${fileId})`);
|
|
4516
|
+
return data.existed;
|
|
4517
|
+
}
|
|
4518
|
+
/** List file summaries in the space. Requires space membership. */
|
|
4519
|
+
async listFiles(spaceId) {
|
|
4520
|
+
const url = `${this.filesBase(spaceId)}`;
|
|
4521
|
+
const res = await this.fetchFn(url, this.jsonRequest("GET"));
|
|
4522
|
+
const data = await parseOrThrow$1(res, "listFiles");
|
|
4523
|
+
return Array.isArray(data.files) ? data.files : [];
|
|
4524
|
+
}
|
|
4525
|
+
// -------------------------------------------------------------------------
|
|
4526
|
+
filesBase(spaceId) {
|
|
4527
|
+
return `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files`;
|
|
4528
|
+
}
|
|
4529
|
+
jsonRequest(method, body) {
|
|
4530
|
+
const init = { method };
|
|
4531
|
+
if (body !== undefined) {
|
|
4532
|
+
init.headers = { "Content-Type": "application/json" };
|
|
4533
|
+
init.body = JSON.stringify(body);
|
|
4182
4534
|
}
|
|
4183
|
-
|
|
4184
|
-
}
|
|
4185
|
-
return _readyPromise;
|
|
4535
|
+
return init;
|
|
4536
|
+
}
|
|
4186
4537
|
}
|
|
4538
|
+
async function parseOrThrow$1(response, op) {
|
|
4539
|
+
let parsed = null;
|
|
4540
|
+
try {
|
|
4541
|
+
parsed = await response.json();
|
|
4542
|
+
}
|
|
4543
|
+
catch {
|
|
4544
|
+
// non-JSON body
|
|
4545
|
+
}
|
|
4546
|
+
if (!response.ok) {
|
|
4547
|
+
const msg = parsed && typeof parsed === "object" && "error" in parsed
|
|
4548
|
+
? String(parsed.error)
|
|
4549
|
+
: `${response.status} ${response.statusText}`;
|
|
4550
|
+
throw new Error(`SharedSpaceClient.${op}: ${msg}`);
|
|
4551
|
+
}
|
|
4552
|
+
return parsed;
|
|
4553
|
+
}
|
|
4554
|
+
|
|
4187
4555
|
/**
|
|
4188
|
-
*
|
|
4189
|
-
*
|
|
4190
|
-
*
|
|
4191
|
-
*
|
|
4556
|
+
* `client.storage` — file storage. Hides the `FileStorage` / `ShardClient` /
|
|
4557
|
+
* `SharedSpaceClient` primitives behind the unified client.
|
|
4558
|
+
*
|
|
4559
|
+
* const { stat, manifest } = await client.storage.writeFile({ spaceId, data, metadata })
|
|
4560
|
+
* const { data } = await client.storage.readFile(spaceId, fileId)
|
|
4561
|
+
* const files = await client.storage.listFiles() // my shared files
|
|
4562
|
+
* const files = await client.storage.listFiles({ spaceId }) // a space's files
|
|
4563
|
+
* await client.storage.deleteFile(spaceId, fileId)
|
|
4564
|
+
*
|
|
4565
|
+
* Model:
|
|
4566
|
+
* - **Files are written to a SharedSpace** (never the personal space). A file
|
|
4567
|
+
* is its manifest (per-chunk keys + global shard hashes); the manifest lives
|
|
4568
|
+
* in the space's gated, metered `/files` store, the shard ciphertext in the
|
|
4569
|
+
* global open `/api/shards` store.
|
|
4570
|
+
* - **The manifest is the capability** — anyone holding it reads the file from
|
|
4571
|
+
* the global shards, anywhere ({@link readByManifest}). Share a file by
|
|
4572
|
+
* sharing its manifest.
|
|
4573
|
+
* - **Personal discovery mirror** — on a successful write the SDK also records
|
|
4574
|
+
* the manifest in the user's `client.kv` under a reserved collection, so
|
|
4575
|
+
* {@link listFiles} (no `spaceId`) can show "files I've shared" without
|
|
4576
|
+
* enumerating spaces. The mirror is unmetered and is NOT a write path.
|
|
4577
|
+
*
|
|
4578
|
+
* For small structured values use `client.kv` instead.
|
|
4192
4579
|
*/
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4580
|
+
/** Reserved `client.kv` collection holding the personal manifest mirror. */
|
|
4581
|
+
const MIRROR_COLLECTION = "__files__";
|
|
4582
|
+
class StorageNamespace {
|
|
4583
|
+
deps;
|
|
4584
|
+
fileStorage = null;
|
|
4585
|
+
constructor(deps) {
|
|
4586
|
+
this.deps = deps;
|
|
4587
|
+
}
|
|
4588
|
+
/**
|
|
4589
|
+
* Write a file to a SharedSpace: shard ciphertext → the global store, the
|
|
4590
|
+
* manifest → the space's gated store, and a mirror copy → the caller's
|
|
4591
|
+
* personal index. Returns the `stat` and the `manifest` (share the manifest
|
|
4592
|
+
* to let others read the file).
|
|
4593
|
+
*/
|
|
4594
|
+
async writeFile(input) {
|
|
4595
|
+
if (!input?.spaceId) {
|
|
4596
|
+
throw new Error("client.storage.writeFile: `spaceId` is required (files live in a SharedSpace).");
|
|
4198
4597
|
}
|
|
4199
|
-
|
|
4200
|
-
|
|
4598
|
+
const fs = this.fs();
|
|
4599
|
+
const { manifest, stat } = await fs.writeFileToShards({
|
|
4600
|
+
data: input.data,
|
|
4601
|
+
metadata: input.metadata,
|
|
4602
|
+
});
|
|
4603
|
+
// Authoritative, metered write: the manifest into the space's gated store.
|
|
4604
|
+
await this.space().writeFileManifest(input.spaceId, manifest);
|
|
4605
|
+
// Best-effort personal discovery mirror (unmetered). A failure here
|
|
4606
|
+
// leaves the file intact in the space — log and continue.
|
|
4607
|
+
try {
|
|
4608
|
+
await this.deps.kv.set(MIRROR_COLLECTION, manifest.id, {
|
|
4609
|
+
spaceId: input.spaceId,
|
|
4610
|
+
manifest,
|
|
4611
|
+
});
|
|
4612
|
+
}
|
|
4613
|
+
catch (err) {
|
|
4614
|
+
console.warn("client.storage.writeFile: discovery mirror failed (file still written):", err);
|
|
4201
4615
|
}
|
|
4616
|
+
return { stat, manifest };
|
|
4202
4617
|
}
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
if (!wasm) {
|
|
4207
|
-
throw new Error("rs.ts: WASM not initialized. Call `await initRsWasm()` first.");
|
|
4618
|
+
/** Read a file from its space (resolves the manifest, then the shards). */
|
|
4619
|
+
async readFile(spaceId, fileId) {
|
|
4620
|
+
return this.fs().readFile(spaceId, fileId);
|
|
4208
4621
|
}
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
* shards must be Uint8Arrays of equal length; output contains the input shards
|
|
4216
|
-
* unchanged plus `parityShard` newly computed parity shards.
|
|
4217
|
-
*/
|
|
4218
|
-
function encode(dataShards, parityShard) {
|
|
4219
|
-
ensureReady();
|
|
4220
|
-
const w = wasm;
|
|
4221
|
-
try {
|
|
4222
|
-
const retptr = w.__wbindgen_add_to_stack_pointer(-16);
|
|
4223
|
-
const ptr0 = passArrayJsValueToWasm(dataShards, w.__wbindgen_malloc);
|
|
4224
|
-
const len0 = WASM_VECTOR_LEN;
|
|
4225
|
-
w.encode(retptr, ptr0, len0, parityShard);
|
|
4226
|
-
const r0 = i32()[retptr / 4 + 0];
|
|
4227
|
-
const r1 = i32()[retptr / 4 + 1];
|
|
4228
|
-
const v1 = getArrayJsValueFromWasm(r0, r1).slice();
|
|
4229
|
-
w.__wbindgen_free(r0, r1 * 4);
|
|
4230
|
-
return v1;
|
|
4622
|
+
/**
|
|
4623
|
+
* Read a file straight from a manifest — the capability path. No space
|
|
4624
|
+
* membership needed; this is how a shared file is opened.
|
|
4625
|
+
*/
|
|
4626
|
+
async readByManifest(manifest) {
|
|
4627
|
+
return this.fs().readFileFromShards(manifest);
|
|
4231
4628
|
}
|
|
4232
|
-
|
|
4233
|
-
|
|
4629
|
+
/**
|
|
4630
|
+
* List files. With no argument, returns the caller's own shared files from
|
|
4631
|
+
* the personal mirror; with `{ spaceId }`, lists that space's files.
|
|
4632
|
+
*/
|
|
4633
|
+
async listFiles(opts) {
|
|
4634
|
+
if (opts?.spaceId)
|
|
4635
|
+
return this.space().listFiles(opts.spaceId);
|
|
4636
|
+
const ids = await this.deps.kv.list(MIRROR_COLLECTION);
|
|
4637
|
+
const entries = await Promise.all(ids.map((id) => this.deps.kv.get(MIRROR_COLLECTION, id)));
|
|
4638
|
+
return entries
|
|
4639
|
+
.filter((e) => Boolean(e?.manifest))
|
|
4640
|
+
.map((e) => manifestToStat(e.manifest));
|
|
4641
|
+
}
|
|
4642
|
+
/** Delete a file from its space and remove the personal mirror entry. */
|
|
4643
|
+
async deleteFile(spaceId, fileId) {
|
|
4644
|
+
const existed = await this.fs().deleteFile(spaceId, fileId);
|
|
4645
|
+
try {
|
|
4646
|
+
await this.deps.kv.delete(MIRROR_COLLECTION, fileId);
|
|
4647
|
+
}
|
|
4648
|
+
catch {
|
|
4649
|
+
// mirror cleanup is best-effort
|
|
4650
|
+
}
|
|
4651
|
+
return existed;
|
|
4234
4652
|
}
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
* list (data + parity) with placeholder Uint8Arrays at positions listed in
|
|
4239
|
-
* `deadShardIndexes`. Returns the recovered, complete list of shards.
|
|
4240
|
-
*
|
|
4241
|
-
* Up to `parityShard` shards may be missing; more than that is uncorrectable
|
|
4242
|
-
* and the underlying WASM throws.
|
|
4243
|
-
*/
|
|
4244
|
-
function reconstruct(corruptedShards, parityShard, deadShardIndexes) {
|
|
4245
|
-
ensureReady();
|
|
4246
|
-
const w = wasm;
|
|
4247
|
-
try {
|
|
4248
|
-
const retptr = w.__wbindgen_add_to_stack_pointer(-16);
|
|
4249
|
-
const ptr0 = passArrayJsValueToWasm(corruptedShards, w.__wbindgen_malloc);
|
|
4250
|
-
const len0 = WASM_VECTOR_LEN;
|
|
4251
|
-
w.reconstruct(retptr, ptr0, len0, parityShard, addHeapObject(deadShardIndexes));
|
|
4252
|
-
const r0 = i32()[retptr / 4 + 0];
|
|
4253
|
-
const r1 = i32()[retptr / 4 + 1];
|
|
4254
|
-
const v1 = getArrayJsValueFromWasm(r0, r1).slice();
|
|
4255
|
-
w.__wbindgen_free(r0, r1 * 4);
|
|
4256
|
-
return v1;
|
|
4653
|
+
/** Read a manifest verbatim (e.g. to share a file). */
|
|
4654
|
+
async getManifest(spaceId, fileId) {
|
|
4655
|
+
return this.fs().readManifest(spaceId, fileId);
|
|
4257
4656
|
}
|
|
4258
|
-
|
|
4259
|
-
|
|
4657
|
+
// -------------------------------------------------------------------------
|
|
4658
|
+
// Internals — lazily built so an unauthenticated client is cheap to create.
|
|
4659
|
+
// -------------------------------------------------------------------------
|
|
4660
|
+
fs() {
|
|
4661
|
+
if (!this.fileStorage) {
|
|
4662
|
+
this.fileStorage = new FileStorage({
|
|
4663
|
+
shards: this.shards(),
|
|
4664
|
+
space: this.space(),
|
|
4665
|
+
rsWasmModule: this.deps.rsWasmModule,
|
|
4666
|
+
});
|
|
4667
|
+
}
|
|
4668
|
+
return this.fileStorage;
|
|
4669
|
+
}
|
|
4670
|
+
_shards = null;
|
|
4671
|
+
shards() {
|
|
4672
|
+
// Global, content-addressed shard store. The credential-stamping fetch
|
|
4673
|
+
// attaches the app key so PUTs are attributed + quota-gated; GETs are open.
|
|
4674
|
+
if (!this._shards) {
|
|
4675
|
+
this._shards = new ShardClient({
|
|
4676
|
+
baseUrl: this.deps.baseUrl,
|
|
4677
|
+
pathPrefix: "/api/shards",
|
|
4678
|
+
fetch: this.deps.http.fetch,
|
|
4679
|
+
});
|
|
4680
|
+
}
|
|
4681
|
+
return this._shards;
|
|
4682
|
+
}
|
|
4683
|
+
_space = null;
|
|
4684
|
+
space() {
|
|
4685
|
+
if (!this._space) {
|
|
4686
|
+
this._space = new SharedSpaceClient({
|
|
4687
|
+
baseUrl: this.deps.baseUrl,
|
|
4688
|
+
fetch: this.deps.http.fetch,
|
|
4689
|
+
});
|
|
4690
|
+
}
|
|
4691
|
+
return this._space;
|
|
4260
4692
|
}
|
|
4261
4693
|
}
|
|
4262
4694
|
|
|
4263
4695
|
/**
|
|
4264
|
-
*
|
|
4265
|
-
*
|
|
4266
|
-
*
|
|
4267
|
-
*
|
|
4268
|
-
*
|
|
4269
|
-
*
|
|
4270
|
-
* Usage:
|
|
4271
|
-
* const codec = new ReedSolomonCodec();
|
|
4272
|
-
* await codec.ready();
|
|
4273
|
-
* const { shards, shardSize, originalSize } = codec.encode(plaintext, 4, 2);
|
|
4274
|
-
* // 6 shards (4 data + 2 parity), each `shardSize` bytes
|
|
4275
|
-
* // …ship them, lose up to 2, then …
|
|
4276
|
-
* const recovered = codec.decode(shards, 2, deadIndexes, originalSize);
|
|
4696
|
+
* An end-to-end-encrypted communication session for one local identity
|
|
4697
|
+
* (`myId`) talking to an arbitrary set of peers. Wraps `KeyStore` +
|
|
4698
|
+
* per-pair `DoubleRatchet` instances and owns the protocol bookkeeping
|
|
4699
|
+
* (handshake reciprocation, dedup, role assignment, broadcast fan-out,
|
|
4700
|
+
* recipient filtering) so application code doesn't have to.
|
|
4277
4701
|
*
|
|
4278
|
-
*
|
|
4279
|
-
*
|
|
4280
|
-
*
|
|
4281
|
-
*
|
|
4702
|
+
* Transport-agnostic by design: the Session never touches a WebSocket
|
|
4703
|
+
* (or HTTP, or anything else). Application code:
|
|
4704
|
+
* - calls `session.encrypt(text)` and gets back an array of frames it
|
|
4705
|
+
* ships over whatever transport it has;
|
|
4706
|
+
* - calls `session.receive(frame)` with each inbound frame and acts on
|
|
4707
|
+
* the typed result (plaintext, handshake-done, ignored).
|
|
4708
|
+
*
|
|
4709
|
+
* Frames are plain JSON objects with the same shape the existing chat
|
|
4710
|
+
* protocol uses (`{ keyExchange: {...} }` and `{ cipherMessage: {...} }`),
|
|
4711
|
+
* so this can drop into a chat app without changing the wire format.
|
|
4282
4712
|
*/
|
|
4283
|
-
|
|
4713
|
+
// ---------------------------------------------------------------------------
|
|
4714
|
+
// EncryptedSession
|
|
4715
|
+
// ---------------------------------------------------------------------------
|
|
4716
|
+
class EncryptedSession {
|
|
4717
|
+
myId;
|
|
4718
|
+
keyStore;
|
|
4719
|
+
/** One DoubleRatchet per peer pair. */
|
|
4720
|
+
ratchets = new Map();
|
|
4721
|
+
/** Peers we've already sent our keyExchange to in this Session. */
|
|
4722
|
+
sentHandshakeTo = new Set();
|
|
4723
|
+
/** Resolved when own keys are generated and ready to use. */
|
|
4724
|
+
_ready;
|
|
4725
|
+
_resolveReady;
|
|
4726
|
+
_initialized = false;
|
|
4727
|
+
constructor(opts) {
|
|
4728
|
+
if (!opts || !opts.myId) {
|
|
4729
|
+
throw new Error("EncryptedSession: `myId` is required");
|
|
4730
|
+
}
|
|
4731
|
+
this.myId = opts.myId;
|
|
4732
|
+
this.keyStore = KeyStore.getInstance();
|
|
4733
|
+
this._ready = new Promise((resolve) => {
|
|
4734
|
+
this._resolveReady = resolve;
|
|
4735
|
+
});
|
|
4736
|
+
}
|
|
4737
|
+
/** A promise that resolves once `initialize()` has completed. */
|
|
4738
|
+
get ready() {
|
|
4739
|
+
return this._ready;
|
|
4740
|
+
}
|
|
4741
|
+
/** Our local identifier. */
|
|
4742
|
+
get id() {
|
|
4743
|
+
return this.myId;
|
|
4744
|
+
}
|
|
4284
4745
|
/**
|
|
4285
|
-
*
|
|
4286
|
-
*
|
|
4287
|
-
* Pass `wasmModule` to use a pre-compiled module (e.g. in tests where the
|
|
4288
|
-
* rollup-inlined loader path isn't available, or in CF Workers where you
|
|
4289
|
-
* want to load the module at deploy time). Default path uses the bundled
|
|
4290
|
-
* base64-inlined wasm — works in Node, browsers, and Workers.
|
|
4746
|
+
* Generate this client's ECDH + ECDSA keypair (idempotent — safe to call
|
|
4747
|
+
* multiple times across reconnects; only the first call generates).
|
|
4291
4748
|
*/
|
|
4292
|
-
|
|
4293
|
-
|
|
4749
|
+
async initialize() {
|
|
4750
|
+
if (this._initialized)
|
|
4751
|
+
return;
|
|
4752
|
+
if (!this.keyStore.getKeyPair(this.myId)) {
|
|
4753
|
+
await this.keyStore.generateOwnKeyPair(this.myId);
|
|
4754
|
+
}
|
|
4755
|
+
this._initialized = true;
|
|
4756
|
+
this._resolveReady();
|
|
4294
4757
|
}
|
|
4295
4758
|
/**
|
|
4296
|
-
*
|
|
4297
|
-
*
|
|
4759
|
+
* Build the keyExchange frame this client should send to peers when it
|
|
4760
|
+
* comes online. Application is responsible for actually transmitting it
|
|
4761
|
+
* (e.g. ws.send(JSON.stringify(frame))).
|
|
4298
4762
|
*/
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
throw new Error("ReedSolomonCodec.encode: data is empty");
|
|
4306
|
-
// The wbindgen-generated WASM in `wasm-reed-solomon-erasure@0.2.2` takes
|
|
4307
|
-
// the `parity_shard` count separately and infers `total = data.length +
|
|
4308
|
-
// parity_shard`. Every shard must be the same length, so we pad the
|
|
4309
|
-
// input to `dataShards * shardSize` with trailing zeros.
|
|
4310
|
-
const shardSize = Math.ceil(data.length / dataShards);
|
|
4311
|
-
const splitInputs = [];
|
|
4312
|
-
for (let i = 0; i < dataShards; i++) {
|
|
4313
|
-
// Each shard is its own backing ArrayBuffer so the WASM side
|
|
4314
|
-
// doesn't observe overlapping views (which would confuse the
|
|
4315
|
-
// wbindgen length lookups).
|
|
4316
|
-
const shard = new Uint8Array(shardSize);
|
|
4317
|
-
const start = i * shardSize;
|
|
4318
|
-
const end = Math.min(start + shardSize, data.length);
|
|
4319
|
-
if (start < data.length) {
|
|
4320
|
-
shard.set(data.subarray(start, end));
|
|
4321
|
-
}
|
|
4322
|
-
splitInputs.push(shard);
|
|
4763
|
+
async getOwnKeyExchange() {
|
|
4764
|
+
await this._ready;
|
|
4765
|
+
const ownEcdh = this.keyStore.getKeyPair(this.myId);
|
|
4766
|
+
const ownEcdsa = this.keyStore.getAuthKeyPair(this.myId);
|
|
4767
|
+
if (!ownEcdh || !ownEcdsa) {
|
|
4768
|
+
throw new Error("EncryptedSession.getOwnKeyExchange: keys not initialized");
|
|
4323
4769
|
}
|
|
4324
|
-
const shards = encode(splitInputs, parityShards);
|
|
4325
|
-
// The underlying call returns N+M shards; first N are the (padded)
|
|
4326
|
-
// data shards we just sent in, last M are the parity shards.
|
|
4327
4770
|
return {
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4771
|
+
keyExchange: {
|
|
4772
|
+
type: "handshake",
|
|
4773
|
+
userId: this.myId,
|
|
4774
|
+
ecdhPublicKey: await exportJwkBase64(ownEcdh.publicKey),
|
|
4775
|
+
ecdsaPublicKey: await exportJwkBase64(ownEcdsa.publicKey),
|
|
4776
|
+
},
|
|
4333
4777
|
};
|
|
4334
4778
|
}
|
|
4335
4779
|
/**
|
|
4336
|
-
*
|
|
4337
|
-
*
|
|
4338
|
-
*
|
|
4339
|
-
* `deadShardIndexes`.
|
|
4780
|
+
* Encrypt a plaintext to every peer we currently have a ratchet for, and
|
|
4781
|
+
* return one cipherMessage frame per recipient. The application sends
|
|
4782
|
+
* each frame over its transport.
|
|
4340
4783
|
*
|
|
4341
|
-
*
|
|
4784
|
+
* If there are no peers yet (or all peers are still mid-handshake), the
|
|
4785
|
+
* returned array may be empty — the caller should typically render the
|
|
4786
|
+
* outgoing message locally in that case (since there's no one to send
|
|
4787
|
+
* it to yet).
|
|
4342
4788
|
*/
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
const dataShards = shards.length - parityShards;
|
|
4353
|
-
const recovered = deadShardIndexes.length === 0
|
|
4354
|
-
? shards
|
|
4355
|
-
: reconstruct(shards, parityShards, new Uint32Array(deadShardIndexes));
|
|
4356
|
-
// Concatenate the leading `dataShards`, then trim padding back off.
|
|
4357
|
-
const shardSize = recovered[0]?.length ?? 0;
|
|
4358
|
-
const out = new Uint8Array(dataShards * shardSize);
|
|
4359
|
-
for (let i = 0; i < dataShards; i++) {
|
|
4360
|
-
out.set(recovered[i], i * shardSize);
|
|
4789
|
+
async encrypt(plaintext) {
|
|
4790
|
+
await this._ready;
|
|
4791
|
+
const frames = [];
|
|
4792
|
+
for (const peerId of this.ratchets.keys()) {
|
|
4793
|
+
const ratchet = this.ratchets.get(peerId);
|
|
4794
|
+
const sessionId = this._sessionIdFor(peerId);
|
|
4795
|
+
const cipherMessage = await ratchet.encrypt(plaintext,
|
|
4796
|
+
/* newDhKey */ false, this.myId, peerId, sessionId, "specific");
|
|
4797
|
+
frames.push({ cipherMessage });
|
|
4361
4798
|
}
|
|
4362
|
-
return
|
|
4363
|
-
}
|
|
4364
|
-
}
|
|
4365
|
-
|
|
4366
|
-
/**
|
|
4367
|
-
* HTTP client for the open content-addressed shard store.
|
|
4368
|
-
*
|
|
4369
|
-
* Each shard is named by the lowercase hex SHA-256 of its ciphertext bytes.
|
|
4370
|
-
* The server verifies the hash on PUT, so the address authenticates the
|
|
4371
|
-
* contents — there's no benefit to ACLing PUT/GET, and making them open
|
|
4372
|
-
* enables cross-user dedup down the line.
|
|
4373
|
-
*
|
|
4374
|
-
* Wire protocol:
|
|
4375
|
-
* PUT /api/shards/:hash body: shard bytes (application/octet-stream)
|
|
4376
|
-
* 200 { ok: true, hash, size, dedup: boolean }
|
|
4377
|
-
* 400 if hash doesn't match body
|
|
4378
|
-
*
|
|
4379
|
-
* GET /api/shards/:hash 200 with shard bytes (application/octet-stream)
|
|
4380
|
-
* 404 if absent
|
|
4381
|
-
*
|
|
4382
|
-
* HEAD /api/shards/:hash 200 if present (no body) — used by callers to
|
|
4383
|
-
* skip a PUT they'd otherwise dedup-rewrite. The
|
|
4384
|
-
* server can ignore the optimization if it wants.
|
|
4385
|
-
*/
|
|
4386
|
-
/**
|
|
4387
|
-
* Compute the lowercase hex SHA-256 of `bytes`. Available via WebCrypto in
|
|
4388
|
-
* every runtime this SDK targets.
|
|
4389
|
-
*/
|
|
4390
|
-
async function shardHash(bytes) {
|
|
4391
|
-
const subtle = globalThis.crypto?.subtle;
|
|
4392
|
-
if (!subtle) {
|
|
4393
|
-
throw new Error("shardHash: `globalThis.crypto.subtle` is unavailable in this runtime.");
|
|
4799
|
+
return frames;
|
|
4394
4800
|
}
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
//
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4801
|
+
/**
|
|
4802
|
+
* Hand an inbound frame to the session. Returns a typed result the
|
|
4803
|
+
* application acts on.
|
|
4804
|
+
*
|
|
4805
|
+
* - `kind: 'plaintext'` — decrypted; render `result.text`
|
|
4806
|
+
* - `kind: 'handshake'` — a peer's keys are now stored, and if
|
|
4807
|
+
* `result.outbound` is set, send it
|
|
4808
|
+
* - `kind: 'ignored'` — not for us, malformed, duplicate, etc.
|
|
4809
|
+
*
|
|
4810
|
+
* Calling code should always handle `'ignored'` (no UI side effect).
|
|
4811
|
+
*/
|
|
4812
|
+
async receive(frame) {
|
|
4813
|
+
await this._ready;
|
|
4814
|
+
// -- handshake frame -----------------------------------------------------
|
|
4815
|
+
if (isKeyExchangeFrame(frame)) {
|
|
4816
|
+
const kx = frame.keyExchange;
|
|
4817
|
+
const peerId = kx.userId;
|
|
4818
|
+
if (peerId === this.myId) {
|
|
4819
|
+
return { kind: "ignored", reason: "own keyExchange echo" };
|
|
4820
|
+
}
|
|
4821
|
+
const isNew = !this.ratchets.has(peerId);
|
|
4822
|
+
// (Re-)store the peer's pubkeys + (re-)build the ratchet if needed.
|
|
4823
|
+
const peerEcdh = await importJwkBase64(kx.ecdhPublicKey, "ECDH");
|
|
4824
|
+
const peerEcdsa = await importJwkBase64(kx.ecdsaPublicKey, "ECDSA");
|
|
4825
|
+
await this.keyStore.storeRemotePublicKeys(peerId, peerEcdh, peerEcdsa);
|
|
4826
|
+
if (isNew) {
|
|
4827
|
+
const isClient = this.myId < peerId;
|
|
4828
|
+
const ratchet = new DoubleRatchet(this.myId, peerId, "specific", isClient);
|
|
4829
|
+
await ratchet.initializeSession(isClient);
|
|
4830
|
+
this.ratchets.set(peerId, ratchet);
|
|
4831
|
+
}
|
|
4832
|
+
// Reciprocate exactly once per peer per session lifetime.
|
|
4833
|
+
let outbound = null;
|
|
4834
|
+
if (isNew && !this.sentHandshakeTo.has(peerId)) {
|
|
4835
|
+
outbound = await this.getOwnKeyExchange();
|
|
4836
|
+
this.sentHandshakeTo.add(peerId);
|
|
4837
|
+
}
|
|
4838
|
+
return { kind: "handshake", peerId, outbound };
|
|
4839
|
+
}
|
|
4840
|
+
// -- ciphertext frame ----------------------------------------------------
|
|
4841
|
+
if (isCipherFrame(frame)) {
|
|
4842
|
+
const cipherMessage = frame.cipherMessage;
|
|
4843
|
+
const header = cipherMessage?.header;
|
|
4844
|
+
if (!header) {
|
|
4845
|
+
return { kind: "ignored", reason: "cipherMessage has no header" };
|
|
4846
|
+
}
|
|
4847
|
+
if (header.senderId === this.myId) {
|
|
4848
|
+
return { kind: "ignored", reason: "own outbound echo" };
|
|
4849
|
+
}
|
|
4850
|
+
if (header.recipientId && header.recipientId !== this.myId) {
|
|
4851
|
+
return { kind: "ignored", reason: "addressed to a different peer" };
|
|
4852
|
+
}
|
|
4853
|
+
const ratchet = this.ratchets.get(header.senderId);
|
|
4854
|
+
if (!ratchet) {
|
|
4855
|
+
return {
|
|
4856
|
+
kind: "ignored",
|
|
4857
|
+
reason: `no ratchet with sender ${header.senderId} (handshake hasn't completed)`,
|
|
4858
|
+
};
|
|
4859
|
+
}
|
|
4860
|
+
const isClient = this.myId < header.senderId;
|
|
4861
|
+
let text;
|
|
4862
|
+
try {
|
|
4863
|
+
text = await ratchet.decrypt(cipherMessage, isClient);
|
|
4864
|
+
}
|
|
4865
|
+
catch (err) {
|
|
4866
|
+
// DoubleRatchet's wallclock-based replay-protection check throws
|
|
4867
|
+
// "CipherMessage too old, possible replay attack" for any frame
|
|
4868
|
+
// older than 5 minutes. Real replays still fail downstream because
|
|
4869
|
+
// the message key has been consumed; the wallclock check is a
|
|
4870
|
+
// defense-in-depth heuristic, not a security boundary. Treating it
|
|
4871
|
+
// as `ignored` lets the channel survive legacy backlog replays and
|
|
4872
|
+
// CF WS-idle-timeout reconnect cycles without spamming the
|
|
4873
|
+
// channel's ERROR stream.
|
|
4874
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4875
|
+
if (msg.includes("too old") || msg.includes("replay attack")) {
|
|
4876
|
+
return { kind: "ignored", reason: `stale cipherMessage rejected: ${msg}` };
|
|
4877
|
+
}
|
|
4878
|
+
throw err;
|
|
4879
|
+
}
|
|
4880
|
+
return { kind: "plaintext", from: header.senderId, text, cipherMessage };
|
|
4415
4881
|
}
|
|
4416
|
-
|
|
4882
|
+
return { kind: "ignored", reason: "unknown frame type" };
|
|
4417
4883
|
}
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4884
|
+
// -- introspection ---------------------------------------------------------
|
|
4885
|
+
/** List of peer ids we have an established ratchet for. */
|
|
4886
|
+
peers() {
|
|
4887
|
+
return Array.from(this.ratchets.keys());
|
|
4421
4888
|
}
|
|
4422
|
-
/**
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
* naming. Re-uploading an existing shard is idempotent — the server
|
|
4426
|
-
* returns `dedup: true` in that case.
|
|
4427
|
-
*/
|
|
4428
|
-
async putShard(hash, bytes) {
|
|
4429
|
-
const url = this.urlForHash(hash);
|
|
4430
|
-
const response = await this.fetchFn(url, {
|
|
4431
|
-
method: "PUT",
|
|
4432
|
-
headers: { "content-type": "application/octet-stream" },
|
|
4433
|
-
// BufferSource is what fetch's body expects; a Uint8Array satisfies it.
|
|
4434
|
-
body: bytes,
|
|
4435
|
-
});
|
|
4436
|
-
if (!response.ok) {
|
|
4437
|
-
const text = await safeText(response);
|
|
4438
|
-
throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
|
|
4439
|
-
}
|
|
4440
|
-
let dedup = false;
|
|
4441
|
-
try {
|
|
4442
|
-
const json = (await response.json());
|
|
4443
|
-
dedup = Boolean(json?.dedup);
|
|
4444
|
-
}
|
|
4445
|
-
catch {
|
|
4446
|
-
// Server returned 200 with no body — treat as fresh upload.
|
|
4447
|
-
}
|
|
4448
|
-
return { dedup };
|
|
4889
|
+
/** Has the handshake completed for this peer? */
|
|
4890
|
+
hasRatchetFor(peerId) {
|
|
4891
|
+
return this.ratchets.has(peerId);
|
|
4449
4892
|
}
|
|
4450
4893
|
/**
|
|
4451
|
-
*
|
|
4452
|
-
* `
|
|
4894
|
+
* Tear down a peer's ratchet (e.g. when they leave the room). The peer's
|
|
4895
|
+
* stored keys remain in `KeyStore` so a fresh handshake can be set up
|
|
4896
|
+
* later without re-fetching them, but the ratchet state is dropped.
|
|
4453
4897
|
*/
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
if (response.status === 404)
|
|
4458
|
-
return null;
|
|
4459
|
-
if (!response.ok) {
|
|
4460
|
-
const text = await safeText(response);
|
|
4461
|
-
throw new Error(`ShardClient.getShard(${hash}) failed (${response.status}): ${text}`);
|
|
4462
|
-
}
|
|
4463
|
-
const buf = await response.arrayBuffer();
|
|
4464
|
-
return new Uint8Array(buf);
|
|
4898
|
+
forgetPeer(peerId) {
|
|
4899
|
+
this.ratchets.delete(peerId);
|
|
4900
|
+
this.sentHandshakeTo.delete(peerId);
|
|
4465
4901
|
}
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
*/
|
|
4471
|
-
async hasShard(hash) {
|
|
4472
|
-
const url = this.urlForHash(hash);
|
|
4473
|
-
const response = await this.fetchFn(url, { method: "HEAD" });
|
|
4474
|
-
return response.status === 200;
|
|
4902
|
+
// -- internals -------------------------------------------------------------
|
|
4903
|
+
/** Deterministic per-pair session id — same string on both sides. */
|
|
4904
|
+
_sessionIdFor(peerId) {
|
|
4905
|
+
return [this.myId, peerId].sort().join(":");
|
|
4475
4906
|
}
|
|
4476
4907
|
}
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4908
|
+
// ---------------------------------------------------------------------------
|
|
4909
|
+
// Helpers — JWK <-> base64 string for key transport
|
|
4910
|
+
// ---------------------------------------------------------------------------
|
|
4911
|
+
async function exportJwkBase64(key) {
|
|
4912
|
+
const jwk = await crypto.subtle.exportKey("jwk", key);
|
|
4913
|
+
return btoa(JSON.stringify(jwk));
|
|
4914
|
+
}
|
|
4915
|
+
async function importJwkBase64(b64, type) {
|
|
4916
|
+
const jwk = JSON.parse(atob(b64));
|
|
4917
|
+
const algorithm = type === "ECDH"
|
|
4918
|
+
? { name: "ECDH", namedCurve: "P-384" }
|
|
4919
|
+
: { name: "ECDSA", namedCurve: "P-384" };
|
|
4920
|
+
const usages = type === "ECDH" ? [] : ["verify"];
|
|
4921
|
+
return await crypto.subtle.importKey("jwk", jwk, algorithm, true, usages);
|
|
4922
|
+
}
|
|
4923
|
+
// ---------------------------------------------------------------------------
|
|
4924
|
+
// Type guards
|
|
4925
|
+
// ---------------------------------------------------------------------------
|
|
4926
|
+
function isKeyExchangeFrame(frame) {
|
|
4927
|
+
const kx = frame.keyExchange;
|
|
4928
|
+
if (!kx || typeof kx !== "object")
|
|
4929
|
+
return false;
|
|
4930
|
+
const k = kx;
|
|
4931
|
+
return (typeof k.userId === "string" &&
|
|
4932
|
+
typeof k.ecdhPublicKey === "string" &&
|
|
4933
|
+
typeof k.ecdsaPublicKey === "string");
|
|
4934
|
+
}
|
|
4935
|
+
function isCipherFrame(frame) {
|
|
4936
|
+
const cm = frame.cipherMessage;
|
|
4937
|
+
if (!cm || typeof cm !== "object")
|
|
4938
|
+
return false;
|
|
4939
|
+
const m = cm;
|
|
4940
|
+
return Boolean(m.header && m.ciphertext && m.nonce);
|
|
4484
4941
|
}
|
|
4485
4942
|
|
|
4486
4943
|
/**
|
|
4487
|
-
*
|
|
4488
|
-
*
|
|
4944
|
+
* BroadcastChannel — a turnkey end-to-end encrypted channel for multi-peer
|
|
4945
|
+
* "room"-style apps (e.g. a chat). Wires together:
|
|
4489
4946
|
*
|
|
4490
|
-
*
|
|
4491
|
-
*
|
|
4492
|
-
*
|
|
4493
|
-
* a. Generate a fresh AES-256-GCM key + 12-byte IV (`ChunkCipher`).
|
|
4494
|
-
* b. Encrypt the chunk → ciphertext (plaintext.length + 16 bytes tag).
|
|
4495
|
-
* c. Reed–Solomon encode the ciphertext into `dataShards + parityShards`
|
|
4496
|
-
* equal-length shards (`ReedSolomonCodec`).
|
|
4497
|
-
* d. PUT every shard to the content-addressed shard store, keyed by
|
|
4498
|
-
* SHA-256 of the shard bytes (`ShardClient`).
|
|
4499
|
-
* 3. Build a `FileManifest` listing every chunk's keys, IVs, sizes, and
|
|
4500
|
-
* the SHA-256 of every shard in order.
|
|
4947
|
+
* - `WSTransport` for socket lifecycle, reconnect, and offline frame queue
|
|
4948
|
+
* - `EncryptedSession` for per-peer Double Ratchet state, handshake
|
|
4949
|
+
* reciprocation, fan-out encryption, and recipient filtering
|
|
4501
4950
|
*
|
|
4502
|
-
*
|
|
4503
|
-
*
|
|
4504
|
-
* then responsible for delivering it to readers (e.g. by E2E-encrypting
|
|
4505
|
-
* it as a chat message). The chat application takes this path.
|
|
4506
|
-
* - {@link FileStorage.writeFile} additionally POSTs the manifest to a
|
|
4507
|
-
* gated multi-user space (`SharedSpaceClient`) so any participant of the
|
|
4508
|
-
* space can read the file by manifest id later.
|
|
4951
|
+
* The host app only deals with high-level events and method calls; it never
|
|
4952
|
+
* touches `new WebSocket(...)` or builds cipherMessage frames by hand.
|
|
4509
4953
|
*
|
|
4510
|
-
*
|
|
4511
|
-
*
|
|
4512
|
-
*
|
|
4954
|
+
* Wire protocol (kept compatible with the existing chat protocol so this
|
|
4955
|
+
* drops into the deployed Accelerator without server changes):
|
|
4956
|
+
*
|
|
4957
|
+
* outbound: `{ keyExchange: {...} }` — produced by us on `announce()`
|
|
4958
|
+
* `{ cipherMessage: {...} }` — produced by `send(text)`,
|
|
4959
|
+
* one frame per peer
|
|
4960
|
+
* arbitrary JSON — sent via `sendRaw(obj)`
|
|
4961
|
+
*
|
|
4962
|
+
* inbound: `{ keyExchange: {...} }` — routed into EncryptedSession;
|
|
4963
|
+
* emits `peer_handshake`
|
|
4964
|
+
* `{ cipherMessage: {...} }` — decrypted; emits `message`
|
|
4965
|
+
* anything else — passes through as `raw_frame`
|
|
4966
|
+
* (the app decides what to do)
|
|
4967
|
+
*
|
|
4968
|
+
* **Event scoping.** Unlike `Network`, BroadcastChannel uses a per-instance
|
|
4969
|
+
* `EventTarget`, so two channels (e.g. two rooms in the same app) don't
|
|
4970
|
+
* cross-contaminate each other's events.
|
|
4971
|
+
*
|
|
4972
|
+
* Note: this class shadows the `BroadcastChannel` Web API global (cross-tab
|
|
4973
|
+
* messaging) only when imported by name. They're unrelated.
|
|
4513
4974
|
*/
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4975
|
+
// ---------------------------------------------------------------------------
|
|
4976
|
+
// Event names
|
|
4977
|
+
// ---------------------------------------------------------------------------
|
|
4978
|
+
/**
|
|
4979
|
+
* Event names BroadcastChannel emits. Lifecycle events reuse the
|
|
4980
|
+
* `EventCoreEvents` string values (`connected`, `disconnected`, etc.) so
|
|
4981
|
+
* apps can use shared constants if they want. Channel-specific events are
|
|
4982
|
+
* prefixed `channel:` to avoid colliding with other event systems in the
|
|
4983
|
+
* same app.
|
|
4984
|
+
*/
|
|
4985
|
+
const BroadcastChannelEvents = {
|
|
4986
|
+
CONNECTED: EventCoreEvents.CONNECTED,
|
|
4987
|
+
DISCONNECTED: EventCoreEvents.DISCONNECTED,
|
|
4988
|
+
RECONNECTING: EventCoreEvents.RECONNECTING,
|
|
4989
|
+
ERROR: EventCoreEvents.ERROR,
|
|
4990
|
+
/** A peer's ratchet is now ready. `detail.peerId` is the peer's id. */
|
|
4991
|
+
PEER_HANDSHAKE: "channel:peer_handshake",
|
|
4992
|
+
/** Decrypted message. `detail` is `{ from, text, cipherMessage }`. */
|
|
4993
|
+
MESSAGE: "channel:message",
|
|
4994
|
+
/** Inbound frame BroadcastChannel didn't recognize. App handles it. */
|
|
4995
|
+
RAW_FRAME: "channel:raw_frame",
|
|
4996
|
+
};
|
|
4997
|
+
// ---------------------------------------------------------------------------
|
|
4998
|
+
// BroadcastChannel
|
|
4999
|
+
// ---------------------------------------------------------------------------
|
|
5000
|
+
class BroadcastChannel {
|
|
5001
|
+
transport;
|
|
5002
|
+
session;
|
|
5003
|
+
myId;
|
|
5004
|
+
autoAnnounce;
|
|
5005
|
+
announced = false;
|
|
5006
|
+
/** Per-instance EventTarget — events do NOT leak across BroadcastChannels. */
|
|
5007
|
+
events = new EventTarget();
|
|
4524
5008
|
constructor(opts) {
|
|
4525
|
-
if (!opts
|
|
4526
|
-
throw new Error("
|
|
4527
|
-
|
|
4528
|
-
this.
|
|
4529
|
-
this.
|
|
4530
|
-
this.
|
|
4531
|
-
this.
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
5009
|
+
if (!opts || !opts.myId) {
|
|
5010
|
+
throw new Error("BroadcastChannel: `myId` is required");
|
|
5011
|
+
}
|
|
5012
|
+
this.myId = opts.myId;
|
|
5013
|
+
this.autoAnnounce = opts.autoAnnounce ?? false;
|
|
5014
|
+
this.transport = new WSTransport(opts);
|
|
5015
|
+
this.session = new EncryptedSession({ myId: opts.myId });
|
|
5016
|
+
// Pass-through lifecycle events from the transport. `EventCore.on` is
|
|
5017
|
+
// static so it routes back through the shared singleton; we bridge it
|
|
5018
|
+
// into our instance-scoped event target.
|
|
5019
|
+
this.transport.on(EventCoreEvents.CONNECTED, () => {
|
|
5020
|
+
// Reset the announce flag on every (re)connect. CF Workers WebSockets
|
|
5021
|
+
// hit an idle timeout and force reconnects; without resetting, the
|
|
5022
|
+
// SDK would short-circuit announce() and any peer who joined the
|
|
5023
|
+
// room after our last announce would never see our pubkey.
|
|
5024
|
+
this.announced = false;
|
|
5025
|
+
this.emit(BroadcastChannelEvents.CONNECTED);
|
|
5026
|
+
if (this.autoAnnounce) {
|
|
5027
|
+
void this.announce();
|
|
5028
|
+
}
|
|
5029
|
+
});
|
|
5030
|
+
this.transport.on(EventCoreEvents.DISCONNECTED, () => {
|
|
5031
|
+
this.emit(BroadcastChannelEvents.DISCONNECTED);
|
|
5032
|
+
});
|
|
5033
|
+
this.transport.on(EventCoreEvents.RECONNECTING, (e) => {
|
|
5034
|
+
this.emit(BroadcastChannelEvents.RECONNECTING, e.detail);
|
|
5035
|
+
});
|
|
5036
|
+
this.transport.on(EventCoreEvents.ERROR, (e) => {
|
|
5037
|
+
this.emit(BroadcastChannelEvents.ERROR, e.detail);
|
|
5038
|
+
});
|
|
5039
|
+
// Route inbound raw frames through our framing/decryption logic.
|
|
5040
|
+
this.transport.on(EventCoreEvents.MESSAGE, (e) => {
|
|
5041
|
+
void this.handleInboundFrame(e.detail);
|
|
5042
|
+
});
|
|
5043
|
+
}
|
|
5044
|
+
// -- event API -------------------------------------------------------------
|
|
5045
|
+
/** Subscribe to a channel event. Handler receives a CustomEvent. */
|
|
5046
|
+
on(event, handler) {
|
|
5047
|
+
this.events.addEventListener(event, handler);
|
|
5048
|
+
}
|
|
5049
|
+
/** Unsubscribe. */
|
|
5050
|
+
off(event, handler) {
|
|
5051
|
+
this.events.removeEventListener(event, handler);
|
|
5052
|
+
}
|
|
5053
|
+
emit(event, detail) {
|
|
5054
|
+
this.events.dispatchEvent(new CustomEvent(event, { detail }));
|
|
5055
|
+
}
|
|
5056
|
+
// -- lifecycle -------------------------------------------------------------
|
|
5057
|
+
/** Generate our keys (idempotent) and open the WebSocket. */
|
|
5058
|
+
async connect() {
|
|
5059
|
+
await this.session.initialize();
|
|
5060
|
+
await this.transport.connect();
|
|
5061
|
+
}
|
|
5062
|
+
/** Close the WebSocket and stop reconnecting. */
|
|
5063
|
+
disconnect() {
|
|
5064
|
+
this.transport.disconnect();
|
|
4540
5065
|
}
|
|
4541
|
-
// -------------------------------------------------------------------------
|
|
4542
|
-
// Shard-only pipeline — the caller owns the manifest. Used by chat file
|
|
4543
|
-
// sharing, where the manifest rides as an E2E-encrypted chat message.
|
|
4544
|
-
// -------------------------------------------------------------------------
|
|
4545
5066
|
/**
|
|
4546
|
-
*
|
|
4547
|
-
*
|
|
4548
|
-
*
|
|
5067
|
+
* Send our `keyExchange` frame to the room. Idempotent — only sends on the
|
|
5068
|
+
* first call. Call this once your higher-level handshake (e.g. sending a
|
|
5069
|
+
* `{name}` identification frame) has completed.
|
|
4549
5070
|
*/
|
|
4550
|
-
async
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4554
|
-
const
|
|
4555
|
-
|
|
4556
|
-
// Slicing + encrypting + encoding + uploading is done per-chunk so
|
|
4557
|
-
// the peak memory footprint stays at ~chunkSize even for huge files.
|
|
4558
|
-
// Within a chunk, the shard uploads are parallelized up to
|
|
4559
|
-
// `this.concurrency`.
|
|
4560
|
-
for (let chunkIndex = 0; chunkIndex * this.chunkSize < Math.max(1, data.length); chunkIndex++) {
|
|
4561
|
-
const start = chunkIndex * this.chunkSize;
|
|
4562
|
-
const end = Math.min(start + this.chunkSize, data.length);
|
|
4563
|
-
const plaintext = data.subarray(start, end);
|
|
4564
|
-
chunks.push(await this.writeChunk(plaintext, chunkIndex));
|
|
4565
|
-
// Stop after one iteration if the file is empty — we still emit a
|
|
4566
|
-
// single zero-length chunk so the manifest has a stable shape.
|
|
4567
|
-
if (data.length === 0)
|
|
4568
|
-
break;
|
|
4569
|
-
}
|
|
4570
|
-
const manifest = {
|
|
4571
|
-
id: fileId,
|
|
4572
|
-
name: input.metadata.name,
|
|
4573
|
-
size: data.length,
|
|
4574
|
-
type: input.metadata.type,
|
|
4575
|
-
path: input.metadata.path,
|
|
4576
|
-
lastModified: input.metadata.lastModified ?? createdAt,
|
|
4577
|
-
createdAt,
|
|
4578
|
-
chunks,
|
|
4579
|
-
version: 1,
|
|
4580
|
-
};
|
|
4581
|
-
return { manifest, stat: manifestToStat(manifest) };
|
|
5071
|
+
async announce() {
|
|
5072
|
+
if (this.announced)
|
|
5073
|
+
return;
|
|
5074
|
+
this.announced = true;
|
|
5075
|
+
const frame = await this.session.getOwnKeyExchange();
|
|
5076
|
+
this.transport.send(JSON.stringify(frame));
|
|
4582
5077
|
}
|
|
4583
5078
|
/**
|
|
4584
|
-
*
|
|
4585
|
-
*
|
|
4586
|
-
*
|
|
4587
|
-
*
|
|
5079
|
+
* Encrypt `plaintext` to every peer we have a ratchet with, and send each
|
|
5080
|
+
* cipherMessage as its own WS frame. Returns the number of peers we sent to
|
|
5081
|
+
* (0 if no peers are handshaken yet — in which case the caller should
|
|
5082
|
+
* typically render the message locally for the sender and try again later).
|
|
4588
5083
|
*/
|
|
4589
|
-
async
|
|
4590
|
-
await this.
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
// `Uint8Array`; a streaming variant would reuse `readChunk` unchanged.
|
|
4594
|
-
const plaintextChunks = [];
|
|
4595
|
-
for (const chunkManifest of [...manifest.chunks].sort((a, b) => a.chunkIndex - b.chunkIndex)) {
|
|
4596
|
-
plaintextChunks.push(await this.readChunk(chunkManifest));
|
|
4597
|
-
}
|
|
4598
|
-
let total = 0;
|
|
4599
|
-
for (const c of plaintextChunks)
|
|
4600
|
-
total += c.length;
|
|
4601
|
-
const out = new Uint8Array(total);
|
|
4602
|
-
let offset = 0;
|
|
4603
|
-
for (const c of plaintextChunks) {
|
|
4604
|
-
out.set(c, offset);
|
|
4605
|
-
offset += c.length;
|
|
5084
|
+
async send(plaintext) {
|
|
5085
|
+
const frames = await this.session.encrypt(plaintext);
|
|
5086
|
+
for (const frame of frames) {
|
|
5087
|
+
this.transport.send(JSON.stringify(frame));
|
|
4606
5088
|
}
|
|
4607
|
-
return
|
|
4608
|
-
}
|
|
4609
|
-
// -------------------------------------------------------------------------
|
|
4610
|
-
// Space-backed convenience wrappers — encrypt + upload + write manifest.
|
|
4611
|
-
// Require a `SharedSpaceClient` to have been provided at construction.
|
|
4612
|
-
// -------------------------------------------------------------------------
|
|
4613
|
-
/** As {@link writeFileToShards}, plus POSTs the manifest to `input.spaceId`. */
|
|
4614
|
-
async writeFile(input) {
|
|
4615
|
-
const space = this.requireSpace("writeFile");
|
|
4616
|
-
const { manifest, stat } = await this.writeFileToShards(input);
|
|
4617
|
-
await space.writeFileManifest(input.spaceId, manifest);
|
|
4618
|
-
return stat;
|
|
4619
|
-
}
|
|
4620
|
-
/** As {@link readFileFromShards}, but fetches the manifest from a space first. */
|
|
4621
|
-
async readFile(spaceId, fileId) {
|
|
4622
|
-
const space = this.requireSpace("readFile");
|
|
4623
|
-
const manifest = await space.readFileManifest(spaceId, fileId);
|
|
4624
|
-
return await this.readFileFromShards(manifest);
|
|
5089
|
+
return frames.length;
|
|
4625
5090
|
}
|
|
4626
5091
|
/**
|
|
4627
|
-
*
|
|
4628
|
-
*
|
|
4629
|
-
*
|
|
5092
|
+
* Send an arbitrary JSON-serializable frame as-is. The app uses this for
|
|
5093
|
+
* any frame that isn't channel-managed (e.g. a chat's `{name}`, `{file}`,
|
|
5094
|
+
* or unencrypted `{message}`).
|
|
4630
5095
|
*/
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
return space.deleteFileManifest(spaceId, fileId);
|
|
4634
|
-
}
|
|
4635
|
-
/** Convenience pass-through. */
|
|
4636
|
-
async listFiles(spaceId) {
|
|
4637
|
-
const space = this.requireSpace("listFiles");
|
|
4638
|
-
return space.listFiles(spaceId);
|
|
5096
|
+
sendRaw(frame) {
|
|
5097
|
+
this.transport.send(JSON.stringify(frame));
|
|
4639
5098
|
}
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
return
|
|
5099
|
+
// -- introspection ---------------------------------------------------------
|
|
5100
|
+
/** List of peer ids we have an established ratchet for. */
|
|
5101
|
+
peers() {
|
|
5102
|
+
return this.session.peers();
|
|
4644
5103
|
}
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
requireSpace(operation) {
|
|
4649
|
-
if (!this.space) {
|
|
4650
|
-
throw new Error(`FileStorage.${operation}: a SharedSpaceClient is required for this operation ` +
|
|
4651
|
-
"(constructor was called without `space`)");
|
|
4652
|
-
}
|
|
4653
|
-
return this.space;
|
|
5104
|
+
/** Drop a peer's ratchet (e.g. when they leave the room). */
|
|
5105
|
+
forgetPeer(peerId) {
|
|
5106
|
+
this.session.forgetPeer(peerId);
|
|
4654
5107
|
}
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
const keyMaterial = this.cipher.generateKeyMaterial();
|
|
4658
|
-
const ciphertext = plaintext.length === 0
|
|
4659
|
-
? new Uint8Array(0)
|
|
4660
|
-
: await this.cipher.encryptChunk(plaintext, keyMaterial);
|
|
4661
|
-
// An empty chunk encodes to nothing — emit a manifest entry with no
|
|
4662
|
-
// shards so the reader can re-emit an empty plaintext and skip the
|
|
4663
|
-
// shard round-trip.
|
|
4664
|
-
if (ciphertext.length === 0) {
|
|
4665
|
-
return {
|
|
4666
|
-
id: generateId(),
|
|
4667
|
-
chunkIndex,
|
|
4668
|
-
originalSize: 0,
|
|
4669
|
-
ciphertextSize: 0,
|
|
4670
|
-
dataShards: this.dataShards,
|
|
4671
|
-
parityShards: this.parityShards,
|
|
4672
|
-
shardSize: 0,
|
|
4673
|
-
shardHashes: [],
|
|
4674
|
-
cipher: keyMaterial,
|
|
4675
|
-
};
|
|
4676
|
-
}
|
|
4677
|
-
const encoded = this.codec.encode(ciphertext, this.dataShards, this.parityShards);
|
|
4678
|
-
const hashes = await Promise.all(encoded.shards.map((s) => shardHash(s)));
|
|
4679
|
-
await this.runWithConcurrency(encoded.shards.map((bytes, i) => async () => {
|
|
4680
|
-
await this.shards.putShard(hashes[i], bytes);
|
|
4681
|
-
}));
|
|
4682
|
-
return {
|
|
4683
|
-
id: generateId(),
|
|
4684
|
-
chunkIndex,
|
|
4685
|
-
originalSize: plaintext.length,
|
|
4686
|
-
ciphertextSize: ciphertext.length,
|
|
4687
|
-
dataShards: encoded.dataShards,
|
|
4688
|
-
parityShards: encoded.parityShards,
|
|
4689
|
-
shardSize: encoded.shardSize,
|
|
4690
|
-
shardHashes: hashes,
|
|
4691
|
-
cipher: keyMaterial,
|
|
4692
|
-
};
|
|
5108
|
+
isConnected() {
|
|
5109
|
+
return this.transport.isConnected();
|
|
4693
5110
|
}
|
|
4694
|
-
|
|
4695
|
-
async
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
5111
|
+
// -- internals -------------------------------------------------------------
|
|
5112
|
+
async handleInboundFrame(raw) {
|
|
5113
|
+
let parsed;
|
|
5114
|
+
try {
|
|
5115
|
+
parsed = JSON.parse(raw);
|
|
4699
5116
|
}
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
const fetched = new Array(manifest.shardHashes.length);
|
|
4704
|
-
await this.runWithConcurrency(manifest.shardHashes.map((hash, i) => async () => {
|
|
4705
|
-
fetched[i] = await this.shards.getShard(hash);
|
|
4706
|
-
}));
|
|
4707
|
-
const deadIndexes = [];
|
|
4708
|
-
const shards = fetched.map((bytes, i) => {
|
|
4709
|
-
if (bytes === null) {
|
|
4710
|
-
deadIndexes.push(i);
|
|
4711
|
-
return new Uint8Array(manifest.shardSize);
|
|
4712
|
-
}
|
|
4713
|
-
if (bytes.length !== manifest.shardSize) {
|
|
4714
|
-
throw new Error(`FileStorage: shard ${manifest.shardHashes[i]} has wrong size ` +
|
|
4715
|
-
`(got ${bytes.length}, expected ${manifest.shardSize})`);
|
|
4716
|
-
}
|
|
4717
|
-
return bytes;
|
|
4718
|
-
});
|
|
4719
|
-
if (deadIndexes.length > manifest.parityShards) {
|
|
4720
|
-
throw new Error(`FileStorage: chunk ${manifest.id} unrecoverable — ${deadIndexes.length} shards missing, ` +
|
|
4721
|
-
`only ${manifest.parityShards} tolerated`);
|
|
5117
|
+
catch (err) {
|
|
5118
|
+
this.emit(BroadcastChannelEvents.ERROR, err);
|
|
5119
|
+
return;
|
|
4722
5120
|
}
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
5121
|
+
if (!parsed || typeof parsed !== "object") {
|
|
5122
|
+
this.emit(BroadcastChannelEvents.RAW_FRAME, parsed);
|
|
5123
|
+
return;
|
|
5124
|
+
}
|
|
5125
|
+
const obj = parsed;
|
|
5126
|
+
// Channel-managed: keyExchange & cipherMessage. Anything else is RAW_FRAME.
|
|
5127
|
+
if ("keyExchange" in obj || "cipherMessage" in obj) {
|
|
5128
|
+
try {
|
|
5129
|
+
const result = await this.session.receive(obj);
|
|
5130
|
+
if (result.kind === "handshake") {
|
|
5131
|
+
if (result.outbound) {
|
|
5132
|
+
this.transport.send(JSON.stringify(result.outbound));
|
|
5133
|
+
}
|
|
5134
|
+
this.emit(BroadcastChannelEvents.PEER_HANDSHAKE, { peerId: result.peerId });
|
|
5135
|
+
}
|
|
5136
|
+
else if (result.kind === "plaintext") {
|
|
5137
|
+
this.emit(BroadcastChannelEvents.MESSAGE, {
|
|
5138
|
+
from: result.from,
|
|
5139
|
+
text: result.text,
|
|
5140
|
+
cipherMessage: result.cipherMessage,
|
|
5141
|
+
});
|
|
5142
|
+
}
|
|
5143
|
+
// `ignored` is intentionally swallowed; subscribe to ERROR for bad frames.
|
|
4734
5144
|
}
|
|
5145
|
+
catch (err) {
|
|
5146
|
+
this.emit(BroadcastChannelEvents.ERROR, err);
|
|
5147
|
+
}
|
|
5148
|
+
return;
|
|
4735
5149
|
}
|
|
4736
|
-
|
|
4737
|
-
.fill(null)
|
|
4738
|
-
.map(() => nextWorker());
|
|
4739
|
-
await Promise.all(workers);
|
|
5150
|
+
this.emit(BroadcastChannelEvents.RAW_FRAME, obj);
|
|
4740
5151
|
}
|
|
4741
5152
|
}
|
|
4742
|
-
/** Normalize `data` to a flat `Uint8Array`. */
|
|
4743
|
-
async function toUint8Array(data) {
|
|
4744
|
-
if (data instanceof Uint8Array)
|
|
4745
|
-
return data;
|
|
4746
|
-
// Blob covers File too (File extends Blob in every runtime we target).
|
|
4747
|
-
const ab = await data.arrayBuffer();
|
|
4748
|
-
return new Uint8Array(ab);
|
|
4749
|
-
}
|
|
4750
5153
|
|
|
4751
5154
|
/**
|
|
4752
5155
|
* SpaceCipher — pure crypto for the fan-out group-encryption layer.
|
|
@@ -4791,6 +5194,46 @@ async function importEcdhPublicKey(encoded) {
|
|
|
4791
5194
|
}
|
|
4792
5195
|
/** The space's id is just its encoded ECDH public key. */
|
|
4793
5196
|
const encodeSpaceId = exportEcdhPublicKey;
|
|
5197
|
+
// ---------------------------------------------------------------------------
|
|
5198
|
+
// Sender authentication (ECDSA P-384, matching the Double Ratchet path).
|
|
5199
|
+
// A fan-out message is signed by its sender's identity ECDSA key so receivers
|
|
5200
|
+
// can verify authorship end-to-end — independent of the relay, which only
|
|
5201
|
+
// stamps `source` server-side. SHA-256 hash to match `DoubleRatchet`.
|
|
5202
|
+
// ---------------------------------------------------------------------------
|
|
5203
|
+
const ECDSA_PARAMS = { name: "ECDSA", namedCurve: "P-384" };
|
|
5204
|
+
const ECDSA_SIGN = { name: "ECDSA", hash: "SHA-256" };
|
|
5205
|
+
/** Encode an ECDSA public `CryptoKey` as a base64url JWK string. */
|
|
5206
|
+
async function exportEcdsaPublicKey(key) {
|
|
5207
|
+
const jwk = await getSubtle().exportKey("jwk", key);
|
|
5208
|
+
return toBase64Url(utf8Encode(JSON.stringify(jwk)));
|
|
5209
|
+
}
|
|
5210
|
+
/** Decode a base64url JWK string back into an ECDSA verify-only public key. */
|
|
5211
|
+
async function importEcdsaPublicKey(encoded) {
|
|
5212
|
+
const jwk = JSON.parse(utf8Decode(fromBase64Url(encoded)));
|
|
5213
|
+
return getSubtle().importKey("jwk", jwk, ECDSA_PARAMS, true, ["verify"]);
|
|
5214
|
+
}
|
|
5215
|
+
/**
|
|
5216
|
+
* Canonical bytes a sender signs (and a receiver re-derives). Binds the claimed
|
|
5217
|
+
* sender + routing + the sealed ciphertext, so a relay can't relabel `source`
|
|
5218
|
+
* and a member can't reuse another's ciphertext under a new envelope.
|
|
5219
|
+
*/
|
|
5220
|
+
function canonicalMessage(fields) {
|
|
5221
|
+
return [fields.source, fields.target, fields.subject, fields.epoch, fields.iv, fields.ciphertext].join("\n");
|
|
5222
|
+
}
|
|
5223
|
+
/** Sign the canonical bytes with the sender's ECDSA identity private key. */
|
|
5224
|
+
async function signSpaceMessage(canonical, ecdsaPriv) {
|
|
5225
|
+
const sig = await getSubtle().sign(ECDSA_SIGN, ecdsaPriv, utf8Encode(canonical));
|
|
5226
|
+
return toBase64(new Uint8Array(sig));
|
|
5227
|
+
}
|
|
5228
|
+
/** Verify a signature over the canonical bytes against the sender's ECDSA key. */
|
|
5229
|
+
async function verifySpaceMessage(canonical, sigB64, ecdsaPub) {
|
|
5230
|
+
try {
|
|
5231
|
+
return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64(sigB64), utf8Encode(canonical));
|
|
5232
|
+
}
|
|
5233
|
+
catch {
|
|
5234
|
+
return false;
|
|
5235
|
+
}
|
|
5236
|
+
}
|
|
4794
5237
|
/** Generate a fresh space identity keypair (P-384 ECDH). */
|
|
4795
5238
|
async function generateSpaceIdentity() {
|
|
4796
5239
|
const kp = await getSubtle().generateKey(ECDH_PARAMS, true, ["deriveBits"]);
|
|
@@ -5016,16 +5459,7 @@ class Space {
|
|
|
5016
5459
|
* persisted `spaceMessage`. Requires a keyring.
|
|
5017
5460
|
*/
|
|
5018
5461
|
async sendMessage(payload, opts = {}) {
|
|
5019
|
-
|
|
5020
|
-
const message = new Message(payload);
|
|
5021
|
-
const headers = await cipher.seal(message.serialize());
|
|
5022
|
-
const packet = new Packet({
|
|
5023
|
-
subject: opts.channel ?? "default",
|
|
5024
|
-
source: this.deps.myId(),
|
|
5025
|
-
target: this.name,
|
|
5026
|
-
headers,
|
|
5027
|
-
});
|
|
5028
|
-
this.sendRaw({ spaceMessage: packet.serialize() });
|
|
5462
|
+
this.sendRaw({ spaceMessage: await this.sealMessage(payload, opts.channel, opts.contentType) });
|
|
5029
5463
|
}
|
|
5030
5464
|
/** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
|
|
5031
5465
|
onMessage(handler) {
|
|
@@ -5033,6 +5467,56 @@ class Space {
|
|
|
5033
5467
|
this.events.addEventListener("message", listener);
|
|
5034
5468
|
return () => this.events.removeEventListener("message", listener);
|
|
5035
5469
|
}
|
|
5470
|
+
/**
|
|
5471
|
+
* Edit a persisted message IN PLACE, addressed by its server
|
|
5472
|
+
* {@link SpaceMessageEvent.handle}. Re-seals `payload` and asks the server
|
|
5473
|
+
* to replace the stored entry; the server authorizes against the original
|
|
5474
|
+
* author. Receivers get an {@link onMessageEdited} event carrying the new
|
|
5475
|
+
* content at the same handle. This is a generic persisted-item edit — the
|
|
5476
|
+
* server has no notion of what the payload means.
|
|
5477
|
+
*/
|
|
5478
|
+
async editMessage(handle, payload, opts = {}) {
|
|
5479
|
+
const spaceMessage = await this.sealMessage(payload, opts.channel, opts.contentType);
|
|
5480
|
+
this.sendRaw({ editSpaceMessage: { ts: handle, spaceMessage } });
|
|
5481
|
+
}
|
|
5482
|
+
/**
|
|
5483
|
+
* Hard-delete a persisted message by its server {@link SpaceMessageEvent.handle}
|
|
5484
|
+
* — the ciphertext is removed from storage. Server authorizes against the
|
|
5485
|
+
* original author. Receivers get an {@link onMessageDeleted} event.
|
|
5486
|
+
*/
|
|
5487
|
+
async deleteMessage(handle) {
|
|
5488
|
+
this.sendRaw({ deleteSpaceMessage: { ts: handle } });
|
|
5489
|
+
}
|
|
5490
|
+
/** Subscribe to in-place edits of persisted messages (new content at the same handle). */
|
|
5491
|
+
onMessageEdited(handler) {
|
|
5492
|
+
const listener = (e) => handler(e.detail);
|
|
5493
|
+
this.events.addEventListener("message-edited", listener);
|
|
5494
|
+
return () => this.events.removeEventListener("message-edited", listener);
|
|
5495
|
+
}
|
|
5496
|
+
/** Subscribe to deletions of persisted messages. */
|
|
5497
|
+
onMessageDeleted(handler) {
|
|
5498
|
+
const listener = (e) => handler(e.detail);
|
|
5499
|
+
this.events.addEventListener("message-deleted", listener);
|
|
5500
|
+
return () => this.events.removeEventListener("message-deleted", listener);
|
|
5501
|
+
}
|
|
5502
|
+
/**
|
|
5503
|
+
* Broadcast an ephemeral, never-persisted signal to the room over the
|
|
5504
|
+
* generic `pub` relay. `subject` is an app-defined routing key; `data` is
|
|
5505
|
+
* opaque. The transport stays domain-agnostic — app concepts like typing
|
|
5506
|
+
* indicators, presence, or live cursors are built on top of this. No-op if
|
|
5507
|
+
* the space isn't connected (ephemeral signals aren't worth queuing).
|
|
5508
|
+
*/
|
|
5509
|
+
sendEphemeral(subject, data) {
|
|
5510
|
+
if (!this.isConnected())
|
|
5511
|
+
return;
|
|
5512
|
+
this.sendRaw({ pub: { subject, data } });
|
|
5513
|
+
}
|
|
5514
|
+
/** Subscribe to inbound ephemeral signals. `from` is the authenticated sender. */
|
|
5515
|
+
onEphemeral(handler) {
|
|
5516
|
+
const listener = (e) => handler(e.detail);
|
|
5517
|
+
this.events.addEventListener("ephemeral", listener);
|
|
5518
|
+
return () => this.events.removeEventListener("ephemeral", listener);
|
|
5519
|
+
}
|
|
5036
5520
|
/** Subscribe to inbound join requests (for a key-holder to admit). */
|
|
5037
5521
|
onJoinRequest(handler) {
|
|
5038
5522
|
const listener = (e) => handler(e.detail);
|
|
@@ -5056,6 +5540,9 @@ class Space {
|
|
|
5056
5540
|
if (!res.ok)
|
|
5057
5541
|
throw new Error(`Space.history: ${res.status} ${res.statusText}`);
|
|
5058
5542
|
const body = (await res.json());
|
|
5543
|
+
// Warm the member directory once so historical senders' signatures verify.
|
|
5544
|
+
// (refreshDirectory swallows its own transport errors.)
|
|
5545
|
+
await this.deps.keyring?.refreshDirectory();
|
|
5059
5546
|
const messages = [];
|
|
5060
5547
|
for (const raw of body.messages ?? []) {
|
|
5061
5548
|
const decoded = await this.decodeFrame(raw, cipher);
|
|
@@ -5091,6 +5578,9 @@ class Space {
|
|
|
5091
5578
|
const keyring = this.requireKeyring();
|
|
5092
5579
|
await keyring.bootstrapNew();
|
|
5093
5580
|
await this.connect();
|
|
5581
|
+
// Publish our identity in the member directory so peers can verify our
|
|
5582
|
+
// message signatures (the creator never "joins", so register here).
|
|
5583
|
+
await keyring.requestKey().catch(() => { });
|
|
5094
5584
|
// Admit anyone who requested a key before we were listening.
|
|
5095
5585
|
if (this.deps.autoAdmit !== false) {
|
|
5096
5586
|
await keyring.admitPending().catch(() => { });
|
|
@@ -5100,6 +5590,10 @@ class Space {
|
|
|
5100
5590
|
async admit(memberId, identityEcdhPub) {
|
|
5101
5591
|
await this.requireKeyring().admit(memberId, identityEcdhPub);
|
|
5102
5592
|
}
|
|
5593
|
+
/** Invite a user to this (private) channel's membership allowlist. */
|
|
5594
|
+
async invite(username) {
|
|
5595
|
+
await this.requireKeyring().invite(username);
|
|
5596
|
+
}
|
|
5103
5597
|
/** Rotate to a fresh epoch and re-wrap to the roster (rotate spaces). */
|
|
5104
5598
|
async rotate(roster) {
|
|
5105
5599
|
return this.requireKeyring().rotate(roster);
|
|
@@ -5112,9 +5606,13 @@ class Space {
|
|
|
5112
5606
|
return this.fileStorage().readFileFromShards(manifest);
|
|
5113
5607
|
}
|
|
5114
5608
|
fileStorage() {
|
|
5609
|
+
// Global, content-addressed shard store — same store `client.storage`
|
|
5610
|
+
// uses, so a file shared into a channel resolves from anywhere by
|
|
5611
|
+
// manifest. (Shards are encrypted ciphertext; the manifest is the
|
|
5612
|
+
// capability.)
|
|
5115
5613
|
const shards = new ShardClient({
|
|
5116
5614
|
baseUrl: this.deps.httpBaseUrl,
|
|
5117
|
-
pathPrefix:
|
|
5615
|
+
pathPrefix: "/api/shards",
|
|
5118
5616
|
fetch: this.deps.fetch,
|
|
5119
5617
|
});
|
|
5120
5618
|
return new FileStorage({ shards });
|
|
@@ -5134,15 +5632,45 @@ class Space {
|
|
|
5134
5632
|
if (!f || typeof f !== "object")
|
|
5135
5633
|
return;
|
|
5136
5634
|
if (typeof f.spaceMessage === "string" && this.cipher) {
|
|
5137
|
-
const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true);
|
|
5635
|
+
const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, Number(f.timestamp ?? 0));
|
|
5138
5636
|
if (decoded)
|
|
5139
5637
|
this.emitEvent("message", decoded);
|
|
5140
5638
|
return;
|
|
5141
5639
|
}
|
|
5640
|
+
if (f.editSpaceMessage && typeof f.editSpaceMessage === "object" && this.cipher) {
|
|
5641
|
+
// Server-authoritative in-place edit: new sealed content at the same
|
|
5642
|
+
// handle. Decode and surface it; the app replaces by `handle`.
|
|
5643
|
+
const e = f.editSpaceMessage;
|
|
5644
|
+
if (typeof e.spaceMessage === "string") {
|
|
5645
|
+
const decoded = await this.decodeFrame(e.spaceMessage, this.cipher, /*raw*/ true, Number(e.ts ?? 0));
|
|
5646
|
+
if (decoded)
|
|
5647
|
+
this.emitEvent("message-edited", decoded);
|
|
5648
|
+
}
|
|
5649
|
+
return;
|
|
5650
|
+
}
|
|
5651
|
+
if (f.deleteSpaceMessage && typeof f.deleteSpaceMessage === "object") {
|
|
5652
|
+
const d = f.deleteSpaceMessage;
|
|
5653
|
+
this.emitEvent("message-deleted", { handle: Number(d.ts ?? 0) });
|
|
5654
|
+
return;
|
|
5655
|
+
}
|
|
5656
|
+
if (f.pub && typeof f.pub === "object") {
|
|
5657
|
+
// Generic ephemeral relay frame. The server stamps the authenticated
|
|
5658
|
+
// sender as `name`; `pub` carries the app's {subject, data}. We
|
|
5659
|
+
// surface the authenticated `name` as `from` (not any client-set
|
|
5660
|
+
// value inside `pub`), so app signals can't spoof their sender.
|
|
5661
|
+
const p = f.pub;
|
|
5662
|
+
const from = typeof f.name === "string" ? f.name : "";
|
|
5663
|
+
if (from && typeof p.subject === "string") {
|
|
5664
|
+
this.emitEvent("ephemeral", { from, subject: p.subject, data: p.data });
|
|
5665
|
+
}
|
|
5666
|
+
return;
|
|
5667
|
+
}
|
|
5142
5668
|
if (f.joinRequest && typeof f.joinRequest === "object") {
|
|
5143
5669
|
const req = f.joinRequest;
|
|
5144
5670
|
this.emitEvent("join-request", req);
|
|
5145
|
-
//
|
|
5671
|
+
// A new member appeared — refresh the directory so their future
|
|
5672
|
+
// messages verify, and (if we hold the key) auto-admit them.
|
|
5673
|
+
void this.deps.keyring?.refreshDirectory();
|
|
5146
5674
|
if (this.deps.autoAdmit !== false && this.deps.keyring?.hasAnyKey() && req.memberId !== this.deps.myId()) {
|
|
5147
5675
|
void this.deps.keyring.admit(req.memberId, req.identityEcdhPub).catch(() => { });
|
|
5148
5676
|
}
|
|
@@ -5153,23 +5681,57 @@ class Space {
|
|
|
5153
5681
|
void this.deps.keyring.pullKeys().catch(() => { });
|
|
5154
5682
|
}
|
|
5155
5683
|
}
|
|
5684
|
+
/**
|
|
5685
|
+
* Seal `payload` once with the current group key + sign it, returning the
|
|
5686
|
+
* serialized `Packet` string. Shared by send (a new message) and edit
|
|
5687
|
+
* (replacement content for an existing handle). Requires a keyring.
|
|
5688
|
+
*/
|
|
5689
|
+
async sealMessage(payload, channel, contentType) {
|
|
5690
|
+
const cipher = this.requireCipher(contentType);
|
|
5691
|
+
const message = new Message(payload);
|
|
5692
|
+
const headers = await cipher.seal(message.serialize());
|
|
5693
|
+
const source = this.deps.myId();
|
|
5694
|
+
const subject = channel ?? "default";
|
|
5695
|
+
// Sign with our identity ECDSA key so receivers can verify authorship
|
|
5696
|
+
// end-to-end (independent of the relay's `source` stamp).
|
|
5697
|
+
const authPriv = KeyStore.getInstance().getAuthKeyPair(source)?.privateKey;
|
|
5698
|
+
if (authPriv) {
|
|
5699
|
+
const canonical = canonicalMessage({
|
|
5700
|
+
source, target: this.name, subject,
|
|
5701
|
+
epoch: Number(headers.epoch), iv: String(headers.iv), ciphertext: String(headers.ciphertext),
|
|
5702
|
+
});
|
|
5703
|
+
headers.sig = await signSpaceMessage(canonical, authPriv);
|
|
5704
|
+
}
|
|
5705
|
+
return new Packet({ subject, source, target: this.name, headers }).serialize();
|
|
5706
|
+
}
|
|
5156
5707
|
/**
|
|
5157
5708
|
* Decode one persisted/broadcast frame into a SpaceMessageEvent.
|
|
5158
5709
|
* `isRawFrame` true means `input` is the already-extracted `spaceMessage`
|
|
5159
|
-
* packet string
|
|
5710
|
+
* packet string (and `serverTs` carries the message's storage handle from
|
|
5711
|
+
* the enclosing frame); false means `input` is the full
|
|
5712
|
+
* `{spaceMessage, name, timestamp}` frame and the handle is read from it.
|
|
5160
5713
|
*/
|
|
5161
|
-
async decodeFrame(input, cipher, isRawFrame = false) {
|
|
5714
|
+
async decodeFrame(input, cipher, isRawFrame = false, serverTs = 0) {
|
|
5162
5715
|
try {
|
|
5163
5716
|
let packetJson = input;
|
|
5717
|
+
let handle = serverTs;
|
|
5164
5718
|
if (!isRawFrame) {
|
|
5165
5719
|
const outer = JSON.parse(input);
|
|
5166
5720
|
if (typeof outer.spaceMessage !== "string")
|
|
5167
5721
|
return null;
|
|
5168
5722
|
packetJson = outer.spaceMessage;
|
|
5723
|
+
handle = Number(outer.timestamp ?? 0);
|
|
5169
5724
|
}
|
|
5170
5725
|
const packet = Packet.deserialize(packetJson);
|
|
5171
5726
|
if (!cipher.handles(packet.headers))
|
|
5172
5727
|
return null;
|
|
5728
|
+
// Verify sender authenticity end-to-end: the message must be signed
|
|
5729
|
+
// by `source`'s identity ECDSA key (looked up in the member
|
|
5730
|
+
// directory). Drop anything unsigned, signed by an unknown member,
|
|
5731
|
+
// or whose signature doesn't match — this defeats both member
|
|
5732
|
+
// impersonation and a relay rewriting `source`.
|
|
5733
|
+
if (!(await this.verifySender(packet)))
|
|
5734
|
+
return null;
|
|
5173
5735
|
const serialized = await cipher.open(packet.headers);
|
|
5174
5736
|
if (serialized === null)
|
|
5175
5737
|
return null; // epoch key we don't hold
|
|
@@ -5179,6 +5741,7 @@ class Space {
|
|
|
5179
5741
|
channel: packet.subject,
|
|
5180
5742
|
epoch: Number(packet.headers?.epoch ?? 0),
|
|
5181
5743
|
contentType: packet.headers?.contentType,
|
|
5744
|
+
handle,
|
|
5182
5745
|
message,
|
|
5183
5746
|
};
|
|
5184
5747
|
}
|
|
@@ -5187,6 +5750,34 @@ class Space {
|
|
|
5187
5750
|
return null;
|
|
5188
5751
|
}
|
|
5189
5752
|
}
|
|
5753
|
+
/**
|
|
5754
|
+
* True if `packet` carries a valid signature by `source`'s identity key.
|
|
5755
|
+
* On a directory cache miss, refresh once (a new member may have just
|
|
5756
|
+
* joined) before giving up. Without a keyring (legacy handle) there's
|
|
5757
|
+
* nothing to verify against → reject.
|
|
5758
|
+
*/
|
|
5759
|
+
async verifySender(packet) {
|
|
5760
|
+
const keyring = this.deps.keyring;
|
|
5761
|
+
const sig = packet.headers?.sig;
|
|
5762
|
+
if (!keyring || typeof sig !== "string")
|
|
5763
|
+
return false;
|
|
5764
|
+
let key = keyring.ecdsaKeyFor(packet.source);
|
|
5765
|
+
if (!key) {
|
|
5766
|
+
await keyring.refreshDirectory();
|
|
5767
|
+
key = keyring.ecdsaKeyFor(packet.source);
|
|
5768
|
+
if (!key)
|
|
5769
|
+
return false;
|
|
5770
|
+
}
|
|
5771
|
+
const canonical = canonicalMessage({
|
|
5772
|
+
source: packet.source,
|
|
5773
|
+
target: packet.target,
|
|
5774
|
+
subject: packet.subject,
|
|
5775
|
+
epoch: Number(packet.headers?.epoch ?? 0),
|
|
5776
|
+
iv: String(packet.headers?.iv ?? ""),
|
|
5777
|
+
ciphertext: String(packet.headers?.ciphertext ?? ""),
|
|
5778
|
+
});
|
|
5779
|
+
return verifySpaceMessage(canonical, sig, key);
|
|
5780
|
+
}
|
|
5190
5781
|
emitEvent(type, detail) {
|
|
5191
5782
|
this.events.dispatchEvent(new CustomEvent(type, { detail }));
|
|
5192
5783
|
}
|
|
@@ -5416,10 +6007,37 @@ class SpaceKeyring {
|
|
|
5416
6007
|
keys = new Map();
|
|
5417
6008
|
epoch = 0;
|
|
5418
6009
|
policy;
|
|
6010
|
+
/** Cached member directory: memberId → imported ECDSA verify key. */
|
|
6011
|
+
memberEcdsa = new Map();
|
|
5419
6012
|
constructor(deps) {
|
|
5420
6013
|
this.deps = deps;
|
|
5421
6014
|
this.policy = deps.historyPolicy ?? "static";
|
|
5422
6015
|
}
|
|
6016
|
+
// -- member directory (for verifying sender signatures) -------------------
|
|
6017
|
+
/** The cached ECDSA verify key for a member, if known. */
|
|
6018
|
+
ecdsaKeyFor(memberId) {
|
|
6019
|
+
return this.memberEcdsa.get(memberId);
|
|
6020
|
+
}
|
|
6021
|
+
/** Refresh the member directory (memberId → ECDSA key) from the roster. */
|
|
6022
|
+
async refreshDirectory() {
|
|
6023
|
+
let roster;
|
|
6024
|
+
try {
|
|
6025
|
+
roster = await this.deps.transport.fetchRoster();
|
|
6026
|
+
}
|
|
6027
|
+
catch {
|
|
6028
|
+
return;
|
|
6029
|
+
}
|
|
6030
|
+
for (const m of roster) {
|
|
6031
|
+
if (!m.identityEcdsaPub || this.memberEcdsa.has(m.memberId))
|
|
6032
|
+
continue;
|
|
6033
|
+
try {
|
|
6034
|
+
this.memberEcdsa.set(m.memberId, await importEcdsaPublicKey(m.identityEcdsaPub));
|
|
6035
|
+
}
|
|
6036
|
+
catch {
|
|
6037
|
+
/* skip malformed entries */
|
|
6038
|
+
}
|
|
6039
|
+
}
|
|
6040
|
+
}
|
|
5423
6041
|
// -- EpochKeyProvider ------------------------------------------------------
|
|
5424
6042
|
currentEpoch() {
|
|
5425
6043
|
return this.epoch;
|
|
@@ -5473,11 +6091,16 @@ class SpaceKeyring {
|
|
|
5473
6091
|
await this.persist();
|
|
5474
6092
|
}
|
|
5475
6093
|
// -- newcomer: request + pull ---------------------------------------------
|
|
5476
|
-
/**
|
|
6094
|
+
/**
|
|
6095
|
+
* Post a join request so an online key-holder can wrap a key for us. Also
|
|
6096
|
+
* publishes our identity keys in the member directory (so others can verify
|
|
6097
|
+
* our message signatures, and the keeper can wrap to us).
|
|
6098
|
+
*/
|
|
5477
6099
|
async requestKey(desiredEpoch) {
|
|
5478
6100
|
await this.deps.transport.postJoinRequest({
|
|
5479
6101
|
memberId: this.deps.memberId,
|
|
5480
6102
|
identityEcdhPub: this.deps.identityEcdhPub,
|
|
6103
|
+
identityEcdsaPub: this.deps.identityEcdsaPub,
|
|
5481
6104
|
desiredEpoch,
|
|
5482
6105
|
});
|
|
5483
6106
|
}
|
|
@@ -5563,6 +6186,10 @@ class SpaceKeyring {
|
|
|
5563
6186
|
await this.persist();
|
|
5564
6187
|
return nextEpoch;
|
|
5565
6188
|
}
|
|
6189
|
+
/** Add `username` to a private channel's membership allowlist (member-only). */
|
|
6190
|
+
async invite(username) {
|
|
6191
|
+
await this.deps.transport.invite(username);
|
|
6192
|
+
}
|
|
5566
6193
|
}
|
|
5567
6194
|
|
|
5568
6195
|
/**
|
|
@@ -5623,6 +6250,9 @@ class KeyringClient {
|
|
|
5623
6250
|
async rotate(nextEpoch) {
|
|
5624
6251
|
return this.post("/keyring/rotate", { nextEpoch });
|
|
5625
6252
|
}
|
|
6253
|
+
async invite(username) {
|
|
6254
|
+
await this.post("/keyring/invite", { username });
|
|
6255
|
+
}
|
|
5626
6256
|
async fetchMetadata() {
|
|
5627
6257
|
try {
|
|
5628
6258
|
return await this.get("/metadata");
|
|
@@ -5679,7 +6309,9 @@ class SpaceNamespace {
|
|
|
5679
6309
|
const historyPolicy = opts.historyPolicy ?? "static";
|
|
5680
6310
|
const identity = await generateSpaceIdentity();
|
|
5681
6311
|
// Register the space (server records pubkey + policy; the DO is lazy).
|
|
5682
|
-
|
|
6312
|
+
// For a private space the server also records the creator (this user) as
|
|
6313
|
+
// its first member — so the metadata POST must carry the user session.
|
|
6314
|
+
await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy, visibility: opts.private ? "private" : "public" });
|
|
5683
6315
|
const space = await this.build(identity.id, historyPolicy);
|
|
5684
6316
|
await space.create();
|
|
5685
6317
|
return space;
|
|
@@ -5727,7 +6359,7 @@ class SpaceNamespace {
|
|
|
5727
6359
|
const space = await this.createSpace(opts);
|
|
5728
6360
|
// Register name → id first so a name clash doesn't leave an admitted keeper.
|
|
5729
6361
|
try {
|
|
5730
|
-
await this.deps.http.post("/api/app/channels", { name, spaceId: space.id });
|
|
6362
|
+
await this.deps.http.post("/api/app/channels", { name, spaceId: space.id, visibility: opts.private ? "private" : "public" });
|
|
5731
6363
|
}
|
|
5732
6364
|
catch (err) {
|
|
5733
6365
|
if (err instanceof HttpError && err.status === 409) {
|
|
@@ -5762,7 +6394,7 @@ class SpaceNamespace {
|
|
|
5762
6394
|
// -------------------------------------------------------------------------
|
|
5763
6395
|
async build(spaceId, historyPolicy) {
|
|
5764
6396
|
const memberId = this.myId();
|
|
5765
|
-
const
|
|
6397
|
+
const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
|
|
5766
6398
|
const transport = new KeyringClient({
|
|
5767
6399
|
spaceId,
|
|
5768
6400
|
httpBaseUrl: this.deps.http.baseUrl,
|
|
@@ -5771,7 +6403,8 @@ class SpaceNamespace {
|
|
|
5771
6403
|
const keyring = new SpaceKeyring({
|
|
5772
6404
|
spaceId,
|
|
5773
6405
|
memberId,
|
|
5774
|
-
identityEcdhPub,
|
|
6406
|
+
identityEcdhPub: ecdhPub,
|
|
6407
|
+
identityEcdsaPub: ecdsaPub,
|
|
5775
6408
|
ownPrivateKey: () => KeyStore.getInstance().getKeyPair(memberId)?.privateKey ?? null,
|
|
5776
6409
|
transport,
|
|
5777
6410
|
cache: this.deps.cache,
|
|
@@ -5788,14 +6421,15 @@ class SpaceNamespace {
|
|
|
5788
6421
|
historyPolicy,
|
|
5789
6422
|
});
|
|
5790
6423
|
}
|
|
5791
|
-
/** Ensure the member has an identity
|
|
6424
|
+
/** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
|
|
5792
6425
|
async ensureIdentity(memberId) {
|
|
5793
6426
|
const store = KeyStore.getInstance();
|
|
5794
6427
|
if (!store.getKeyPair(memberId)) {
|
|
5795
6428
|
await store.generateOwnKeyPair(memberId);
|
|
5796
6429
|
}
|
|
5797
|
-
const
|
|
5798
|
-
|
|
6430
|
+
const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
|
|
6431
|
+
const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
|
|
6432
|
+
return { ecdhPub, ecdsaPub };
|
|
5799
6433
|
}
|
|
5800
6434
|
async fetchPolicy(spaceId) {
|
|
5801
6435
|
const meta = await new KeyringClient({
|
|
@@ -5858,7 +6492,9 @@ class Client {
|
|
|
5858
6492
|
baseUrl;
|
|
5859
6493
|
/** Authentication — `client.auth.zk.login(...)`, etc. */
|
|
5860
6494
|
auth;
|
|
5861
|
-
/** Per-user
|
|
6495
|
+
/** Per-user key/value storage — `client.kv.set(...)`, etc. */
|
|
6496
|
+
kv;
|
|
6497
|
+
/** File storage — `client.storage.writeFile(...)`, etc. */
|
|
5862
6498
|
storage;
|
|
5863
6499
|
/** Realtime messaging — `client.message.subscribe(...)`, etc. */
|
|
5864
6500
|
message;
|
|
@@ -5866,6 +6502,10 @@ class Client {
|
|
|
5866
6502
|
space;
|
|
5867
6503
|
session;
|
|
5868
6504
|
http;
|
|
6505
|
+
/** Subscribers notified when a session expires and can't be recovered. */
|
|
6506
|
+
sessionExpiredHandlers = new Set();
|
|
6507
|
+
/** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
|
|
6508
|
+
recoverInFlight = null;
|
|
5869
6509
|
constructor(options = {}) {
|
|
5870
6510
|
if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
|
|
5871
6511
|
globalThis.appLogger.setLevel(options.logLevel);
|
|
@@ -5877,19 +6517,24 @@ class Client {
|
|
|
5877
6517
|
apiKey: options.apiKey,
|
|
5878
6518
|
getSessionToken: () => this.session.token,
|
|
5879
6519
|
fetch: options.fetch,
|
|
6520
|
+
// Self-heal stale sessions: on a 401, try a silent re-auth (only
|
|
6521
|
+
// works while unlocked). If it can't, `recoverSession` fires the
|
|
6522
|
+
// session-expired event so the app can redirect to login.
|
|
6523
|
+
onUnauthorized: () => this.recoverSession(),
|
|
5880
6524
|
});
|
|
5881
6525
|
const circuits = options.circuits ?? defaultCircuitUrls(this.baseUrl);
|
|
5882
6526
|
const authClient = new AuthClient({ baseUrl: this.baseUrl, fetch: this.http.fetch });
|
|
5883
6527
|
const wsBaseUrl = toWsBase(this.baseUrl);
|
|
5884
6528
|
this.auth = new AuthNamespace({ auth: authClient, circuits, session: this.session });
|
|
5885
|
-
this.
|
|
6529
|
+
this.kv = new KvNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
6530
|
+
this.storage = new StorageNamespace({ http: this.http, baseUrl: this.baseUrl, kv: this.kv });
|
|
5886
6531
|
this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
5887
6532
|
// Group keys are cached in the user's PersonalSpace (encrypted at rest
|
|
5888
|
-
// by
|
|
5889
|
-
//
|
|
6533
|
+
// by client.kv), so a returning member hydrates without a fresh keyring
|
|
6534
|
+
// round-trip. The server only ever sees the ciphertext.
|
|
5890
6535
|
const spaceKeyCache = {
|
|
5891
|
-
loadKeys: (spaceId) => this.
|
|
5892
|
-
saveKeys: (spaceId, keys) => this.
|
|
6536
|
+
loadKeys: (spaceId) => this.kv.get("space-keys", spaceId),
|
|
6537
|
+
saveKeys: (spaceId, keys) => this.kv.set("space-keys", spaceId, keys),
|
|
5893
6538
|
};
|
|
5894
6539
|
this.space = new SpaceNamespace({
|
|
5895
6540
|
http: this.http,
|
|
@@ -5906,6 +6551,48 @@ class Client {
|
|
|
5906
6551
|
get isAuthenticated() {
|
|
5907
6552
|
return this.session.isAuthenticated;
|
|
5908
6553
|
}
|
|
6554
|
+
/**
|
|
6555
|
+
* Subscribe to session expiry that couldn't be silently recovered (the
|
|
6556
|
+
* user's identity wasn't in memory to re-prove — typically after a reload
|
|
6557
|
+
* where only the token was persisted, and that token has since gone stale).
|
|
6558
|
+
* This is the signal to send the user back to the login screen.
|
|
6559
|
+
*
|
|
6560
|
+
* Returns an unsubscribe function.
|
|
6561
|
+
*
|
|
6562
|
+
* const off = client.onSessionExpired(() => router.push('/login'));
|
|
6563
|
+
*/
|
|
6564
|
+
onSessionExpired(handler) {
|
|
6565
|
+
this.sessionExpiredHandlers.add(handler);
|
|
6566
|
+
return () => this.sessionExpiredHandlers.delete(handler);
|
|
6567
|
+
}
|
|
6568
|
+
/**
|
|
6569
|
+
* Attempt a silent re-auth of the current user. Concurrent callers share a
|
|
6570
|
+
* single in-flight attempt (a stale token usually 401s several requests at
|
|
6571
|
+
* once — we re-auth once, not once per request). On failure the
|
|
6572
|
+
* session-expired event fires so the app can react. Exposed so apps can
|
|
6573
|
+
* also drive recovery proactively (e.g. on `visibilitychange`).
|
|
6574
|
+
*/
|
|
6575
|
+
recoverSession() {
|
|
6576
|
+
if (this.recoverInFlight)
|
|
6577
|
+
return this.recoverInFlight;
|
|
6578
|
+
this.recoverInFlight = this.auth.zk
|
|
6579
|
+
.recover()
|
|
6580
|
+
.then((ok) => {
|
|
6581
|
+
if (!ok) {
|
|
6582
|
+
for (const handler of this.sessionExpiredHandlers) {
|
|
6583
|
+
try {
|
|
6584
|
+
handler();
|
|
6585
|
+
}
|
|
6586
|
+
catch { /* a bad listener mustn't break recovery */ }
|
|
6587
|
+
}
|
|
6588
|
+
}
|
|
6589
|
+
return ok;
|
|
6590
|
+
})
|
|
6591
|
+
.finally(() => {
|
|
6592
|
+
this.recoverInFlight = null;
|
|
6593
|
+
});
|
|
6594
|
+
return this.recoverInFlight;
|
|
6595
|
+
}
|
|
5909
6596
|
}
|
|
5910
6597
|
|
|
5911
6598
|
const appLogger$1 = new Logger("connect", 'ERROR');
|
|
@@ -6214,7 +6901,7 @@ function alloc(u32, size) {
|
|
|
6214
6901
|
* a convenient way to passphrase-encrypt a payload before putting it.
|
|
6215
6902
|
*/
|
|
6216
6903
|
let _snarkjs = null;
|
|
6217
|
-
async function loadSnarkjs
|
|
6904
|
+
async function loadSnarkjs() {
|
|
6218
6905
|
if (_snarkjs)
|
|
6219
6906
|
return _snarkjs;
|
|
6220
6907
|
_snarkjs = (await import('snarkjs'));
|
|
@@ -6228,21 +6915,21 @@ async function loadSnarkjs$1() {
|
|
|
6228
6915
|
* Must stay in sync with the `nonce` public-signal width used by both
|
|
6229
6916
|
* accelerator's `verifyZkAuthProof` and the `preimagePoK` circuit.
|
|
6230
6917
|
*/
|
|
6231
|
-
const FIELD_SIZE
|
|
6918
|
+
const FIELD_SIZE = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
|
|
6232
6919
|
/**
|
|
6233
6920
|
* Reduce a hex nonce into a BN254 field-element decimal string, matching what
|
|
6234
6921
|
* the Circom circuit expects as the `nonce` public signal.
|
|
6235
6922
|
*/
|
|
6236
|
-
function nonceHexToFieldString
|
|
6923
|
+
function nonceHexToFieldString(nonceHex) {
|
|
6237
6924
|
const hex = nonceHex.startsWith("0x") ? nonceHex : "0x" + nonceHex;
|
|
6238
|
-
return (BigInt(hex) % FIELD_SIZE
|
|
6925
|
+
return (BigInt(hex) % FIELD_SIZE).toString();
|
|
6239
6926
|
}
|
|
6240
6927
|
/**
|
|
6241
6928
|
* Read the response body as JSON, falling back to text for the error message
|
|
6242
6929
|
* when the body isn't JSON. Centralized so every method handles failures the
|
|
6243
6930
|
* same way.
|
|
6244
6931
|
*/
|
|
6245
|
-
async function parseOrThrow
|
|
6932
|
+
async function parseOrThrow(response, label) {
|
|
6246
6933
|
if (!response.ok) {
|
|
6247
6934
|
let detail = "";
|
|
6248
6935
|
try {
|
|
@@ -6298,7 +6985,7 @@ class PersonalSpaceClient {
|
|
|
6298
6985
|
const url = `${this.baseUrl}/api/personal/${encodeURIComponent(this.commitment)}/kv/${encodeURIComponent(key)}`;
|
|
6299
6986
|
const body = { challengeId, proof, publicSignals, value };
|
|
6300
6987
|
const response = await this.fetch(url, "POST", body);
|
|
6301
|
-
await parseOrThrow
|
|
6988
|
+
await parseOrThrow(response, `put(${key})`);
|
|
6302
6989
|
}
|
|
6303
6990
|
/** Fetch the value stored under `key`. Returns `null` if absent. */
|
|
6304
6991
|
async get(key) {
|
|
@@ -6306,7 +6993,7 @@ class PersonalSpaceClient {
|
|
|
6306
6993
|
const url = `${this.baseUrl}/api/personal/${encodeURIComponent(this.commitment)}/kv/${encodeURIComponent(key)}/get`;
|
|
6307
6994
|
const body = { challengeId, proof, publicSignals };
|
|
6308
6995
|
const response = await this.fetch(url, "POST", body);
|
|
6309
|
-
const data = await parseOrThrow
|
|
6996
|
+
const data = await parseOrThrow(response, `get(${key})`);
|
|
6310
6997
|
return data.value ?? null;
|
|
6311
6998
|
}
|
|
6312
6999
|
/** Remove `key` from the user's space. Returns whether the key existed. */
|
|
@@ -6315,7 +7002,7 @@ class PersonalSpaceClient {
|
|
|
6315
7002
|
const url = `${this.baseUrl}/api/personal/${encodeURIComponent(this.commitment)}/kv/${encodeURIComponent(key)}`;
|
|
6316
7003
|
const body = { challengeId, proof, publicSignals };
|
|
6317
7004
|
const response = await this.fetch(url, "DELETE", body);
|
|
6318
|
-
const data = await parseOrThrow
|
|
7005
|
+
const data = await parseOrThrow(response, `delete(${key})`);
|
|
6319
7006
|
return data.existed;
|
|
6320
7007
|
}
|
|
6321
7008
|
/** List every key currently stored in the user's space. */
|
|
@@ -6324,7 +7011,7 @@ class PersonalSpaceClient {
|
|
|
6324
7011
|
const url = `${this.baseUrl}/api/personal/${encodeURIComponent(this.commitment)}/list`;
|
|
6325
7012
|
const body = { challengeId, proof, publicSignals };
|
|
6326
7013
|
const response = await this.fetch(url, "POST", body);
|
|
6327
|
-
const data = await parseOrThrow
|
|
7014
|
+
const data = await parseOrThrow(response, "list()");
|
|
6328
7015
|
return Array.isArray(data.keys) ? data.keys : [];
|
|
6329
7016
|
}
|
|
6330
7017
|
// -------------------------------------------------------------------------
|
|
@@ -6337,7 +7024,7 @@ class PersonalSpaceClient {
|
|
|
6337
7024
|
*/
|
|
6338
7025
|
async proveFreshChallenge() {
|
|
6339
7026
|
const challenge = await this.requestChallenge();
|
|
6340
|
-
const nonceField = nonceHexToFieldString
|
|
7027
|
+
const nonceField = nonceHexToFieldString(challenge.nonce);
|
|
6341
7028
|
// Inputs to the `preimagePoK` circuit. Public signals are
|
|
6342
7029
|
// `[commitment, nonce, ecdsaPubHash]`; the rest are private witnesses.
|
|
6343
7030
|
const input = {
|
|
@@ -6348,7 +7035,7 @@ class PersonalSpaceClient {
|
|
|
6348
7035
|
salt: this.salt,
|
|
6349
7036
|
ecdsaPub: this.ecdsaPub,
|
|
6350
7037
|
};
|
|
6351
|
-
const snarkjs = await loadSnarkjs
|
|
7038
|
+
const snarkjs = await loadSnarkjs();
|
|
6352
7039
|
const { proof, publicSignals } = await snarkjs.groth16.fullProve(input, this.wasmUrl, this.zkeyUrl);
|
|
6353
7040
|
return {
|
|
6354
7041
|
challengeId: challenge.challengeId,
|
|
@@ -6360,7 +7047,7 @@ class PersonalSpaceClient {
|
|
|
6360
7047
|
async requestChallenge() {
|
|
6361
7048
|
const url = `${this.baseUrl}/api/personal/${encodeURIComponent(this.commitment)}/challenge`;
|
|
6362
7049
|
const response = await this.fetch(url, "POST", {});
|
|
6363
|
-
return await parseOrThrow
|
|
7050
|
+
return await parseOrThrow(response, "challenge");
|
|
6364
7051
|
}
|
|
6365
7052
|
/**
|
|
6366
7053
|
* Thin wrapper over `globalThis.fetch` so all methods share a single set of
|
|
@@ -6381,251 +7068,6 @@ class PersonalSpaceClient {
|
|
|
6381
7068
|
}
|
|
6382
7069
|
}
|
|
6383
7070
|
|
|
6384
|
-
/**
|
|
6385
|
-
* HTTP client for the multi-user `SharedSpaceDO` (Phase B).
|
|
6386
|
-
*
|
|
6387
|
-
* Mirrors the proven shape of `PersonalSpaceClient` — every gated call:
|
|
6388
|
-
* 1. POSTs `/challenge` to get a fresh nonce keyed to the caller's commitment
|
|
6389
|
-
* 2. Generates a Groth16 proof for the `preimagePoK` circuit with the nonce
|
|
6390
|
-
* reduced into a BN254 field element as the public signal
|
|
6391
|
-
* 3. POSTs the operation with `{ challengeId, proof, publicSignals, ... }`
|
|
6392
|
-
*
|
|
6393
|
-
* What's different from `PersonalSpaceClient`:
|
|
6394
|
-
* - Addressed by space ID, not user commitment. Multiple users can be
|
|
6395
|
-
* members of the same space.
|
|
6396
|
-
* - The space carries a participants list + ACL (shared types from
|
|
6397
|
-
* `src/types/permissions.ts`). The server checks that the proving
|
|
6398
|
-
* commitment is a participant with the requested permission before each
|
|
6399
|
-
* mutation.
|
|
6400
|
-
* - Stores file manifests (not generic KV blobs). The shape is constrained
|
|
6401
|
-
* by `FileManifest` from `../types`.
|
|
6402
|
-
*
|
|
6403
|
-
* Wire protocol (this client implements the client side; the server side is
|
|
6404
|
-
* the next slice of work in accelerator):
|
|
6405
|
-
*
|
|
6406
|
-
* POST /api/spaces create a new space (gated by caller proof)
|
|
6407
|
-
* body: { challengeId, proof, publicSignals, initialAcl? }
|
|
6408
|
-
* returns: { spaceId }
|
|
6409
|
-
*
|
|
6410
|
-
* POST /api/spaces/:spaceId/challenge issue a nonce for the caller
|
|
6411
|
-
* body: { commitment }
|
|
6412
|
-
* returns: { challengeId, nonce }
|
|
6413
|
-
*
|
|
6414
|
-
* POST /api/spaces/:spaceId/files write a file manifest
|
|
6415
|
-
* body: { challengeId, proof, publicSignals, manifest }
|
|
6416
|
-
* returns: { ok: true, fileId }
|
|
6417
|
-
*
|
|
6418
|
-
* POST /api/spaces/:spaceId/files/:fileId/get read a file manifest
|
|
6419
|
-
* body: { challengeId, proof, publicSignals }
|
|
6420
|
-
* returns: { manifest }
|
|
6421
|
-
*
|
|
6422
|
-
* DELETE /api/spaces/:spaceId/files/:fileId delete a file manifest
|
|
6423
|
-
* body: { challengeId, proof, publicSignals }
|
|
6424
|
-
* returns: { ok: true, existed: boolean }
|
|
6425
|
-
*
|
|
6426
|
-
* POST /api/spaces/:spaceId/files/list list manifests in space
|
|
6427
|
-
* body: { challengeId, proof, publicSignals }
|
|
6428
|
-
* returns: { files: FileStat[] }
|
|
6429
|
-
*
|
|
6430
|
-
* POST /api/spaces/:spaceId/participants add a participant
|
|
6431
|
-
* body: { challengeId, proof, publicSignals, participant: Participant }
|
|
6432
|
-
* returns: { ok: true }
|
|
6433
|
-
*
|
|
6434
|
-
* DELETE /api/spaces/:spaceId/participants/:commitment remove a participant
|
|
6435
|
-
* body: { challengeId, proof, publicSignals }
|
|
6436
|
-
* returns: { ok: true, existed: boolean }
|
|
6437
|
-
*
|
|
6438
|
-
* POST /api/spaces/:spaceId/acl set / replace an ACL entry
|
|
6439
|
-
* body: { challengeId, proof, publicSignals, entry: ACLEntry }
|
|
6440
|
-
* returns: { ok: true }
|
|
6441
|
-
*/
|
|
6442
|
-
let snarkjsModule = null;
|
|
6443
|
-
async function loadSnarkjs() {
|
|
6444
|
-
if (snarkjsModule)
|
|
6445
|
-
return snarkjsModule;
|
|
6446
|
-
snarkjsModule = (await import('snarkjs'));
|
|
6447
|
-
return snarkjsModule;
|
|
6448
|
-
}
|
|
6449
|
-
// BN254 scalar field modulus — same value used everywhere else in the ZK path.
|
|
6450
|
-
const FIELD_SIZE = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
|
|
6451
|
-
class SharedSpaceClient {
|
|
6452
|
-
baseUrl;
|
|
6453
|
-
commitment;
|
|
6454
|
-
secret;
|
|
6455
|
-
salt;
|
|
6456
|
-
ecdsaPub;
|
|
6457
|
-
ecdsaPubHash;
|
|
6458
|
-
wasmUrl;
|
|
6459
|
-
zkeyUrl;
|
|
6460
|
-
fetchFn;
|
|
6461
|
-
constructor(opts) {
|
|
6462
|
-
if (!opts?.baseUrl)
|
|
6463
|
-
throw new Error("SharedSpaceClient: `baseUrl` is required");
|
|
6464
|
-
if (!opts.commitment)
|
|
6465
|
-
throw new Error("SharedSpaceClient: `commitment` is required");
|
|
6466
|
-
if (!opts.secret)
|
|
6467
|
-
throw new Error("SharedSpaceClient: `secret` is required");
|
|
6468
|
-
if (!opts.salt)
|
|
6469
|
-
throw new Error("SharedSpaceClient: `salt` is required");
|
|
6470
|
-
if (!opts.ecdsaPub)
|
|
6471
|
-
throw new Error("SharedSpaceClient: `ecdsaPub` is required");
|
|
6472
|
-
if (!opts.ecdsaPubHash)
|
|
6473
|
-
throw new Error("SharedSpaceClient: `ecdsaPubHash` is required");
|
|
6474
|
-
if (!opts.circuits?.wasmUrl || !opts.circuits?.zkeyUrl) {
|
|
6475
|
-
throw new Error("SharedSpaceClient: `circuits.wasmUrl` and `circuits.zkeyUrl` are required");
|
|
6476
|
-
}
|
|
6477
|
-
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
6478
|
-
this.commitment = opts.commitment;
|
|
6479
|
-
this.secret = opts.secret;
|
|
6480
|
-
this.salt = opts.salt;
|
|
6481
|
-
this.ecdsaPub = opts.ecdsaPub;
|
|
6482
|
-
this.ecdsaPubHash = opts.ecdsaPubHash;
|
|
6483
|
-
this.wasmUrl = opts.circuits.wasmUrl;
|
|
6484
|
-
this.zkeyUrl = opts.circuits.zkeyUrl;
|
|
6485
|
-
const f = opts.fetch ?? globalThis.fetch;
|
|
6486
|
-
if (typeof f !== "function") {
|
|
6487
|
-
throw new Error("SharedSpaceClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
|
|
6488
|
-
}
|
|
6489
|
-
this.fetchFn = f.bind(globalThis);
|
|
6490
|
-
}
|
|
6491
|
-
/**
|
|
6492
|
-
* Create a brand new space. The caller becomes the owner; `initialAcl`
|
|
6493
|
-
* adds any additional ACL entries up front (e.g. inviting a co-owner at
|
|
6494
|
-
* creation time).
|
|
6495
|
-
*/
|
|
6496
|
-
async createSpace(initialAcl) {
|
|
6497
|
-
// Creating a space still requires the caller to prove they hold the
|
|
6498
|
-
// commitment they're claiming to own — that's what the proof
|
|
6499
|
-
// accomplishes here. The server issues a challenge against a global
|
|
6500
|
-
// "newspace" pseudo-id for the bootstrap step.
|
|
6501
|
-
const proof = await this.proveFreshChallenge("__new__");
|
|
6502
|
-
const body = { ...proof };
|
|
6503
|
-
if (initialAcl)
|
|
6504
|
-
body.initialAcl = initialAcl;
|
|
6505
|
-
const url = `${this.baseUrl}/api/spaces`;
|
|
6506
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
6507
|
-
const data = await parseOrThrow(response, "createSpace");
|
|
6508
|
-
return { spaceId: data.spaceId };
|
|
6509
|
-
}
|
|
6510
|
-
/** Write a file manifest into the space. Requires `write` permission. */
|
|
6511
|
-
async writeFileManifest(spaceId, manifest) {
|
|
6512
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
6513
|
-
const body = { ...proof, manifest };
|
|
6514
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files`;
|
|
6515
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
6516
|
-
await parseOrThrow(response, `writeFileManifest(${manifest.id})`);
|
|
6517
|
-
}
|
|
6518
|
-
/** Read a file manifest by id. Requires `read` permission. */
|
|
6519
|
-
async readFileManifest(spaceId, fileId) {
|
|
6520
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
6521
|
-
const body = { ...proof };
|
|
6522
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files/${encodeURIComponent(fileId)}/get`;
|
|
6523
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
6524
|
-
const data = await parseOrThrow(response, `readFileManifest(${fileId})`);
|
|
6525
|
-
return data.manifest;
|
|
6526
|
-
}
|
|
6527
|
-
/** Delete a file manifest by id. Requires `delete` permission. */
|
|
6528
|
-
async deleteFileManifest(spaceId, fileId) {
|
|
6529
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
6530
|
-
const body = { ...proof };
|
|
6531
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files/${encodeURIComponent(fileId)}`;
|
|
6532
|
-
const response = await this.fetchFn(url, this.jsonRequest("DELETE", body));
|
|
6533
|
-
const data = await parseOrThrow(response, `deleteFileManifest(${fileId})`);
|
|
6534
|
-
return data.existed;
|
|
6535
|
-
}
|
|
6536
|
-
/** List file summaries in the space. Requires `read` permission. */
|
|
6537
|
-
async listFiles(spaceId) {
|
|
6538
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
6539
|
-
const body = { ...proof };
|
|
6540
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files/list`;
|
|
6541
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
6542
|
-
const data = await parseOrThrow(response, "listFiles");
|
|
6543
|
-
return Array.isArray(data.files) ? data.files : [];
|
|
6544
|
-
}
|
|
6545
|
-
/** Add a participant. Requires `invite` or `admin`. */
|
|
6546
|
-
async addParticipant(spaceId, participant) {
|
|
6547
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
6548
|
-
const body = { ...proof, participant };
|
|
6549
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/participants`;
|
|
6550
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
6551
|
-
await parseOrThrow(response, "addParticipant");
|
|
6552
|
-
}
|
|
6553
|
-
/** Remove a participant by their public-key identity. Requires `admin`. */
|
|
6554
|
-
async removeParticipant(spaceId, participantCommitment) {
|
|
6555
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
6556
|
-
const body = { ...proof };
|
|
6557
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/participants/${encodeURIComponent(participantCommitment)}`;
|
|
6558
|
-
const response = await this.fetchFn(url, this.jsonRequest("DELETE", body));
|
|
6559
|
-
const data = await parseOrThrow(response, "removeParticipant");
|
|
6560
|
-
return data.existed;
|
|
6561
|
-
}
|
|
6562
|
-
/** Set / replace an ACL entry. Requires `admin`. */
|
|
6563
|
-
async setACLEntry(spaceId, entry) {
|
|
6564
|
-
const proof = await this.proveFreshChallenge(spaceId);
|
|
6565
|
-
const body = { ...proof, entry };
|
|
6566
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/acl`;
|
|
6567
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", body));
|
|
6568
|
-
await parseOrThrow(response, "setACLEntry");
|
|
6569
|
-
}
|
|
6570
|
-
// -------------------------------------------------------------------------
|
|
6571
|
-
// Internals
|
|
6572
|
-
// -------------------------------------------------------------------------
|
|
6573
|
-
/** Request a challenge for `spaceId` and generate the matching proof. */
|
|
6574
|
-
async proveFreshChallenge(spaceId) {
|
|
6575
|
-
const challenge = await this.requestChallenge(spaceId);
|
|
6576
|
-
const nonceField = nonceHexToFieldString(challenge.nonce);
|
|
6577
|
-
const input = {
|
|
6578
|
-
commitment: this.commitment,
|
|
6579
|
-
nonce: nonceField,
|
|
6580
|
-
ecdsaPubHash: this.ecdsaPubHash,
|
|
6581
|
-
secret: this.secret,
|
|
6582
|
-
salt: this.salt,
|
|
6583
|
-
ecdsaPub: this.ecdsaPub,
|
|
6584
|
-
};
|
|
6585
|
-
const snarkjs = await loadSnarkjs();
|
|
6586
|
-
const { proof, publicSignals } = await snarkjs.groth16.fullProve(input, this.wasmUrl, this.zkeyUrl);
|
|
6587
|
-
return {
|
|
6588
|
-
challengeId: challenge.challengeId,
|
|
6589
|
-
proof: proof,
|
|
6590
|
-
publicSignals: publicSignals,
|
|
6591
|
-
};
|
|
6592
|
-
}
|
|
6593
|
-
/**
|
|
6594
|
-
* Hit `/challenge`. The body includes the caller's commitment so the
|
|
6595
|
-
* server can scope the issued nonce to this principal.
|
|
6596
|
-
*/
|
|
6597
|
-
async requestChallenge(spaceId) {
|
|
6598
|
-
const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/challenge`;
|
|
6599
|
-
const response = await this.fetchFn(url, this.jsonRequest("POST", { commitment: this.commitment }));
|
|
6600
|
-
return await parseOrThrow(response, "challenge");
|
|
6601
|
-
}
|
|
6602
|
-
jsonRequest(method, body) {
|
|
6603
|
-
return {
|
|
6604
|
-
method,
|
|
6605
|
-
headers: { "content-type": "application/json" },
|
|
6606
|
-
body: JSON.stringify(body ?? {}),
|
|
6607
|
-
};
|
|
6608
|
-
}
|
|
6609
|
-
}
|
|
6610
|
-
function nonceHexToFieldString(nonceHex) {
|
|
6611
|
-
const hex = nonceHex.startsWith("0x") ? nonceHex : "0x" + nonceHex;
|
|
6612
|
-
return (BigInt(hex) % FIELD_SIZE).toString();
|
|
6613
|
-
}
|
|
6614
|
-
async function parseOrThrow(response, label) {
|
|
6615
|
-
if (!response.ok) {
|
|
6616
|
-
let detail = "";
|
|
6617
|
-
try {
|
|
6618
|
-
const text = await response.text();
|
|
6619
|
-
detail = text ? `: ${text}` : "";
|
|
6620
|
-
}
|
|
6621
|
-
catch {
|
|
6622
|
-
// ignore — we still throw with the status
|
|
6623
|
-
}
|
|
6624
|
-
throw new Error(`SharedSpaceClient ${label} failed (${response.status})${detail}`);
|
|
6625
|
-
}
|
|
6626
|
-
return (await response.json());
|
|
6627
|
-
}
|
|
6628
|
-
|
|
6629
7071
|
/**
|
|
6630
7072
|
* PacketCipher — the strategy `Network` uses to seal/open a packet's payload.
|
|
6631
7073
|
*
|
|
@@ -6689,5 +7131,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
6689
7131
|
default: loadRsWasm
|
|
6690
7132
|
});
|
|
6691
7133
|
|
|
6692
|
-
export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, DEFAULT_BASE_URL, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, WSTransport, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deserialize, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, getIPAddress, getId, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNumber, log, manifestToStat, poseidonHash, quickVerify, randomBytes, serialize, shardHash, signMessage, toBase64, toBase64Url, toField, toHex, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
|
|
7134
|
+
export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, DEFAULT_BASE_URL, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvNamespace, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, WSTransport, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deserialize, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, getIPAddress, getId, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNumber, log, manifestToStat, poseidonHash, quickVerify, randomBytes, serialize, shardHash, signMessage, toBase64, toBase64Url, toField, toHex, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
|
|
6693
7135
|
//# sourceMappingURL=index.js.map
|