@rine-network/core 0.4.3 → 0.5.0
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 +5 -5
- package/dist/index.js +1007 -21
- package/dist/src/api-types.d.ts +14 -0
- package/dist/src/crypto/hybrid.d.ts +8 -0
- package/dist/src/crypto/index.d.ts +5 -0
- package/dist/src/crypto/keys.d.ts +10 -0
- package/dist/src/crypto/message.d.ts +25 -4
- package/dist/src/crypto/mls-api.d.ts +91 -0
- package/dist/src/crypto/mls-init.d.ts +6 -0
- package/dist/src/crypto/mls-state.d.ts +29 -0
- package/dist/src/crypto/mls.d.ts +63 -0
- package/dist/src/errors.d.ts +2 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/mls-ops-join.d.ts +24 -0
- package/dist/src/mls-ops-sync.d.ts +23 -0
- package/dist/src/mls-ops.d.ts +25 -0
- package/dist/src/types.d.ts +2 -0
- package/dist/test/crypto/hybrid.test.d.ts +1 -0
- package/dist/test/crypto/message-hybrid.test.d.ts +1 -0
- package/dist/test/crypto/mls.test.d.ts +1 -0
- package/dist/test/mls-external-join.test.d.ts +1 -0
- package/dist/test/mls-ops.test.d.ts +1 -0
- package/package.json +6 -4
package/dist/index.js
CHANGED
|
@@ -4,15 +4,19 @@ import { join } from "node:path";
|
|
|
4
4
|
import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core";
|
|
5
5
|
import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519";
|
|
6
6
|
import { ed25519, x25519 } from "@noble/curves/ed25519.js";
|
|
7
|
-
import {
|
|
7
|
+
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
8
8
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
9
|
+
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
|
|
10
|
+
import { hmac } from "@noble/hashes/hmac.js";
|
|
11
|
+
import { MlsError, acceptAll, clientStateDecoder, clientStateEncoder, createApplicationMessage, createCommit, createGroup, createGroupInfoWithExternalPubAndRatchetTree, decode, defaultCredentialTypes, encode, generateKeyPackageWithKey, getCiphersuiteImpl, joinGroup, joinGroupExternal, keyPackageDecoder, keyPackageEncoder, makeKeyPackageRef, mlsMessageDecoder, mlsMessageEncoder, privateKeyPackageDecoder, privateKeyPackageEncoder, processMessage, processPrivateMessage, protocolVersions, unsafeTestingAuthenticationService, wireformats, zeroOutUint8Array } from "ts-mls";
|
|
9
12
|
//#region src/errors.ts
|
|
10
13
|
var RineApiError = class extends Error {
|
|
11
|
-
constructor(status, detail, raw) {
|
|
14
|
+
constructor(status, detail, raw, code) {
|
|
12
15
|
super(`${status}: ${detail}`);
|
|
13
16
|
this.status = status;
|
|
14
17
|
this.detail = detail;
|
|
15
18
|
this.raw = raw;
|
|
19
|
+
this.code = code;
|
|
16
20
|
this.name = "RineApiError";
|
|
17
21
|
}
|
|
18
22
|
};
|
|
@@ -116,13 +120,29 @@ function getCredentialEntry(configDir, profile = "default") {
|
|
|
116
120
|
}
|
|
117
121
|
//#endregion
|
|
118
122
|
//#region src/http.ts
|
|
119
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Parse an error response body ONCE, extracting both the human-readable detail
|
|
125
|
+
* and the structured error code. The server sends {"error": "<code>", "detail":
|
|
126
|
+
* "..."} for typed errors (e.g. epoch_conflict); callers use `code` to branch.
|
|
127
|
+
*/
|
|
128
|
+
async function parseError(res) {
|
|
120
129
|
try {
|
|
121
130
|
const body = await res.json();
|
|
122
|
-
|
|
123
|
-
if (
|
|
131
|
+
const code = typeof body.error === "string" ? body.error : void 0;
|
|
132
|
+
if (typeof body.detail === "string") return {
|
|
133
|
+
detail: body.detail,
|
|
134
|
+
code
|
|
135
|
+
};
|
|
136
|
+
if (Array.isArray(body.detail)) return {
|
|
137
|
+
detail: body.detail.map((e) => e.msg).join("; "),
|
|
138
|
+
code
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
detail: res.statusText,
|
|
142
|
+
code
|
|
143
|
+
};
|
|
124
144
|
} catch {}
|
|
125
|
-
return res.statusText;
|
|
145
|
+
return { detail: res.statusText };
|
|
126
146
|
}
|
|
127
147
|
var HttpClient = class {
|
|
128
148
|
baseUrl;
|
|
@@ -161,8 +181,8 @@ var HttpClient = class {
|
|
|
161
181
|
let res = await doFetch();
|
|
162
182
|
if (res.status === 401 && this.canRefresh) res = await doFetch(true);
|
|
163
183
|
if (!res.ok) {
|
|
164
|
-
const detail = await
|
|
165
|
-
throw new RineApiError(res.status, detail, res);
|
|
184
|
+
const { detail, code } = await parseError(res);
|
|
185
|
+
throw new RineApiError(res.status, detail, res, code);
|
|
166
186
|
}
|
|
167
187
|
if (res.status === 204) return void 0;
|
|
168
188
|
return res.json();
|
|
@@ -191,8 +211,8 @@ var HttpClient = class {
|
|
|
191
211
|
const url = qs ? `${apiUrl}${path}?${qs}` : `${apiUrl}${path}`;
|
|
192
212
|
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
|
193
213
|
if (!res.ok) {
|
|
194
|
-
const detail = await
|
|
195
|
-
throw new RineApiError(res.status, detail, res);
|
|
214
|
+
const { detail, code } = await parseError(res);
|
|
215
|
+
throw new RineApiError(res.status, detail, res, code);
|
|
196
216
|
}
|
|
197
217
|
return res.json();
|
|
198
218
|
}
|
|
@@ -378,6 +398,85 @@ async function open(recipientPrivateKey, encryptedPayload, aad) {
|
|
|
378
398
|
return new Uint8Array(await recipient.open(ct, aad));
|
|
379
399
|
}
|
|
380
400
|
//#endregion
|
|
401
|
+
//#region src/crypto/hybrid.ts
|
|
402
|
+
const VERSION_HYBRID = 3;
|
|
403
|
+
const X25519_ENC_SIZE = 32;
|
|
404
|
+
const MLKEM_CT_SIZE = 1088;
|
|
405
|
+
const AES_KEY_SIZE = 32;
|
|
406
|
+
const HKDF_INFO = new TextEncoder().encode("rine.e2ee.v1.hpke-hybrid");
|
|
407
|
+
const GCM_NONCE = new Uint8Array(12);
|
|
408
|
+
/** Copy into a plain ArrayBuffer-backed Uint8Array (required by Web Crypto). */
|
|
409
|
+
function toPlainBuffer$1(src) {
|
|
410
|
+
const buf = new Uint8Array(src.length);
|
|
411
|
+
buf.set(src);
|
|
412
|
+
return buf;
|
|
413
|
+
}
|
|
414
|
+
/** Generate an ML-KEM-768 key pair for post-quantum key encapsulation. */
|
|
415
|
+
function generatePqKeyPair() {
|
|
416
|
+
const { publicKey, secretKey } = ml_kem768.keygen();
|
|
417
|
+
return {
|
|
418
|
+
publicKey,
|
|
419
|
+
privateKey: secretKey
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
/** HKDF-SHA256 over (classical || pq) shared secrets with hybrid domain separation. */
|
|
423
|
+
function combineSecrets(x25519Shared, mlkemShared) {
|
|
424
|
+
const ikm = new Uint8Array(x25519Shared.length + mlkemShared.length);
|
|
425
|
+
ikm.set(x25519Shared, 0);
|
|
426
|
+
ikm.set(mlkemShared, x25519Shared.length);
|
|
427
|
+
const key = hkdf(sha256, ikm, void 0, HKDF_INFO, AES_KEY_SIZE);
|
|
428
|
+
ikm.fill(0);
|
|
429
|
+
return key;
|
|
430
|
+
}
|
|
431
|
+
async function aesGcm(mode, key, data, aad) {
|
|
432
|
+
const cryptoKey = await crypto.subtle.importKey("raw", toPlainBuffer$1(key), "AES-GCM", false, [mode]);
|
|
433
|
+
const params = {
|
|
434
|
+
name: "AES-GCM",
|
|
435
|
+
iv: GCM_NONCE
|
|
436
|
+
};
|
|
437
|
+
if (aad) params.additionalData = toPlainBuffer$1(aad);
|
|
438
|
+
const buf = toPlainBuffer$1(data);
|
|
439
|
+
const out = mode === "encrypt" ? await crypto.subtle.encrypt(params, cryptoKey, buf) : await crypto.subtle.decrypt(params, cryptoKey, buf);
|
|
440
|
+
return new Uint8Array(out);
|
|
441
|
+
}
|
|
442
|
+
async function sealHybrid(recipientX25519Pk, recipientMlKemPk, innerEnvelope, aad) {
|
|
443
|
+
const ephSk = x25519.utils.randomSecretKey();
|
|
444
|
+
const ephPk = x25519.getPublicKey(ephSk);
|
|
445
|
+
const x25519Shared = x25519.getSharedSecret(ephSk, recipientX25519Pk);
|
|
446
|
+
const { cipherText: mlkemCt, sharedSecret: mlkemShared } = ml_kem768.encapsulate(recipientMlKemPk);
|
|
447
|
+
const key = combineSecrets(x25519Shared, mlkemShared);
|
|
448
|
+
x25519Shared.fill(0);
|
|
449
|
+
mlkemShared.fill(0);
|
|
450
|
+
ephSk.fill(0);
|
|
451
|
+
const ciphertext = await aesGcm("encrypt", key, innerEnvelope, aad);
|
|
452
|
+
key.fill(0);
|
|
453
|
+
const out = new Uint8Array(1 + X25519_ENC_SIZE + MLKEM_CT_SIZE + ciphertext.length);
|
|
454
|
+
out[0] = 3;
|
|
455
|
+
out.set(ephPk, 1);
|
|
456
|
+
out.set(mlkemCt, 1 + X25519_ENC_SIZE);
|
|
457
|
+
out.set(ciphertext, 1 + X25519_ENC_SIZE + MLKEM_CT_SIZE);
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
460
|
+
async function openHybrid(recipientX25519Sk, recipientMlKemSk, encryptedPayload, aad) {
|
|
461
|
+
const headerSize = 1 + X25519_ENC_SIZE + MLKEM_CT_SIZE;
|
|
462
|
+
if (encryptedPayload.length < headerSize + 1) throw new Error("encrypted payload too short");
|
|
463
|
+
const version = encryptedPayload[0];
|
|
464
|
+
if (version !== 3) throw new Error(`unsupported version: 0x${version?.toString(16).padStart(2, "0")}`);
|
|
465
|
+
const ephPk = encryptedPayload.slice(1, 1 + X25519_ENC_SIZE);
|
|
466
|
+
const mlkemCt = encryptedPayload.slice(1 + X25519_ENC_SIZE, headerSize);
|
|
467
|
+
const ciphertext = encryptedPayload.slice(headerSize);
|
|
468
|
+
const x25519Shared = x25519.getSharedSecret(recipientX25519Sk, ephPk);
|
|
469
|
+
const mlkemShared = ml_kem768.decapsulate(mlkemCt, recipientMlKemSk);
|
|
470
|
+
const key = combineSecrets(x25519Shared, mlkemShared);
|
|
471
|
+
x25519Shared.fill(0);
|
|
472
|
+
mlkemShared.fill(0);
|
|
473
|
+
try {
|
|
474
|
+
return await aesGcm("decrypt", key, ciphertext, aad);
|
|
475
|
+
} finally {
|
|
476
|
+
key.fill(0);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
//#endregion
|
|
381
480
|
//#region src/crypto/sign.ts
|
|
382
481
|
function signPayload(signingPrivateKey, plaintext) {
|
|
383
482
|
return ed25519.sign(plaintext, signingPrivateKey);
|
|
@@ -417,6 +516,8 @@ function decodeEnvelope(data) {
|
|
|
417
516
|
}
|
|
418
517
|
//#endregion
|
|
419
518
|
//#region src/crypto/keys.ts
|
|
519
|
+
const MLKEM_PUBLIC_KEY_SIZE = 1184;
|
|
520
|
+
const MLKEM_SECRET_KEY_SIZE = 2400;
|
|
420
521
|
function toBase64Url(bytes) {
|
|
421
522
|
return Buffer.from(bytes).toString("base64url");
|
|
422
523
|
}
|
|
@@ -440,7 +541,8 @@ function generateEncryptionKeyPair() {
|
|
|
440
541
|
function generateAgentKeys() {
|
|
441
542
|
return {
|
|
442
543
|
signing: generateSigningKeyPair(),
|
|
443
|
-
encryption: generateEncryptionKeyPair()
|
|
544
|
+
encryption: generateEncryptionKeyPair(),
|
|
545
|
+
pqEncryption: generatePqKeyPair()
|
|
444
546
|
};
|
|
445
547
|
}
|
|
446
548
|
function signingPublicKeyToJWK(publicKey) {
|
|
@@ -462,6 +564,18 @@ function jwkToPublicKey(jwk) {
|
|
|
462
564
|
if (key.length !== 32) throw new Error(`Invalid public key: expected 32 bytes, got ${key.length}`);
|
|
463
565
|
return key;
|
|
464
566
|
}
|
|
567
|
+
function pqPublicKeyToJWK(publicKey) {
|
|
568
|
+
return {
|
|
569
|
+
kty: "MLKEM",
|
|
570
|
+
alg: "ML-KEM-768",
|
|
571
|
+
x: toBase64Url(publicKey)
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
function jwkToPqPublicKey(jwk) {
|
|
575
|
+
const key = fromBase64Url(jwk.x);
|
|
576
|
+
if (key.length !== MLKEM_PUBLIC_KEY_SIZE) throw new Error(`Invalid PQ public key: expected ${MLKEM_PUBLIC_KEY_SIZE} bytes, got ${key.length}`);
|
|
577
|
+
return key;
|
|
578
|
+
}
|
|
465
579
|
const KID_RE = /^rine:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
|
466
580
|
function agentIdFromKid(kid) {
|
|
467
581
|
const match = KID_RE.exec(kid);
|
|
@@ -485,6 +599,22 @@ function saveAgentKeys(configDir, agentId, keys) {
|
|
|
485
599
|
const encPath = join(dir, "encryption.key");
|
|
486
600
|
fs.writeFileSync(encPath, toBase64Url(keys.encryption.privateKey), "utf-8");
|
|
487
601
|
fs.chmodSync(encPath, 384);
|
|
602
|
+
if (keys.pqEncryption) {
|
|
603
|
+
const pqPath = join(dir, "pq_encryption.key");
|
|
604
|
+
fs.writeFileSync(pqPath, toBase64Url(keys.pqEncryption.privateKey), "utf-8");
|
|
605
|
+
fs.chmodSync(pqPath, 384);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
function savePqEncryptionKey(configDir, agentId, pqKeyPair) {
|
|
609
|
+
validatePathId(agentId, "agent ID");
|
|
610
|
+
const dir = join(configDir, "keys", agentId);
|
|
611
|
+
fs.mkdirSync(dir, {
|
|
612
|
+
recursive: true,
|
|
613
|
+
mode: 448
|
|
614
|
+
});
|
|
615
|
+
const pqPath = join(dir, "pq_encryption.key");
|
|
616
|
+
fs.writeFileSync(pqPath, toBase64Url(pqKeyPair.privateKey), "utf-8");
|
|
617
|
+
fs.chmodSync(pqPath, 384);
|
|
488
618
|
}
|
|
489
619
|
function loadAgentKeys(configDir, agentId) {
|
|
490
620
|
validatePathId(agentId, "agent ID");
|
|
@@ -495,7 +625,7 @@ function loadAgentKeys(configDir, agentId) {
|
|
|
495
625
|
const encPriv = fromBase64Url(fs.readFileSync(join(dir, "encryption.key"), "utf-8").trim());
|
|
496
626
|
if (encPriv.length !== 32) throw new Error(`Corrupt encryption key: expected 32 bytes, got ${encPriv.length}`);
|
|
497
627
|
const encPub = x25519.getPublicKey(encPriv);
|
|
498
|
-
|
|
628
|
+
const keys = {
|
|
499
629
|
signing: {
|
|
500
630
|
privateKey: sigPriv,
|
|
501
631
|
publicKey: sigPub
|
|
@@ -505,6 +635,16 @@ function loadAgentKeys(configDir, agentId) {
|
|
|
505
635
|
publicKey: encPub
|
|
506
636
|
}
|
|
507
637
|
};
|
|
638
|
+
const pqPath = join(dir, "pq_encryption.key");
|
|
639
|
+
if (fs.existsSync(pqPath)) {
|
|
640
|
+
const pqPriv = fromBase64Url(fs.readFileSync(pqPath, "utf-8").trim());
|
|
641
|
+
if (pqPriv.length !== MLKEM_SECRET_KEY_SIZE) throw new Error(`Corrupt PQ key: expected ${MLKEM_SECRET_KEY_SIZE} bytes, got ${pqPriv.length}`);
|
|
642
|
+
keys.pqEncryption = {
|
|
643
|
+
privateKey: pqPriv,
|
|
644
|
+
publicKey: ml_kem768.getPublicKey(pqPriv)
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
return keys;
|
|
508
648
|
}
|
|
509
649
|
function validateSigningKey(bytes) {
|
|
510
650
|
if (bytes.length !== 32) throw new Error(`expected 32 bytes, got ${bytes.length}`);
|
|
@@ -728,6 +868,662 @@ async function openGroup(senderKeyStates, encryptedPayload) {
|
|
|
728
868
|
};
|
|
729
869
|
}
|
|
730
870
|
//#endregion
|
|
871
|
+
//#region src/crypto/mls-init.ts
|
|
872
|
+
let cachedCs = null;
|
|
873
|
+
let cachedCtx = null;
|
|
874
|
+
let initPromise = null;
|
|
875
|
+
async function ensureInit() {
|
|
876
|
+
if (cachedCs) return;
|
|
877
|
+
cachedCs = await getCiphersuiteImpl("MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519");
|
|
878
|
+
cachedCtx = {
|
|
879
|
+
cipherSuite: cachedCs,
|
|
880
|
+
authService: unsafeTestingAuthenticationService
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
/** Lazily initializes and returns the MLS cipher suite singleton. */
|
|
884
|
+
async function getMlsCipherSuite() {
|
|
885
|
+
if (!initPromise) initPromise = ensureInit();
|
|
886
|
+
await initPromise;
|
|
887
|
+
return cachedCs;
|
|
888
|
+
}
|
|
889
|
+
/** Lazily initializes and returns the MLS context singleton. */
|
|
890
|
+
async function getMlsContext() {
|
|
891
|
+
if (!initPromise) initPromise = ensureInit();
|
|
892
|
+
await initPromise;
|
|
893
|
+
return cachedCtx;
|
|
894
|
+
}
|
|
895
|
+
const MLS_CIPHER_SUITE_ID = 1;
|
|
896
|
+
//#endregion
|
|
897
|
+
//#region src/crypto/mls.ts
|
|
898
|
+
const VERSION_MLS = 4;
|
|
899
|
+
function encState(s) {
|
|
900
|
+
return encode(clientStateEncoder, s);
|
|
901
|
+
}
|
|
902
|
+
function decState(b) {
|
|
903
|
+
const s = decode(clientStateDecoder, b);
|
|
904
|
+
if (!s) throw new Error("Failed to decode MLS client state");
|
|
905
|
+
return s;
|
|
906
|
+
}
|
|
907
|
+
function zeroConsumed(consumed) {
|
|
908
|
+
for (const c of consumed) zeroOutUint8Array(c);
|
|
909
|
+
}
|
|
910
|
+
function basicCred(publicKey) {
|
|
911
|
+
return {
|
|
912
|
+
credentialType: defaultCredentialTypes.basic,
|
|
913
|
+
identity: publicKey
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Build a fresh, current-epoch GroupInfo blob (with external_pub + ratchet_tree)
|
|
918
|
+
* so a subsequent external joiner can self-join against this group's latest
|
|
919
|
+
* epoch. Single-use per epoch (RFC 9420 §12.4.3.2) — every commit must publish
|
|
920
|
+
* a fresh one or the next external join targets a stale epoch and is rejected.
|
|
921
|
+
*/
|
|
922
|
+
async function freshGroupInfoBlob(state, cs) {
|
|
923
|
+
const gi = await createGroupInfoWithExternalPubAndRatchetTree(state, [], cs);
|
|
924
|
+
return encode(mlsMessageEncoder, {
|
|
925
|
+
wireformat: wireformats.mls_group_info,
|
|
926
|
+
groupInfo: gi,
|
|
927
|
+
version: protocolVersions.mls10
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
async function generateMlsKeyPackage(signingKey) {
|
|
931
|
+
const cs = await getMlsCipherSuite();
|
|
932
|
+
const publicKey = ed25519.getPublicKey(signingKey);
|
|
933
|
+
const { publicPackage, privatePackage } = await generateKeyPackageWithKey({
|
|
934
|
+
credential: basicCred(publicKey),
|
|
935
|
+
signatureKeyPair: {
|
|
936
|
+
signKey: signingKey,
|
|
937
|
+
publicKey
|
|
938
|
+
},
|
|
939
|
+
cipherSuite: cs
|
|
940
|
+
});
|
|
941
|
+
return {
|
|
942
|
+
publicBlob: encode(keyPackageEncoder, publicPackage),
|
|
943
|
+
privateBlob: encode(privateKeyPackageEncoder, privatePackage),
|
|
944
|
+
keyPackageRef: await makeKeyPackageRef(publicPackage, cs.hash)
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
async function createMlsGroup(signingKey, _cipherSuite) {
|
|
948
|
+
const cs = await getMlsCipherSuite();
|
|
949
|
+
const ctx = await getMlsContext();
|
|
950
|
+
const publicKey = ed25519.getPublicKey(signingKey);
|
|
951
|
+
const { publicPackage, privatePackage } = await generateKeyPackageWithKey({
|
|
952
|
+
credential: basicCred(publicKey),
|
|
953
|
+
signatureKeyPair: {
|
|
954
|
+
signKey: signingKey,
|
|
955
|
+
publicKey
|
|
956
|
+
},
|
|
957
|
+
cipherSuite: cs
|
|
958
|
+
});
|
|
959
|
+
const { newState, commit, consumed } = await createCommit({
|
|
960
|
+
context: ctx,
|
|
961
|
+
state: await createGroup({
|
|
962
|
+
context: ctx,
|
|
963
|
+
groupId: crypto.getRandomValues(new Uint8Array(16)),
|
|
964
|
+
keyPackage: publicPackage,
|
|
965
|
+
privateKeyPackage: privatePackage
|
|
966
|
+
})
|
|
967
|
+
});
|
|
968
|
+
zeroConsumed(consumed);
|
|
969
|
+
return {
|
|
970
|
+
stateBlob: encState(newState),
|
|
971
|
+
groupInfo: await freshGroupInfoBlob(newState, cs),
|
|
972
|
+
commitBlob: encode(mlsMessageEncoder, commit),
|
|
973
|
+
epoch: Number(newState.groupContext.epoch)
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
async function addMemberToMlsGroup(stateBlob, keyPackageBlob) {
|
|
977
|
+
const cs = await getMlsCipherSuite();
|
|
978
|
+
const ctx = await getMlsContext();
|
|
979
|
+
const kp = decode(keyPackageDecoder, keyPackageBlob);
|
|
980
|
+
if (!kp) throw new Error("Failed to decode key package");
|
|
981
|
+
const { newState, welcome, commit, consumed } = await createCommit({
|
|
982
|
+
context: ctx,
|
|
983
|
+
state: decState(stateBlob),
|
|
984
|
+
extraProposals: [{
|
|
985
|
+
proposalType: 1,
|
|
986
|
+
add: { keyPackage: kp }
|
|
987
|
+
}],
|
|
988
|
+
ratchetTreeExtension: true
|
|
989
|
+
});
|
|
990
|
+
zeroConsumed(consumed);
|
|
991
|
+
if (!welcome) throw new Error("createCommit produced no welcome for add");
|
|
992
|
+
return {
|
|
993
|
+
commitBlob: encode(mlsMessageEncoder, commit),
|
|
994
|
+
welcomeBlob: encode(mlsMessageEncoder, welcome),
|
|
995
|
+
updatedState: encState(newState),
|
|
996
|
+
groupInfo: await freshGroupInfoBlob(newState, cs),
|
|
997
|
+
epoch: Number(newState.groupContext.epoch)
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
async function removeMemberFromMlsGroup(stateBlob, leafIndex) {
|
|
1001
|
+
const cs = await getMlsCipherSuite();
|
|
1002
|
+
const { newState, commit, consumed } = await createCommit({
|
|
1003
|
+
context: await getMlsContext(),
|
|
1004
|
+
state: decState(stateBlob),
|
|
1005
|
+
extraProposals: [{
|
|
1006
|
+
proposalType: 3,
|
|
1007
|
+
remove: { removed: leafIndex }
|
|
1008
|
+
}]
|
|
1009
|
+
});
|
|
1010
|
+
zeroConsumed(consumed);
|
|
1011
|
+
return {
|
|
1012
|
+
commitBlob: encode(mlsMessageEncoder, commit),
|
|
1013
|
+
updatedState: encState(newState),
|
|
1014
|
+
groupInfo: await freshGroupInfoBlob(newState, cs),
|
|
1015
|
+
epoch: Number(newState.groupContext.epoch)
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
async function processMlsWelcome(welcomeBlob, keyPackageBlob, privateKeyBlob) {
|
|
1019
|
+
const ctx = await getMlsContext();
|
|
1020
|
+
const msg = decode(mlsMessageDecoder, welcomeBlob);
|
|
1021
|
+
if (!msg || msg.wireformat !== wireformats.mls_welcome) throw new Error("Failed to decode MLS welcome message");
|
|
1022
|
+
const kp = decode(keyPackageDecoder, keyPackageBlob);
|
|
1023
|
+
if (!kp) throw new Error("Failed to decode key package for welcome");
|
|
1024
|
+
const pkp = decode(privateKeyPackageDecoder, privateKeyBlob);
|
|
1025
|
+
if (!pkp) throw new Error("Failed to decode private key package for welcome");
|
|
1026
|
+
const state = await joinGroup({
|
|
1027
|
+
context: ctx,
|
|
1028
|
+
welcome: msg.welcome,
|
|
1029
|
+
keyPackage: kp,
|
|
1030
|
+
privateKeys: pkp
|
|
1031
|
+
});
|
|
1032
|
+
return {
|
|
1033
|
+
stateBlob: encState(state),
|
|
1034
|
+
groupId: new TextDecoder().decode(state.groupContext.groupId),
|
|
1035
|
+
epoch: Number(state.groupContext.epoch),
|
|
1036
|
+
cipherSuite: state.groupContext.cipherSuite
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
async function processMlsCommit(stateBlob, commitBlob) {
|
|
1040
|
+
const ctx = await getMlsContext();
|
|
1041
|
+
const msg = decode(mlsMessageDecoder, commitBlob);
|
|
1042
|
+
if (!msg) throw new MlsError("Failed to decode MLS commit message");
|
|
1043
|
+
if (msg.wireformat !== wireformats.mls_private_message && msg.wireformat !== wireformats.mls_public_message) throw new MlsError(`Unexpected wireformat for commit: ${msg.wireformat}`);
|
|
1044
|
+
const result = await processMessage({
|
|
1045
|
+
context: ctx,
|
|
1046
|
+
state: decState(stateBlob),
|
|
1047
|
+
message: msg,
|
|
1048
|
+
callback: acceptAll
|
|
1049
|
+
});
|
|
1050
|
+
if (result.kind !== "newState") throw new MlsError("Expected commit to produce newState");
|
|
1051
|
+
zeroConsumed(result.consumed);
|
|
1052
|
+
return {
|
|
1053
|
+
updatedState: encState(result.newState),
|
|
1054
|
+
epoch: Number(result.newState.groupContext.epoch)
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Self-join an MLS group via an RFC 9420 external commit (no Welcome required).
|
|
1059
|
+
* The joiner mints a fresh KeyPackage with its own Ed25519 signing key, derives
|
|
1060
|
+
* a fresh init_secret against the published GroupInfo's external_pub, and emits
|
|
1061
|
+
* a public External Commit that existing members process to advance the epoch.
|
|
1062
|
+
* Returns the public commit (to broadcast), the joiner's new state, a fresh
|
|
1063
|
+
* GroupInfo (so the NEXT external joiner has a current-epoch token), and the new
|
|
1064
|
+
* epoch. `resync: false` — the joiner is a brand-new member, not in the tree.
|
|
1065
|
+
*/
|
|
1066
|
+
async function externalJoinMlsGroup$1(signingKey, groupInfoBlob) {
|
|
1067
|
+
const cs = await getMlsCipherSuite();
|
|
1068
|
+
const ctx = await getMlsContext();
|
|
1069
|
+
const msg = decode(mlsMessageDecoder, groupInfoBlob);
|
|
1070
|
+
if (!msg || msg.wireformat !== wireformats.mls_group_info) throw new Error("Failed to decode MLS GroupInfo for external join");
|
|
1071
|
+
const groupInfo = msg.groupInfo;
|
|
1072
|
+
const publicKey = ed25519.getPublicKey(signingKey);
|
|
1073
|
+
const { publicPackage, privatePackage } = await generateKeyPackageWithKey({
|
|
1074
|
+
credential: basicCred(publicKey),
|
|
1075
|
+
signatureKeyPair: {
|
|
1076
|
+
signKey: signingKey,
|
|
1077
|
+
publicKey
|
|
1078
|
+
},
|
|
1079
|
+
cipherSuite: cs
|
|
1080
|
+
});
|
|
1081
|
+
const { publicMessage, newState } = await joinGroupExternal({
|
|
1082
|
+
context: ctx,
|
|
1083
|
+
groupInfo,
|
|
1084
|
+
keyPackage: publicPackage,
|
|
1085
|
+
privateKeys: privatePackage,
|
|
1086
|
+
resync: false
|
|
1087
|
+
});
|
|
1088
|
+
return {
|
|
1089
|
+
commitBlob: encode(mlsMessageEncoder, {
|
|
1090
|
+
wireformat: wireformats.mls_public_message,
|
|
1091
|
+
publicMessage,
|
|
1092
|
+
version: protocolVersions.mls10
|
|
1093
|
+
}),
|
|
1094
|
+
stateBlob: encState(newState),
|
|
1095
|
+
groupInfo: await freshGroupInfoBlob(newState, cs),
|
|
1096
|
+
epoch: Number(newState.groupContext.epoch)
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
async function encryptMlsAppMessage(stateBlob, plaintext) {
|
|
1100
|
+
const { newState, message, consumed } = await createApplicationMessage({
|
|
1101
|
+
context: await getMlsContext(),
|
|
1102
|
+
state: decState(stateBlob),
|
|
1103
|
+
message: plaintext
|
|
1104
|
+
});
|
|
1105
|
+
zeroConsumed(consumed);
|
|
1106
|
+
return {
|
|
1107
|
+
ciphertext: encode(mlsMessageEncoder, message),
|
|
1108
|
+
updatedState: encState(newState)
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
async function decryptMlsAppMessage(stateBlob, ciphertext) {
|
|
1112
|
+
const ctx = await getMlsContext();
|
|
1113
|
+
const msg = decode(mlsMessageDecoder, ciphertext);
|
|
1114
|
+
if (!msg || msg.wireformat !== wireformats.mls_private_message) throw new Error("Failed to decode MLS private message");
|
|
1115
|
+
const result = await processPrivateMessage({
|
|
1116
|
+
context: ctx,
|
|
1117
|
+
state: decState(stateBlob),
|
|
1118
|
+
privateMessage: msg.privateMessage,
|
|
1119
|
+
callback: acceptAll
|
|
1120
|
+
});
|
|
1121
|
+
if (result.kind !== "applicationMessage") throw new Error("Expected application message, got commit/proposal");
|
|
1122
|
+
zeroConsumed(result.consumed);
|
|
1123
|
+
return {
|
|
1124
|
+
plaintext: result.message,
|
|
1125
|
+
updatedState: encState(result.newState)
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
//#endregion
|
|
1129
|
+
//#region src/crypto/mls-state.ts
|
|
1130
|
+
function mlsGroupDir(configDir, agentId, groupId) {
|
|
1131
|
+
return join(configDir, "keys", agentId, "mls", groupId);
|
|
1132
|
+
}
|
|
1133
|
+
function saveMlsState(configDir, agentId, groupId, stateBlob, metadata) {
|
|
1134
|
+
validatePathId(agentId, "agent ID");
|
|
1135
|
+
validatePathId(groupId, "group ID");
|
|
1136
|
+
const dir = mlsGroupDir(configDir, agentId, groupId);
|
|
1137
|
+
fs.mkdirSync(dir, {
|
|
1138
|
+
recursive: true,
|
|
1139
|
+
mode: 448
|
|
1140
|
+
});
|
|
1141
|
+
const statePath = join(dir, "state.bin");
|
|
1142
|
+
fs.writeFileSync(statePath, Buffer.from(stateBlob));
|
|
1143
|
+
fs.chmodSync(statePath, 384);
|
|
1144
|
+
const metaPath = join(dir, "epoch.json");
|
|
1145
|
+
fs.writeFileSync(metaPath, JSON.stringify(metadata), "utf-8");
|
|
1146
|
+
fs.chmodSync(metaPath, 384);
|
|
1147
|
+
}
|
|
1148
|
+
function loadMlsState(configDir, agentId, groupId) {
|
|
1149
|
+
validatePathId(agentId, "agent ID");
|
|
1150
|
+
validatePathId(groupId, "group ID");
|
|
1151
|
+
const dir = mlsGroupDir(configDir, agentId, groupId);
|
|
1152
|
+
const statePath = join(dir, "state.bin");
|
|
1153
|
+
const metaPath = join(dir, "epoch.json");
|
|
1154
|
+
if (!fs.existsSync(statePath) || !fs.existsSync(metaPath)) return null;
|
|
1155
|
+
return {
|
|
1156
|
+
stateBlob: new Uint8Array(fs.readFileSync(statePath)),
|
|
1157
|
+
metadata: JSON.parse(fs.readFileSync(metaPath, "utf-8"))
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
function deleteMlsState(configDir, agentId, groupId) {
|
|
1161
|
+
validatePathId(agentId, "agent ID");
|
|
1162
|
+
validatePathId(groupId, "group ID");
|
|
1163
|
+
const dir = mlsGroupDir(configDir, agentId, groupId);
|
|
1164
|
+
fs.rmSync(dir, {
|
|
1165
|
+
recursive: true,
|
|
1166
|
+
force: true
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
const SELF_READ_MAX = 256;
|
|
1170
|
+
function selfReadCachePath(configDir, agentId, groupId) {
|
|
1171
|
+
return join(mlsGroupDir(configDir, agentId, groupId), "self-read-cache.json");
|
|
1172
|
+
}
|
|
1173
|
+
function cacheMlsSelfRead(configDir, agentId, groupId, encryptedPayload, envelope) {
|
|
1174
|
+
const cachePath = selfReadCachePath(configDir, agentId, groupId);
|
|
1175
|
+
let cache = {};
|
|
1176
|
+
try {
|
|
1177
|
+
cache = JSON.parse(fs.readFileSync(cachePath, "utf-8"));
|
|
1178
|
+
} catch {}
|
|
1179
|
+
cache[encryptedPayload] = Buffer.from(envelope).toString("base64");
|
|
1180
|
+
const keys = Object.keys(cache);
|
|
1181
|
+
if (keys.length > SELF_READ_MAX) for (const k of keys.slice(0, keys.length - SELF_READ_MAX)) delete cache[k];
|
|
1182
|
+
fs.writeFileSync(cachePath, JSON.stringify(cache), "utf-8");
|
|
1183
|
+
fs.chmodSync(cachePath, 384);
|
|
1184
|
+
}
|
|
1185
|
+
function lookupMlsSelfRead(configDir, agentId, groupId, encryptedPayload) {
|
|
1186
|
+
const cachePath = selfReadCachePath(configDir, agentId, groupId);
|
|
1187
|
+
try {
|
|
1188
|
+
const entry = JSON.parse(fs.readFileSync(cachePath, "utf-8"))[encryptedPayload];
|
|
1189
|
+
if (entry) return new Uint8Array(Buffer.from(entry, "base64"));
|
|
1190
|
+
} catch {}
|
|
1191
|
+
return null;
|
|
1192
|
+
}
|
|
1193
|
+
function keyPackagesDir(configDir, agentId) {
|
|
1194
|
+
return join(configDir, "keys", agentId, "mls", "key_packages");
|
|
1195
|
+
}
|
|
1196
|
+
function hexRef(ref) {
|
|
1197
|
+
return Buffer.from(ref).toString("hex");
|
|
1198
|
+
}
|
|
1199
|
+
function savePrivateKeyPackages(configDir, agentId, packages) {
|
|
1200
|
+
validatePathId(agentId, "agent ID");
|
|
1201
|
+
const dir = keyPackagesDir(configDir, agentId);
|
|
1202
|
+
fs.mkdirSync(dir, {
|
|
1203
|
+
recursive: true,
|
|
1204
|
+
mode: 448
|
|
1205
|
+
});
|
|
1206
|
+
for (const pkg of packages) {
|
|
1207
|
+
const filePath = join(dir, `${hexRef(pkg.ref)}.json`);
|
|
1208
|
+
const data = JSON.stringify({
|
|
1209
|
+
publicBlob: Buffer.from(pkg.publicBlob).toString("base64"),
|
|
1210
|
+
privateBlob: Buffer.from(pkg.privateBlob).toString("base64")
|
|
1211
|
+
});
|
|
1212
|
+
fs.writeFileSync(filePath, data, "utf-8");
|
|
1213
|
+
fs.chmodSync(filePath, 384);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
function loadPrivateKeyPackage(configDir, agentId, ref) {
|
|
1217
|
+
validatePathId(agentId, "agent ID");
|
|
1218
|
+
const filePath = join(keyPackagesDir(configDir, agentId), `${hexRef(ref)}.json`);
|
|
1219
|
+
if (!fs.existsSync(filePath)) return null;
|
|
1220
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
1221
|
+
return {
|
|
1222
|
+
publicBlob: new Uint8Array(Buffer.from(raw.publicBlob, "base64")),
|
|
1223
|
+
privateBlob: new Uint8Array(Buffer.from(raw.privateBlob, "base64"))
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
function deletePrivateKeyPackage(configDir, agentId, ref) {
|
|
1227
|
+
validatePathId(agentId, "agent ID");
|
|
1228
|
+
const filePath = join(keyPackagesDir(configDir, agentId), `${hexRef(ref)}.json`);
|
|
1229
|
+
fs.rmSync(filePath, { force: true });
|
|
1230
|
+
}
|
|
1231
|
+
function listPrivateKeyPackages(configDir, agentId) {
|
|
1232
|
+
validatePathId(agentId, "agent ID");
|
|
1233
|
+
const dir = keyPackagesDir(configDir, agentId);
|
|
1234
|
+
if (!fs.existsSync(dir)) return [];
|
|
1235
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => {
|
|
1236
|
+
const ref = f.replace(".json", "");
|
|
1237
|
+
const raw = JSON.parse(fs.readFileSync(join(dir, f), "utf-8"));
|
|
1238
|
+
return {
|
|
1239
|
+
ref,
|
|
1240
|
+
publicBlob: new Uint8Array(Buffer.from(raw.publicBlob, "base64")),
|
|
1241
|
+
privateBlob: new Uint8Array(Buffer.from(raw.privateBlob, "base64"))
|
|
1242
|
+
};
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
//#endregion
|
|
1246
|
+
//#region src/crypto/mls-api.ts
|
|
1247
|
+
function toBase64(bytes) {
|
|
1248
|
+
return Buffer.from(bytes).toString("base64");
|
|
1249
|
+
}
|
|
1250
|
+
function fromBase64(s) {
|
|
1251
|
+
return new Uint8Array(Buffer.from(s, "base64"));
|
|
1252
|
+
}
|
|
1253
|
+
async function uploadKeyPackages(client, agentId, packages) {
|
|
1254
|
+
return await client.post(`/agents/${agentId}/mls/key-packages`, { packages });
|
|
1255
|
+
}
|
|
1256
|
+
async function claimKeyPackages(client, agentId, count = 1) {
|
|
1257
|
+
return await client.get(`/agents/${agentId}/mls/key-packages`, { count: String(count) });
|
|
1258
|
+
}
|
|
1259
|
+
async function mlsInit(client, groupId, extraHeaders) {
|
|
1260
|
+
return await client.post(`/groups/${groupId}/mls/init`, {}, extraHeaders);
|
|
1261
|
+
}
|
|
1262
|
+
async function mlsCommit(client, groupId, body, extraHeaders) {
|
|
1263
|
+
return await client.post(`/groups/${groupId}/mls/commit`, body, extraHeaders);
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* ACK a handshake the local client processed cleanly (H4). Once MLS_ACK_QUORUM
|
|
1267
|
+
* distinct clean ACKs land server-side, the tentative commit + its GroupInfo are
|
|
1268
|
+
* confirmed and mls_confirmed_epoch advances. extraHeaders is threaded so a
|
|
1269
|
+
* multi-agent client ACKs as the right acting agent (sibling /groups bug: do not
|
|
1270
|
+
* drop extraHeaders here).
|
|
1271
|
+
*/
|
|
1272
|
+
async function mlsAck(client, groupId, body, extraHeaders) {
|
|
1273
|
+
return await client.post(`/groups/${groupId}/mls/ack`, body, extraHeaders);
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* NACK a handshake the local client could NOT process (H4). The server marks the
|
|
1277
|
+
* delivery and lazily rolls the tentative commit back. A NACK is non-destructive:
|
|
1278
|
+
* it only ever un-advances unconfirmed, never-served state. extraHeaders threaded.
|
|
1279
|
+
*/
|
|
1280
|
+
async function mlsNack(client, groupId, body, extraHeaders) {
|
|
1281
|
+
return await client.post(`/groups/${groupId}/mls/nack`, body, extraHeaders);
|
|
1282
|
+
}
|
|
1283
|
+
async function fetchHandshakes(client, groupId, sinceEpoch, extraHeaders) {
|
|
1284
|
+
const params = {};
|
|
1285
|
+
if (sinceEpoch !== void 0) params["since_epoch"] = String(sinceEpoch);
|
|
1286
|
+
return await client.get(`/groups/${groupId}/mls/handshakes`, params, extraHeaders);
|
|
1287
|
+
}
|
|
1288
|
+
async function fetchWelcomes(client, agentId) {
|
|
1289
|
+
return await client.get(`/agents/${agentId}/mls/welcomes`);
|
|
1290
|
+
}
|
|
1291
|
+
async function fetchGroupInfo(client, groupId, extraHeaders) {
|
|
1292
|
+
return await client.get(`/groups/${groupId}/mls/group-info`, void 0, extraHeaders);
|
|
1293
|
+
}
|
|
1294
|
+
async function submitProposal(client, groupId, proposal, extraHeaders) {
|
|
1295
|
+
return await client.post(`/groups/${groupId}/mls/proposals`, { proposal }, extraHeaders);
|
|
1296
|
+
}
|
|
1297
|
+
//#endregion
|
|
1298
|
+
//#region src/mls-ops-join.ts
|
|
1299
|
+
/** A commit was rejected because our GroupInfo targeted a stale epoch. */
|
|
1300
|
+
function isEpochConflict(err) {
|
|
1301
|
+
return err instanceof RineApiError && err.code === "epoch_conflict";
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* The group's only GroupInfo is still tentative (awaiting a peer ACK), so the
|
|
1305
|
+
* server has no confirmed join token yet (H4). Distinct from epoch_conflict: a
|
|
1306
|
+
* short bounded retry resolves it once a peer ACKs, so it must NOT consume the
|
|
1307
|
+
* single epoch-conflict retry below.
|
|
1308
|
+
*/
|
|
1309
|
+
function isPendingConfirmation(err) {
|
|
1310
|
+
return err instanceof RineApiError && err.code === "pending_confirmation";
|
|
1311
|
+
}
|
|
1312
|
+
const CIPHER_SUITE = 1;
|
|
1313
|
+
const PENDING_RETRY_LIMIT = 3;
|
|
1314
|
+
const PENDING_RETRY_DELAY_MS = 400;
|
|
1315
|
+
function sleep(ms) {
|
|
1316
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Self-join an MLS-active group via an RFC 9420 external commit (Phase 6).
|
|
1320
|
+
*
|
|
1321
|
+
* Used for OPEN enrollment, where the rine server adds a joiner to the group
|
|
1322
|
+
* with no member in the loop — so nobody mints a Welcome and the joiner is a
|
|
1323
|
+
* rine member but not yet an MLS member. The joiner fetches the group's latest
|
|
1324
|
+
* published GroupInfo, derives a fresh init_secret against its external_pub,
|
|
1325
|
+
* and posts a public External Commit that existing members process (via the
|
|
1326
|
+
* EXISTING syncMlsGroup path) to advance the epoch.
|
|
1327
|
+
*
|
|
1328
|
+
* State is keyed by the RINE group UUID (groupId) — the same key
|
|
1329
|
+
* encrypt/decryptGroupMessage use — so the joiner can immediately send/receive.
|
|
1330
|
+
*
|
|
1331
|
+
* Epoch-conflict handling (research-guided, RFC 9420 §12.4.3.2 + RFC 9750):
|
|
1332
|
+
* a GroupInfo is single-use per epoch. If a competing external join lands first,
|
|
1333
|
+
* our GroupInfo is stale and our commit targets a stale epoch (members reject).
|
|
1334
|
+
* We do a single discard-and-rebuild retry against a freshly-fetched GroupInfo;
|
|
1335
|
+
* on a second failure we surface a clear, actionable terminal error rather than
|
|
1336
|
+
* looping (an unbounded retry against an advancing epoch is a self-DoS).
|
|
1337
|
+
*/
|
|
1338
|
+
async function externalJoinMlsGroup(configDir, agentId, groupId, client, extraHeaders) {
|
|
1339
|
+
const keys = loadAgentKeys(configDir, agentId);
|
|
1340
|
+
const attempt = async () => {
|
|
1341
|
+
const info = await fetchGroupInfo(client, groupId, extraHeaders);
|
|
1342
|
+
if (!info.group_info) throw new Error(`Cannot self-join MLS group ${groupId}: no published GroupInfo. An existing member must publish one (open enrollment requires a current-epoch GroupInfo).`);
|
|
1343
|
+
const groupInfoBlob = fromBase64(info.group_info);
|
|
1344
|
+
const joined = await externalJoinMlsGroup$1(keys.signing.privateKey, groupInfoBlob);
|
|
1345
|
+
const epoch = (await mlsCommit(client, groupId, {
|
|
1346
|
+
commit: toBase64(joined.commitBlob),
|
|
1347
|
+
group_info: toBase64(joined.groupInfo),
|
|
1348
|
+
epoch: joined.epoch
|
|
1349
|
+
}, extraHeaders)).epoch ?? joined.epoch;
|
|
1350
|
+
saveMlsState(configDir, agentId, groupId, joined.stateBlob, {
|
|
1351
|
+
groupId,
|
|
1352
|
+
epoch,
|
|
1353
|
+
cipherSuite: CIPHER_SUITE
|
|
1354
|
+
});
|
|
1355
|
+
return { epoch };
|
|
1356
|
+
};
|
|
1357
|
+
let firstErr = void 0;
|
|
1358
|
+
for (let i = 0;; i++) try {
|
|
1359
|
+
return await attempt();
|
|
1360
|
+
} catch (err) {
|
|
1361
|
+
if (!isPendingConfirmation(err)) {
|
|
1362
|
+
firstErr = err;
|
|
1363
|
+
break;
|
|
1364
|
+
}
|
|
1365
|
+
if (i >= PENDING_RETRY_LIMIT - 1) throw new Error(`Cannot self-join MLS group ${groupId}: GroupInfo is still pending peer confirmation after a brief retry. Try again shortly.`, { cause: err });
|
|
1366
|
+
await sleep(PENDING_RETRY_DELAY_MS);
|
|
1367
|
+
}
|
|
1368
|
+
if (!isEpochConflict(firstErr)) throw firstErr;
|
|
1369
|
+
try {
|
|
1370
|
+
return await attempt();
|
|
1371
|
+
} catch (retryErr) {
|
|
1372
|
+
const detail = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
1373
|
+
throw new Error(`External MLS self-join failed for group ${groupId} after one retry (likely an epoch conflict — another joiner landed first): ${detail}`, { cause: firstErr });
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
//#endregion
|
|
1377
|
+
//#region src/mls-ops-sync.ts
|
|
1378
|
+
/**
|
|
1379
|
+
* Fetch all pending MLS welcomes for an agent and process them, establishing
|
|
1380
|
+
* local group state for each group the agent has been added to.
|
|
1381
|
+
*/
|
|
1382
|
+
async function processMlsWelcomes(configDir, agentId, client) {
|
|
1383
|
+
const { welcomes } = await fetchWelcomes(client, agentId);
|
|
1384
|
+
const storedPkgs = listPrivateKeyPackages(configDir, agentId);
|
|
1385
|
+
const pkgByRef = new Map(storedPkgs.map((p) => [p.ref, p]));
|
|
1386
|
+
const groupIds = [];
|
|
1387
|
+
for (const welcome of welcomes) {
|
|
1388
|
+
const rineGroupId = welcome.group_id;
|
|
1389
|
+
if (!rineGroupId) continue;
|
|
1390
|
+
const welcomeBlob = fromBase64(welcome.blob);
|
|
1391
|
+
const msg = decode(mlsMessageDecoder, welcomeBlob);
|
|
1392
|
+
if (!msg || msg.wireformat !== wireformats.mls_welcome) throw new Error("Failed to decode welcome message");
|
|
1393
|
+
const welcomeData = msg.welcome;
|
|
1394
|
+
let matched = null;
|
|
1395
|
+
for (const secret of welcomeData.secrets) {
|
|
1396
|
+
const refHex = Buffer.from(secret.newMember).toString("hex");
|
|
1397
|
+
const pkg = pkgByRef.get(refHex);
|
|
1398
|
+
if (pkg) {
|
|
1399
|
+
matched = {
|
|
1400
|
+
publicBlob: pkg.publicBlob,
|
|
1401
|
+
privateBlob: pkg.privateBlob,
|
|
1402
|
+
refHex
|
|
1403
|
+
};
|
|
1404
|
+
break;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
if (!matched) throw new Error("No matching private key package found for welcome");
|
|
1408
|
+
const { stateBlob, epoch, cipherSuite } = await processMlsWelcome(welcomeBlob, matched.publicBlob, matched.privateBlob);
|
|
1409
|
+
saveMlsState(configDir, agentId, rineGroupId, stateBlob, {
|
|
1410
|
+
groupId: rineGroupId,
|
|
1411
|
+
epoch,
|
|
1412
|
+
cipherSuite
|
|
1413
|
+
});
|
|
1414
|
+
deletePrivateKeyPackage(configDir, agentId, Buffer.from(matched.refHex, "hex"));
|
|
1415
|
+
groupIds.push(rineGroupId);
|
|
1416
|
+
}
|
|
1417
|
+
return {
|
|
1418
|
+
processed: groupIds.length,
|
|
1419
|
+
groupIds
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Remove a member from an MLS group by leaf index and post the remove commit.
|
|
1424
|
+
*/
|
|
1425
|
+
async function removeMlsGroupMember(configDir, agentId, groupId, leafIndex, client, extraHeaders) {
|
|
1426
|
+
const stored = loadMlsState(configDir, agentId, groupId);
|
|
1427
|
+
if (!stored) throw new Error(`No MLS state for group ${groupId}`);
|
|
1428
|
+
const { commitBlob, updatedState, groupInfo, epoch: commitEpoch } = await removeMemberFromMlsGroup(stored.stateBlob, leafIndex);
|
|
1429
|
+
const epoch = (await mlsCommit(client, groupId, {
|
|
1430
|
+
commit: toBase64(commitBlob),
|
|
1431
|
+
group_info: toBase64(groupInfo),
|
|
1432
|
+
epoch: commitEpoch
|
|
1433
|
+
}, extraHeaders)).epoch ?? commitEpoch;
|
|
1434
|
+
saveMlsState(configDir, agentId, groupId, updatedState, {
|
|
1435
|
+
...stored.metadata,
|
|
1436
|
+
epoch
|
|
1437
|
+
});
|
|
1438
|
+
return { epoch };
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Sync local MLS group state by fetching and applying all commits posted
|
|
1442
|
+
* since the current epoch.
|
|
1443
|
+
*/
|
|
1444
|
+
async function syncMlsGroup(configDir, agentId, groupId, client, extraHeaders) {
|
|
1445
|
+
const stored = loadMlsState(configDir, agentId, groupId);
|
|
1446
|
+
if (!stored) throw new Error(`No MLS state for group ${groupId}`);
|
|
1447
|
+
const currentEpoch = stored.metadata.epoch;
|
|
1448
|
+
const { handshakes } = await fetchHandshakes(client, groupId, currentEpoch, extraHeaders);
|
|
1449
|
+
let currentState = stored.stateBlob;
|
|
1450
|
+
let epoch = currentEpoch;
|
|
1451
|
+
let processed = 0;
|
|
1452
|
+
for (const handshake of handshakes) {
|
|
1453
|
+
const commitBlob = fromBase64(handshake.blob);
|
|
1454
|
+
let result;
|
|
1455
|
+
try {
|
|
1456
|
+
result = await processMlsCommit(currentState, commitBlob);
|
|
1457
|
+
} catch (err) {
|
|
1458
|
+
if (err instanceof MlsError) {
|
|
1459
|
+
const reconciled = await reconcileIfOrphaned(configDir, agentId, groupId, epoch, client, extraHeaders);
|
|
1460
|
+
if (reconciled !== null) return {
|
|
1461
|
+
epoch: reconciled,
|
|
1462
|
+
processed
|
|
1463
|
+
};
|
|
1464
|
+
await emitNack(client, groupId, handshake, err, extraHeaders);
|
|
1465
|
+
break;
|
|
1466
|
+
}
|
|
1467
|
+
throw err;
|
|
1468
|
+
}
|
|
1469
|
+
currentState = result.updatedState;
|
|
1470
|
+
epoch = result.epoch;
|
|
1471
|
+
processed += 1;
|
|
1472
|
+
saveMlsState(configDir, agentId, groupId, currentState, {
|
|
1473
|
+
...stored.metadata,
|
|
1474
|
+
epoch
|
|
1475
|
+
});
|
|
1476
|
+
await emitAck(client, groupId, handshake, extraHeaders);
|
|
1477
|
+
}
|
|
1478
|
+
return {
|
|
1479
|
+
epoch,
|
|
1480
|
+
processed
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* H4 (MEDIUM-1) reconcile: detect whether a processMlsCommit failure is caused
|
|
1485
|
+
* by our OWN local state being orphaned (ahead of the server's confirmed epoch)
|
|
1486
|
+
* rather than by a genuinely un-processable commit.
|
|
1487
|
+
*
|
|
1488
|
+
* The orphan signal: the server's confirmed GroupInfo epoch is BELOW our local
|
|
1489
|
+
* epoch. That can only happen if our own tentative commit was rolled back — the
|
|
1490
|
+
* server cannot serve a confirmed token as high as the epoch we locally hold. In
|
|
1491
|
+
* that case we re-bootstrap via an external self-join from mls_confirmed_epoch
|
|
1492
|
+
* (no un-processing of honestly-confirmed state — the B1 win); the re-join only
|
|
1493
|
+
* overwrites the stale local state once the server accepts the commit, so a
|
|
1494
|
+
* mid-flight re-join failure leaves the stale state intact for the next sync to
|
|
1495
|
+
* retry rather than stranding the agent stateless. Returns the re-bootstrapped
|
|
1496
|
+
* epoch on reconcile, or null when the state is NOT orphaned (caller should NACK
|
|
1497
|
+
* the garbage commit).
|
|
1498
|
+
*/
|
|
1499
|
+
async function reconcileIfOrphaned(configDir, agentId, groupId, localEpoch, client, extraHeaders) {
|
|
1500
|
+
let confirmedEpoch;
|
|
1501
|
+
try {
|
|
1502
|
+
confirmedEpoch = (await fetchGroupInfo(client, groupId, extraHeaders)).epoch;
|
|
1503
|
+
} catch {
|
|
1504
|
+
return null;
|
|
1505
|
+
}
|
|
1506
|
+
if (confirmedEpoch === null || confirmedEpoch >= localEpoch) return null;
|
|
1507
|
+
const { epoch } = await externalJoinMlsGroup(configDir, agentId, groupId, client, extraHeaders);
|
|
1508
|
+
return epoch;
|
|
1509
|
+
}
|
|
1510
|
+
/** Fire-and-forget ACK; a failed ACK must never fail the sync. */
|
|
1511
|
+
async function emitAck(client, groupId, handshake, extraHeaders) {
|
|
1512
|
+
try {
|
|
1513
|
+
await mlsAck(client, groupId, { object_id: handshake.id }, extraHeaders);
|
|
1514
|
+
} catch {}
|
|
1515
|
+
}
|
|
1516
|
+
/** Fire-and-forget NACK; a failed NACK must never fail the sync. */
|
|
1517
|
+
async function emitNack(client, groupId, handshake, err, extraHeaders) {
|
|
1518
|
+
try {
|
|
1519
|
+
await mlsNack(client, groupId, {
|
|
1520
|
+
object_id: handshake.id,
|
|
1521
|
+
epoch: handshake.epoch ?? 0,
|
|
1522
|
+
reason: err.message.slice(0, 256)
|
|
1523
|
+
}, extraHeaders);
|
|
1524
|
+
} catch {}
|
|
1525
|
+
}
|
|
1526
|
+
//#endregion
|
|
731
1527
|
//#region src/crypto/message.ts
|
|
732
1528
|
async function verifyEnvelopeSender(decoded, client) {
|
|
733
1529
|
let verified = false;
|
|
@@ -745,23 +1541,38 @@ async function verifyEnvelopeSender(decoded, client) {
|
|
|
745
1541
|
function signingKid(agentId) {
|
|
746
1542
|
return `rine:${agentId}`;
|
|
747
1543
|
}
|
|
748
|
-
async function encryptMessage(configDir, senderAgentId, recipientEncryptionPk, payload) {
|
|
1544
|
+
async function encryptMessage(configDir, senderAgentId, recipientEncryptionPk, payload, recipientPqEncryptionPk) {
|
|
749
1545
|
const keys = loadAgentKeys(configDir, senderAgentId);
|
|
750
1546
|
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
751
1547
|
const kid = signingKid(senderAgentId);
|
|
1548
|
+
const envelope = encodeEnvelope(kid, signPayload(keys.signing.privateKey, plaintext), plaintext);
|
|
1549
|
+
if (recipientPqEncryptionPk && recipientPqEncryptionPk.length > 0) return {
|
|
1550
|
+
encrypted_payload: toBase64Url(await sealHybrid(recipientEncryptionPk, recipientPqEncryptionPk, envelope)),
|
|
1551
|
+
encryption_version: "hpke-hybrid-v1",
|
|
1552
|
+
sender_signing_kid: kid
|
|
1553
|
+
};
|
|
752
1554
|
return {
|
|
753
|
-
encrypted_payload: toBase64Url(await seal(recipientEncryptionPk,
|
|
1555
|
+
encrypted_payload: toBase64Url(await seal(recipientEncryptionPk, envelope)),
|
|
754
1556
|
encryption_version: "hpke-v1",
|
|
755
1557
|
sender_signing_kid: kid
|
|
756
1558
|
};
|
|
757
1559
|
}
|
|
758
|
-
|
|
759
|
-
|
|
1560
|
+
/** Fetch a recipient's encryption keys, including the optional PQ key for hybrid mode. */
|
|
1561
|
+
async function fetchRecipientKeys(client, agentId) {
|
|
1562
|
+
const data = await client.get(`/agents/${agentId}/keys`);
|
|
1563
|
+
const out = { encryption: jwkToPublicKey(data.encryption_public_key) };
|
|
1564
|
+
if (data.pq_encryption_public_key) out.pqEncryption = jwkToPqPublicKey(data.pq_encryption_public_key);
|
|
1565
|
+
return out;
|
|
760
1566
|
}
|
|
761
1567
|
async function decryptMessage(configDir, recipientAgentId, encryptedPayloadB64, client) {
|
|
762
1568
|
const keys = loadAgentKeys(configDir, recipientAgentId);
|
|
763
1569
|
const encrypted = fromBase64Url(encryptedPayloadB64);
|
|
764
|
-
|
|
1570
|
+
let envelopeBytes;
|
|
1571
|
+
if (encrypted[0] === 3) {
|
|
1572
|
+
if (!keys.pqEncryption) throw new Error("received hpke-hybrid-v1 message but recipient has no PQ encryption key");
|
|
1573
|
+
envelopeBytes = await openHybrid(keys.encryption.privateKey, keys.pqEncryption.privateKey, encrypted);
|
|
1574
|
+
} else envelopeBytes = await open(keys.encryption.privateKey, encrypted);
|
|
1575
|
+
const decoded = decodeEnvelope(envelopeBytes);
|
|
765
1576
|
const plaintext = new TextDecoder("utf-8", { fatal: true }).decode(decoded.payload);
|
|
766
1577
|
const { verified, verificationStatus } = await verifyEnvelopeSender(decoded, client);
|
|
767
1578
|
return {
|
|
@@ -793,11 +1604,87 @@ async function encryptGroupMessage(configDir, senderAgentId, groupId, senderKeyS
|
|
|
793
1604
|
updatedState
|
|
794
1605
|
};
|
|
795
1606
|
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Encrypt a payload for an MLS-active group.
|
|
1609
|
+
*
|
|
1610
|
+
* Mirrors {@link encryptGroupMessage} but uses MLS instead of sender keys.
|
|
1611
|
+
* The signed inner envelope (kid + Ed25519 signature + plaintext) is the MLS
|
|
1612
|
+
* application-message payload, so {@link decryptGroupMessage}'s 0x04 branch can
|
|
1613
|
+
* run `decodeEnvelope` + `verifyEnvelopeSender` unchanged. The MLS ciphertext is
|
|
1614
|
+
* prefixed with {@link VERSION_MLS} (0x04) and base64url-encoded to match the
|
|
1615
|
+
* version-byte dispatch in `decryptGroupMessage`.
|
|
1616
|
+
*
|
|
1617
|
+
* State is keyed by the rine group UUID (`groupId`), the same identifier used by
|
|
1618
|
+
* `decryptGroupMessage` and the MLS API routes — never the opaque `mls_group_id`
|
|
1619
|
+
* latch or the MLS-internal group id.
|
|
1620
|
+
*/
|
|
1621
|
+
async function encryptMlsGroupMessage(configDir, senderAgentId, groupId, payload) {
|
|
1622
|
+
const stored = loadMlsState(configDir, senderAgentId, groupId);
|
|
1623
|
+
if (!stored) throw new Error(`No MLS state for group ${groupId}`);
|
|
1624
|
+
const keys = loadAgentKeys(configDir, senderAgentId);
|
|
1625
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
1626
|
+
const kid = signingKid(senderAgentId);
|
|
1627
|
+
const envelope = encodeEnvelope(kid, signPayload(keys.signing.privateKey, plaintext), plaintext);
|
|
1628
|
+
const { ciphertext, updatedState } = await encryptMlsAppMessage(stored.stateBlob, envelope);
|
|
1629
|
+
saveMlsState(configDir, senderAgentId, groupId, updatedState, stored.metadata);
|
|
1630
|
+
const tagged = new Uint8Array(1 + ciphertext.length);
|
|
1631
|
+
tagged[0] = 4;
|
|
1632
|
+
tagged.set(ciphertext, 1);
|
|
1633
|
+
const encrypted_payload = toBase64Url(tagged);
|
|
1634
|
+
cacheMlsSelfRead(configDir, senderAgentId, groupId, encrypted_payload, envelope);
|
|
1635
|
+
return {
|
|
1636
|
+
encrypted_payload,
|
|
1637
|
+
encryption_version: "mls-v1",
|
|
1638
|
+
sender_signing_kid: kid
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
async function decryptMlsMessage(configDir, recipientAgentId, groupId, mlsCiphertext, client) {
|
|
1642
|
+
const stored = loadMlsState(configDir, recipientAgentId, groupId);
|
|
1643
|
+
if (!stored) throw new Error(`No MLS state for group ${groupId}`);
|
|
1644
|
+
const { plaintext: mlsPlain, updatedState } = await decryptMlsAppMessage(stored.stateBlob, mlsCiphertext);
|
|
1645
|
+
saveMlsState(configDir, recipientAgentId, groupId, updatedState, stored.metadata);
|
|
1646
|
+
const mlsEnvelope = decodeEnvelope(mlsPlain);
|
|
1647
|
+
const mlsText = new TextDecoder("utf-8", { fatal: true }).decode(mlsEnvelope.payload);
|
|
1648
|
+
const { verified, verificationStatus } = await verifyEnvelopeSender(mlsEnvelope, client);
|
|
1649
|
+
if (verificationStatus === "invalid") throw new Error("Sender signature verification failed");
|
|
1650
|
+
return {
|
|
1651
|
+
plaintext: mlsText,
|
|
1652
|
+
senderKid: mlsEnvelope.kid,
|
|
1653
|
+
verified,
|
|
1654
|
+
verificationStatus
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
796
1657
|
async function decryptGroupMessage(configDir, recipientAgentId, groupId, encryptedPayloadB64, client) {
|
|
797
1658
|
const states = loadSenderKeyStates(configDir, recipientAgentId, groupId);
|
|
798
1659
|
const stateMap = /* @__PURE__ */ new Map();
|
|
799
1660
|
for (const s of states) stateMap.set(s.senderKeyId, s);
|
|
800
|
-
const
|
|
1661
|
+
const encrypted = fromBase64Url(encryptedPayloadB64);
|
|
1662
|
+
if (encrypted[0] === 4) {
|
|
1663
|
+
const cached = lookupMlsSelfRead(configDir, recipientAgentId, groupId, encryptedPayloadB64);
|
|
1664
|
+
if (cached) {
|
|
1665
|
+
const env = decodeEnvelope(cached);
|
|
1666
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(env.payload);
|
|
1667
|
+
const { verified, verificationStatus } = await verifyEnvelopeSender(env, client);
|
|
1668
|
+
if (verificationStatus === "invalid") throw new Error("Sender signature verification failed");
|
|
1669
|
+
return {
|
|
1670
|
+
plaintext: text,
|
|
1671
|
+
senderKid: env.kid,
|
|
1672
|
+
verified,
|
|
1673
|
+
verificationStatus
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
const mlsCiphertext = encrypted.slice(1);
|
|
1677
|
+
try {
|
|
1678
|
+
return await decryptMlsMessage(configDir, recipientAgentId, groupId, mlsCiphertext, client);
|
|
1679
|
+
} catch (err) {
|
|
1680
|
+
if (!(err instanceof MlsError || err instanceof Error && err.message.startsWith("No MLS state for group"))) throw err;
|
|
1681
|
+
}
|
|
1682
|
+
await processMlsWelcomes(configDir, recipientAgentId, client);
|
|
1683
|
+
if (!loadMlsState(configDir, recipientAgentId, groupId)) await externalJoinMlsGroup(configDir, recipientAgentId, groupId, client);
|
|
1684
|
+
await syncMlsGroup(configDir, recipientAgentId, groupId, client);
|
|
1685
|
+
return await decryptMlsMessage(configDir, recipientAgentId, groupId, mlsCiphertext, client);
|
|
1686
|
+
}
|
|
1687
|
+
const { innerEnvelope, updatedState, messageIndex } = await openGroup(stateMap, encrypted);
|
|
801
1688
|
const decoded = decodeEnvelope(innerEnvelope);
|
|
802
1689
|
const plaintext = new TextDecoder("utf-8", { fatal: true }).decode(decoded.payload);
|
|
803
1690
|
const { verified, verificationStatus } = await verifyEnvelopeSender(decoded, client);
|
|
@@ -820,7 +1707,7 @@ async function decryptGroupMessage(configDir, recipientAgentId, groupId, encrypt
|
|
|
820
1707
|
}
|
|
821
1708
|
function getAgentPublicKeys(configDir, agentId, agentKeys) {
|
|
822
1709
|
const keys = agentKeys ?? loadAgentKeys(configDir, agentId);
|
|
823
|
-
|
|
1710
|
+
const result = {
|
|
824
1711
|
signing_public_key: signingPublicKeyToJWK(keys.signing.publicKey),
|
|
825
1712
|
encryption_public_key: {
|
|
826
1713
|
kty: "OKP",
|
|
@@ -828,6 +1715,8 @@ function getAgentPublicKeys(configDir, agentId, agentKeys) {
|
|
|
828
1715
|
x: toBase64Url(keys.encryption.publicKey)
|
|
829
1716
|
}
|
|
830
1717
|
};
|
|
1718
|
+
if (keys.pqEncryption) result.pq_encryption_public_key = pqPublicKeyToJWK(keys.pqEncryption.publicKey);
|
|
1719
|
+
return result;
|
|
831
1720
|
}
|
|
832
1721
|
//#endregion
|
|
833
1722
|
//#region src/crypto/ingest.ts
|
|
@@ -886,7 +1775,7 @@ async function distributeSenderKey(client, configDir, senderAgentId, state, grou
|
|
|
886
1775
|
continue;
|
|
887
1776
|
}
|
|
888
1777
|
try {
|
|
889
|
-
const encrypted = await encryptMessage(configDir, senderAgentId, fromBase64Url(recipientKeyData.encryption_public_key.x), distPayload);
|
|
1778
|
+
const encrypted = await encryptMessage(configDir, senderAgentId, fromBase64Url(recipientKeyData.encryption_public_key.x), distPayload, recipientKeyData.pq_encryption_public_key ? jwkToPqPublicKey(recipientKeyData.pq_encryption_public_key) : void 0);
|
|
890
1779
|
await client.post("/messages", {
|
|
891
1780
|
to_agent_id: recipientId,
|
|
892
1781
|
type: "rine.v1.sender_key_distribution",
|
|
@@ -968,6 +1857,99 @@ async function fetchAndIngestPendingSKDistributions(client, configDir, agentId,
|
|
|
968
1857
|
return ingested;
|
|
969
1858
|
}
|
|
970
1859
|
//#endregion
|
|
1860
|
+
//#region src/mls-ops.ts
|
|
1861
|
+
/**
|
|
1862
|
+
* Generate and upload MLS key packages for an agent, enabling other agents
|
|
1863
|
+
* to add them to MLS groups.
|
|
1864
|
+
*/
|
|
1865
|
+
async function publishMlsKeyPackages(configDir, agentId, client, count = 10) {
|
|
1866
|
+
const keys = loadAgentKeys(configDir, agentId);
|
|
1867
|
+
const packages = [];
|
|
1868
|
+
const privatePackages = [];
|
|
1869
|
+
for (let i = 0; i < count; i++) {
|
|
1870
|
+
const kp = await generateMlsKeyPackage(keys.signing.privateKey);
|
|
1871
|
+
packages.push(toBase64(kp.publicBlob));
|
|
1872
|
+
privatePackages.push({
|
|
1873
|
+
ref: kp.keyPackageRef,
|
|
1874
|
+
publicBlob: kp.publicBlob,
|
|
1875
|
+
privateBlob: kp.privateBlob
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
savePrivateKeyPackages(configDir, agentId, privatePackages);
|
|
1879
|
+
return uploadKeyPackages(client, agentId, packages);
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Initialize an MLS group: create the group state, add all members who have
|
|
1883
|
+
* uploaded key packages, and post the initial commit + welcomes to the server.
|
|
1884
|
+
*/
|
|
1885
|
+
async function initMlsGroup(configDir, agentId, groupId, client, extraHeaders) {
|
|
1886
|
+
const initResp = await mlsInit(client, groupId, extraHeaders);
|
|
1887
|
+
const { stateBlob, groupInfo, commitBlob, epoch: createEpoch } = await createMlsGroup(loadAgentKeys(configDir, agentId).signing.privateKey);
|
|
1888
|
+
const agentIdLower = agentId.toLowerCase();
|
|
1889
|
+
const eligibleMembers = initResp.members.filter((m) => m.has_key_package && m.agent_id.toLowerCase() !== agentIdLower);
|
|
1890
|
+
let currentState = stateBlob;
|
|
1891
|
+
let currentCommit = commitBlob;
|
|
1892
|
+
let currentGroupInfo = groupInfo;
|
|
1893
|
+
let currentEpoch = createEpoch;
|
|
1894
|
+
const welcomes = [];
|
|
1895
|
+
for (const member of eligibleMembers) {
|
|
1896
|
+
const firstPackage = (await claimKeyPackages(client, member.agent_id)).packages[0];
|
|
1897
|
+
if (!firstPackage) continue;
|
|
1898
|
+
const kpBlob = fromBase64(firstPackage);
|
|
1899
|
+
const result = await addMemberToMlsGroup(currentState, kpBlob);
|
|
1900
|
+
welcomes.push({
|
|
1901
|
+
agent_id: member.agent_id,
|
|
1902
|
+
welcome: toBase64(result.welcomeBlob)
|
|
1903
|
+
});
|
|
1904
|
+
currentState = result.updatedState;
|
|
1905
|
+
currentCommit = result.commitBlob;
|
|
1906
|
+
currentGroupInfo = result.groupInfo;
|
|
1907
|
+
currentEpoch = result.epoch;
|
|
1908
|
+
}
|
|
1909
|
+
const epoch = (await mlsCommit(client, groupId, {
|
|
1910
|
+
commit: toBase64(currentCommit),
|
|
1911
|
+
welcomes,
|
|
1912
|
+
group_info: toBase64(currentGroupInfo),
|
|
1913
|
+
cipher_suite: 1,
|
|
1914
|
+
epoch: currentEpoch
|
|
1915
|
+
}, extraHeaders)).epoch ?? currentEpoch;
|
|
1916
|
+
saveMlsState(configDir, agentId, groupId, currentState, {
|
|
1917
|
+
groupId,
|
|
1918
|
+
epoch,
|
|
1919
|
+
cipherSuite: 1
|
|
1920
|
+
});
|
|
1921
|
+
return {
|
|
1922
|
+
epoch,
|
|
1923
|
+
membersAdded: welcomes.length
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Add a single member to an existing MLS group by claiming their key package
|
|
1928
|
+
* and posting an add commit + welcome.
|
|
1929
|
+
*/
|
|
1930
|
+
async function addMlsGroupMember(configDir, agentId, groupId, memberAgentId, client, extraHeaders) {
|
|
1931
|
+
const stored = loadMlsState(configDir, agentId, groupId);
|
|
1932
|
+
if (!stored) throw new Error(`No MLS state for group ${groupId}`);
|
|
1933
|
+
const firstPackage = (await claimKeyPackages(client, memberAgentId)).packages[0];
|
|
1934
|
+
if (!firstPackage) throw new Error(`No key packages available for ${memberAgentId}`);
|
|
1935
|
+
const kpBlob = fromBase64(firstPackage);
|
|
1936
|
+
const { commitBlob, welcomeBlob, updatedState, groupInfo, epoch: commitEpoch } = await addMemberToMlsGroup(stored.stateBlob, kpBlob);
|
|
1937
|
+
const epoch = (await mlsCommit(client, groupId, {
|
|
1938
|
+
commit: toBase64(commitBlob),
|
|
1939
|
+
welcomes: [{
|
|
1940
|
+
agent_id: memberAgentId,
|
|
1941
|
+
welcome: toBase64(welcomeBlob)
|
|
1942
|
+
}],
|
|
1943
|
+
group_info: toBase64(groupInfo),
|
|
1944
|
+
epoch: commitEpoch
|
|
1945
|
+
}, extraHeaders)).epoch ?? commitEpoch;
|
|
1946
|
+
saveMlsState(configDir, agentId, groupId, updatedState, {
|
|
1947
|
+
...stored.metadata,
|
|
1948
|
+
epoch
|
|
1949
|
+
});
|
|
1950
|
+
return { epoch };
|
|
1951
|
+
}
|
|
1952
|
+
//#endregion
|
|
971
1953
|
//#region src/onboard.ts
|
|
972
1954
|
/** Validate an org slug: 2-32 chars, lowercase alphanumeric + hyphens, no leading/trailing hyphen. */
|
|
973
1955
|
function validateSlug(slug) {
|
|
@@ -1039,10 +2021,14 @@ async function performAgentCreation(client, configDir, profile, params) {
|
|
|
1039
2021
|
signing_public_key: signingPublicKeyToJWK(agentKeys.signing.publicKey),
|
|
1040
2022
|
encryption_public_key: encryptionPublicKeyToJWK(agentKeys.encryption.publicKey)
|
|
1041
2023
|
};
|
|
2024
|
+
if (agentKeys.pqEncryption) body.pq_encryption_public_key = pqPublicKeyToJWK(agentKeys.pqEncryption.publicKey);
|
|
1042
2025
|
if (params.humanOversight !== void 0) body.human_oversight = params.humanOversight;
|
|
1043
2026
|
if (params.unlisted !== void 0) body.unlisted = params.unlisted;
|
|
1044
2027
|
const agent = await client.post("/agents", body);
|
|
1045
2028
|
saveAgentKeys(configDir, agent.id, agentKeys);
|
|
2029
|
+
try {
|
|
2030
|
+
await publishMlsKeyPackages(configDir, agent.id, client);
|
|
2031
|
+
} catch {}
|
|
1046
2032
|
if (agent.poll_url) {
|
|
1047
2033
|
const creds = loadCredentials(configDir);
|
|
1048
2034
|
if (creds[profile]) {
|
|
@@ -1053,4 +2039,4 @@ async function performAgentCreation(client, configDir, profile, params) {
|
|
|
1053
2039
|
return agent;
|
|
1054
2040
|
}
|
|
1055
2041
|
//#endregion
|
|
1056
|
-
export { DEFAULT_API_URL, HttpClient, RineApiError, UUID_RE, advanceChain, agentIdFromKid, agentKeysExist, bytesToUuid, cacheToken, decodeEnvelope, decryptGroupMessage, decryptMessage, deriveMessageKey, distributeSenderKey, encodeEnvelope, encryptGroupMessage, encryptMessage, encryptionPublicKeyToJWK, fetchAgents, fetchAndIngestPendingSKDistributions, fetchOAuthToken,
|
|
2042
|
+
export { DEFAULT_API_URL, HttpClient, MLKEM_CT_SIZE, MLS_CIPHER_SUITE_ID, RineApiError, UUID_RE, VERSION_HYBRID, VERSION_MLS, addMemberToMlsGroup, addMlsGroupMember, advanceChain, agentIdFromKid, agentKeysExist, bytesToUuid, cacheMlsSelfRead, cacheToken, claimKeyPackages, createMlsGroup, decodeEnvelope, decryptGroupMessage, decryptMessage, decryptMlsAppMessage, deleteMlsState, deletePrivateKeyPackage, deriveMessageKey, distributeSenderKey, encodeEnvelope, encryptGroupMessage, encryptMessage, encryptMlsAppMessage, encryptMlsGroupMessage, encryptionPublicKeyToJWK, externalJoinMlsGroup, fetchAgents, fetchAndIngestPendingSKDistributions, fetchGroupInfo, fetchHandshakes, fetchOAuthToken, fetchRecipientKeys, fetchWelcomes, formatError, fromBase64Url, generateAgentKeys, generateEncryptionKeyPair, generateMlsKeyPackage, generatePqKeyPair, generateSenderKey, generateSigningKeyPair, getAgentPublicKeys, getCredentialEntry, getMlsCipherSuite, getMlsContext, getOrCreateSenderKey, getOrRefreshToken, ingestSenderKeyDistribution, initMlsGroup, isBareAgentName, jwkToPqPublicKey, jwkToPublicKey, listPrivateKeyPackages, loadAgentKeys, loadCredentials, loadMlsState, loadPrivateKeyPackage, loadSenderKeyStates, loadTokenCache, lookupMlsSelfRead, mlsAck, mlsCommit, fromBase64 as mlsFromBase64, mlsInit, mlsNack, toBase64 as mlsToBase64, needsRotation, normalizeHandle, open, openGroup, openHybrid, performAgentCreation, performRegistration, pqPublicKeyToJWK, processMlsCommit, processMlsWelcome, processMlsWelcomes, publishMlsKeyPackages, removeMemberFromMlsGroup, removeMlsGroupMember, resolveAgent, resolveApiUrl, resolveConfigDir, resolveHandleViaWebFinger, resolveToUuid, saveAgentKeys, saveCredentials, saveMlsState, savePqEncryptionKey, savePrivateKeyPackages, saveSenderKeyState, saveTokenCache, seal, sealGroup, sealHybrid, signPayload, signingPublicKeyToJWK, solveTimeLock, solveTimeLockWithProgress, submitProposal, syncMlsGroup, toBase64Url, uploadKeyPackages, uuidToBytes, validateEncryptionKey, validatePathId, validateSigningKey, validateSlug, verifySignature };
|