@muhkoo/connect 0.2.0-alpha.1 → 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.
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # @muhkoo/connect
2
2
 
3
3
  Client SDK for the Muhkoo Accelerator. The headline surface is a single
4
- **`Client`** that hangs everything off three namespaces —
5
- `client.auth` / `client.storage` / `client.message` — backed by
6
- end-to-end-encrypted messaging primitives (Double Ratchet + ECDH P-384), a
7
- turnkey multi-peer `BroadcastChannel`, ZK identity + personal/shared spaces,
8
- and a Cloudflare-Workers-compatible Groth16 verifier — all bundled from a
9
- single TypeScript source tree.
4
+ **`Client`** that hangs everything off four namespaces —
5
+ `client.auth` / `client.storage` / `client.message` / `client.space` — backed by
6
+ end-to-end-encrypted messaging (fan-out group channels with history, plus
7
+ Double Ratchet + ECDH P-384 for direct messages), ZK identity + personal/shared
8
+ spaces, and a Cloudflare-Workers-compatible Groth16 verifier — all bundled from
9
+ a single TypeScript source tree.
10
10
 
11
11
  > Status: alpha. The library is consumed in tree by `../accelerator` and the
12
12
  > `../web` SPA.
@@ -140,20 +140,28 @@ await client.storage.delete("todos", "t1");
140
140
  const sub = client.message.subscribe("todos", (e) => console.log("update:", e.data));
141
141
  await client.message.publish("todos", { id: "t1", done: true });
142
142
  await client.message.send("user:abc", { text: "Hello!" });
