@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/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<
|
|
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
|
-
/**
|
|
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
|
|
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<
|
|
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
|
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
5798
|
-
|
|
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({
|