@stvor/sdk 2.4.0 → 3.0.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.
Files changed (82) hide show
  1. package/dist/facade/app.cjs +29 -0
  2. package/dist/facade/app.d.ts +83 -76
  3. package/dist/facade/app.js +330 -195
  4. package/dist/facade/crypto-session.cjs +29 -0
  5. package/dist/facade/crypto-session.d.ts +49 -54
  6. package/dist/facade/crypto-session.js +117 -140
  7. package/dist/facade/errors.cjs +29 -0
  8. package/dist/facade/errors.d.ts +29 -12
  9. package/dist/facade/errors.js +49 -8
  10. package/dist/facade/index.cjs +29 -0
  11. package/dist/facade/index.d.ts +27 -8
  12. package/dist/facade/index.js +23 -3
  13. package/dist/facade/local-storage-identity-store.cjs +29 -0
  14. package/dist/facade/local-storage-identity-store.d.ts +50 -0
  15. package/dist/facade/local-storage-identity-store.js +100 -0
  16. package/dist/facade/metrics-attestation.cjs +29 -0
  17. package/dist/facade/metrics-attestation.d.ts +209 -0
  18. package/dist/facade/metrics-attestation.js +333 -0
  19. package/dist/facade/metrics-engine.cjs +29 -0
  20. package/dist/facade/metrics-engine.d.ts +91 -0
  21. package/dist/facade/metrics-engine.js +170 -0
  22. package/dist/facade/redis-replay-cache.cjs +29 -0
  23. package/dist/facade/redis-replay-cache.d.ts +88 -0
  24. package/dist/facade/redis-replay-cache.js +60 -0
  25. package/dist/facade/relay-client.cjs +29 -0
  26. package/dist/facade/relay-client.d.ts +22 -23
  27. package/dist/facade/relay-client.js +107 -128
  28. package/dist/facade/replay-manager.cjs +29 -0
  29. package/dist/facade/replay-manager.d.ts +28 -35
  30. package/dist/facade/replay-manager.js +102 -69
  31. package/dist/facade/sodium-singleton.cjs +29 -0
  32. package/dist/facade/tofu-manager.cjs +29 -0
  33. package/dist/facade/tofu-manager.d.ts +38 -36
  34. package/dist/facade/tofu-manager.js +109 -77
  35. package/dist/facade/types.cjs +29 -0
  36. package/dist/facade/types.d.ts +2 -0
  37. package/dist/index.cjs +29 -0
  38. package/dist/index.d.cts +6 -0
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.js +7 -0
  41. package/dist/legacy.cjs +29 -0
  42. package/dist/legacy.d.ts +31 -1
  43. package/dist/legacy.js +90 -2
  44. package/dist/ratchet/core-production.cjs +29 -0
  45. package/dist/ratchet/core-production.d.ts +95 -0
  46. package/dist/ratchet/core-production.js +286 -0
  47. package/dist/ratchet/index.cjs +29 -0
  48. package/dist/ratchet/index.d.ts +49 -78
  49. package/dist/ratchet/index.js +313 -288
  50. package/dist/ratchet/key-recovery.cjs +29 -0
  51. package/dist/ratchet/replay-protection.cjs +29 -0
  52. package/dist/ratchet/tofu.cjs +29 -0
  53. package/dist/src/facade/app.cjs +29 -0
  54. package/dist/src/facade/app.d.ts +105 -0
  55. package/dist/src/facade/app.js +245 -0
  56. package/dist/src/facade/crypto.cjs +29 -0
  57. package/dist/src/facade/errors.cjs +29 -0
  58. package/dist/src/facade/errors.d.ts +19 -0
  59. package/dist/src/facade/errors.js +21 -0
  60. package/dist/src/facade/index.cjs +29 -0
  61. package/dist/src/facade/index.d.ts +8 -0
  62. package/dist/src/facade/index.js +5 -0
  63. package/dist/src/facade/relay-client.cjs +29 -0
  64. package/dist/src/facade/relay-client.d.ts +36 -0
  65. package/dist/src/facade/relay-client.js +154 -0
  66. package/dist/src/facade/types.cjs +29 -0
  67. package/dist/src/facade/types.d.ts +50 -0
  68. package/dist/src/facade/types.js +4 -0
  69. package/dist/src/index.cjs +29 -0
  70. package/dist/src/index.d.ts +2 -0
  71. package/dist/src/index.js +2 -0
  72. package/dist/src/legacy.cjs +29 -0
  73. package/dist/src/legacy.d.ts +0 -0
  74. package/dist/src/legacy.js +1 -0
  75. package/dist/src/mock-relay-server.cjs +29 -0
  76. package/dist/src/mock-relay-server.d.ts +30 -0
  77. package/dist/src/mock-relay-server.js +236 -0
  78. package/package.json +37 -11
  79. package/dist/ratchet/tests/ratchet.test.d.ts +0 -1
  80. package/dist/ratchet/tests/ratchet.test.js +0 -160
  81. /package/dist/{facade → src/facade}/crypto.d.ts +0 -0
  82. /package/dist/{facade → src/facade}/crypto.js +0 -0
