@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/dist/connect.d.ts CHANGED
@@ -2385,6 +2385,14 @@ interface JoinRequest {
2385
2385
  desiredEpoch?: number;
2386
2386
  ts?: number;
2387
2387
  }
2388
+ /** A space member's published identity keys (the keyring directory). */
2389
+ interface RosterMember {
2390
+ memberId: string;
2391
+ /** Base64url-encoded JWK of the member's identity ECDH public key. */
2392
+ identityEcdhPub: string;
2393
+ /** Base64url-encoded JWK of the member's identity ECDSA public key. */
2394
+ identityEcdsaPub?: string;
2395
+ }
2388
2396
 
2389
2397
  /**
2390
2398
  * PacketCipher — the strategy `Network` uses to seal/open a packet's payload.
@@ -2504,15 +2512,14 @@ interface KeyringTransport {
2504
2512
  postWrappedKey(targetMemberId: string, wrapped: WrappedKey, fromMemberId: string): Promise<void>;
2505
2513
  /** Outstanding join requests (for an online key-holder to fulfill). */
2506
2514
  fetchPending(): Promise<JoinRequest[]>;
2507
- /** Current roster: member ids + their identity ECDH public keys. */
2508
- fetchRoster(): Promise<Array<{
2509
- memberId: string;
2510
- identityEcdhPub: string;
2511
- }>>;
2515
+ /** Current roster: member ids + their identity ECDH/ECDSA public keys. */
2516
+ fetchRoster(): Promise<RosterMember[]>;
2512
2517
  /** Advisory epoch bump; returns the agreed new epoch. */
2513
2518
  rotate(nextEpoch: number): Promise<{
2514
2519
  epoch: number;
2515
2520
  }>;
2521
+ /** Add `username` to a private channel's membership allowlist. */
2522
+ invite(username: string): Promise<void>;
2516
2523
  fetchMetadata(): Promise<SpaceMetadata | null>;
2517
2524
  }
2518
2525
  /** PersonalSpace-backed cache of this member's group keys (epoch → base64 key). */
