@rine-network/core 0.4.4 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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 { hmac } from "@noble/hashes/hmac.js";
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
  };
@@ -45,9 +49,7 @@ function resolveConfigDir() {
45
49
  mode: 448
46
50
  });
47
51
  return dir;
48
- } catch {
49
- continue;
50
- }
52
+ } catch {}
51
53
  return candidates[0];
52
54
  }
53
55
  /** Resolve API URL from environment or default. */
@@ -98,7 +100,9 @@ function cacheToken(configDir, profile, token) {
98
100
  access_token: token.access_token,
99
101
  expires_at: Date.now() / 1e3 + token.expires_in
100
102
  };
101
- saveTokenCache(configDir, cache);
103
+ try {
104
+ saveTokenCache(configDir, cache);
105
+ } catch {}
102
106
  }
103
107
  /**
104
108
  * Resolves credentials for a profile. Priority:
@@ -116,13 +120,29 @@ function getCredentialEntry(configDir, profile = "default") {
116
120
  }
117
121
  //#endregion
118
122
  //#region src/http.ts
119
- async function parseErrorDetail(res) {
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
- if (typeof body.detail === "string") return body.detail;
123
- if (Array.isArray(body.detail)) return body.detail.map((e) => e.msg).join("; ");
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 parseErrorDetail(res);
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 parseErrorDetail(res);
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
- return {
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, encodeEnvelope(kid, signPayload(keys.signing.privateKey, plaintext), plaintext))),
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
- async function fetchRecipientEncryptionKey(client, agentId) {
759
- return jwkToPublicKey((await client.get(`/agents/${agentId}/keys`)).encryption_public_key);
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
- const decoded = decodeEnvelope(await open(keys.encryption.privateKey, encrypted));
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 { innerEnvelope, updatedState, messageIndex } = await openGroup(stateMap, fromBase64Url(encryptedPayloadB64));
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
- return {
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, fetchRecipientEncryptionKey, formatError, fromBase64Url, generateAgentKeys, generateEncryptionKeyPair, generateSenderKey, generateSigningKeyPair, getAgentPublicKeys, getCredentialEntry, getOrCreateSenderKey, getOrRefreshToken, ingestSenderKeyDistribution, isBareAgentName, jwkToPublicKey, loadAgentKeys, loadCredentials, loadSenderKeyStates, loadTokenCache, needsRotation, normalizeHandle, open, openGroup, performAgentCreation, performRegistration, resolveAgent, resolveApiUrl, resolveConfigDir, resolveHandleViaWebFinger, resolveToUuid, saveAgentKeys, saveCredentials, saveSenderKeyState, saveTokenCache, seal, sealGroup, signPayload, signingPublicKeyToJWK, solveTimeLock, solveTimeLockWithProgress, toBase64Url, uuidToBytes, validateEncryptionKey, validatePathId, validateSigningKey, validateSlug, verifySignature };
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 };