@@ -1,318 +1,343 @@
1
- import sodium from 'libsodium-wrappers';
2
- import { ensureSodiumReady } from '../facade/sodium-singleton.js';
3
- // Initialize libsodium (safe to call multiple times)
4
- export async function initializeCrypto() {
5
- await ensureSodiumReady();
6
- }
7
1
  /**
8
- * X3DH Session Establishment
9
- * @param identityKeyPair - The user's identity key pair
10
- * @param signedPreKeyPair - The user's signed pre-key pair
11
- * @param oneTimePreKey - A one-time pre-key
12
- * @param recipientIdentityKey - The recipient's identity key
13
- * @param recipientSignedPreKey - The recipient's signed pre-key
14
- * @param recipientOneTimePreKey - The recipient's one-time pre-key
15
- * @param recipientSPKSignature - Signature of SPK by recipient's identity key
16
- * @param protocolVersion - The protocol version
17
- * @param cipherSuite - The cipher suite
18
- * @returns SessionState
2
+ * X3DH + Double Ratchet Implementation
3
+ * Uses ONLY Node.js built-in crypto module zero external dependencies
4
+ *
5
+ * Implements the Signal Protocol Double Ratchet with deferred initialization:
6
+ * - First send → "initiator" DH ratchet (DH with peer's SPK)
7
+ * - First receive "responder" DH ratchet (use own SPK, then fresh key)
8
+ * This allows either side to send first after symmetric X3DH key agreement.
9
+ *
10
+ * Provides:
11
+ * - X3DH key agreement (symmetric variant, both sides derive same SK)
12
+ * - Double Ratchet with DH ratchet + symmetric-key ratchet
13
+ * - AES-256-GCM AEAD encryption with header as AAD
14
+ * - ECDSA P-256 signing / verification
15
+ * - HKDF-SHA256 key derivation
16
+ * - HMAC-based chain-key ratchet (Signal-style)
19
17
  */
