@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 +26 -18
- package/dist/browser/index.js +152 -15
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +42 -11
- package/dist/server/index.js +152 -15
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
5
|
-
`client.auth` / `client.storage` / `client.message` — backed by
|
|
6
|
-
end-to-end-encrypted messaging
|
|
7
|
-
|
|
8
|
-
and a Cloudflare-Workers-compatible Groth16 verifier — all bundled from
|
|
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
|
-
`
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
`
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
package/dist/browser/index.js
CHANGED
|
@@ -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
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
6312
|
-
|
|
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({
|