@muhkoo/connect 0.1.0-alpha.2 → 0.2.0-alpha.2

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.
@@ -275,6 +275,10 @@ function fromBase64(b64) {
275
275
  out[i] = binary.charCodeAt(i);
276
276
  return out;
277
277
  }
278
+ /** Encode a byte array as a base64url string (no padding). */
279
+ function toBase64Url(bytes) {
280
+ return toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
281
+ }
278
282
  /** Decode a base64url string (with or without padding) to bytes. */
279
283
  function fromBase64Url(b64url) {
280
284
  const padded = b64url.replace(/-/g, "+").replace(/_/g, "/") +
@@ -5259,50 +5263,269 @@ class StorageNamespace {
5259
5263
  }
5260
5264
 
5261
5265
  /**
5262
- * `Room` — a client handle for one shared space (a chat-style room).
5263
- *
5264
- * A shared space carries two things, and `Room` exposes both:
5265
- * - **realtime E2E messaging** over a {@link BroadcastChannel} (group
5266
- * handshake, ratchet send/receive, roster frames);
5267
- * - **room-scoped file storage** via {@link FileStorage} over the room's
5268
- * shard endpoint.
5269
- *
5270
- * Obtained from `client.message.room(name)`. The websocket isn't opened until
5271
- * `connect()` — and `putFile`/`getFile` don't need it at all (they're plain
5272
- * shard HTTP), so reading a shared file never opens a socket. The upgrade
5273
- * ticket + identity are resolved lazily inside `connect()`.
5274
- *
5275
- * `Room` intentionally mirrors the `BroadcastChannel` surface the chat hook
5276
- * already uses (`on`/`off`/`send`/`sendRaw`/`announce`/`peers`/`isConnected`)
5277
- * so adopting it is a drop-in swap for `new BroadcastChannel(...)`.
5266
+ * SpaceCipher — pure crypto for the fan-out group-encryption layer.
5267
+ *
5268
+ * Everything here routes through the canonical primitives in
5269
+ * `src/crypto/primitives` (AES-256-GCM, HKDF-SHA256, randomBytes) and the
5270
+ * P-384 ECDH `deriveBits` call the Double Ratchet already uses. No new
5271
+ * algorithms, no `crypto.subtle` reach-arounds beyond key gen / derive /
5272
+ * import-export (which the primitive layer intentionally doesn't cover).
5273
+ *
5274
+ * Three concerns:
5275
+ * 1. Space identity — a P-384 ECDH keypair; the encoded public key is the
5276
+ * space's id.
5277
+ * 2. Group key distribution — ECIES wrap/unwrap of `K_space` to a member's
5278
+ * identity ECDH public key (ephemeral-static ECDH → HKDF → AES-GCM).
5279
+ * 3. Message sealing — encrypt the serialized `Message` once under the
5280
+ * epoch's group key.
5281
+ *
5282
+ * Public keys are transported as base64url-encoded JWK, matching the JWK
5283
+ * transport convention already used in `EncryptedSession`.
5284
+ */
5285
+ /** HKDF label binding the wrap key to this protocol + version. */
5286
+ const WRAP_INFO = "muhkoo/space/keywrap/v1";
5287
+ /** Algorithm tag stamped into every WrappedKey. */
5288
+ const WRAP_ALG = "ECDH-P384/HKDF-SHA256/AES-256-GCM";
5289
+ /** Bits in a P-384 ECDH shared secret. */
5290
+ const P384_BITS = 384;
5291
+ const ECDH_PARAMS = { name: "ECDH", namedCurve: "P-384" };
5292
+ // ---------------------------------------------------------------------------
5293
+ // Public-key transport (base64url JWK)
5294
+ // ---------------------------------------------------------------------------
5295
+ /** Encode an ECDH public `CryptoKey` as a base64url JWK string. */
5296
+ async function exportEcdhPublicKey(key) {
5297
+ const jwk = await getSubtle().exportKey("jwk", key);
5298
+ return toBase64Url(utf8Encode(JSON.stringify(jwk)));
5299
+ }
5300
+ /** Decode a base64url JWK string back into an ECDH public `CryptoKey`. */
5301
+ async function importEcdhPublicKey(encoded) {
5302
+ const jwk = JSON.parse(utf8Decode(fromBase64Url(encoded)));
5303
+ // Public keys carry no usages.
5304
+ return getSubtle().importKey("jwk", jwk, ECDH_PARAMS, true, []);
5305
+ }
5306
+ /** The space's id is just its encoded ECDH public key. */
5307
+ const encodeSpaceId = exportEcdhPublicKey;
5308
+ // ---------------------------------------------------------------------------
5309
+ // Sender authentication (ECDSA P-384, matching the Double Ratchet path).
5310
+ // A fan-out message is signed by its sender's identity ECDSA key so receivers
5311
+ // can verify authorship end-to-end — independent of the relay, which only
5312
+ // stamps `source` server-side. SHA-256 hash to match `DoubleRatchet`.
5313
+ // ---------------------------------------------------------------------------
5314
+ const ECDSA_PARAMS = { name: "ECDSA", namedCurve: "P-384" };
5315
+ const ECDSA_SIGN = { name: "ECDSA", hash: "SHA-256" };
5316
+ /** Encode an ECDSA public `CryptoKey` as a base64url JWK string. */
5317
+ async function exportEcdsaPublicKey(key) {
5318
+ const jwk = await getSubtle().exportKey("jwk", key);
5319
+ return toBase64Url(utf8Encode(JSON.stringify(jwk)));
5320
+ }
5321
+ /** Decode a base64url JWK string back into an ECDSA verify-only public key. */
5322
+ async function importEcdsaPublicKey(encoded) {
5323
+ const jwk = JSON.parse(utf8Decode(fromBase64Url(encoded)));
5324
+ return getSubtle().importKey("jwk", jwk, ECDSA_PARAMS, true, ["verify"]);
5325
+ }
5326
+ /**
5327
+ * Canonical bytes a sender signs (and a receiver re-derives). Binds the claimed
5328
+ * sender + routing + the sealed ciphertext, so a relay can't relabel `source`
5329
+ * and a member can't reuse another's ciphertext under a new envelope.
5330
+ */
5331
+ function canonicalMessage(fields) {
5332
+ return [fields.source, fields.target, fields.subject, fields.epoch, fields.iv, fields.ciphertext].join("\n");
5333
+ }
5334
+ /** Sign the canonical bytes with the sender's ECDSA identity private key. */
5335
+ async function signSpaceMessage(canonical, ecdsaPriv) {
5336
+ const sig = await getSubtle().sign(ECDSA_SIGN, ecdsaPriv, utf8Encode(canonical));
5337
+ return toBase64(new Uint8Array(sig));
5338
+ }
5339
+ /** Verify a signature over the canonical bytes against the sender's ECDSA key. */
5340
+ async function verifySpaceMessage(canonical, sigB64, ecdsaPub) {
5341
+ try {
5342
+ return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64(sigB64), utf8Encode(canonical));
5343
+ }
5344
+ catch {
5345
+ return false;
5346
+ }
5347
+ }
5348
+ /** Generate a fresh space identity keypair (P-384 ECDH). */
5349
+ async function generateSpaceIdentity() {
5350
+ const kp = await getSubtle().generateKey(ECDH_PARAMS, true, ["deriveBits"]);
5351
+ const id = await encodeSpaceId(kp.publicKey);
5352
+ return { id, publicKey: kp.publicKey, privateKey: kp.privateKey };
5353
+ }
5354
+ // ---------------------------------------------------------------------------
5355
+ // Group key
5356
+ // ---------------------------------------------------------------------------
5357
+ /** Mint a fresh 256-bit group key for an epoch. */
5358
+ function generateSpaceKey() {
5359
+ return randomBytes(AES_GCM_KEY_BYTES);
5360
+ }
5361
+ // ---------------------------------------------------------------------------
5362
+ // ECIES wrap / unwrap of the group key
5363
+ // ---------------------------------------------------------------------------
5364
+ /**
5365
+ * Wrap `kSpace` for `recipientEcdhPub` using an ephemeral ECDH keypair.
5366
+ * The HKDF salt binds the wrap key to `epoch`, so a wrap minted for one epoch
5367
+ * cannot be replayed to unwrap a different epoch's blob.
5368
+ */
5369
+ async function wrapSpaceKey(kSpace, epoch, recipientEcdhPub) {
5370
+ const eph = await getSubtle().generateKey(ECDH_PARAMS, true, ["deriveBits"]);
5371
+ const shared = new Uint8Array(await getSubtle().deriveBits({ name: "ECDH", public: recipientEcdhPub }, eph.privateKey, P384_BITS));
5372
+ const wrapKey = await deriveBitsHkdf(shared, WRAP_INFO, AES_GCM_KEY_BYTES, utf8Encode(`epoch:${epoch}`));
5373
+ const iv = randomBytes(AES_GCM_IV_BYTES);
5374
+ const ciphertext = await encryptAesGcm(wrapKey, iv, kSpace);
5375
+ return {
5376
+ epoch,
5377
+ ephemeralPub: await exportEcdhPublicKey(eph.publicKey),
5378
+ iv: toBase64(iv),
5379
+ ciphertext: toBase64(ciphertext),
5380
+ alg: WRAP_ALG,
5381
+ };
5382
+ }
5383
+ /**
5384
+ * Unwrap a {@link WrappedKey} using the recipient's identity ECDH private key.
5385
+ * Throws (GCM tag failure) on the wrong private key or a tampered blob.
5386
+ */
5387
+ async function unwrapSpaceKey(wrapped, ownEcdhPriv) {
5388
+ const ephPub = await importEcdhPublicKey(wrapped.ephemeralPub);
5389
+ const shared = new Uint8Array(await getSubtle().deriveBits({ name: "ECDH", public: ephPub }, ownEcdhPriv, P384_BITS));
5390
+ const wrapKey = await deriveBitsHkdf(shared, WRAP_INFO, AES_GCM_KEY_BYTES, utf8Encode(`epoch:${wrapped.epoch}`));
5391
+ return decryptAesGcm(wrapKey, fromBase64(wrapped.iv), fromBase64(wrapped.ciphertext));
5392
+ }
5393
+ // ---------------------------------------------------------------------------
5394
+ // Message sealing
5395
+ // ---------------------------------------------------------------------------
5396
+ /** Seal an already-serialized message string under the group key. */
5397
+ async function sealSerialized(kSpace, serialized) {
5398
+ const iv = randomBytes(AES_GCM_IV_BYTES);
5399
+ const ciphertext = await encryptAesGcm(kSpace, iv, utf8Encode(serialized));
5400
+ return { iv: toBase64(iv), ciphertext: toBase64(ciphertext) };
5401
+ }
5402
+ /**
5403
+ * Open a sealed payload back into its serialized message string. Throws (GCM
5404
+ * tag failure) on the wrong group key or a tampered ciphertext.
5405
+ */
5406
+ async function openSerialized(kSpace, iv, ciphertext) {
5407
+ const plaintext = await decryptAesGcm(kSpace, fromBase64(iv), fromBase64(ciphertext));
5408
+ return utf8Decode(plaintext);
5409
+ }
5410
+
5411
+ /**
5412
+ * SpacePacketCipher — the {@link PacketCipher} that seals packets with a
5413
+ * space's group key instead of a per-peer Double Ratchet.
5414
+ *
5415
+ * It pulls the active epoch's key from an {@link EpochKeyProvider} (the
5416
+ * SpaceKeyring), so this adapter stays decoupled from the keyring's transport
5417
+ * and caching concerns — it only needs "what key seals epoch N".
5418
+ */
5419
+ class SpacePacketCipher {
5420
+ keys;
5421
+ contentType;
5422
+ constructor(keys, contentType) {
5423
+ this.keys = keys;
5424
+ this.contentType = contentType;
5425
+ }
5426
+ async seal(serializedMessage) {
5427
+ const epoch = this.keys.currentEpoch();
5428
+ const key = this.keys.keyForEpoch(epoch);
5429
+ if (!key) {
5430
+ throw new Error(`SpacePacketCipher: no group key for epoch ${epoch}`);
5431
+ }
5432
+ const { iv, ciphertext } = await sealSerialized(key, serializedMessage);
5433
+ const headers = { spaceSealed: true, epoch, iv, ciphertext };
5434
+ if (this.contentType)
5435
+ headers.contentType = this.contentType;
5436
+ return headers;
5437
+ }
5438
+ handles(headers) {
5439
+ return headers?.spaceSealed === true &&
5440
+ typeof headers?.iv === "string" &&
5441
+ typeof headers?.ciphertext === "string";
5442
+ }
5443
+ /** Returns null for epochs we don't hold (surfaced as undecryptable). */
5444
+ async open(headers) {
5445
+ if (!this.handles(headers))
5446
+ return null;
5447
+ const epoch = Number(headers.epoch);
5448
+ const key = this.keys.keyForEpoch(epoch);
5449
+ if (!key)
5450
+ return null;
5451
+ return openSerialized(key, headers.iv, headers.ciphertext);
5452
+ }
5453
+ }
5454
+
5455
+ /**
5456
+ * `Space` — a client handle for one shared space.
5457
+ *
5458
+ * A space is the unit everything is built on. It carries:
5459
+ * - **realtime transport** over a {@link BroadcastChannel} (group handshake,
5460
+ * roster frames, raw-frame passthrough);
5461
+ * - **fan-out group messaging** — `Message`s sealed once with the space's
5462
+ * group key (via {@link SpacePacketCipher}), wrapped in a cleartext
5463
+ * {@link Packet} envelope, broadcast AND persisted by the server as
5464
+ * history (the `spaceMessage` frame);
5465
+ * - **room-scoped file storage** via {@link FileStorage} over the shard
5466
+ * endpoint;
5467
+ * - **legacy per-peer Double Ratchet messaging** (`send`/`announce`) for the
5468
+ * ephemeral, no-history path — unchanged so existing callers (the chat hook
5469
+ * reached via `client.message.room(name)`) keep working.
5470
+ *
5471
+ * `Space` was formerly `Room`; `Room` remains exported as an alias. The
5472
+ * fan-out methods (`sendMessage`/`onMessage`/`history`/`join`/`admit`/`rotate`)
5473
+ * require a {@link SpaceKeyring} dep; without one only the transport + DR +
5474
+ * file surface is available.
5278
5475
  */