20
- export function establishSession(identityKeyPair, signedPreKeyPair, oneTimePreKey, recipientIdentityKey, recipientSignedPreKey, recipientOneTimePreKey, recipientSPKSignature, protocolVersion, cipherSuite) {
21
- // Validate protocol version and cipher suite
22
- validateProtocolVersion(protocolVersion);
23
- validateCipherSuite(cipherSuite);
24
- // Verify SPK signature
25
- verifySPKSignature(recipientSignedPreKey, recipientSPKSignature, recipientIdentityKey);
26
- // Derive shared secret with cryptographic binding
27
- const sharedSecret = deriveSharedSecret(identityKeyPair.publicKey, recipientSignedPreKey, recipientOneTimePreKey, protocolVersion, cipherSuite);
28
- if (!sharedSecret) {
29
- throw new Error('Failed to derive shared secret');
30
- }
31
- // Derive root key
32
- const rootKey = deriveKey(sharedSecret, 'x3dh-root-key', sodium.from_string(protocolVersion));
18
+ import crypto from 'crypto';
19
+ /* ================================================================
20
+ * Constants
21
+ * ================================================================ */
22
+ const CURVE = 'prime256v1';
23
+ const PUB_LEN = 65; // uncompressed P-256 public key
24
+ const HEADER_LEN = 85; // 65 + 4 + 4 + 12
25
+ const MAX_SKIP = 256;
26
+ /* ================================================================
27
+ * Init (no-op Node.js crypto is always ready)
28
+ * ================================================================ */
29
+ export async function initializeCrypto() { }
30
+ /* ================================================================
31
+ * Key generation
32
+ * ================================================================ */
33
+ export function generateKeyPair() {
34
+ const ecdh = crypto.createECDH(CURVE);
35
+ ecdh.generateKeys();
33
36
  return {
34
- identityKey: identityKeyPair.publicKey,
35
- signedPreKey: signedPreKeyPair.publicKey,
36
- oneTimePreKey,
37
- rootKey,
38
- sendingChainKey: rootKey,
39
- receivingChainKey: rootKey,
40
- skippedMessageKeys: new Map(),
41
- isPostCompromise: false,
37
+ publicKey: Buffer.from(ecdh.getPublicKey()),
38
+ privateKey: Buffer.from(ecdh.getPrivateKey()),
42
39
  };
43
40
  }
44
- /**
45
- * Double Ratchet Encryption
46
- * @param plaintext - The message to encrypt
47
- * @param session - The current session state
48
- * @returns { ciphertext: Uint8Array; header: { publicKey: Uint8Array; nonce: Uint8Array } }
49
- */
50
- export function encryptMessage(plaintext, session) {
51
- // Generate a new ratchet key pair
52
- const ratchetKeyPair = sodium.crypto_kx_keypair();
53
- // Perform a Diffie-Hellman exchange with the recipient's public key
54
- const sharedSecret = sodium.crypto_kx_client_session_keys(ratchetKeyPair.publicKey, ratchetKeyPair.privateKey, session.identityKey);
55
- // Update root key and derive new sending chain key
56
- const newRootKey = sodium.crypto_generichash(32, new Uint8Array([...session.rootKey, ...sharedSecret.sharedTx]));
57
- const newSendingChainKey = sodium.crypto_generichash(32, newRootKey);
58
- // Derive a message key
59
- const messageKey = sodium.crypto_generichash(32, newSendingChainKey);
60
- // Encrypt the message
61
- const nonce = sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
62
- const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(sodium.from_string(plaintext), null, // No additional data
63
- null, nonce, messageKey);
64
- // Update session state
65
- session.rootKey = newRootKey;
66
- session.sendingChainKey = newSendingChainKey;
67
- return {
68
- ciphertext,
69
- header: {
70
- publicKey: ratchetKeyPair.publicKey,
71
- nonce,
72
- },
73
- };
41
+ /* ================================================================
42
+ * Low-level crypto helpers
43
+ * ================================================================ */
44
+ /** ECDH shared secret */
45
+ function ecdhSecret(priv, pub) {
46
+ const ecdh = crypto.createECDH(CURVE);
47
+ ecdh.setPrivateKey(priv);
48
+ return Buffer.from(ecdh.computeSecret(pub));
74
49
  }
75
- /**
76
- * Double Ratchet Decryption
77
- * @param ciphertext - The encrypted message
78
- * @param header - The message header containing the sender's public key and nonce
79
- * @param session - The current session state
80
- * @returns The decrypted plaintext
81
- */
82
- export function decryptMessage(ciphertext, header, session) {
83
- // Check for skipped message keys
84
- const skippedKey = session.skippedMessageKeys.get(header.nonce.toString());
85
- if (skippedKey) {
86
- session.skippedMessageKeys.delete(header.nonce.toString());
87
- const plaintext = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertext, null, // No additional data
88
- header.nonce, skippedKey);
89
- return sodium.to_string(plaintext);
90
- }
91
- // Perform a Diffie-Hellman exchange with the sender's public key
92
- const sharedSecret = sodium.crypto_kx_client_session_keys(session.identityKey, session.signedPreKey, header.publicKey);
93
- // Update root key and derive new receiving chain key
94
- const newRootKey = sodium.crypto_generichash(32, new Uint8Array([...session.rootKey, ...sharedSecret.sharedTx]));
95
- const newReceivingChainKey = sodium.crypto_generichash(32, newRootKey);
96
- // Derive the message key
97
- const messageKey = sodium.crypto_generichash(32, newReceivingChainKey);
98
- // Decrypt the message
99
- const plaintext = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertext, null, // No additional data
100
- header.nonce, messageKey);
101
- // Update session state
102
- session.rootKey = newRootKey;
103
- session.receivingChainKey = newReceivingChainKey;
104
- return sodium.to_string(plaintext);
105
- }
106
- // Enhanced KDF with explicit domain separation and transcript binding
107
- function deriveKey(inputKey, context, transcript) {
108
- const label = sodium.from_string(context);
109
- return sodium.crypto_generichash(32, new Uint8Array([...label, ...inputKey, ...transcript]));
50
+ /** HKDF-SHA256 */
51
+ function hkdf(ikm, salt, info, len) {
52
+ return Buffer.from(crypto.hkdfSync('sha256', ikm, salt, info, len));
110
53
  }
111
- function hkdfExtract(salt, inputKeyMaterial) {
112
- return sodium.crypto_generichash(32, new Uint8Array([...salt, ...inputKeyMaterial]));
113
- }
114
- function hkdfExpand(prk, info, length) {
115
- const infoBytes = sodium.from_string(info);
116
- return sodium.crypto_generichash(length, new Uint8Array([...prk, ...infoBytes]));
117
- }
118
- function deriveRootKey(oldRootKey, dhOutput, transcript) {
119
- const salt = deriveKey(oldRootKey, 'DR:dh', transcript);
120
- const prk = hkdfExtract(salt, dhOutput);
121
- return hkdfExpand(prk, 'DR:root', 32);
54
+ /** Root-key KDF → new rootKey + chainKey */
55
+ function kdfRK(rk, dhOut) {
56
+ const d = hkdf(dhOut, rk, 'stvor-rk', 64);
57
+ return {
58
+ rootKey: Buffer.from(d.subarray(0, 32)),
59
+ chainKey: Buffer.from(d.subarray(32, 64)),
60
+ };
122
61
  }
123
- function deriveChainKey(rootKey, transcript) {
124
- const prk = hkdfExtract(rootKey, sodium.from_string('DR:chain'));
125
- return hkdfExpand(prk, 'DR:chain', 32);
62
+ /** Chain-key KDF → new chainKey + messageKey */
63
+ function kdfCK(ck) {
64
+ return {
65
+ chainKey: Buffer.from(crypto.createHmac('sha256', ck).update('\x01').digest()),
66
+ messageKey: Buffer.from(crypto.createHmac('sha256', ck).update('\x02').digest()),
67
+ };
126
68
  }
127
- function deriveMessageKey(chainKey, transcript) {
128
- const prk = hkdfExtract(chainKey, sodium.from_string('DR:message'));
129
- return hkdfExpand(prk, 'DR:message', 32);
69
+ /** AES-256-GCM encrypt with AAD */
70
+ function aeadEnc(key, pt, nonce, aad) {
71
+ const c = crypto.createCipheriv('aes-256-gcm', key, nonce);
72
+ c.setAAD(aad);
73
+ const enc = Buffer.concat([c.update(pt), c.final()]);
74
+ return Buffer.concat([enc, c.getAuthTag()]); // ciphertext ‖ 16-byte tag
130
75
  }
131
- // Updated skipped keys handling with bounds
132
- const MAX_SKIPPED_KEYS = 50; // Limit to prevent DoS
133
- // Enhanced skipped keys handling with state exhaustion protection
134
- const MAX_TOTAL_SKIPPED_KEYS = 500; // Global limit across sessions
135
- let totalSkippedKeys = 0;
136
- export function addSkippedKey(session, header, messageKey) {
137
- const keyId = `${header.publicKey.toString()}:${header.nonce.toString()}`;
138
- if (session.skippedMessageKeys.size >= MAX_SKIPPED_KEYS) {
139
- throw new Error('Skipped keys limit exceeded for session');
140
- }
141
- if (totalSkippedKeys >= MAX_TOTAL_SKIPPED_KEYS) {
142
- throw new Error('Global skipped keys limit exceeded');
143
- }
144
- session.skippedMessageKeys.set(keyId, messageKey);
145
- totalSkippedKeys++;
76
+ /** AES-256-GCM decrypt with AAD */
77
+ function aeadDec(key, ct, nonce, aad) {
78
+ const d = crypto.createDecipheriv('aes-256-gcm', key, nonce);
79
+ d.setAAD(aad);
80
+ d.setAuthTag(ct.subarray(-16));
81
+ return Buffer.concat([d.update(ct.subarray(0, -16)), d.final()]);
146
82
  }
147
- export function removeSkippedKey(session, header) {
148
- const keyId = `${header.publicKey.toString()}:${header.nonce.toString()}`;
149
- if (session.skippedMessageKeys.delete(keyId)) {
150
- totalSkippedKeys--;
151
- }
83
+ /* ================================================================
84
+ * ECDSA P-256 — sign / verify
85
+ * ================================================================ */
86
+ function toPrivKeyObj(pub, priv) {
87
+ return crypto.createPrivateKey({
88
+ key: {
89
+ kty: 'EC', crv: 'P-256',
90
+ x: pub.subarray(1, 33).toString('base64url'),
91
+ y: pub.subarray(33, 65).toString('base64url'),
92
+ d: priv.toString('base64url'),
93
+ },
94
+ format: 'jwk',
95
+ });
152
96
  }
153
- // Improved skipped keys eviction policy
154
- function cleanUpSkippedKeys(session) {
155
- const currentTime = Date.now();
156
- session.skippedMessageKeys.forEach((_, keyId) => {
157
- const [timestamp] = keyId.split(':');
158
- if (currentTime - parseInt(timestamp, 10) > 300000) { // Evict keys older than 5 minutes
159
- session.skippedMessageKeys.delete(keyId);
160
- totalSkippedKeys--;
161
- }
97
+ function toPubKeyObj(pub) {
98
+ return crypto.createPublicKey({
99
+ key: {
100
+ kty: 'EC', crv: 'P-256',
101
+ x: pub.subarray(1, 33).toString('base64url'),
102
+ y: pub.subarray(33, 65).toString('base64url'),
103
+ },
104
+ format: 'jwk',
162
105
  });
163
106
  }
164
- export function processSkippedKeys(session) {
165
- cleanUpSkippedKeys(session);
107
+ /** ECDSA-P256-SHA256 sign */
108
+ export function ecSign(data, kp) {
109
+ return Buffer.from(crypto.sign('sha256', data, toPrivKeyObj(kp.publicKey, kp.privateKey)));
166
110
  }
167
- // Updated simultaneous send handling
168
- export function handleSimultaneousSend(session, isInitiator) {
169
- if (isInitiator) {
170
- // Initiator ratchets forward
171
- session.sendingChainKey = deriveChainKey(session.sendingChainKey, sodium.from_string('initiator'));
172
- }
173
- else {
174
- // Responder ratchets forward
175
- session.receivingChainKey = deriveChainKey(session.receivingChainKey, sodium.from_string('responder'));
176
- }
111
+ /** ECDSA-P256-SHA256 verify */
112
+ export function ecVerify(data, sig, pub) {
113
+ return crypto.verify('sha256', data, toPubKeyObj(pub), sig);
177
114
  }
178
- // Updated SPK signature verification with downgrade protection
179
- function verifySPKSignature(spk, spkSignature, identityKey) {
180
- const isValid = sodium.crypto_sign_verify_detached(spkSignature, spk, identityKey);
181
- if (!isValid) {
182
- throw new Error('Invalid SPK signature');
183
- }
115
+ /* ================================================================
116
+ * X3DH Symmetric Variant
117
+ *
118
+ * Both sides independently derive the SAME shared secret from
119
+ * their own (IK, SPK) and the peer's (IK, SPK).
120
+ * Canonical ordering by comparing IK public keys.
121
+ * ================================================================ */
122
+ export function x3dhSymmetric(myIK, mySPK, peerIK, peerSPK) {
123
+ const iAmLower = Buffer.compare(myIK.publicKey, peerIK) < 0;
124
+ const d1 = iAmLower
125
+ ? ecdhSecret(myIK.privateKey, peerSPK)
126
+ : ecdhSecret(mySPK.privateKey, peerIK);
127
+ const d2 = iAmLower
128
+ ? ecdhSecret(mySPK.privateKey, peerIK)
129
+ : ecdhSecret(myIK.privateKey, peerSPK);
130
+ const d3 = ecdhSecret(mySPK.privateKey, peerSPK);
131
+ return hkdf(Buffer.concat([d1, d2, d3]), Buffer.alloc(32), 'X3DH', 32);
184
132
  }
185
- // Updated OPK exhaustion handling
186
- const OPK_POOL_SIZE = 100; // Example pool size
187
- let opkPool = [];
188
- export function generateOPKPool() {
189
- opkPool = Array.from({ length: OPK_POOL_SIZE }, () => sodium.crypto_kx_keypair().publicKey);
133
+ /* ================================================================
134
+ * Session Establishment
135
+ *
136
+ * Creates a "pending" session. The first send triggers an
137
+ * initiator DH ratchet; the first receive triggers a responder
138
+ * DH ratchet. Either side can go first.
139
+ * ================================================================ */
140
+ export function establishSession(myIK, mySPK, peerIK, peerSPK) {
141
+ const sk = x3dhSymmetric(myIK, mySPK, peerIK, peerSPK);
142
+ const ratchetKP = generateKeyPair();
143
+ return {
144
+ myIdentityPublicKey: Buffer.from(myIK.publicKey),
145
+ peerIdentityPublicKey: Buffer.from(peerIK),
146
+ rootKey: sk, // raw shared secret
147
+ sendingChainKey: Buffer.alloc(32), // set by first DH ratchet
148
+ receivingChainKey: Buffer.alloc(32),
149
+ myRatchetKeyPair: ratchetKP,
150
+ theirRatchetPublicKey: null,
151
+ sendCount: 0,
152
+ recvCount: 0,
153
+ prevSendCount: 0,
154
+ skippedKeys: new Map(),
155
+ isPostCompromise: false,
156
+ /* deferred init data */
157
+ peerSPK: Buffer.from(peerSPK),
158
+ mySPKPair: {
159
+ publicKey: Buffer.from(mySPK.publicKey),
160
+ privateKey: Buffer.from(mySPK.privateKey),
161
+ },
162
+ /* legacy compat */
163
+ identityKey: myIK.publicKey,
164
+ signedPreKey: mySPK.publicKey,
165
+ oneTimePreKey: new Uint8Array(0),
166
+ sendingChainMessageNumber: 0,
167
+ receivingChainMessageNumber: 0,
168
+ previousSendingChainLength: 0,
169
+ };
190
170
  }
191
- // Improved X3DH race safety and OPK handling
192
- const OPK_LOCK = new Map(); // Lock for atomic OPK consumption
193
- export function consumeOPKAtomically(userId) {
194
- if (OPK_LOCK.get(userId)) {
195
- throw new Error('OPK consumption in progress');
196
- }
197
- OPK_LOCK.set(userId, true);
198
- try {
199
- const opk = consumeOPK();
200
- return opk;
171
+ /* ================================================================
172
+ * Double Ratchet Encrypt
173
+ *
174
+ * Header layout (85 bytes):
175
+ * [0..64] ratchet public key (65 B)
176
+ * [65..68] prev sending chain length (u32 BE)
177
+ * [69..72] message number (u32 BE)
178
+ * [73..84] AES-GCM nonce (12 B)
179
+ * ================================================================ */
180
+ export function encryptMessage(session, plaintext) {
181
+ /* ---- Deferred init: initiator DH ratchet on first send ---- */
182
+ if (!session.theirRatchetPublicKey && session.peerSPK) {
183
+ const dhOut = ecdhSecret(session.myRatchetKeyPair.privateKey, session.peerSPK);
184
+ const r = kdfRK(session.rootKey, dhOut);
185
+ session.rootKey = r.rootKey;
186
+ session.sendingChainKey = r.chainKey;
187
+ session.theirRatchetPublicKey = Buffer.from(session.peerSPK);
188
+ session.peerSPK = null; // consumed
189
+ session.mySPKPair = null;
201
190
  }
202
- finally {
203
- OPK_LOCK.delete(userId);
191
+ /* ---- Symmetric ratchet ---- */
192
+ const { chainKey, messageKey } = kdfCK(session.sendingChainKey);
193
+ session.sendingChainKey = chainKey;
194
+ const nonce = crypto.randomBytes(12);
195
+ const header = Buffer.alloc(HEADER_LEN);
196
+ session.myRatchetKeyPair.publicKey.copy(header, 0);
197
+ header.writeUInt32BE(session.prevSendCount, PUB_LEN);
198
+ header.writeUInt32BE(session.sendCount, PUB_LEN + 4);
199
+ nonce.copy(header, PUB_LEN + 8);
200
+ const ct = aeadEnc(messageKey, plaintext, nonce, header);
201
+ session.sendCount++;
202
+ session.sendingChainMessageNumber = session.sendCount;
203
+ return { ciphertext: ct, header };
204
+ }
205
+ /* ================================================================
206
+ * Double Ratchet — Decrypt
207
+ * ================================================================ */
208
+ function skipKeys(s, until) {
209
+ if (until - s.recvCount > MAX_SKIP)
210
+ throw new Error('Too many skipped messages');
211
+ while (s.recvCount < until) {
212
+ const { chainKey, messageKey } = kdfCK(s.receivingChainKey);
213
+ s.receivingChainKey = chainKey;
214
+ s.skippedKeys.set(`${s.theirRatchetPublicKey.toString('hex')}:${s.recvCount}`, messageKey);
215
+ s.recvCount++;
204
216
  }
205
217
  }
206
- // Enhanced X3DH with cryptographic binding and explicit abort semantics
207
- function deriveSharedSecret(ik, spk, opk, protocolVersion, cipherSuite) {
208
- const context = sodium.from_string(`${protocolVersion}:${cipherSuite}`);
209
- return sodium.crypto_generichash(32, new Uint8Array([...ik, ...spk, ...opk, ...context]));
218
+ function dhRatchetStep(s, theirNewKey) {
219
+ s.prevSendCount = s.sendCount;
220
+ s.sendCount = 0;
221
+ s.recvCount = 0;
222
+ s.theirRatchetPublicKey = Buffer.from(theirNewKey);
223
+ // Derive receiving chain
224
+ const dh1 = ecdhSecret(s.myRatchetKeyPair.privateKey, theirNewKey);
225
+ const r1 = kdfRK(s.rootKey, dh1);
226
+ s.rootKey = r1.rootKey;
227
+ s.receivingChainKey = r1.chainKey;
228
+ // New ratchet key pair → derive sending chain
229
+ s.myRatchetKeyPair = generateKeyPair();
230
+ const dh2 = ecdhSecret(s.myRatchetKeyPair.privateKey, theirNewKey);
231
+ const r2 = kdfRK(s.rootKey, dh2);
232
+ s.rootKey = r2.rootKey;
233
+ s.sendingChainKey = r2.chainKey;
210
234
  }
211
- // Final improvements for X3DH
212
- function validateProtocolVersion(version) {
213
- const supportedVersions = ['1.0'];
214
- if (!supportedVersions.includes(version)) {
215
- throw new Error(`Unsupported protocol version: ${version}`);
235
+ export function decryptMessage(session, ciphertext, header) {
236
+ const theirPub = header.subarray(0, PUB_LEN);
237
+ const prevChain = header.readUInt32BE(PUB_LEN);
238
+ const msgNum = header.readUInt32BE(PUB_LEN + 4);
239
+ const nonce = header.subarray(PUB_LEN + 8, HEADER_LEN);
240
+ /* ---- Deferred init: responder role on first receive ---- */
241
+ if (!session.theirRatchetPublicKey && session.mySPKPair) {
242
+ // Use our SPK as ratchet key for the first DH step
243
+ // so DH(mySPK, theirRatchetKey) matches initiator's DH(theirRatchetKey, mySPK)
244
+ session.myRatchetKeyPair = session.mySPKPair;
245
+ session.mySPKPair = null; // consumed
246
+ session.peerSPK = null;
216
247
  }
217
- }
218
- function validateCipherSuite(cipherSuite) {
219
- const supportedSuites = ['AES-GCM'];
220
- if (!supportedSuites.includes(cipherSuite)) {
221
- throw new Error(`Unsupported cipher suite: ${cipherSuite}`);
248
+ // 1. Try skipped key
249
+ const skId = `${theirPub.toString('hex')}:${msgNum}`;
250
+ const skMK = session.skippedKeys.get(skId);
251
+ if (skMK) {
252
+ session.skippedKeys.delete(skId);
253
+ return aeadDec(skMK, ciphertext, nonce, header);
222
254
  }
223
- }
224
- /**
225
- * Policy for forced DH rotation.
226
- * Triggers a DH ratchet step based on:
227
- * - Number of messages sent.
228
- * - Time elapsed since the last ratchet.
229
- * - Explicit compromise flag.
230
- */
231
- const DH_RATCHET_POLICY = {
232
- maxMessages: 50, // Trigger after 50 messages
233
- maxTime: 10 * 60 * 1000, // Trigger after 10 minutes
234
- };
235
- let lastRatchetTime = Date.now();
236
- let messageCounter = 0;
237
- export function enforceDHRatchetPolicy(session, remotePublicKey, suspectedCompromise = false) {
238
- const currentTime = Date.now();
239
- // Check if policy conditions are met
240
- if (messageCounter >= DH_RATCHET_POLICY.maxMessages ||
241
- currentTime - lastRatchetTime >= DH_RATCHET_POLICY.maxTime ||
242
- suspectedCompromise) {
243
- forceDHRatchet(session, remotePublicKey);
244
- // Reset counters
245
- lastRatchetTime = currentTime;
246
- messageCounter = 0;
255
+ // 2. DH ratchet if new ratchet key
256
+ const needsRatchet = !session.theirRatchetPublicKey ||
257
+ Buffer.compare(session.theirRatchetPublicKey, theirPub) !== 0;
258
+ if (needsRatchet) {
259
+ if (session.theirRatchetPublicKey) {
260
+ skipKeys(session, prevChain);
261
+ }
262
+ dhRatchetStep(session, theirPub);
247
263
  }
264
+ // 3. Skip to this message number
265
+ skipKeys(session, msgNum);
266
+ // 4. Derive message key
267
+ const { chainKey, messageKey } = kdfCK(session.receivingChainKey);
268
+ session.receivingChainKey = chainKey;
269
+ session.recvCount++;
270
+ session.receivingChainMessageNumber = session.recvCount;
271
+ return aeadDec(messageKey, ciphertext, nonce, header);
248
272
  }
249
- /**
250
- * Increment message counter and enforce policy.
251
- */
252
- export function incrementMessageCounter(session, remotePublicKey) {
253
- messageCounter++;
254
- enforceDHRatchetPolicy(session, remotePublicKey);
273
+ /* ================================================================
274
+ * Force Ratchet (post-compromise security)
275
+ * ================================================================ */
276
+ export function forceRatchet(session) {
277
+ session.myRatchetKeyPair = generateKeyPair();
278
+ session.sendCount = 0;
279
+ session.recvCount = 0;
280
+ session.prevSendCount = 0;
281
+ session.isPostCompromise = true;
255
282
  }
256
- /**
257
- * Force a DH ratchet step to enable PCS.
258
- * @param session - The current session state.
259
- * @param remotePublicKey - The remote party's ephemeral public key.
260
- */
261
- export function forceDHRatchet(session, remotePublicKey) {
262
- // Generate a new ephemeral key pair
263
- const ephemeralKeyPair = sodium.crypto_kx_keypair();
264
- // Perform a Diffie-Hellman exchange
265
- const dhOutput = sodium.crypto_kx_client_session_keys(ephemeralKeyPair.publicKey, ephemeralKeyPair.privateKey, remotePublicKey);
266
- // Update the root key
267
- const newRootKey = deriveRootKey(session.rootKey, dhOutput.sharedTx, sodium.from_string('dh-ratchet-recovery'));
268
- // Clear compromised keys
269
- session.sendingChainKey = newRootKey;
270
- session.receivingChainKey = newRootKey;
271
- session.skippedMessageKeys.clear();
272
- // Update session state
273
- session.rootKey = newRootKey;
283
+ /* ================================================================
284
+ * Serialisation / Deserialisation
285
+ * ================================================================ */
286
+ export function serializeSession(s) {
287
+ const sk = {};
288
+ for (const [k, v] of s.skippedKeys)
289
+ sk[k] = Array.from(v);
290
+ return Buffer.from(JSON.stringify({
291
+ myIK: Array.from(s.myIdentityPublicKey),
292
+ peerIK: Array.from(s.peerIdentityPublicKey),
293
+ rk: Array.from(s.rootKey),
294
+ sck: Array.from(s.sendingChainKey),
295
+ rck: Array.from(s.receivingChainKey),
296
+ mrk: {
297
+ pub: Array.from(s.myRatchetKeyPair.publicKey),
298
+ priv: Array.from(s.myRatchetKeyPair.privateKey),
299
+ },
300
+ trpk: s.theirRatchetPublicKey ? Array.from(s.theirRatchetPublicKey) : null,
301
+ sc: s.sendCount, rc: s.recvCount, psc: s.prevSendCount,
302
+ sk, ipc: s.isPostCompromise ? 1 : 0,
303
+ pspk: s.peerSPK ? Array.from(s.peerSPK) : null,
304
+ mspk: s.mySPKPair
305
+ ? { pub: Array.from(s.mySPKPair.publicKey), priv: Array.from(s.mySPKPair.privateKey) }
306
+ : null,
307
+ }));
274
308
  }
275
- /**
276
- * Ensure rootKey updates only occur through DH ratchet.
277
- * @param session - The current session state.
278
- * @param dhOutput - The DH output used to update the root key.
279
- */
280
- function enforceDHRatchetOnly(session, dhOutput) {
281
- if (!dhOutput) {
282
- throw new Error('Root key updates must occur through DH ratchet');
309
+ export function deserializeSession(data) {
310
+ const o = JSON.parse(data.toString());
311
+ const skipped = new Map();
312
+ if (o.sk) {
313
+ for (const [k, v] of Object.entries(o.sk)) {
314
+ skipped.set(k, Buffer.from(v));
315
+ }
283
316
  }
284
- // Update the root key
285
- const newRootKey = deriveRootKey(session.rootKey, dhOutput, sodium.from_string('dh-ratchet-only'));
286
- session.rootKey = newRootKey;
287
- }
288
- /**
289
- * Trigger PCS recovery only after receiving a new DH public key.
290
- * @param session - The current session state.
291
- * @param remotePublicKey - The new DH public key from the remote party.
292
- */
293
- export function receiveNewDHPublicKey(session, remotePublicKey) {
294
- // Generate a new ephemeral key pair
295
- const ephemeralKeyPair = sodium.crypto_kx_keypair();
296
- // Perform a Diffie-Hellman exchange
297
- const dhOutput = sodium.crypto_kx_client_session_keys(ephemeralKeyPair.publicKey, ephemeralKeyPair.privateKey, remotePublicKey);
298
- // Update the root key
299
- const newRootKey = deriveRootKey(session.rootKey, dhOutput, sodium.from_string('dh-ratchet-recovery'));
300
- // Clear compromised keys
301
- session.sendingChainKey = newRootKey;
302
- session.receivingChainKey = newRootKey;
303
- session.skippedMessageKeys.clear();
304
- // Update session state
305
- session.rootKey = newRootKey;
306
- }
307
- /**
308
- * Define and enforce state transitions between epochs.
309
- * @param session - The current session state.
310
- */
311
- function transitionToPostCompromiseEpoch(session) {
312
- // Clear all pre-compromise state
313
- session.sendingChainKey = null;
314
- session.receivingChainKey = null;
315
- session.skippedMessageKeys.clear();
316
- // Mark the session as post-compromise
317
- session.isPostCompromise = true;
317
+ const myIK = Buffer.from(o.myIK);
318
+ return {
319
+ myIdentityPublicKey: myIK,
320
+ peerIdentityPublicKey: Buffer.from(o.peerIK),
321
+ rootKey: Buffer.from(o.rk),
322
+ sendingChainKey: Buffer.from(o.sck),
323
+ receivingChainKey: Buffer.from(o.rck),
324
+ myRatchetKeyPair: {
325
+ publicKey: Buffer.from(o.mrk.pub),
326
+ privateKey: Buffer.from(o.mrk.priv),
327
+ },
328
+ theirRatchetPublicKey: o.trpk ? Buffer.from(o.trpk) : null,
329
+ sendCount: o.sc, recvCount: o.rc, prevSendCount: o.psc,
330
+ skippedKeys: skipped,
331
+ isPostCompromise: o.ipc === 1,
332
+ peerSPK: o.pspk ? Buffer.from(o.pspk) : null,
333
+ mySPKPair: o.mspk
334
+ ? { publicKey: Buffer.from(o.mspk.pub), privateKey: Buffer.from(o.mspk.priv) }
335
+ : null,
336
+ identityKey: myIK,
337
+ signedPreKey: myIK,
338
+ oneTimePreKey: new Uint8Array(0),
339
+ sendingChainMessageNumber: o.sc,
340
+ receivingChainMessageNumber: o.rc,
341
+ previousSendingChainLength: o.psc,
342
+ };
318
343
  }