@stvor/sdk 2.2.1 → 2.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/facade/crypto-session.d.ts +76 -0
- package/dist/facade/crypto-session.js +175 -0
- package/dist/facade/index.d.ts +1 -1
- package/dist/facade/index.js +1 -1
- package/dist/facade/replay-manager.d.ts +58 -0
- package/dist/facade/replay-manager.js +117 -0
- package/dist/facade/sodium-singleton.d.ts +20 -0
- package/dist/facade/sodium-singleton.js +44 -0
- package/dist/facade/tofu-manager.d.ts +80 -0
- package/dist/facade/tofu-manager.js +134 -0
- package/dist/ratchet/index.d.ts +88 -0
- package/dist/ratchet/index.js +318 -0
- package/dist/ratchet/key-recovery.d.ts +45 -0
- package/dist/ratchet/key-recovery.js +148 -0
- package/dist/ratchet/replay-protection.d.ts +21 -0
- package/dist/ratchet/replay-protection.js +50 -0
- package/dist/ratchet/tests/ratchet.test.d.ts +1 -0
- package/dist/ratchet/tests/ratchet.test.js +160 -0
- package/dist/ratchet/tofu.d.ts +27 -0
- package/dist/ratchet/tofu.js +62 -0
- package/package.json +1 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STVOR TOFU (Trust On First Use) Manager
|
|
3
|
+
* Integrates fingerprint verification with in-memory fallback
|
|
4
|
+
*
|
|
5
|
+
* SEMANTICS:
|
|
6
|
+
* - Fingerprint = BLAKE2b(identity_public_key)
|
|
7
|
+
* - Binding: identity key ONLY (not bundle, not SPK)
|
|
8
|
+
* - Key rotation: requires manual re-trust via trustNewFingerprint()
|
|
9
|
+
* - Multi-device: NOT supported (each device = new identity)
|
|
10
|
+
* - Reinstall: fingerprint lost (in-memory only)
|
|
11
|
+
*
|
|
12
|
+
* LIMITATIONS:
|
|
13
|
+
* - First-use MITM vulnerability (standard TOFU)
|
|
14
|
+
* - No persistence (keys lost on restart)
|
|
15
|
+
* - No out-of-band verification UX
|
|
16
|
+
*
|
|
17
|
+
* TODO:
|
|
18
|
+
* - Add persistent storage (IndexedDB/localStorage)
|
|
19
|
+
* - Add manual verification UI (compare fingerprints)
|
|
20
|
+
* - Add key rotation notification system
|
|
21
|
+
*/
|
|
22
|
+
import sodium from 'libsodium-wrappers';
|
|
23
|
+
// In-memory fingerprint cache (fallback when PostgreSQL unavailable)
|
|
24
|
+
const fingerprintCache = new Map();
|
|
25
|
+
const FINGERPRINT_VERSION = 1; // Increment on breaking changes
|
|
26
|
+
/**
|
|
27
|
+
* Generate BLAKE2b-256 fingerprint from identity public key
|
|
28
|
+
*
|
|
29
|
+
* BINDING: Identity key ONLY
|
|
30
|
+
* - SPK rotation does NOT change fingerprint
|
|
31
|
+
* - OPK exhaustion does NOT change fingerprint
|
|
32
|
+
* - Only identity key rotation changes fingerprint
|
|
33
|
+
*/
|
|
34
|
+
export function generateFingerprint(identityPublicKey) {
|
|
35
|
+
const hash = sodium.crypto_generichash(32, identityPublicKey);
|
|
36
|
+
return sodium.to_hex(hash);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Store fingerprint for user (in-memory fallback)
|
|
40
|
+
*/
|
|
41
|
+
export async function storeFingerprint(userId, fingerprint) {
|
|
42
|
+
const record = {
|
|
43
|
+
fingerprint,
|
|
44
|
+
firstSeen: new Date(),
|
|
45
|
+
version: FINGERPRINT_VERSION,
|
|
46
|
+
};
|
|
47
|
+
fingerprintCache.set(userId, record);
|
|
48
|
+
// TODO: Add PostgreSQL persistence when available
|
|
49
|
+
// try {
|
|
50
|
+
// await pool.query(
|
|
51
|
+
// 'INSERT INTO fingerprints (user_id, fingerprint, first_seen, version) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id) DO UPDATE SET fingerprint = $2',
|
|
52
|
+
// [userId, fingerprint, record.firstSeen, record.version]
|
|
53
|
+
// );
|
|
54
|
+
// } catch (error) {
|
|
55
|
+
// // Fallback to in-memory storage
|
|
56
|
+
// fingerprintCache.set(userId, record);
|
|
57
|
+
// }
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Verify fingerprint against stored value
|
|
61
|
+
*
|
|
62
|
+
* BEHAVIOR:
|
|
63
|
+
* - First use: stores fingerprint, returns true
|
|
64
|
+
* - Match: returns true
|
|
65
|
+
* - Mismatch: throws error (HARD FAILURE)
|
|
66
|
+
*
|
|
67
|
+
* KEY ROTATION:
|
|
68
|
+
* - Automatic rotation NOT supported
|
|
69
|
+
* - Requires manual trustNewFingerprint() call
|
|
70
|
+
* - Otherwise connection fails on mismatch
|
|
71
|
+
*
|
|
72
|
+
* @throws Error on fingerprint mismatch (possible MITM or key rotation)
|
|
73
|
+
*/
|
|
74
|
+
export async function verifyFingerprint(userId, identityPublicKey) {
|
|
75
|
+
const fingerprint = generateFingerprint(identityPublicKey);
|
|
76
|
+
// Check in-memory cache first
|
|
77
|
+
const storedRecord = fingerprintCache.get(userId);
|
|
78
|
+
if (!storedRecord) {
|
|
79
|
+
// First use - store fingerprint
|
|
80
|
+
await storeFingerprint(userId, fingerprint);
|
|
81
|
+
console.log(`[TOFU] ✓ First contact: ${userId} (${fingerprint.slice(0, 16)}...)`);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
// Verify fingerprint matches
|
|
85
|
+
if (storedRecord.fingerprint !== fingerprint) {
|
|
86
|
+
throw new Error(`[TOFU] ✗ SECURITY ALERT: Identity key mismatch for ${userId}\n` +
|
|
87
|
+
` Expected: ${storedRecord.fingerprint.slice(0, 16)}...\n` +
|
|
88
|
+
` Received: ${fingerprint.slice(0, 16)}...\n` +
|
|
89
|
+
` First seen: ${storedRecord.firstSeen.toISOString()}\n\n` +
|
|
90
|
+
`POSSIBLE CAUSES:\n` +
|
|
91
|
+
` 1. MITM attack (key substitution)\n` +
|
|
92
|
+
` 2. User reinstalled app (legitimate key rotation)\n` +
|
|
93
|
+
` 3. Multi-device not supported (different keys)\n\n` +
|
|
94
|
+
`ACTION: Verify out-of-band or call trustNewFingerprint()`);
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Manually trust a new fingerprint (key rotation)
|
|
100
|
+
*
|
|
101
|
+
* USE CASES:
|
|
102
|
+
* - User reinstalled app and lost keys
|
|
103
|
+
* - Legitimate key rotation after compromise
|
|
104
|
+
* - Migration from old device
|
|
105
|
+
*
|
|
106
|
+
* SECURITY: Should be called ONLY after out-of-band verification
|
|
107
|
+
*/
|
|
108
|
+
export async function trustNewFingerprint(userId, identityPublicKey) {
|
|
109
|
+
const fingerprint = generateFingerprint(identityPublicKey);
|
|
110
|
+
const oldRecord = fingerprintCache.get(userId);
|
|
111
|
+
await storeFingerprint(userId, fingerprint);
|
|
112
|
+
console.log(`[TOFU] ⚠️ Manually trusted new identity for ${userId}\n` +
|
|
113
|
+
` Old: ${oldRecord?.fingerprint.slice(0, 16) || 'none'}...\n` +
|
|
114
|
+
` New: ${fingerprint.slice(0, 16)}...`);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Get stored fingerprint record for user
|
|
118
|
+
*/
|
|
119
|
+
export function getStoredFingerprint(userId) {
|
|
120
|
+
return fingerprintCache.get(userId);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Format fingerprint for display (groups of 4 hex chars)
|
|
124
|
+
* Example: "a3f2 d8c1 5e90 7b4a ..."
|
|
125
|
+
*/
|
|
126
|
+
export function formatFingerprint(fingerprint) {
|
|
127
|
+
return fingerprint.match(/.{1,4}/g)?.join(' ') || fingerprint;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Clear all stored fingerprints (TESTING ONLY)
|
|
131
|
+
*/
|
|
132
|
+
export function clearFingerprints() {
|
|
133
|
+
fingerprintCache.clear();
|
|
134
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X3DH + Double Ratchet Implementation
|
|
3
|
+
* This module handles session establishment and message encryption/decryption.
|
|
4
|
+
*/
|
|
5
|
+
export interface SessionState {
|
|
6
|
+
identityKey: Uint8Array;
|
|
7
|
+
signedPreKey: Uint8Array;
|
|
8
|
+
oneTimePreKey: Uint8Array;
|
|
9
|
+
rootKey: Uint8Array;
|
|
10
|
+
sendingChainKey: Uint8Array;
|
|
11
|
+
receivingChainKey: Uint8Array;
|
|
12
|
+
skippedMessageKeys: Map<string, Uint8Array>;
|
|
13
|
+
isPostCompromise: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function initializeCrypto(): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* X3DH Session Establishment
|
|
18
|
+
* @param identityKeyPair - The user's identity key pair
|
|
19
|
+
* @param signedPreKeyPair - The user's signed pre-key pair
|
|
20
|
+
* @param oneTimePreKey - A one-time pre-key
|
|
21
|
+
* @param recipientIdentityKey - The recipient's identity key
|
|
22
|
+
* @param recipientSignedPreKey - The recipient's signed pre-key
|
|
23
|
+
* @param recipientOneTimePreKey - The recipient's one-time pre-key
|
|
24
|
+
* @param recipientSPKSignature - Signature of SPK by recipient's identity key
|
|
25
|
+
* @param protocolVersion - The protocol version
|
|
26
|
+
* @param cipherSuite - The cipher suite
|
|
27
|
+
* @returns SessionState
|
|
28
|
+
*/
|
|
29
|
+
export declare function establishSession(identityKeyPair: {
|
|
30
|
+
publicKey: Uint8Array;
|
|
31
|
+
privateKey: Uint8Array;
|
|
32
|
+
}, signedPreKeyPair: {
|
|
33
|
+
publicKey: Uint8Array;
|
|
34
|
+
privateKey: Uint8Array;
|
|
35
|
+
}, oneTimePreKey: Uint8Array, recipientIdentityKey: Uint8Array, recipientSignedPreKey: Uint8Array, recipientOneTimePreKey: Uint8Array, recipientSPKSignature: Uint8Array, protocolVersion: string, cipherSuite: string): SessionState;
|
|
36
|
+
/**
|
|
37
|
+
* Double Ratchet Encryption
|
|
38
|
+
* @param plaintext - The message to encrypt
|
|
39
|
+
* @param session - The current session state
|
|
40
|
+
* @returns { ciphertext: Uint8Array; header: { publicKey: Uint8Array; nonce: Uint8Array } }
|
|
41
|
+
*/
|
|
42
|
+
export declare function encryptMessage(plaintext: string, session: SessionState): {
|
|
43
|
+
ciphertext: any;
|
|
44
|
+
header: {
|
|
45
|
+
publicKey: any;
|
|
46
|
+
nonce: any;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Double Ratchet Decryption
|
|
51
|
+
* @param ciphertext - The encrypted message
|
|
52
|
+
* @param header - The message header containing the sender's public key and nonce
|
|
53
|
+
* @param session - The current session state
|
|
54
|
+
* @returns The decrypted plaintext
|
|
55
|
+
*/
|
|
56
|
+
export declare function decryptMessage(ciphertext: Uint8Array, header: {
|
|
57
|
+
publicKey: Uint8Array;
|
|
58
|
+
nonce: Uint8Array;
|
|
59
|
+
}, session: SessionState): string;
|
|
60
|
+
export declare function addSkippedKey(session: SessionState, header: {
|
|
61
|
+
publicKey: Uint8Array;
|
|
62
|
+
nonce: Uint8Array;
|
|
63
|
+
}, messageKey: Uint8Array): void;
|
|
64
|
+
export declare function removeSkippedKey(session: SessionState, header: {
|
|
65
|
+
publicKey: Uint8Array;
|
|
66
|
+
nonce: Uint8Array;
|
|
67
|
+
}): void;
|
|
68
|
+
export declare function processSkippedKeys(session: SessionState): void;
|
|
69
|
+
export declare function handleSimultaneousSend(session: SessionState, isInitiator: boolean): void;
|
|
70
|
+
export declare function generateOPKPool(): void;
|
|
71
|
+
export declare function consumeOPKAtomically(userId: string): Uint8Array;
|
|
72
|
+
export declare function enforceDHRatchetPolicy(session: SessionState, remotePublicKey: Uint8Array, suspectedCompromise?: boolean): void;
|
|
73
|
+
/**
|
|
74
|
+
* Increment message counter and enforce policy.
|
|
75
|
+
*/
|
|
76
|
+
export declare function incrementMessageCounter(session: SessionState, remotePublicKey: Uint8Array): void;
|
|
77
|
+
/**
|
|
78
|
+
* Force a DH ratchet step to enable PCS.
|
|
79
|
+
* @param session - The current session state.
|
|
80
|
+
* @param remotePublicKey - The remote party's ephemeral public key.
|
|
81
|
+
*/
|
|
82
|
+
export declare function forceDHRatchet(session: SessionState, remotePublicKey: Uint8Array): void;
|
|
83
|
+
/**
|
|
84
|
+
* Trigger PCS recovery only after receiving a new DH public key.
|
|
85
|
+
* @param session - The current session state.
|
|
86
|
+
* @param remotePublicKey - The new DH public key from the remote party.
|
|
87
|
+
*/
|
|
88
|
+
export declare function receiveNewDHPublicKey(session: SessionState, remotePublicKey: Uint8Array): void;
|
|
@@ -0,0 +1,318 @@
|
|
|
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
|
+
/**
|
|
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
|
|
19
|
+
*/
|
|
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));
|
|
33
|
+
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,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
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
|
+
};
|
|
74
|
+
}
|
|
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]));
|
|
110
|
+
}
|
|
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);
|
|
122
|
+
}
|
|
123
|
+
function deriveChainKey(rootKey, transcript) {
|
|
124
|
+
const prk = hkdfExtract(rootKey, sodium.from_string('DR:chain'));
|
|
125
|
+
return hkdfExpand(prk, 'DR:chain', 32);
|
|
126
|
+
}
|
|
127
|
+
function deriveMessageKey(chainKey, transcript) {
|
|
128
|
+
const prk = hkdfExtract(chainKey, sodium.from_string('DR:message'));
|
|
129
|
+
return hkdfExpand(prk, 'DR:message', 32);
|
|
130
|
+
}
|
|
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++;
|
|
146
|
+
}
|
|
147
|
+
export function removeSkippedKey(session, header) {
|
|
148
|
+
const keyId = `${header.publicKey.toString()}:${header.nonce.toString()}`;
|
|
149
|
+
if (session.skippedMessageKeys.delete(keyId)) {
|
|
150
|
+
totalSkippedKeys--;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
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
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
export function processSkippedKeys(session) {
|
|
165
|
+
cleanUpSkippedKeys(session);
|
|
166
|
+
}
|
|
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
|
+
}
|
|
177
|
+
}
|
|
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
|
+
}
|
|
184
|
+
}
|
|
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);
|
|
190
|
+
}
|
|
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;
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
OPK_LOCK.delete(userId);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
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]));
|
|
210
|
+
}
|
|
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}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function validateCipherSuite(cipherSuite) {
|
|
219
|
+
const supportedSuites = ['AES-GCM'];
|
|
220
|
+
if (!supportedSuites.includes(cipherSuite)) {
|
|
221
|
+
throw new Error(`Unsupported cipher suite: ${cipherSuite}`);
|
|
222
|
+
}
|
|
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;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Increment message counter and enforce policy.
|
|
251
|
+
*/
|
|
252
|
+
export function incrementMessageCounter(session, remotePublicKey) {
|
|
253
|
+
messageCounter++;
|
|
254
|
+
enforceDHRatchetPolicy(session, remotePublicKey);
|
|
255
|
+
}
|
|
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;
|
|
274
|
+
}
|
|
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');
|
|
283
|
+
}
|
|
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;
|
|
318
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate recovery key shares using Shamir's Secret Sharing.
|
|
3
|
+
* @param secret - The secret to split (e.g., a recovery key).
|
|
4
|
+
* @returns An array of two shares.
|
|
5
|
+
*/
|
|
6
|
+
export declare function generateRecoveryShares(secret: Uint8Array): Uint8Array[];
|
|
7
|
+
/**
|
|
8
|
+
* Combine recovery key shares to reconstruct the secret.
|
|
9
|
+
* @param shares - An array of shares.
|
|
10
|
+
* @returns The reconstructed secret.
|
|
11
|
+
*/
|
|
12
|
+
export declare function combineRecoveryShares(shares: Uint8Array[]): Uint8Array;
|
|
13
|
+
/**
|
|
14
|
+
* Store recovery shares securely.
|
|
15
|
+
* @param userId - The user ID.
|
|
16
|
+
* @param shares - The recovery shares.
|
|
17
|
+
*/
|
|
18
|
+
export declare function storeRecoveryShares(userId: string, shares: Uint8Array[]): void;
|
|
19
|
+
/**
|
|
20
|
+
* Retrieve recovery shares for a user.
|
|
21
|
+
* @param userId - The user ID.
|
|
22
|
+
* @returns The recovery shares.
|
|
23
|
+
*/
|
|
24
|
+
export declare function retrieveRecoveryShares(userId: string): Uint8Array[];
|
|
25
|
+
/**
|
|
26
|
+
* Revoke recovery shares for a user.
|
|
27
|
+
* @param userId - The user ID.
|
|
28
|
+
*/
|
|
29
|
+
export declare function revokeRecoveryShares(userId: string): void;
|
|
30
|
+
export declare function verifyShareIntegrity(share: Uint8Array, expectedHash: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Formal Policy for Recovery Shares
|
|
33
|
+
*/
|
|
34
|
+
declare const recoveryPolicy: {
|
|
35
|
+
minAdmins: number;
|
|
36
|
+
tamperEvidence: boolean;
|
|
37
|
+
};
|
|
38
|
+
export declare function getRecoveryPolicy(): typeof recoveryPolicy;
|
|
39
|
+
/**
|
|
40
|
+
* Example Admin Authentication Model
|
|
41
|
+
*/
|
|
42
|
+
export declare function authenticateAdmin(adminToken: string): boolean;
|
|
43
|
+
export declare function approveRecovery(userId: string, adminId: string): void;
|
|
44
|
+
export declare function revokeRecovery(userId: string, adminId: string): void;
|
|
45
|
+
export {};
|