5279
- class Room {
5476
+ class Space {
5280
5477
  deps;
5281
5478
  name;
5479
+ historyPolicy;
5282
5480
  channel = null;
5283
5481
  pending = [];
5482
+ cipher;
5483
+ /** Per-instance event bus for fan-out events (isolated across spaces). */
5484
+ events = new EventTarget();
5485
+ rawWired = false;
5284
5486
  constructor(deps) {
5285
5487
  this.deps = deps;
5286
5488
  this.name = deps.name;
5489
+ this.historyPolicy = deps.historyPolicy ?? "static";
5490
+ this.cipher = deps.keyring ? new SpacePacketCipher(deps.keyring) : null;
5491
+ }
5492
+ /** The encoded space id (same as the transport name for fan-out spaces). */
5493
+ get id() {
5494
+ return this.name;
5287
5495
  }
5288
- // -- messaging -------------------------------------------------------------
5289
- /** Open the room websocket (resolves the ticket + identity lazily). */
5496
+ /** The keyring backing this space's group keys, if any. */
5497
+ get keyring() {
5498
+ return this.deps.keyring;
5499
+ }
5500
+ // -- transport -------------------------------------------------------------
5501
+ /** Open the space websocket (resolves the ticket + identity lazily). */
5290
5502
  async connect() {
5291
5503
  if (!this.channel) {
5292
5504
  const ticket = await this.deps.fetchTicket();
5293
5505
  const url = `${this.deps.wsBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/websocket` +
5294
5506
  (ticket ? `?ticket=${encodeURIComponent(ticket)}` : "");
5295
- this.channel = new BroadcastChannel({ url, myId: this.deps.myId(), autoAnnounce: false });
5507
+ this.channel = this.deps.createChannel
5508
+ ? this.deps.createChannel(url, this.deps.myId())
5509
+ : new BroadcastChannel({ url, myId: this.deps.myId(), autoAnnounce: false });
5296
5510
  for (const [event, handler] of this.pending)
5297
5511
  this.channel.on(event, handler);
5298
5512
  this.pending.length = 0;
5513
+ // Fan-out spaces drive their own `{name}` handshake + frame routing.
5514
+ // Legacy (keyring-less) callers wire these themselves, so leave that
5515
+ // path byte-for-byte unchanged.
5516
+ if (this.deps.keyring) {
5517
+ this.channel.on(BroadcastChannelEvents.CONNECTED, () => {
5518
+ this.channel?.sendRaw({ name: this.deps.myId() });
5519
+ });
5520
+ this.wireFanout();
5521
+ }
5299
5522
  }
5300
5523
  await this.channel.connect();
5301
5524
  }
5302
5525
  disconnect() {
5303
5526
  this.channel?.disconnect();
5304
5527
  }
5305
- /** Subscribe to a channel event. Buffered until `connect()` if early. */
5528
+ /** Subscribe to a raw channel event. Buffered until `connect()` if early. */
5306
5529
  on(event, handler) {
5307
5530
  if (this.channel)
5308
5531
  this.channel.on(event, handler);
@@ -5318,11 +5541,12 @@ class Room {
5318
5541
  this.pending.splice(i, 1);
5319
5542
  }
5320
5543
  }
5544
+ // -- legacy Double Ratchet path (unchanged) --------------------------------
5321
5545
  /** Advertise our keyExchange to the room (idempotent per connection). */
5322
5546
  announce() {
5323
5547
  return this.channel ? this.channel.announce() : Promise.resolve();
5324
5548
  }
5325
- /** E2E-encrypt `text` to every peer; returns the recipient count. */
5549
+ /** E2E-encrypt `text` to every peer via the Double Ratchet (no history). */
5326
5550
  send(text) {
5327
5551
  return this.channel ? this.channel.send(text) : Promise.resolve(0);
5328
5552
  }
@@ -5331,21 +5555,130 @@ class Room {
5331
5555
  this.channel?.sendRaw(frame);
5332
5556
  }
5333
5557
  peers() {
5334
- return this.channel ? this.channel.peers() : [];
5558
+ return this.channel?.peers ? this.channel.peers() : [];
5335
5559
  }
5336
5560
  isConnected() {
5337
- return this.channel ? this.channel.isConnected() : false;
5561
+ return this.channel?.isConnected ? this.channel.isConnected() : false;
5338
5562
  }
5339
5563
  /** The underlying channel, for advanced use. `null` before `connect()`. */
5340
5564
  get raw() {
5341
5565
  return this.channel;
5342
5566
  }
5567
+ // -- fan-out group messaging -----------------------------------------------
5568
+ /**
5569
+ * Seal `payload` once with the current group key and broadcast it as a
5570
+ * persisted `spaceMessage`. Requires a keyring.
5571
+ */
5572
+ async sendMessage(payload, opts = {}) {
5573
+ const cipher = this.requireCipher(opts.contentType);
5574
+ const message = new Message(payload);
5575
+ const headers = await cipher.seal(message.serialize());
5576
+ const source = this.deps.myId();
5577
+ const subject = opts.channel ?? "default";
5578
+ // Sign the message with our identity ECDSA key so receivers can verify
5579
+ // authorship end-to-end (independent of the relay's `source` stamp).
5580
+ const authPriv = KeyStore.getInstance().getAuthKeyPair(source)?.privateKey;
5581
+ if (authPriv) {
5582
+ const canonical = canonicalMessage({
5583
+ source, target: this.name, subject,
5584
+ epoch: Number(headers.epoch), iv: String(headers.iv), ciphertext: String(headers.ciphertext),
5585
+ });
5586
+ headers.sig = await signSpaceMessage(canonical, authPriv);
5587
+ }
5588
+ const packet = new Packet({ subject, source, target: this.name, headers });
5589
+ this.sendRaw({ spaceMessage: packet.serialize() });
5590
+ }
5591
+ /** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
5592
+ onMessage(handler) {
5593
+ const listener = (e) => handler(e.detail);
5594
+ this.events.addEventListener("message", listener);
5595
+ return () => this.events.removeEventListener("message", listener);
5596
+ }
5597
+ /** Subscribe to inbound join requests (for a key-holder to admit). */
5598
+ onJoinRequest(handler) {
5599
+ const listener = (e) => handler(e.detail);
5600
+ this.events.addEventListener("join-request", listener);
5601
+ return () => this.events.removeEventListener("join-request", listener);
5602
+ }
5603
+ /**
5604
+ * Fetch + decrypt persisted history beyond the live backlog.
5605
+ * `before` is an opaque cursor returned as `nextCursor`.
5606
+ */
5607
+ async history(opts = {}) {
5608
+ const cipher = this.requireCipher();
5609
+ const params = new URLSearchParams();
5610
+ if (opts.before)
5611
+ params.set("before", opts.before);
5612
+ if (opts.limit)
5613
+ params.set("limit", String(opts.limit));
5614
+ const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
5615
+ (params.toString() ? `?${params}` : "");
5616
+ const res = await this.deps.fetch(url);
5617
+ if (!res.ok)
5618
+ throw new Error(`Space.history: ${res.status} ${res.statusText}`);
5619
+ const body = (await res.json());
5620
+ // Warm the member directory once so historical senders' signatures verify.
5621
+ // (refreshDirectory swallows its own transport errors.)
5622
+ await this.deps.keyring?.refreshDirectory();
5623
+ const messages = [];
5624
+ for (const raw of body.messages ?? []) {
5625
+ const decoded = await this.decodeFrame(raw, cipher);
5626
+ if (decoded)
5627
+ messages.push(decoded);
5628
+ }
5629
+ return { messages, nextCursor: body.nextCursor ?? null };
5630
+ }
5631
+ /**
5632
+ * Bring this member's group keys up to date: load from cache, and if we
5633
+ * hold nothing, request a key and poll until one arrives (or timeout).
5634
+ */
5635
+ async join(opts = {}) {
5636
+ const keyring = this.requireKeyring();
5637
+ await this.connect();
5638
+ if (await keyring.loadFromCache())
5639
+ return true;
5640
+ if (keyring.hasAnyKey())
5641
+ return true;
5642
+ await keyring.requestKey();
5643
+ const deadline = Date.now() + (opts.timeoutMs ?? 15000);
5644
+ const pollMs = opts.pollMs ?? 500;
5645
+ // eslint-disable-next-line no-constant-condition
5646
+ while (Date.now() < deadline) {
5647
+ if ((await keyring.pullKeys()) > 0)
5648
+ return true;
5649
+ await delay(pollMs);
5650
+ }
5651
+ return false;
5652
+ }
5653
+ /** Creator path: mint the initial group key, then connect. */
5654
+ async create() {
5655
+ const keyring = this.requireKeyring();
5656
+ await keyring.bootstrapNew();
5657
+ await this.connect();
5658
+ // Publish our identity in the member directory so peers can verify our
5659
+ // message signatures (the creator never "joins", so register here).
5660
+ await keyring.requestKey().catch(() => { });
5661
+ // Admit anyone who requested a key before we were listening.
5662
+ if (this.deps.autoAdmit !== false) {
5663
+ await keyring.admitPending().catch(() => { });
5664
+ }
5665
+ }
5666
+ /** Wrap the group key for a newcomer and post it (key-holder action). */
5667
+ async admit(memberId, identityEcdhPub) {
5668
+ await this.requireKeyring().admit(memberId, identityEcdhPub);
5669
+ }
5670
+ /** Invite a user to this (private) channel's membership allowlist. */
5671
+ async invite(username) {
5672
+ await this.requireKeyring().invite(username);
5673
+ }
5674
+ /** Rotate to a fresh epoch and re-wrap to the roster (rotate spaces). */
5675
+ async rotate(roster) {
5676
+ return this.requireKeyring().rotate(roster);
5677
+ }
5343
5678
  // -- room-scoped files (no websocket needed) -------------------------------
5344
- /** Encrypt + erasure-code + upload `file` to this room's shards. */
5345
5679
  async putFile(file, metadata) {
5346
5680
  return this.fileStorage().writeFileToShards({ data: file, metadata });
5347
5681
  }
5348
- /** Fetch + decode a file previously shared in this room. */
5349
5682
  async getFile(manifest) {
5350
5683
  return this.fileStorage().readFileFromShards(manifest);
5351
5684
  }
@@ -5357,6 +5690,129 @@ class Room {
5357
5690
  });
5358
5691
  return new FileStorage({ shards });
5359
5692
  }