@@ -2525,6 +2532,9 @@ interface SpaceKeyringDeps {
2525
2532
  memberId: string;
2526
2533
  /** This member's identity ECDH public key, base64url JWK (for join requests). */
2527
2534
  identityEcdhPub: string;
2535
+ /** This member's identity ECDSA public key, base64url JWK (published for
2536
+ * sender-signature verification). */
2537
+ identityEcdsaPub?: string;
2528
2538
  /** Resolves this member's identity ECDH private key (for unwrapping). */
2529
2539
  ownPrivateKey: () => CryptoKey | null;
2530
2540
  transport: KeyringTransport;
@@ -2536,7 +2546,13 @@ declare class SpaceKeyring implements EpochKeyProvider {
2536
2546
  private readonly keys;
2537
2547
  private epoch;
2538
2548
  private readonly policy;
2549
+ /** Cached member directory: memberId → imported ECDSA verify key. */
2550
+ private readonly memberEcdsa;
2539
2551
  constructor(deps: SpaceKeyringDeps);
2552
+ /** The cached ECDSA verify key for a member, if known. */
2553
+ ecdsaKeyFor(memberId: string): CryptoKey | undefined;
2554
+ /** Refresh the member directory (memberId → ECDSA key) from the roster. */
2555
+ refreshDirectory(): Promise<void>;
2540
2556
  currentEpoch(): number;
2541
2557
  keyForEpoch(epoch: number): Uint8Array | undefined;
2542
2558
  hasAnyKey(): boolean;
@@ -2546,7 +2562,11 @@ declare class SpaceKeyring implements EpochKeyProvider {
2546
2562
  private persist;
2547
2563
  /** Creator path: mint epoch-0 group key for a brand-new space. */
2548
2564
  bootstrapNew(): Promise<void>;
2549
- /** Post a join request so an online key-holder can wrap a key for us. */
2565
+ /**
2566
+ * Post a join request so an online key-holder can wrap a key for us. Also
2567
+ * publishes our identity keys in the member directory (so others can verify
2568
+ * our message signatures, and the keeper can wrap to us).
2569
+ */
2550
2570
  requestKey(desiredEpoch?: number): Promise<void>;
2551
2571
  /**
2552
2572
  * Pull and unwrap any blobs addressed to us. Returns the number of new
@@ -2572,6 +2592,8 @@ declare class SpaceKeyring implements EpochKeyProvider {
2572
2592
  memberId: string;
2573
2593
  identityEcdhPub: string;
2574
2594
  }>): Promise<number>;
2595
+ /** Add `username` to a private channel's membership allowlist (member-only). */
2596
+ invite(username: string): Promise<void>;
2575
2597
  }
2576
2598
 
2577
2599
  /**
@@ -2718,6 +2740,8 @@ declare class Space {
2718
2740
  create(): Promise<void>;
2719
2741
  /** Wrap the group key for a newcomer and post it (key-holder action). */
2720
2742
  admit(memberId: string, identityEcdhPub: string): Promise<void>;
2743
+ /** Invite a user to this (private) channel's membership allowlist. */
2744
+ invite(username: string): Promise<void>;
2721
2745
  /** Rotate to a fresh epoch and re-wrap to the roster (rotate spaces). */
2722
2746
  rotate(roster?: Array<{
2723
2747
  memberId: string;
@@ -2741,6 +2765,13 @@ declare class Space {
2741
2765
  * packet string; false means it's the full `{spaceMessage, name, ...}` frame.
2742
2766
  */
2743
2767
  private decodeFrame;
2768
+ /**
2769
+ * True if `packet` carries a valid signature by `source`'s identity key.
2770
+ * On a directory cache miss, refresh once (a new member may have just
2771
+ * joined) before giving up. Without a keyring (legacy handle) there's
2772
+ * nothing to verify against → reject.
2773
+ */
2774
+ private verifySender;
2744
2775
  private emitEvent;
2745
2776
  private requireKeyring;
2746
2777
  private requireCipher;
@@ -2883,6 +2914,7 @@ declare class SpaceNamespace {
2883
2914
  */
2884
2915
  createSpace(opts?: {
2885
2916
  historyPolicy?: HistoryPolicy;
2917
+ private?: boolean;
2886
2918
  }): Promise<Space>;
2887
2919
  /** Join an existing space by id (its public key). Resolves once a key arrives. */
2888
2920
  joinSpace(spaceId: string, opts?: {
@@ -2904,6 +2936,7 @@ declare class SpaceNamespace {
2904
2936
  */
2905
2937
  createChannel(name: string, opts?: {
2906
2938
  historyPolicy?: HistoryPolicy;
2939
+ private?: boolean;
2907
2940
  }): Promise<Space>;
2908
2941
  /**
2909
2942
  * Join an existing channel by name. Throws {@link ChannelNotFoundError} if
@@ -2913,7 +2946,7 @@ declare class SpaceNamespace {
2913
2946
  timeoutMs?: number;
2914
2947
  }): Promise<Space>;
2915
2948
  private build;
2916
- /** Ensure the member has an identity ECDH keypair; return its public key. */
2949
+ /** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
2917
2950
  private ensureIdentity;
2918
2951
  private fetchPolicy;
2919
2952
  private fetchTicket;
@@ -3002,13 +3035,11 @@ declare class KeyringClient implements KeyringTransport {
3002
3035
  fetchBlobs(memberId: string): Promise<WrappedKey[]>;
3003
3036
  postWrappedKey(targetMemberId: string, wrapped: WrappedKey, fromMemberId: string): Promise<void>;
3004
3037
  fetchPending(): Promise<JoinRequest[]>;
3005
- fetchRoster(): Promise<Array<{
3006
- memberId: string;
3007
- identityEcdhPub: string;
3008
- }>>;
3038
+ fetchRoster(): Promise<RosterMember[]>;
3009
3039
  rotate(nextEpoch: number): Promise<{
3010
3040
  epoch: number;
3011
3041
  }>;
3042
+ invite(username: string): Promise<void>;
3012
3043
  fetchMetadata(): Promise<SpaceMetadata | null>;
3013
3044
  }
3014
3045
 
@@ -4791,6 +4791,46 @@ async function importEcdhPublicKey(encoded) {
4791
4791
  }
4792
4792
  /** The space's id is just its encoded ECDH public key. */
4793
4793
  const encodeSpaceId = exportEcdhPublicKey;
4794
+ // ---------------------------------------------------------------------------
4795
+ // Sender authentication (ECDSA P-384, matching the Double Ratchet path).
4796
+ // A fan-out message is signed by its sender's identity ECDSA key so receivers
4797
+ // can verify authorship end-to-end — independent of the relay, which only
4798
+ // stamps `source` server-side. SHA-256 hash to match `DoubleRatchet`.
4799
+ // ---------------------------------------------------------------------------
4800
+ const ECDSA_PARAMS = { name: "ECDSA", namedCurve: "P-384" };
4801
+ const ECDSA_SIGN = { name: "ECDSA", hash: "SHA-256" };
4802
+ /** Encode an ECDSA public `CryptoKey` as a base64url JWK string. */
4803
+ async function exportEcdsaPublicKey(key) {
4804
+ const jwk = await getSubtle().exportKey("jwk", key);
4805
+ return toBase64Url(utf8Encode(JSON.stringify(jwk)));
4806
+ }
4807
+ /** Decode a base64url JWK string back into an ECDSA verify-only public key. */
4808
+ async function importEcdsaPublicKey(encoded) {
4809
+ const jwk = JSON.parse(utf8Decode(fromBase64Url(encoded)));
4810
+ return getSubtle().importKey("jwk", jwk, ECDSA_PARAMS, true, ["verify"]);
4811
+ }
4812
+ /**
4813
+ * Canonical bytes a sender signs (and a receiver re-derives). Binds the claimed
4814
+ * sender + routing + the sealed ciphertext, so a relay can't relabel `source`
4815
+ * and a member can't reuse another's ciphertext under a new envelope.
4816
+ */
4817
+ function canonicalMessage(fields) {
4818
+ return [fields.source, fields.target, fields.subject, fields.epoch, fields.iv, fields.ciphertext].join("\n");
4819
+ }
4820
+ /** Sign the canonical bytes with the sender's ECDSA identity private key. */
4821
+ async function signSpaceMessage(canonical, ecdsaPriv) {
4822
+ const sig = await getSubtle().sign(ECDSA_SIGN, ecdsaPriv, utf8Encode(canonical));
4823
+ return toBase64(new Uint8Array(sig));
4824
+ }
4825
+ /** Verify a signature over the canonical bytes against the sender's ECDSA key. */
4826
+ async function verifySpaceMessage(canonical, sigB64, ecdsaPub) {
4827
+ try {
4828
+ return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64(sigB64), utf8Encode(canonical));
4829
+ }
4830
+ catch {
4831
+ return false;
4832
+ }
4833
+ }
4794
4834
  /** Generate a fresh space identity keypair (P-384 ECDH). */
4795
4835
  async function generateSpaceIdentity() {
4796
4836
  const kp = await getSubtle().generateKey(ECDH_PARAMS, true, ["deriveBits"]);
@@ -5019,12 +5059,19 @@ class Space {
5019
5059
  const cipher = this.requireCipher(opts.contentType);
5020
5060
  const message = new Message(payload);
5021
5061
  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
- });
5062
+ const source = this.deps.myId();
5063
+ const subject = opts.channel ?? "default";
5064
+ // Sign the message with our identity ECDSA key so receivers can verify
5065
+ // authorship end-to-end (independent of the relay's `source` stamp).
5066
+ const authPriv = KeyStore.getInstance().getAuthKeyPair(source)?.privateKey;
5067
+ if (authPriv) {
5068
+ const canonical = canonicalMessage({
5069
+ source, target: this.name, subject,
5070
+ epoch: Number(headers.epoch), iv: String(headers.iv), ciphertext: String(headers.ciphertext),
5071
+ });
5072
+ headers.sig = await signSpaceMessage(canonical, authPriv);
5073
+ }
5074
+ const packet = new Packet({ subject, source, target: this.name, headers });
5028
5075
  this.sendRaw({ spaceMessage: packet.serialize() });
5029
5076
  }
5030
5077
  /** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
@@ -5056,6 +5103,9 @@ class Space {
5056
5103
  if (!res.ok)
5057
5104
  throw new Error(`Space.history: ${res.status} ${res.statusText}`);
5058
5105
  const body = (await res.json());
5106
+ // Warm the member directory once so historical senders' signatures verify.
5107
+ // (refreshDirectory swallows its own transport errors.)
5108
+ await this.deps.keyring?.refreshDirectory();
5059
5109
  const messages = [];
5060
5110
  for (const raw of body.messages ?? []) {
5061
5111
  const decoded = await this.decodeFrame(raw, cipher);
@@ -5091,6 +5141,9 @@ class Space {
5091
5141
  const keyring = this.requireKeyring();
5092
5142
  await keyring.bootstrapNew();
5093
5143
  await this.connect();
5144
+ // Publish our identity in the member directory so peers can verify our
5145
+ // message signatures (the creator never "joins", so register here).
5146
+ await keyring.requestKey().catch(() => { });
5094
5147
  // Admit anyone who requested a key before we were listening.
5095
5148
  if (this.deps.autoAdmit !== false) {
5096
5149
  await keyring.admitPending().catch(() => { });
@@ -5100,6 +5153,10 @@ class Space {
5100
5153
  async admit(memberId, identityEcdhPub) {
5101
5154
  await this.requireKeyring().admit(memberId, identityEcdhPub);
5102
5155
  }
5156
+ /** Invite a user to this (private) channel's membership allowlist. */
5157
+ async invite(username) {
5158
+ await this.requireKeyring().invite(username);
5159
+ }
5103
5160
  /** Rotate to a fresh epoch and re-wrap to the roster (rotate spaces). */
5104
5161
  async rotate(roster) {
5105
5162
  return this.requireKeyring().rotate(roster);
@@ -5142,7 +5199,9 @@ class Space {
5142
5199
  if (f.joinRequest && typeof f.joinRequest === "object") {
5143
5200
  const req = f.joinRequest;
5144
5201
  this.emitEvent("join-request", req);
5145
- // Auto-admit: if we hold the group key, wrap it for the newcomer.
5202
+ // A new member appeared — refresh the directory so their future
5203
+ // messages verify, and (if we hold the key) auto-admit them.
5204
+ void this.deps.keyring?.refreshDirectory();
5146
5205
  if (this.deps.autoAdmit !== false && this.deps.keyring?.hasAnyKey() && req.memberId !== this.deps.myId()) {
5147
5206
  void this.deps.keyring.admit(req.memberId, req.identityEcdhPub).catch(() => { });
5148
5207
  }
@@ -5170,6 +5229,13 @@ class Space {
5170
5229
  const packet = Packet.deserialize(packetJson);
5171
5230
  if (!cipher.handles(packet.headers))
5172
5231
  return null;
5232
+ // Verify sender authenticity end-to-end: the message must be signed
5233
+ // by `source`'s identity ECDSA key (looked up in the member
5234
+ // directory). Drop anything unsigned, signed by an unknown member,
5235
+ // or whose signature doesn't match — this defeats both member
5236
+ // impersonation and a relay rewriting `source`.
5237
+ if (!(await this.verifySender(packet)))
5238
+ return null;
5173
5239
  const serialized = await cipher.open(packet.headers);
5174
5240
  if (serialized === null)
5175
5241
  return null; // epoch key we don't hold
@@ -5187,6 +5253,34 @@ class Space {
5187
5253
  return null;
5188
5254
  }
5189
5255
  }
5256
+ /**
5257
+ * True if `packet` carries a valid signature by `source`'s identity key.
5258
+ * On a directory cache miss, refresh once (a new member may have just
5259
+ * joined) before giving up. Without a keyring (legacy handle) there's
5260
+ * nothing to verify against → reject.
5261
+ */
5262
+ async verifySender(packet) {
5263
+ const keyring = this.deps.keyring;
5264
+ const sig = packet.headers?.sig;
5265
+ if (!keyring || typeof sig !== "string")
5266
+ return false;
5267
+ let key = keyring.ecdsaKeyFor(packet.source);
5268
+ if (!key) {
5269
+ await keyring.refreshDirectory();
5270
+ key = keyring.ecdsaKeyFor(packet.source);
5271
+ if (!key)
5272
+ return false;
5273
+ }
5274
+ const canonical = canonicalMessage({
5275
+ source: packet.source,
5276
+ target: packet.target,
5277
+ subject: packet.subject,
5278
+ epoch: Number(packet.headers?.epoch ?? 0),
5279
+ iv: String(packet.headers?.iv ?? ""),
5280
+ ciphertext: String(packet.headers?.ciphertext ?? ""),
5281
+ });
5282
+ return verifySpaceMessage(canonical, sig, key);
5283
+ }
5190
5284
  emitEvent(type, detail) {
5191
5285
  this.events.dispatchEvent(new CustomEvent(type, { detail }));
5192
5286
  }
@@ -5416,10 +5510,37 @@ class SpaceKeyring {
5416
5510
  keys = new Map();
5417
5511
  epoch = 0;
5418
5512
  policy;
5513
+ /** Cached member directory: memberId → imported ECDSA verify key. */
5514
+ memberEcdsa = new Map();
5419
5515
  constructor(deps) {
5420
5516
  this.deps = deps;
5421
5517
  this.policy = deps.historyPolicy ?? "static";
5422
5518
  }
5519
+ // -- member directory (for verifying sender signatures) -------------------
5520
+ /** The cached ECDSA verify key for a member, if known. */
5521
+ ecdsaKeyFor(memberId) {
5522
+ return this.memberEcdsa.get(memberId);
5523
+ }
5524
+ /** Refresh the member directory (memberId → ECDSA key) from the roster. */
5525
+ async refreshDirectory() {
5526
+ let roster;
5527
+ try {
5528
+ roster = await this.deps.transport.fetchRoster();
5529
+ }
5530
+ catch {
5531
+ return;
5532
+ }
5533
+ for (const m of roster) {
5534
+ if (!m.identityEcdsaPub || this.memberEcdsa.has(m.memberId))
5535
+ continue;
5536
+ try {
5537
+ this.memberEcdsa.set(m.memberId, await importEcdsaPublicKey(m.identityEcdsaPub));
5538
+ }
5539
+ catch {
5540
+ /* skip malformed entries */
5541
+ }
5542
+ }
5543
+ }
5423
5544
  // -- EpochKeyProvider ------------------------------------------------------
5424
5545
  currentEpoch() {
5425
5546
  return this.epoch;
@@ -5473,11 +5594,16 @@ class SpaceKeyring {
5473
5594
  await this.persist();
5474
5595
  }
5475
5596
  // -- newcomer: request + pull ---------------------------------------------
5476
- /** Post a join request so an online key-holder can wrap a key for us. */
5597
+ /**
5598
+ * Post a join request so an online key-holder can wrap a key for us. Also
5599
+ * publishes our identity keys in the member directory (so others can verify
5600
+ * our message signatures, and the keeper can wrap to us).
5601
+ */
5477
5602
  async requestKey(desiredEpoch) {
5478
5603
  await this.deps.transport.postJoinRequest({
5479
5604
  memberId: this.deps.memberId,
5480
5605
  identityEcdhPub: this.deps.identityEcdhPub,
5606
+ identityEcdsaPub: this.deps.identityEcdsaPub,
5481
5607
  desiredEpoch,
5482
5608
  });
5483
5609
  }
@@ -5563,6 +5689,10 @@ class SpaceKeyring {
5563
5689
  await this.persist();
5564
5690
  return nextEpoch;
5565
5691
  }
5692
+ /** Add `username` to a private channel's membership allowlist (member-only). */
5693
+ async invite(username) {
5694
+ await this.deps.transport.invite(username);
5695
+ }
5566
5696
  }
5567
5697
 
5568
5698
  /**
@@ -5623,6 +5753,9 @@ class KeyringClient {
5623
5753
  async rotate(nextEpoch) {
5624
5754
  return this.post("/keyring/rotate", { nextEpoch });
5625
5755
  }
5756
+ async invite(username) {
5757
+ await this.post("/keyring/invite", { username });
5758
+ }
5626
5759
  async fetchMetadata() {
5627
5760
  try {
5628
5761
  return await this.get("/metadata");
@@ -5679,7 +5812,9 @@ class SpaceNamespace {
5679
5812
  const historyPolicy = opts.historyPolicy ?? "static";
5680
5813
  const identity = await generateSpaceIdentity();
5681
5814
  // Register the space (server records pubkey + policy; the DO is lazy).
5682
- await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy });
5815
+ // For a private space the server also records the creator (this user) as
5816
+ // its first member — so the metadata POST must carry the user session.
5817
+ await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy, visibility: opts.private ? "private" : "public" });
5683
5818
  const space = await this.build(identity.id, historyPolicy);
5684
5819
  await space.create();
5685
5820
  return space;
@@ -5727,7 +5862,7 @@ class SpaceNamespace {
5727
5862
  const space = await this.createSpace(opts);
5728
5863
  // Register name → id first so a name clash doesn't leave an admitted keeper.
5729
5864
  try {
5730
- await this.deps.http.post("/api/app/channels", { name, spaceId: space.id });
5865
+ await this.deps.http.post("/api/app/channels", { name, spaceId: space.id, visibility: opts.private ? "private" : "public" });
5731
5866
  }
5732
5867
  catch (err) {
5733
5868
  if (err instanceof HttpError && err.status === 409) {
@@ -5762,7 +5897,7 @@ class SpaceNamespace {
5762
5897
  // -------------------------------------------------------------------------
5763
5898
  async build(spaceId, historyPolicy) {
5764
5899
  const memberId = this.myId();
5765
- const identityEcdhPub = await this.ensureIdentity(memberId);
5900
+ const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
5766
5901
  const transport = new KeyringClient({
5767
5902
  spaceId,
5768
5903
  httpBaseUrl: this.deps.http.baseUrl,
@@ -5771,7 +5906,8 @@ class SpaceNamespace {
5771
5906
  const keyring = new SpaceKeyring({
5772
5907
  spaceId,
5773
5908
  memberId,
5774
- identityEcdhPub,
5909
+ identityEcdhPub: ecdhPub,
5910
+ identityEcdsaPub: ecdsaPub,
5775
5911
  ownPrivateKey: () => KeyStore.getInstance().getKeyPair(memberId)?.privateKey ?? null,
5776
5912
  transport,
5777
5913
  cache: this.deps.cache,
@@ -5788,14 +5924,15 @@ class SpaceNamespace {
5788
5924
  historyPolicy,
5789
5925
  });
5790
5926
  }
5791
- /** Ensure the member has an identity ECDH keypair; return its public key. */
5927
+ /** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
5792
5928
  async ensureIdentity(memberId) {
5793
5929
  const store = KeyStore.getInstance();
5794
5930
  if (!store.getKeyPair(memberId)) {
5795
5931
  await store.generateOwnKeyPair(memberId);
5796
5932
  }
5797
- const pub = store.getKeyPair(memberId).publicKey;
5798
- return exportEcdhPublicKey(pub);
5933
+ const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
5934
+ const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
5935
+ return { ecdhPub, ecdsaPub };
5799
5936
  }
5800
5937
  async fetchPolicy(spaceId) {
5801
5938
  const meta = await new KeyringClient({