143
+
144
+ // Space — fan-out group channels with persisted history (group chat).
145
+ const space = await client.space.joinChannel("project-x"); // or createChannel(...)
146
+ space.onMessage((e) => console.log(e.from, e.message.body));
147
+ await space.sendMessage("Hi everyone");
148
+ const { messages } = await space.history({ limit: 100 });
143
149
  ```
144
150
 
145
- `subscribe(subject)` / `publish(subject, data)` are plaintext fan-out to
146
- everyone on that subject. `send("user:<id>", …)` is an **end-to-end-encrypted**
147
- direct message (Double Ratchet); the recipient receives it by subscribing to
148
- their *own* id — `client.message.subscribe("user:<me>", …)`. Both modes ride a
149
- shared-space room websocket under the hood (opened with a short-lived signed
150
- ticket); E2E delivery needs both parties online, like any ratchet.
151
-
152
- For full **group rooms** (multi-peer E2E chat + room-scoped file sharing), use
153
- `client.message.room(name)` — a shared-space handle exposing the group channel
154
- (`connect`/`on`/`send`/`announce`/`peers`/`sendRaw`) plus
155
- `putFile`/`getFile`. `subscribe`/`publish`/`send` are conveniences layered over
156
- this same room primitive.
151
+ `client.message` is lightweight realtime: `subscribe`/`publish` are plaintext
152
+ fan-out; `send("user:<id>", …)` is an **end-to-end-encrypted** direct message
153
+ (Double Ratchet — forward-secret, ephemeral, no history; both parties must be
154
+ online).
155
+
156
+ `client.space` is **fan-out group messaging with history**. A channel seals each
157
+ message once with a shared group key so the server can persist + replay it.
158
+ `createChannel(name)` mints + registers a channel (you become its first
159
+ key-holder); `joinChannel(name)` resolves an existing one and is admitted by the
160
+ app's **keeper** — a trusted, always-available member that re-issues the group
161
+ key — so a channel is joinable even when no one else is online. The relay stays
162
+ blind (it only ever sees opaque, ECIES-wrapped key blobs). The space handle was
163
+ once `Room`; that name is still exported as an alias, and `client.message.room()`
164
+ returns the same handle.
157
165
 
158
166
  Auth lifecycle:
159
167
 
@@ -5305,6 +5305,46 @@ async function importEcdhPublicKey(encoded) {
5305
5305
  }
5306
5306
  /** The space's id is just its encoded ECDH public key. */
5307
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
+ }
5308
5348
  /** Generate a fresh space identity keypair (P-384 ECDH). */
5309
5349
  async function generateSpaceIdentity() {
5310
5350
  const kp = await getSubtle().generateKey(ECDH_PARAMS, true, ["deriveBits"]);
@@ -5533,12 +5573,19 @@ class Space {
5533
5573
  const cipher = this.requireCipher(opts.contentType);
5534
5574
  const message = new Message(payload);
5535
5575
  const headers = await cipher.seal(message.serialize());
5536
- const packet = new Packet({
5537
- subject: opts.channel ?? "default",
5538
- source: this.deps.myId(),
5539
- target: this.name,
5540
- headers,
5541
- });
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 });
5542
5589
  this.sendRaw({ spaceMessage: packet.serialize() });
5543
5590
  }
5544
5591
  /** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
@@ -5570,6 +5617,9 @@ class Space {
5570
5617
  if (!res.ok)
5571
5618
  throw new Error(`Space.history: ${res.status} ${res.statusText}`);
5572
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();
5573
5623
  const messages = [];
5574
5624
  for (const raw of body.messages ?? []) {
5575
5625
  const decoded = await this.decodeFrame(raw, cipher);
@@ -5605,6 +5655,9 @@ class Space {
5605
5655
  const keyring = this.requireKeyring();
5606
5656
  await keyring.bootstrapNew();
5607
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(() => { });
5608
5661
  // Admit anyone who requested a key before we were listening.
5609
5662
  if (this.deps.autoAdmit !== false) {
5610
5663
  await keyring.admitPending().catch(() => { });
@@ -5614,6 +5667,10 @@ class Space {
5614
5667
  async admit(memberId, identityEcdhPub) {
5615
5668
  await this.requireKeyring().admit(memberId, identityEcdhPub);
5616
5669
  }
5670
+ /** Invite a user to this (private) channel's membership allowlist. */
5671
+ async invite(username) {
5672
+ await this.requireKeyring().invite(username);
5673
+ }
5617
5674
  /** Rotate to a fresh epoch and re-wrap to the roster (rotate spaces). */
5618
5675
  async rotate(roster) {
5619
5676
  return this.requireKeyring().rotate(roster);
@@ -5656,7 +5713,9 @@ class Space {
5656
5713
  if (f.joinRequest && typeof f.joinRequest === "object") {
5657
5714
  const req = f.joinRequest;
5658
5715
  this.emitEvent("join-request", req);
5659
- // Auto-admit: if we hold the group key, wrap it for the newcomer.
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();
5660
5719
  if (this.deps.autoAdmit !== false && this.deps.keyring?.hasAnyKey() && req.memberId !== this.deps.myId()) {
5661
5720
  void this.deps.keyring.admit(req.memberId, req.identityEcdhPub).catch(() => { });
5662
5721
  }
@@ -5684,6 +5743,13 @@ class Space {
5684
5743
  const packet = Packet.deserialize(packetJson);
5685
5744
  if (!cipher.handles(packet.headers))
5686
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;
5687
5753
  const serialized = await cipher.open(packet.headers);
5688
5754
  if (serialized === null)
5689
5755
  return null; // epoch key we don't hold
@@ -5701,6 +5767,34 @@ class Space {
5701
5767
  return null;
5702
5768
  }
5703
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
+ }
5704
5798
  emitEvent(type, detail) {
5705
5799
  this.events.dispatchEvent(new CustomEvent(type, { detail }));
5706
5800
  }
@@ -5930,10 +6024,37 @@ class SpaceKeyring {
5930
6024
  keys = new Map();
5931
6025
  epoch = 0;
5932
6026
  policy;
6027
+ /** Cached member directory: memberId → imported ECDSA verify key. */
6028
+ memberEcdsa = new Map();
5933
6029
  constructor(deps) {
5934
6030
  this.deps = deps;
5935
6031
  this.policy = deps.historyPolicy ?? "static";
5936
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
+ }
5937
6058
  // -- EpochKeyProvider ------------------------------------------------------
5938
6059
  currentEpoch() {
5939
6060
  return this.epoch;
@@ -5987,11 +6108,16 @@ class SpaceKeyring {
5987
6108
  await this.persist();
5988
6109
  }
5989
6110
  // -- newcomer: request + pull ---------------------------------------------
5990
- /** Post a join request so an online key-holder can wrap a key for us. */
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
+ */
5991
6116
  async requestKey(desiredEpoch) {
5992
6117
  await this.deps.transport.postJoinRequest({
5993
6118
  memberId: this.deps.memberId,
5994
6119
  identityEcdhPub: this.deps.identityEcdhPub,
6120
+ identityEcdsaPub: this.deps.identityEcdsaPub,
5995
6121
  desiredEpoch,
5996
6122
  });
5997
6123
  }
@@ -6077,6 +6203,10 @@ class SpaceKeyring {
6077
6203
  await this.persist();
6078
6204
  return nextEpoch;
6079
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
+ }
6080
6210
  }
6081
6211
 
6082
6212
  /**
@@ -6137,6 +6267,9 @@ class KeyringClient {
6137
6267
  async rotate(nextEpoch) {
6138
6268
  return this.post("/keyring/rotate", { nextEpoch });
6139
6269
  }
6270
+ async invite(username) {
6271
+ await this.post("/keyring/invite", { username });
6272
+ }
6140
6273
  async fetchMetadata() {
6141
6274
  try {
6142
6275
  return await this.get("/metadata");
@@ -6193,7 +6326,9 @@ class SpaceNamespace {
6193
6326
  const historyPolicy = opts.historyPolicy ?? "static";
6194
6327
  const identity = await generateSpaceIdentity();
6195
6328
  // Register the space (server records pubkey + policy; the DO is lazy).
6196
- await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy });
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" });
6197
6332
  const space = await this.build(identity.id, historyPolicy);
6198
6333
  await space.create();
6199
6334
  return space;
@@ -6241,7 +6376,7 @@ class SpaceNamespace {
6241
6376
  const space = await this.createSpace(opts);
6242
6377
  // Register name → id first so a name clash doesn't leave an admitted keeper.
6243
6378
  try {
6244
- await this.deps.http.post("/api/app/channels", { name, spaceId: space.id });
6379
+ await this.deps.http.post("/api/app/channels", { name, spaceId: space.id, visibility: opts.private ? "private" : "public" });
6245
6380
  }
6246
6381
  catch (err) {
6247
6382
  if (err instanceof HttpError && err.status === 409) {
@@ -6276,7 +6411,7 @@ class SpaceNamespace {
6276
6411
  // -------------------------------------------------------------------------
6277
6412
  async build(spaceId, historyPolicy) {
6278
6413
  const memberId = this.myId();
6279
- const identityEcdhPub = await this.ensureIdentity(memberId);
6414
+ const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
6280
6415
  const transport = new KeyringClient({
6281
6416
  spaceId,
6282
6417
  httpBaseUrl: this.deps.http.baseUrl,
@@ -6285,7 +6420,8 @@ class SpaceNamespace {
6285
6420
  const keyring = new SpaceKeyring({
6286
6421
  spaceId,
6287
6422
  memberId,
6288
- identityEcdhPub,
6423
+ identityEcdhPub: ecdhPub,
6424
+ identityEcdsaPub: ecdsaPub,
6289
6425
  ownPrivateKey: () => KeyStore.getInstance().getKeyPair(memberId)?.privateKey ?? null,
6290
6426
  transport,
6291
6427
  cache: this.deps.cache,
@@ -6302,14 +6438,15 @@ class SpaceNamespace {
6302
6438
  historyPolicy,
6303
6439
  });
6304
6440
  }
6305
- /** Ensure the member has an identity ECDH keypair; return its public key. */
6441
+ /** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
6306
6442
  async ensureIdentity(memberId) {
6307
6443
  const store = KeyStore.getInstance();
6308
6444
  if (!store.getKeyPair(memberId)) {
6309
6445
  await store.generateOwnKeyPair(memberId);
6310
6446
  }
6311
- const pub = store.getKeyPair(memberId).publicKey;
6312
- return exportEcdhPublicKey(pub);
6447
+ const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
6448
+ const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
6449
+ return { ecdhPub, ecdsaPub };
6313
6450
  }
6314
6451
  async fetchPolicy(spaceId) {
6315
6452
  const meta = await new KeyringClient({