5693
+ // -- internals -------------------------------------------------------------
5694
+ /** Wire the RAW_FRAME handler that drives fan-out events. */
5695
+ wireFanout() {
5696
+ if (this.rawWired || !this.channel)
5697
+ return;
5698
+ this.rawWired = true;
5699
+ this.channel.on(BroadcastChannelEvents.RAW_FRAME, (e) => {
5700
+ void this.onRawFrame(e.detail);
5701
+ });
5702
+ }
5703
+ async onRawFrame(frame) {
5704
+ const f = frame;
5705
+ if (!f || typeof f !== "object")
5706
+ return;
5707
+ if (typeof f.spaceMessage === "string" && this.cipher) {
5708
+ const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true);
5709
+ if (decoded)
5710
+ this.emitEvent("message", decoded);
5711
+ return;
5712
+ }
5713
+ if (f.joinRequest && typeof f.joinRequest === "object") {
5714
+ const req = f.joinRequest;
5715
+ this.emitEvent("join-request", req);
5716
+ // A new member appeared — refresh the directory so their future
5717
+ // messages verify, and (if we hold the key) auto-admit them.
5718
+ void this.deps.keyring?.refreshDirectory();
5719
+ if (this.deps.autoAdmit !== false && this.deps.keyring?.hasAnyKey() && req.memberId !== this.deps.myId()) {
5720
+ void this.deps.keyring.admit(req.memberId, req.identityEcdhPub).catch(() => { });
5721
+ }
5722
+ return;
5723
+ }
5724
+ if (f.keyringReady && this.deps.keyring) {
5725
+ // A key was wrapped for us — pull it.
5726
+ void this.deps.keyring.pullKeys().catch(() => { });
5727
+ }
5728
+ }
5729
+ /**
5730
+ * Decode one persisted/broadcast frame into a SpaceMessageEvent.
5731
+ * `isRawFrame` true means `input` is the already-extracted `spaceMessage`
5732
+ * packet string; false means it's the full `{spaceMessage, name, ...}` frame.
5733
+ */
5734
+ async decodeFrame(input, cipher, isRawFrame = false) {
5735
+ try {
5736
+ let packetJson = input;
5737
+ if (!isRawFrame) {
5738
+ const outer = JSON.parse(input);
5739
+ if (typeof outer.spaceMessage !== "string")
5740
+ return null;
5741
+ packetJson = outer.spaceMessage;
5742
+ }
5743
+ const packet = Packet.deserialize(packetJson);
5744
+ if (!cipher.handles(packet.headers))
5745
+ return null;
5746
+ // Verify sender authenticity end-to-end: the message must be signed
5747
+ // by `source`'s identity ECDSA key (looked up in the member
5748
+ // directory). Drop anything unsigned, signed by an unknown member,
5749
+ // or whose signature doesn't match — this defeats both member
5750
+ // impersonation and a relay rewriting `source`.
5751
+ if (!(await this.verifySender(packet)))
5752
+ return null;
5753
+ const serialized = await cipher.open(packet.headers);
5754
+ if (serialized === null)
5755
+ return null; // epoch key we don't hold
5756
+ const message = Message.deserialize(serialized);
5757
+ return {
5758
+ from: packet.source,
5759
+ channel: packet.subject,
5760
+ epoch: Number(packet.headers?.epoch ?? 0),
5761
+ contentType: packet.headers?.contentType,
5762
+ message,
5763
+ };
5764
+ }
5765
+ catch (err) {
5766
+ this.emitEvent("error", err);
5767
+ return null;
5768
+ }
5769
+ }
5770
+ /**
5771
+ * True if `packet` carries a valid signature by `source`'s identity key.
5772
+ * On a directory cache miss, refresh once (a new member may have just
5773
+ * joined) before giving up. Without a keyring (legacy handle) there's
5774
+ * nothing to verify against → reject.
5775
+ */
5776
+ async verifySender(packet) {
5777
+ const keyring = this.deps.keyring;
5778
+ const sig = packet.headers?.sig;
5779
+ if (!keyring || typeof sig !== "string")
5780
+ return false;
5781
+ let key = keyring.ecdsaKeyFor(packet.source);
5782
+ if (!key) {
5783
+ await keyring.refreshDirectory();
5784
+ key = keyring.ecdsaKeyFor(packet.source);
5785
+ if (!key)
5786
+ return false;
5787
+ }
5788
+ const canonical = canonicalMessage({
5789
+ source: packet.source,
5790
+ target: packet.target,
5791
+ subject: packet.subject,
5792
+ epoch: Number(packet.headers?.epoch ?? 0),
5793
+ iv: String(packet.headers?.iv ?? ""),
5794
+ ciphertext: String(packet.headers?.ciphertext ?? ""),
5795
+ });
5796
+ return verifySpaceMessage(canonical, sig, key);
5797
+ }
5798
+ emitEvent(type, detail) {
5799
+ this.events.dispatchEvent(new CustomEvent(type, { detail }));
5800
+ }
5801
+ requireKeyring() {
5802
+ if (!this.deps.keyring) {
5803
+ throw new Error("Space: this operation requires a keyring (open via client.space).");
5804
+ }
5805
+ return this.deps.keyring;
5806
+ }
5807
+ requireCipher(contentType) {
5808
+ if (!this.deps.keyring) {
5809
+ throw new Error("Space: fan-out messaging requires a keyring (open via client.space).");
5810
+ }
5811
+ return contentType ? new SpacePacketCipher(this.deps.keyring, contentType) : this.cipher;
5812
+ }
5813
+ }
5814
+ function delay(ms) {
5815
+ return new Promise((r) => setTimeout(r, ms));
5360
5816
  }
