@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.
- package/dist/facade/app.cjs +29 -0
- package/dist/facade/app.d.ts +83 -76
- package/dist/facade/app.js +330 -195
- package/dist/facade/crypto-session.cjs +29 -0
- package/dist/facade/crypto-session.d.ts +49 -54
- package/dist/facade/crypto-session.js +117 -140
- package/dist/facade/errors.cjs +29 -0
- package/dist/facade/errors.d.ts +29 -12
- package/dist/facade/errors.js +49 -8
- package/dist/facade/index.cjs +29 -0
- package/dist/facade/index.d.ts +27 -8
- package/dist/facade/index.js +23 -3
- package/dist/facade/local-storage-identity-store.cjs +29 -0
- package/dist/facade/local-storage-identity-store.d.ts +50 -0
- package/dist/facade/local-storage-identity-store.js +100 -0
- package/dist/facade/metrics-attestation.cjs +29 -0
- package/dist/facade/metrics-attestation.d.ts +209 -0
- package/dist/facade/metrics-attestation.js +333 -0
- package/dist/facade/metrics-engine.cjs +29 -0
- package/dist/facade/metrics-engine.d.ts +91 -0
- package/dist/facade/metrics-engine.js +170 -0
- package/dist/facade/redis-replay-cache.cjs +29 -0
- package/dist/facade/redis-replay-cache.d.ts +88 -0
- package/dist/facade/redis-replay-cache.js +60 -0
- package/dist/facade/relay-client.cjs +29 -0
- package/dist/facade/relay-client.d.ts +22 -23
- package/dist/facade/relay-client.js +107 -128
- package/dist/facade/replay-manager.cjs +29 -0
- package/dist/facade/replay-manager.d.ts +28 -35
- package/dist/facade/replay-manager.js +102 -69
- package/dist/facade/sodium-singleton.cjs +29 -0
- package/dist/facade/tofu-manager.cjs +29 -0
- package/dist/facade/tofu-manager.d.ts +38 -36
- package/dist/facade/tofu-manager.js +109 -77
- package/dist/facade/types.cjs +29 -0
- package/dist/facade/types.d.ts +2 -0
- package/dist/index.cjs +29 -0
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +7 -0
- package/dist/legacy.cjs +29 -0
- package/dist/legacy.d.ts +31 -1
- package/dist/legacy.js +90 -2
- package/dist/ratchet/core-production.cjs +29 -0
- package/dist/ratchet/core-production.d.ts +95 -0
- package/dist/ratchet/core-production.js +286 -0
- package/dist/ratchet/index.cjs +29 -0
- package/dist/ratchet/index.d.ts +49 -78
- package/dist/ratchet/index.js +313 -288
- package/dist/ratchet/key-recovery.cjs +29 -0
- package/dist/ratchet/replay-protection.cjs +29 -0
- package/dist/ratchet/tofu.cjs +29 -0
- package/dist/src/facade/app.cjs +29 -0
- package/dist/src/facade/app.d.ts +105 -0
- package/dist/src/facade/app.js +245 -0
- package/dist/src/facade/crypto.cjs +29 -0
- package/dist/src/facade/errors.cjs +29 -0
- package/dist/src/facade/errors.d.ts +19 -0
- package/dist/src/facade/errors.js +21 -0
- package/dist/src/facade/index.cjs +29 -0
- package/dist/src/facade/index.d.ts +8 -0
- package/dist/src/facade/index.js +5 -0
- package/dist/src/facade/relay-client.cjs +29 -0
- package/dist/src/facade/relay-client.d.ts +36 -0
- package/dist/src/facade/relay-client.js +154 -0
- package/dist/src/facade/types.cjs +29 -0
- package/dist/src/facade/types.d.ts +50 -0
- package/dist/src/facade/types.js +4 -0
- package/dist/src/index.cjs +29 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +2 -0
- package/dist/src/legacy.cjs +29 -0
- package/dist/src/legacy.d.ts +0 -0
- package/dist/src/legacy.js +1 -0
- package/dist/src/mock-relay-server.cjs +29 -0
- package/dist/src/mock-relay-server.d.ts +30 -0
- package/dist/src/mock-relay-server.js +236 -0
- package/package.json +37 -11
- package/dist/ratchet/tests/ratchet.test.d.ts +0 -1
- package/dist/ratchet/tests/ratchet.test.js +0 -160
- /package/dist/{facade → src/facade}/crypto.d.ts +0 -0
- /package/dist/{facade → src/facade}/crypto.js +0 -0
package/dist/ratchet/index.js
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
*
|
|
46
|
-
*
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
return
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
export function
|
|
169
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
const
|
|
220
|
-
if (
|
|
221
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
*
|
|
251
|
-
*/
|
|
252
|
-
export function
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
*
|
|
258
|
-
*
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
}
|