5361
5817
 
5362
5818
  /**
@@ -5461,7 +5917,7 @@ class MessageNamespace {
5461
5917
  * higher-level conveniences layered over the same space transport).
5462
5918
  */
5463
5919
  room(roomName) {
5464
- return new Room({
5920
+ return new Space({
5465
5921
  name: roomName,
5466
5922
  wsBaseUrl: this.deps.wsBaseUrl,
5467
5923
  httpBaseUrl: this.deps.http.baseUrl,
@@ -5537,6 +5993,486 @@ function parseMaybeJson(text) {
5537
5993
  }
5538
5994
  }
5539
5995
 
5996
+ /**
5997
+ * SpaceKeyring — the client's view of a space's group keys.
5998
+ *
5999
+ * Holds the per-epoch group keys this member possesses (in memory, optionally
6000
+ * mirrored to PersonalSpace), and orchestrates the server-blind distribution
6001
+ * dance against a {@link KeyringTransport}:
6002
+ *
6003
+ * - a newcomer posts a join request and pulls the wrapped key blob addressed
6004
+ * to it, unwrapping with its identity private key;
6005
+ * - an existing key-holder wraps the group key for a newcomer (ECIES to the
6006
+ * newcomer's identity public key) and posts the opaque blob;
6007
+ * - on membership change (rotate spaces) a fresh epoch key is minted and
6008
+ * re-wrapped to the current roster.
6009
+ *
6010
+ * The server only ever stores opaque {@link WrappedKey} blobs — it cannot read
6011
+ * the group key. Implements {@link EpochKeyProvider} so it can back a
6012
+ * {@link SpacePacketCipher} directly.
6013
+ */
6014
+ /**
6015
+ * Reserved member id for the app's "keeper" — a trusted always-available member
6016
+ * (the accelerator's AppDOv1) that holds the group key and re-issues it to
6017
+ * newcomers, so a channel is joinable with no human online. Admitting this id
6018
+ * at channel creation hands the key to the keeper. MUST match the accelerator's
6019
+ * `KEEPER_MEMBER_ID`.
6020
+ */
6021
+ const KEEPER_MEMBER_ID = "__keeper__";
6022
+ class SpaceKeyring {
6023
+ deps;
6024
+ keys = new Map();
6025
+ epoch = 0;
6026
+ policy;
6027
+ /** Cached member directory: memberId → imported ECDSA verify key. */
6028
+ memberEcdsa = new Map();
6029
+ constructor(deps) {
6030
+ this.deps = deps;
6031
+ this.policy = deps.historyPolicy ?? "static";
6032
+ }
6033
+ // -- member directory (for verifying sender signatures) -------------------
6034
+ /** The cached ECDSA verify key for a member, if known. */
6035
+ ecdsaKeyFor(memberId) {
6036
+ return this.memberEcdsa.get(memberId);
6037
+ }
6038
+ /** Refresh the member directory (memberId → ECDSA key) from the roster. */
6039
+ async refreshDirectory() {
6040
+ let roster;
6041
+ try {
6042
+ roster = await this.deps.transport.fetchRoster();
6043
+ }
6044
+ catch {
6045
+ return;
6046
+ }
6047
+ for (const m of roster) {
6048
+ if (!m.identityEcdsaPub || this.memberEcdsa.has(m.memberId))
6049
+ continue;
6050
+ try {
6051
+ this.memberEcdsa.set(m.memberId, await importEcdsaPublicKey(m.identityEcdsaPub));
6052
+ }
6053
+ catch {
6054
+ /* skip malformed entries */
6055
+ }
6056
+ }
6057
+ }
6058
+ // -- EpochKeyProvider ------------------------------------------------------
6059
+ currentEpoch() {
6060
+ return this.epoch;
6061
+ }
6062
+ keyForEpoch(epoch) {
6063
+ return this.keys.get(epoch);
6064
+ }
6065
+ hasAnyKey() {
6066
+ return this.keys.size > 0;
6067
+ }
6068
+ // -- lifecycle -------------------------------------------------------------
6069
+ /** Hydrate keys from the PersonalSpace cache, if available. Best-effort —
6070
+ * a cache miss/failure (e.g. not unlocked) must not block joining. */
6071
+ async loadFromCache() {
6072
+ if (!this.deps.cache)
6073
+ return false;
6074
+ let cached;
6075
+ try {
6076
+ cached = await this.deps.cache.loadKeys(this.deps.spaceId);
6077
+ }
6078
+ catch {
6079
+ return false;
6080
+ }
6081
+ if (!cached)
6082
+ return false;
6083
+ for (const [epochStr, b64] of Object.entries(cached)) {
6084
+ this.keys.set(Number(epochStr), fromBase64(b64));
6085
+ }
6086
+ this.epoch = Math.max(0, ...Array.from(this.keys.keys()));
6087
+ return this.keys.size > 0;
6088
+ }
6089
+ async persist() {
6090
+ if (!this.deps.cache)
6091
+ return;
6092
+ const out = {};
6093
+ for (const [epoch, key] of this.keys)
6094
+ out[String(epoch)] = toBase64(key);
6095
+ // Best-effort: caching is an optimization; a write failure (e.g. locked
6096
+ // storage) must not break key acquisition.
6097
+ try {
6098
+ await this.deps.cache.saveKeys(this.deps.spaceId, out);
6099
+ }
6100
+ catch {
6101
+ /* ignore */
6102
+ }
6103
+ }
6104
+ /** Creator path: mint epoch-0 group key for a brand-new space. */
6105
+ async bootstrapNew() {
6106
+ this.keys.set(0, generateSpaceKey());
6107
+ this.epoch = 0;
6108
+ await this.persist();
6109
+ }
6110
+ // -- newcomer: request + pull ---------------------------------------------
6111
+ /**
6112
+ * Post a join request so an online key-holder can wrap a key for us. Also
6113
+ * publishes our identity keys in the member directory (so others can verify
6114
+ * our message signatures, and the keeper can wrap to us).
6115
+ */
6116
+ async requestKey(desiredEpoch) {
6117
+ await this.deps.transport.postJoinRequest({
6118
+ memberId: this.deps.memberId,
6119
+ identityEcdhPub: this.deps.identityEcdhPub,
6120
+ identityEcdsaPub: this.deps.identityEcdsaPub,
6121
+ desiredEpoch,
6122
+ });
6123
+ }
6124
+ /**
6125
+ * Pull and unwrap any blobs addressed to us. Returns the number of new
6126
+ * epoch keys obtained. Safe to call repeatedly (idempotent).
6127
+ */
6128
+ async pullKeys() {
6129
+ const priv = this.deps.ownPrivateKey();
6130
+ if (!priv)
6131
+ throw new Error("SpaceKeyring: no identity private key to unwrap with");
6132
+ const blobs = await this.deps.transport.fetchBlobs(this.deps.memberId);
6133
+ let added = 0;
6134
+ for (const blob of blobs) {
6135
+ if (this.keys.has(blob.epoch))
6136
+ continue;
6137
+ try {
6138
+ const key = await unwrapSpaceKey(blob, priv);
6139
+ this.keys.set(blob.epoch, key);
6140
+ added++;
6141
+ }
6142
+ catch {
6143
+ // Not for us / tampered — skip this blob.
6144
+ }
6145
+ }
6146
+ if (added > 0) {
6147
+ this.epoch = Math.max(this.epoch, ...Array.from(this.keys.keys()));
6148
+ await this.persist();
6149
+ }
6150
+ return added;
6151
+ }
6152
+ // -- key-holder: admit + rotate -------------------------------------------
6153
+ /**
6154
+ * Wrap our group key(s) for a newcomer and post the blobs. In a `static`
6155
+ * space the newcomer receives every epoch we hold (full history); in a
6156
+ * `rotate` space they receive only the current epoch.
6157
+ */
6158
+ async admit(targetMemberId, identityEcdhPub) {
6159
+ const recipient = await importEcdhPublicKey(identityEcdhPub);
6160
+ const epochs = this.policy === "static"
6161
+ ? Array.from(this.keys.keys())
6162
+ : [this.epoch];
6163
+ for (const epoch of epochs) {
6164
+ const key = this.keys.get(epoch);
6165
+ if (!key)
6166
+ continue;
6167
+ const wrapped = await wrapSpaceKey(key, epoch, recipient);
6168
+ await this.deps.transport.postWrappedKey(targetMemberId, wrapped, this.deps.memberId);
6169
+ }
6170
+ }
6171
+ /**
6172
+ * Admit every outstanding join request (an online key-holder fulfilling
6173
+ * pending newcomers). Returns the ids admitted.
6174
+ */
6175
+ async admitPending() {
6176
+ const pending = await this.deps.transport.fetchPending();
6177
+ const admitted = [];
6178
+ for (const req of pending) {
6179
+ if (req.memberId === this.deps.memberId)
6180
+ continue;
6181
+ await this.admit(req.memberId, req.identityEcdhPub);
6182
+ admitted.push(req.memberId);
6183
+ }
6184
+ return admitted;
6185
+ }
6186
+ /**
6187
+ * Rotate to a fresh epoch (rotate spaces). Mints a new group key, advances
6188
+ * the epoch, and re-wraps it to every member in `roster` (plus ourselves).
6189
+ */
6190
+ async rotate(roster) {
6191
+ const members = roster ?? (await this.deps.transport.fetchRoster());
6192
+ const { epoch: nextEpoch } = await this.deps.transport.rotate(this.epoch + 1);
6193
+ const newKey = generateSpaceKey();
6194
+ this.keys.set(nextEpoch, newKey);
6195
+ this.epoch = nextEpoch;
6196
+ for (const m of members) {
6197
+ if (m.memberId === this.deps.memberId)
6198
+ continue;
6199
+ const recipient = await importEcdhPublicKey(m.identityEcdhPub);
6200
+ const wrapped = await wrapSpaceKey(newKey, nextEpoch, recipient);
6201
+ await this.deps.transport.postWrappedKey(m.memberId, wrapped, this.deps.memberId);
6202
+ }
6203
+ await this.persist();
6204
+ return nextEpoch;
6205
+ }
6206
+ /** Add `username` to a private channel's membership allowlist (member-only). */
6207
+ async invite(username) {
6208
+ await this.deps.transport.invite(username);
6209
+ }
6210
+ }
6211
+
6212
+ /**
6213
+ * KeyringClient — HTTP implementation of {@link KeyringTransport} against the
6214
+ * accelerator's per-space keyring endpoints. All blobs are opaque to the
6215
+ * server; this client just moves them.
6216
+ *
6217
+ * POST /api/spaces/:id/keyring/join-request { ...JoinRequest }
6218
+ * GET /api/spaces/:id/keyring/blobs/:member -> WrappedKey[]
6219
+ * POST /api/spaces/:id/keyring/wrap { targetMemberId, fromMemberId, wrapped }
6220
+ * GET /api/spaces/:id/keyring/pending -> JoinRequest[]
6221
+ * GET /api/spaces/:id/keyring/roster -> { memberId, identityEcdhPub }[]
6222
+ * POST /api/spaces/:id/keyring/rotate { nextEpoch } -> { epoch }
6223
+ * GET /api/spaces/:id/metadata -> SpaceMetadata
6224
+ */
6225
+ class KeyringClient {
6226
+ deps;
6227
+ constructor(deps) {
6228
+ this.deps = deps;
6229
+ }
6230
+ url(suffix) {
6231
+ return `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.deps.spaceId)}${suffix}`;
6232
+ }
6233
+ async post(suffix, body) {
6234
+ const res = await this.deps.fetch(this.url(suffix), {
6235
+ method: "POST",
6236
+ headers: { "Content-Type": "application/json" },
6237
+ body: JSON.stringify(body),
6238
+ });
6239
+ if (!res.ok)
6240
+ throw new Error(`KeyringClient POST ${suffix}: ${res.status} ${res.statusText}`);
6241
+ return (await res.json().catch(() => ({})));
6242
+ }
6243
+ async get(suffix) {
6244
+ const res = await this.deps.fetch(this.url(suffix));
6245
+ if (!res.ok)
6246
+ throw new Error(`KeyringClient GET ${suffix}: ${res.status} ${res.statusText}`);
6247
+ return (await res.json());
6248
+ }
6249
+ async postJoinRequest(req) {
6250
+ await this.post("/keyring/join-request", req);
6251
+ }
6252
+ async fetchBlobs(memberId) {
6253
+ const body = await this.get(`/keyring/blobs/${encodeURIComponent(memberId)}`);
6254
+ return body.blobs ?? [];
6255
+ }
6256
+ async postWrappedKey(targetMemberId, wrapped, fromMemberId) {
6257
+ await this.post("/keyring/wrap", { targetMemberId, fromMemberId, wrapped });
6258
+ }
6259
+ async fetchPending() {
6260
+ const body = await this.get("/keyring/pending");
6261
+ return body.pending ?? [];
6262
+ }
6263
+ async fetchRoster() {
6264
+ const body = await this.get("/keyring/roster");
6265
+ return body.roster ?? [];
6266
+ }
6267
+ async rotate(nextEpoch) {
6268
+ return this.post("/keyring/rotate", { nextEpoch });
6269
+ }
6270
+ async invite(username) {
6271
+ await this.post("/keyring/invite", { username });
6272
+ }
6273
+ async fetchMetadata() {
6274
+ try {
6275
+ return await this.get("/metadata");
6276
+ }
6277
+ catch {
6278
+ return null;
6279
+ }
6280
+ }
6281
+ }
6282
+
6283
+ /**
6284
+ * `client.space` — the fan-out group-encryption surface.
6285
+ *
6286
+ * const space = await client.space.createSpace({ historyPolicy: "static" });
6287
+ * await space.sendMessage({ text: "hello" });
6288
+ * space.onMessage(e => …);
6289
+ *
6290
+ * const joined = await client.space.joinSpace(space.id); // newcomer
6291
+ *
6292
+ * A space owns a client-generated keypair whose public key is its id, a
6293
+ * symmetric group key distributed server-blind via the keyring, and a
6294
+ * persisted message log. This namespace wires the {@link Space} handle to the
6295
+ * HTTP {@link KeyringClient}, the member's identity keys (from {@link KeyStore},
6296
+ * shared with the Double Ratchet path), and a PersonalSpace-backed key cache.
6297
+ */
6298
+ /** Thrown by `joinChannel` when no channel with that name is registered. */
6299
+ class ChannelNotFoundError extends Error {
6300
+ channelName;
6301
+ constructor(channelName) {
6302
+ super(`Channel "${channelName}" does not exist. Create it first.`);
6303
+ this.channelName = channelName;
6304
+ this.name = "ChannelNotFoundError";
6305
+ }
6306
+ }
6307
+ /** Thrown by `createChannel` when the name is already registered. */
6308
+ class ChannelExistsError extends Error {
6309
+ channelName;
6310
+ constructor(channelName) {
6311
+ super(`Channel "${channelName}" already exists.`);
6312
+ this.channelName = channelName;
6313
+ this.name = "ChannelExistsError";
6314
+ }
6315
+ }
6316
+ class SpaceNamespace {
6317
+ deps;
6318
+ constructor(deps) {
6319
+ this.deps = deps;
6320
+ }
6321
+ /**
6322
+ * Create a new space: generate its keypair (public key = id), register
6323
+ * metadata, mint the initial group key, and connect. Returns the open Space.
6324
+ */
6325
+ async createSpace(opts = {}) {
6326
+ const historyPolicy = opts.historyPolicy ?? "static";
6327
+ const identity = await generateSpaceIdentity();
6328
+ // Register the space (server records pubkey + policy; the DO is lazy).
6329
+ // For a private space the server also records the creator (this user) as
6330
+ // its first member — so the metadata POST must carry the user session.
6331
+ await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy, visibility: opts.private ? "private" : "public" });
6332
+ const space = await this.build(identity.id, historyPolicy);
6333
+ await space.create();
6334
+ return space;
6335
+ }
6336
+ /** Join an existing space by id (its public key). Resolves once a key arrives. */
6337
+ async joinSpace(spaceId, opts = {}) {
6338
+ const policy = await this.fetchPolicy(spaceId);
6339
+ const space = await this.build(spaceId, policy);
6340
+ await space.join({ timeoutMs: opts.timeoutMs });
6341
+ return space;
6342
+ }
6343
+ /** A handle to a space without auto-connecting (uses cached keys on demand). */
6344
+ async get(spaceId, historyPolicy = "static") {
6345
+ return this.build(spaceId, historyPolicy);
6346
+ }
6347
+ // -------------------------------------------------------------------------
6348
+ // Channels — named, app-scoped pointers to spaces. The name → space-id
6349
+ // directory is app-public config (server-readable, scoped to this app via
6350
+ // its key); the channel CONTENTS stay fan-out E2E-encrypted. Lets chat use
6351
+ // human room names (`general`) instead of raw pubkey ids.
6352
+ // -------------------------------------------------------------------------
6353
+ /** List this app's channels (name → space-id) for the key's environment. */
6354
+ async listChannels() {
6355
+ const body = await this.deps.http.get("/api/app/channels");
6356
+ return body.channels ?? [];
6357
+ }
6358
+ /** Resolve a channel name to its space id, or null if it doesn't exist. */
6359
+ async resolveChannel(name) {
6360
+ try {
6361
+ const body = await this.deps.http.get(`/api/app/channels/${encodeURIComponent(name)}`);
6362
+ return body.spaceId ?? null;
6363
+ }
6364
+ catch (err) {
6365
+ if (err instanceof HttpError && err.status === 404)
6366
+ return null;
6367
+ throw err;
6368
+ }
6369
+ }
6370
+ /**
6371
+ * Create a new channel: mint a fan-out space (the caller becomes its first
6372
+ * key-holder) and register `name → spaceId` in the app directory. Throws
6373
+ * {@link ChannelExistsError} if the name is already taken.
6374
+ */
6375
+ async createChannel(name, opts = {}) {
6376
+ const space = await this.createSpace(opts);
6377
+ // Register name → id first so a name clash doesn't leave an admitted keeper.
6378
+ try {
6379
+ await this.deps.http.post("/api/app/channels", { name, spaceId: space.id, visibility: opts.private ? "private" : "public" });
6380
+ }
6381
+ catch (err) {
6382
+ if (err instanceof HttpError && err.status === 409) {
6383
+ throw new ChannelExistsError(name);
6384
+ }
6385
+ throw err;
6386
+ }
6387
+ // Admit the app keeper so the channel is joinable with no member online.
6388
+ // Best-effort: a channel still works (members admit each other) without it.
6389
+ try {
6390
+ const { pubkey } = await this.deps.http.get("/api/app/keeper");
6391
+ if (pubkey && space.keyring)
6392
+ await space.keyring.admit(KEEPER_MEMBER_ID, pubkey);
6393
+ }
6394
+ catch (err) {
6395
+ console.warn("[client.space] keeper admit failed (channel still usable):", err);
6396
+ }
6397
+ return space;
6398
+ }
6399
+ /**
6400
+ * Join an existing channel by name. Throws {@link ChannelNotFoundError} if
6401
+ * no channel with that name is registered (use {@link createChannel} first).
6402
+ */
6403
+ async joinChannel(name, opts = {}) {
6404
+ const spaceId = await this.resolveChannel(name);
6405
+ if (!spaceId)
6406
+ throw new ChannelNotFoundError(name);
6407
+ return this.joinSpace(spaceId, opts);
6408
+ }
6409
+ // -------------------------------------------------------------------------
6410
+ // Internals
6411
+ // -------------------------------------------------------------------------
6412
+ async build(spaceId, historyPolicy) {
6413
+ const memberId = this.myId();
6414
+ const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
6415
+ const transport = new KeyringClient({
6416
+ spaceId,
6417
+ httpBaseUrl: this.deps.http.baseUrl,
6418
+ fetch: this.deps.http.fetch,
6419
+ });
6420
+ const keyring = new SpaceKeyring({
6421
+ spaceId,
6422
+ memberId,
6423
+ identityEcdhPub: ecdhPub,
6424
+ identityEcdsaPub: ecdsaPub,
6425
+ ownPrivateKey: () => KeyStore.getInstance().getKeyPair(memberId)?.privateKey ?? null,
6426
+ transport,
6427
+ cache: this.deps.cache,
6428
+ historyPolicy,
6429
+ });
6430
+ return new Space({
6431
+ name: spaceId,
6432
+ wsBaseUrl: this.deps.wsBaseUrl,
6433
+ httpBaseUrl: this.deps.http.baseUrl,
6434
+ fetch: this.deps.http.fetch,
6435
+ myId: () => memberId,
6436
+ fetchTicket: () => this.fetchTicket(),
6437
+ keyring,
6438
+ historyPolicy,
6439
+ });
6440
+ }
6441
+ /** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
6442
+ async ensureIdentity(memberId) {
6443
+ const store = KeyStore.getInstance();
6444
+ if (!store.getKeyPair(memberId)) {
6445
+ await store.generateOwnKeyPair(memberId);
6446
+ }
6447
+ const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
6448
+ const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
6449
+ return { ecdhPub, ecdsaPub };
6450
+ }
6451
+ async fetchPolicy(spaceId) {
6452
+ const meta = await new KeyringClient({
6453
+ spaceId,
6454
+ httpBaseUrl: this.deps.http.baseUrl,
6455
+ fetch: this.deps.http.fetch,
6456
+ }).fetchMetadata();
6457
+ return meta?.historyPolicy ?? "static";
6458
+ }
6459
+ async fetchTicket() {
6460
+ try {
6461
+ const res = await this.deps.http.post("/api/ws-ticket", {});
6462
+ return res?.ticket ?? null;
6463
+ }
6464
+ catch {
6465
+ return null;
6466
+ }
6467
+ }
6468
+ myId() {
6469
+ const id = this.deps.session.username;
6470
+ if (!id)
6471
+ throw new Error("client.space: not signed in — call client.auth.zk.login() first.");
6472
+ return id;
6473
+ }
6474
+ }
6475
+
5540
6476
  /**
5541
6477
  * `Client` — the single entry point to the Muhkoo SDK.
5542
6478
  *
@@ -5577,6 +6513,8 @@ class Client {
5577
6513
  storage;
5578
6514
  /** Realtime messaging — `client.message.subscribe(...)`, etc. */
5579
6515
  message;
6516
+ /** Fan-out group spaces — `client.space.createSpace(...)`, etc. */
6517
+ space;
5580
6518
  session;
5581
6519
  http;
5582
6520
  constructor(options = {}) {
@@ -5597,6 +6535,19 @@ class Client {
5597
6535
  this.auth = new AuthNamespace({ auth: authClient, circuits, session: this.session });
5598
6536
  this.storage = new StorageNamespace({ http: this.http, session: this.session, wsBaseUrl });
5599
6537
  this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
6538
+ // Group keys are cached in the user's PersonalSpace (encrypted at rest
6539
+ // by StorageNamespace), so a returning member hydrates without a fresh
6540
+ // keyring round-trip. The server only ever sees the ciphertext.
6541
+ const spaceKeyCache = {
6542
+ loadKeys: (spaceId) => this.storage.get("space-keys", spaceId),
6543
+ saveKeys: (spaceId, keys) => this.storage.set("space-keys", spaceId, keys),
6544
+ };
6545
+ this.space = new SpaceNamespace({
6546
+ http: this.http,
6547
+ session: this.session,
6548
+ wsBaseUrl,
6549
+ cache: spaceKeyCache,
6550
+ });
5600
6551
  }
5601
6552
  /** The currently signed-in user, or `null`. */
5602
6553
  get user() {
@@ -5608,6 +6559,48 @@ class Client {
5608
6559
  }
5609
6560
  }
5610
6561
 
6562
+ /**
6563
+ * PacketCipher — the strategy `Network` uses to seal/open a packet's payload.
6564
+ *
6565
+ * `Network` owns the wire shape (Message ↔ Packet, header placement, transport,
6566
+ * reconnect, checksum). The *encryption* is pluggable: the default
6567
+ * {@link DoubleRatchetCipher} reproduces the per-peer Double Ratchet behavior
6568
+ * Network has always had, while the Space layer supplies a group-key cipher.
6569
+ *
6570
+ * The sealed ciphertext always rides in `Packet.headers` (cleartext routing
6571
+ * fields stay readable to the server); `Packet.message` is left undefined on
6572
+ * the wire so the server never sees the real payload.
6573
+ */
6574
+ /**
6575
+ * Default cipher: per-peer Double Ratchet. Reproduces Network's original
6576
+ * `{ encrypted: true, cipherMessage: <json> }` header convention exactly so
6577
+ * existing consumers are unaffected.
6578
+ */
6579
+ class DoubleRatchetCipher {
6580
+ deps;
6581
+ constructor(deps) {
6582
+ this.deps = deps;
6583
+ }
6584
+ async seal(serializedMessage) {
6585
+ const sessionId = this.deps.getSessionId();
6586
+ if (!sessionId) {
6587
+ throw new Error("DoubleRatchetCipher: no active session to seal under");
6588
+ }
6589
+ const cipherMessage = await this.deps.ratchetManager.encrypt(this.deps.clientId, this.deps.serverId, sessionId, serializedMessage, false, // newDhKey — let the ratchet manage key rotation
6590
+ this.deps.sessionType);
6591
+ return { encrypted: true, cipherMessage: JSON.stringify(cipherMessage) };
6592
+ }
6593
+ handles(headers) {
6594
+ return !!(headers?.encrypted && headers?.cipherMessage);
6595
+ }
6596
+ async open(headers) {
6597
+ if (!this.handles(headers))
6598
+ return null;
6599
+ const cipherMessage = JSON.parse(headers.cipherMessage);
6600
+ return this.deps.ratchetManager.decrypt(cipherMessage, this.deps.isClient ?? true);
6601
+ }
6602
+ }
6603
+
5611
6604
  const appLogger$1 = new Logger("connect", 'ERROR');
5612
6605
  globalThis.appLogger = appLogger$1;
5613
6606
 
@@ -5632,5 +6625,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
5632
6625
  default: loadRsWasm
5633
6626
  });
5634
6627
 
5635
- export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChunkCipher, Client, DEFAULT_BASE_URL, DoubleRatchet, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, HashKnowledge, HttpClient, HttpError, KeyStore, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, StorageNamespace, WSTransport, ZkAuth, buildCommitment, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, manifestToStat, poseidonHash, quickVerify, randomBytes, shardHash, signMessage, toField, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
6628
+ 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, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, WSTransport, ZkAuth, buildCommitment, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, manifestToStat, poseidonHash, quickVerify, randomBytes, shardHash, signMessage, toField, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
5636
6629
  //# sourceMappingURL=index.js.map