@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,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STVOR Crypto Session Manager
|
|
3
|
+
* Integrates X3DH + Double Ratchet from ratchet module
|
|
4
|
+
*
|
|
5
|
+
* CRITICAL: Identity keys generated ONCE per userId
|
|
6
|
+
* Currently in-memory only - keys lost on restart
|
|
7
|
+
*
|
|
8
|
+
* TODO: Add persistent storage (IndexedDB/Keychain)
|
|
9
|
+
*/
|
|
10
|
+
export interface IdentityKeys {
|
|
11
|
+
identityKeyPair: {
|
|
12
|
+
publicKey: Uint8Array;
|
|
13
|
+
privateKey: Uint8Array;
|
|
14
|
+
};
|
|
15
|
+
signedPreKeyPair: {
|
|
16
|
+
publicKey: Uint8Array;
|
|
17
|
+
privateKey: Uint8Array;
|
|
18
|
+
};
|
|
19
|
+
oneTimePreKeys: Uint8Array[];
|
|
20
|
+
}
|
|
21
|
+
export interface SerializedPublicKeys {
|
|
22
|
+
identityKey: string;
|
|
23
|
+
signedPreKey: string;
|
|
24
|
+
signedPreKeySignature: string;
|
|
25
|
+
oneTimePreKey: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Manages cryptographic sessions for all peers
|
|
29
|
+
*/
|
|
30
|
+
export declare class CryptoSessionManager {
|
|
31
|
+
private userId;
|
|
32
|
+
private identityKeys;
|
|
33
|
+
private sessions;
|
|
34
|
+
private initialized;
|
|
35
|
+
private initPromise;
|
|
36
|
+
constructor(userId: string);
|
|
37
|
+
/**
|
|
38
|
+
* Initialize libsodium and generate identity keys
|
|
39
|
+
* RACE CONDITION SAFE: Returns same promise if called concurrently
|
|
40
|
+
*/
|
|
41
|
+
initialize(): Promise<void>;
|
|
42
|
+
private _doInitialize;
|
|
43
|
+
/**
|
|
44
|
+
* Get serialized public keys for relay registration
|
|
45
|
+
*/
|
|
46
|
+
getPublicKeys(): SerializedPublicKeys;
|
|
47
|
+
/**
|
|
48
|
+
* Establish session with peer (X3DH handshake)
|
|
49
|
+
*/
|
|
50
|
+
establishSessionWithPeer(peerId: string, peerPublicKeys: SerializedPublicKeys): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Encrypt message for peer using Double Ratchet
|
|
53
|
+
*/
|
|
54
|
+
encryptForPeer(peerId: string, plaintext: string): Promise<{
|
|
55
|
+
ciphertext: Uint8Array;
|
|
56
|
+
header: {
|
|
57
|
+
publicKey: Uint8Array;
|
|
58
|
+
nonce: Uint8Array;
|
|
59
|
+
};
|
|
60
|
+
}>;
|
|
61
|
+
/**
|
|
62
|
+
* Decrypt message from peer using Double Ratchet
|
|
63
|
+
*/
|
|
64
|
+
decryptFromPeer(peerId: string, ciphertext: Uint8Array, header: {
|
|
65
|
+
publicKey: Uint8Array;
|
|
66
|
+
nonce: Uint8Array;
|
|
67
|
+
}): Promise<string>;
|
|
68
|
+
/**
|
|
69
|
+
* Check if session exists with peer
|
|
70
|
+
*/
|
|
71
|
+
hasSession(peerId: string): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Destroy all sessions (cleanup)
|
|
74
|
+
*/
|
|
75
|
+
destroy(): void;
|
|
76
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STVOR Crypto Session Manager
|
|
3
|
+
* Integrates X3DH + Double Ratchet from ratchet module
|
|
4
|
+
*
|
|
5
|
+
* CRITICAL: Identity keys generated ONCE per userId
|
|
6
|
+
* Currently in-memory only - keys lost on restart
|
|
7
|
+
*
|
|
8
|
+
* TODO: Add persistent storage (IndexedDB/Keychain)
|
|
9
|
+
*/
|
|
10
|
+
import sodium from 'libsodium-wrappers';
|
|
11
|
+
import { ensureSodiumReady } from './sodium-singleton.js';
|
|
12
|
+
import { encryptMessage as ratchetEncrypt, decryptMessage as ratchetDecrypt, incrementMessageCounter, } from '../ratchet/index.js';
|
|
13
|
+
/**
|
|
14
|
+
* Manages cryptographic sessions for all peers
|
|
15
|
+
*/
|
|
16
|
+
export class CryptoSessionManager {
|
|
17
|
+
constructor(userId) {
|
|
18
|
+
this.identityKeys = null;
|
|
19
|
+
this.sessions = new Map();
|
|
20
|
+
this.initialized = false;
|
|
21
|
+
this.initPromise = null;
|
|
22
|
+
this.userId = userId;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Initialize libsodium and generate identity keys
|
|
26
|
+
* RACE CONDITION SAFE: Returns same promise if called concurrently
|
|
27
|
+
*/
|
|
28
|
+
async initialize() {
|
|
29
|
+
// Already initialized
|
|
30
|
+
if (this.initialized && this.identityKeys) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// Initialization in progress - return existing promise
|
|
34
|
+
if (this.initPromise) {
|
|
35
|
+
return this.initPromise;
|
|
36
|
+
}
|
|
37
|
+
// Start initialization
|
|
38
|
+
this.initPromise = this._doInitialize();
|
|
39
|
+
return this.initPromise;
|
|
40
|
+
}
|
|
41
|
+
async _doInitialize() {
|
|
42
|
+
// Ensure libsodium ready (singleton - safe to call multiple times)
|
|
43
|
+
await ensureSodiumReady();
|
|
44
|
+
// CRITICAL: Check again after await (another call might have completed)
|
|
45
|
+
if (this.initialized && this.identityKeys) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// Generate long-term identity key pair (Ed25519 for signing)
|
|
49
|
+
const identityKeyPair = sodium.crypto_sign_keypair();
|
|
50
|
+
// Generate semi-ephemeral signed pre-key (X25519 for DH)
|
|
51
|
+
const signedPreKeyPair = sodium.crypto_kx_keypair();
|
|
52
|
+
// Generate pool of one-time pre-keys
|
|
53
|
+
const oneTimePreKeys = [];
|
|
54
|
+
for (let i = 0; i < 10; i++) {
|
|
55
|
+
const keypair = sodium.crypto_kx_keypair();
|
|
56
|
+
oneTimePreKeys.push(keypair.publicKey);
|
|
57
|
+
}
|
|
58
|
+
this.identityKeys = {
|
|
59
|
+
identityKeyPair: {
|
|
60
|
+
publicKey: identityKeyPair.publicKey,
|
|
61
|
+
privateKey: identityKeyPair.privateKey,
|
|
62
|
+
},
|
|
63
|
+
signedPreKeyPair: {
|
|
64
|
+
publicKey: signedPreKeyPair.publicKey,
|
|
65
|
+
privateKey: signedPreKeyPair.privateKey,
|
|
66
|
+
},
|
|
67
|
+
oneTimePreKeys,
|
|
68
|
+
};
|
|
69
|
+
this.initialized = true;
|
|
70
|
+
this.initPromise = null;
|
|
71
|
+
console.log(`[Crypto] Identity keys generated for ${this.userId}`);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Get serialized public keys for relay registration
|
|
75
|
+
*/
|
|
76
|
+
getPublicKeys() {
|
|
77
|
+
if (!this.identityKeys) {
|
|
78
|
+
throw new Error('CryptoSessionManager not initialized');
|
|
79
|
+
}
|
|
80
|
+
// Sign the pre-key
|
|
81
|
+
const signedPreKeySignature = sodium.crypto_sign_detached(this.identityKeys.signedPreKeyPair.publicKey, this.identityKeys.identityKeyPair.privateKey);
|
|
82
|
+
return {
|
|
83
|
+
identityKey: sodium.to_base64(this.identityKeys.identityKeyPair.publicKey),
|
|
84
|
+
signedPreKey: sodium.to_base64(this.identityKeys.signedPreKeyPair.publicKey),
|
|
85
|
+
signedPreKeySignature: sodium.to_base64(signedPreKeySignature),
|
|
86
|
+
oneTimePreKey: sodium.to_base64(this.identityKeys.oneTimePreKeys[0] || new Uint8Array(32)),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Establish session with peer (X3DH handshake)
|
|
91
|
+
*/
|
|
92
|
+
async establishSessionWithPeer(peerId, peerPublicKeys) {
|
|
93
|
+
if (!this.identityKeys) {
|
|
94
|
+
throw new Error('CryptoSessionManager not initialized');
|
|
95
|
+
}
|
|
96
|
+
// Skip if session already exists
|
|
97
|
+
if (this.sessions.has(peerId)) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Deserialize peer's public keys
|
|
101
|
+
const recipientIdentityKey = sodium.from_base64(peerPublicKeys.identityKey);
|
|
102
|
+
const recipientSignedPreKey = sodium.from_base64(peerPublicKeys.signedPreKey);
|
|
103
|
+
const recipientSPKSignature = sodium.from_base64(peerPublicKeys.signedPreKeySignature);
|
|
104
|
+
const recipientOneTimePreKey = sodium.from_base64(peerPublicKeys.oneTimePreKey);
|
|
105
|
+
// Verify SPK signature
|
|
106
|
+
const isValid = sodium.crypto_sign_verify_detached(recipientSPKSignature, recipientSignedPreKey, recipientIdentityKey);
|
|
107
|
+
if (!isValid) {
|
|
108
|
+
throw new Error(`Invalid SPK signature for peer ${peerId}`);
|
|
109
|
+
}
|
|
110
|
+
// Perform X3DH to derive shared secret
|
|
111
|
+
const dh1 = sodium.crypto_scalarmult(this.identityKeys.signedPreKeyPair.privateKey, recipientSignedPreKey);
|
|
112
|
+
const dh2 = sodium.crypto_scalarmult(this.identityKeys.identityKeyPair.privateKey, recipientOneTimePreKey);
|
|
113
|
+
const dh3 = sodium.crypto_scalarmult(this.identityKeys.signedPreKeyPair.privateKey, recipientOneTimePreKey);
|
|
114
|
+
// Combine DH outputs
|
|
115
|
+
const sharedSecret = sodium.crypto_generichash(32, new Uint8Array([...dh1, ...dh2, ...dh3]));
|
|
116
|
+
// Derive root key
|
|
117
|
+
const rootKey = sodium.crypto_generichash(32, new Uint8Array([
|
|
118
|
+
...sharedSecret,
|
|
119
|
+
...sodium.from_string('x3dh-root-key-v1'),
|
|
120
|
+
]));
|
|
121
|
+
// Create initial session state
|
|
122
|
+
const session = {
|
|
123
|
+
identityKey: this.identityKeys.identityKeyPair.publicKey,
|
|
124
|
+
signedPreKey: this.identityKeys.signedPreKeyPair.publicKey,
|
|
125
|
+
oneTimePreKey: this.identityKeys.oneTimePreKeys[0] || new Uint8Array(32),
|
|
126
|
+
rootKey,
|
|
127
|
+
sendingChainKey: rootKey,
|
|
128
|
+
receivingChainKey: rootKey,
|
|
129
|
+
skippedMessageKeys: new Map(),
|
|
130
|
+
isPostCompromise: false,
|
|
131
|
+
};
|
|
132
|
+
this.sessions.set(peerId, session);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Encrypt message for peer using Double Ratchet
|
|
136
|
+
*/
|
|
137
|
+
async encryptForPeer(peerId, plaintext) {
|
|
138
|
+
const session = this.sessions.get(peerId);
|
|
139
|
+
if (!session) {
|
|
140
|
+
throw new Error(`No session with peer ${peerId}`);
|
|
141
|
+
}
|
|
142
|
+
// Encrypt using Double Ratchet
|
|
143
|
+
const result = ratchetEncrypt(plaintext, session);
|
|
144
|
+
// Enforce DH ratchet policy (Forward Secrecy + PCS)
|
|
145
|
+
const recipientKey = session.identityKey;
|
|
146
|
+
incrementMessageCounter(session, recipientKey);
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Decrypt message from peer using Double Ratchet
|
|
151
|
+
*/
|
|
152
|
+
async decryptFromPeer(peerId, ciphertext, header) {
|
|
153
|
+
const session = this.sessions.get(peerId);
|
|
154
|
+
if (!session) {
|
|
155
|
+
throw new Error(`No session with peer ${peerId}`);
|
|
156
|
+
}
|
|
157
|
+
// Decrypt using Double Ratchet
|
|
158
|
+
const plaintext = ratchetDecrypt(ciphertext, header, session);
|
|
159
|
+
return plaintext;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Check if session exists with peer
|
|
163
|
+
*/
|
|
164
|
+
hasSession(peerId) {
|
|
165
|
+
return this.sessions.has(peerId);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Destroy all sessions (cleanup)
|
|
169
|
+
*/
|
|
170
|
+
destroy() {
|
|
171
|
+
this.sessions.clear();
|
|
172
|
+
this.identityKeys = null;
|
|
173
|
+
this.initialized = false;
|
|
174
|
+
}
|
|
175
|
+
}
|
package/dist/facade/index.d.ts
CHANGED
|
@@ -4,5 +4,5 @@ export * from './types.js';
|
|
|
4
4
|
export type { DecryptedMessage, SealedPayload } from './types.js';
|
|
5
5
|
export type { StvorAppConfig, AppToken, UserId, MessageContent } from './types.js';
|
|
6
6
|
export { StvorError } from './errors.js';
|
|
7
|
-
export { StvorApp, StvorFacadeClient, Stvor, init } from './app.js';
|
|
7
|
+
export { StvorApp, StvorFacadeClient, Stvor, init, createApp } from './app.js';
|
|
8
8
|
export { ErrorCode as STVOR_ERRORS } from './errors.js';
|
package/dist/facade/index.js
CHANGED
|
@@ -2,4 +2,4 @@ export * from './app.js';
|
|
|
2
2
|
export * from './errors.js';
|
|
3
3
|
export * from './types.js';
|
|
4
4
|
export { StvorError } from './errors.js';
|
|
5
|
-
export { StvorApp, StvorFacadeClient, Stvor, init } from './app.js';
|
|
5
|
+
export { StvorApp, StvorFacadeClient, Stvor, init, createApp } from './app.js';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STVOR Replay Protection Manager
|
|
3
|
+
* Integrates nonce-based replay protection with in-memory fallback
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ CRITICAL LIMITATIONS (v2.1):
|
|
6
|
+
*
|
|
7
|
+
* 1. IN-MEMORY ONLY - DEMO-LEVEL PROTECTION
|
|
8
|
+
* - Process restart → cache cleared → replay window reopens
|
|
9
|
+
* - Clustered deployment → each instance has separate cache
|
|
10
|
+
* - Mobile background → iOS/Android may kill process
|
|
11
|
+
*
|
|
12
|
+
* 2. ATTACK WINDOW: 5 minutes after restart/cache clear
|
|
13
|
+
*
|
|
14
|
+
* 3. PRODUCTION REQUIREMENTS:
|
|
15
|
+
* - Redis or distributed cache (Memcached, DynamoDB)
|
|
16
|
+
* - Persistent storage survives restarts
|
|
17
|
+
* - Shared state across instances
|
|
18
|
+
*
|
|
19
|
+
* 4. ACCEPTABLE FOR:
|
|
20
|
+
* ✓ Single-instance development
|
|
21
|
+
* ✓ Proof-of-concept deployments
|
|
22
|
+
* ✓ Low-security use cases
|
|
23
|
+
*
|
|
24
|
+
* 5. NOT ACCEPTABLE FOR:
|
|
25
|
+
* ✗ Multi-instance production
|
|
26
|
+
* ✗ High-security environments
|
|
27
|
+
* ✗ Mobile apps (background kills)
|
|
28
|
+
*
|
|
29
|
+
* STATUS: Transitional implementation - Redis integration planned for v2.2
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Check if message is a replay attack
|
|
33
|
+
* @param userId - Sender's user ID
|
|
34
|
+
* @param nonce - Message nonce (base64 or hex)
|
|
35
|
+
* @param timestamp - Message timestamp (Unix seconds)
|
|
36
|
+
* @returns true if replay detected
|
|
37
|
+
*/
|
|
38
|
+
export declare function isReplay(userId: string, nonce: string, timestamp: number): Promise<boolean>;
|
|
39
|
+
/**
|
|
40
|
+
* Validate message for replay protection
|
|
41
|
+
* Throws error if replay detected or message too old
|
|
42
|
+
*/
|
|
43
|
+
export declare function validateMessage(userId: string, nonce: string, timestamp: number): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Validate message with Uint8Array nonce
|
|
46
|
+
*/
|
|
47
|
+
export declare function validateMessageWithNonce(userId: string, nonce: Uint8Array, timestamp: number): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Get cache statistics (for monitoring)
|
|
50
|
+
*/
|
|
51
|
+
export declare function getCacheStats(): {
|
|
52
|
+
size: number;
|
|
53
|
+
maxSize: number;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Clear all cached nonces (for testing)
|
|
57
|
+
*/
|
|
58
|
+
export declare function clearNonceCache(): void;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STVOR Replay Protection Manager
|
|
3
|
+
* Integrates nonce-based replay protection with in-memory fallback
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ CRITICAL LIMITATIONS (v2.1):
|
|
6
|
+
*
|
|
7
|
+
* 1. IN-MEMORY ONLY - DEMO-LEVEL PROTECTION
|
|
8
|
+
* - Process restart → cache cleared → replay window reopens
|
|
9
|
+
* - Clustered deployment → each instance has separate cache
|
|
10
|
+
* - Mobile background → iOS/Android may kill process
|
|
11
|
+
*
|
|
12
|
+
* 2. ATTACK WINDOW: 5 minutes after restart/cache clear
|
|
13
|
+
*
|
|
14
|
+
* 3. PRODUCTION REQUIREMENTS:
|
|
15
|
+
* - Redis or distributed cache (Memcached, DynamoDB)
|
|
16
|
+
* - Persistent storage survives restarts
|
|
17
|
+
* - Shared state across instances
|
|
18
|
+
*
|
|
19
|
+
* 4. ACCEPTABLE FOR:
|
|
20
|
+
* ✓ Single-instance development
|
|
21
|
+
* ✓ Proof-of-concept deployments
|
|
22
|
+
* ✓ Low-security use cases
|
|
23
|
+
*
|
|
24
|
+
* 5. NOT ACCEPTABLE FOR:
|
|
25
|
+
* ✗ Multi-instance production
|
|
26
|
+
* ✗ High-security environments
|
|
27
|
+
* ✗ Mobile apps (background kills)
|
|
28
|
+
*
|
|
29
|
+
* STATUS: Transitional implementation - Redis integration planned for v2.2
|
|
30
|
+
*/
|
|
31
|
+
import sodium from 'libsodium-wrappers';
|
|
32
|
+
// In-memory nonce cache (fallback when Redis unavailable)
|
|
33
|
+
// ⚠️ LOST ON RESTART - see limitations above
|
|
34
|
+
const nonceCache = new Map();
|
|
35
|
+
const NONCE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
|
|
36
|
+
const MAX_CACHE_SIZE = 10000; // Prevent memory exhaustion
|
|
37
|
+
/**
|
|
38
|
+
* Check if message is a replay attack
|
|
39
|
+
* @param userId - Sender's user ID
|
|
40
|
+
* @param nonce - Message nonce (base64 or hex)
|
|
41
|
+
* @param timestamp - Message timestamp (Unix seconds)
|
|
42
|
+
* @returns true if replay detected
|
|
43
|
+
*/
|
|
44
|
+
export async function isReplay(userId, nonce, timestamp) {
|
|
45
|
+
const key = `${userId}:${nonce}`;
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
// Check if nonce already seen
|
|
48
|
+
const cached = nonceCache.get(key);
|
|
49
|
+
if (cached) {
|
|
50
|
+
// Replay detected
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
// Check if message is too old
|
|
54
|
+
const messageAge = now - timestamp * 1000;
|
|
55
|
+
if (messageAge > NONCE_EXPIRY_MS) {
|
|
56
|
+
throw new Error('Message rejected: too old');
|
|
57
|
+
}
|
|
58
|
+
// Store nonce with timestamp
|
|
59
|
+
nonceCache.set(key, { timestamp: now });
|
|
60
|
+
// Cleanup old entries if cache is too large
|
|
61
|
+
if (nonceCache.size > MAX_CACHE_SIZE) {
|
|
62
|
+
await cleanupExpiredNonces();
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Validate message for replay protection
|
|
68
|
+
* Throws error if replay detected or message too old
|
|
69
|
+
*/
|
|
70
|
+
export async function validateMessage(userId, nonce, timestamp) {
|
|
71
|
+
const replay = await isReplay(userId, nonce, timestamp);
|
|
72
|
+
if (replay) {
|
|
73
|
+
throw new Error(`Replay attack detected from user ${userId}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Validate message with Uint8Array nonce
|
|
78
|
+
*/
|
|
79
|
+
export async function validateMessageWithNonce(userId, nonce, timestamp) {
|
|
80
|
+
const nonceHex = sodium.to_hex(nonce);
|
|
81
|
+
await validateMessage(userId, nonceHex, timestamp);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Cleanup expired nonces from cache
|
|
85
|
+
*/
|
|
86
|
+
async function cleanupExpiredNonces() {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
let cleaned = 0;
|
|
89
|
+
for (const [key, value] of nonceCache.entries()) {
|
|
90
|
+
if (now - value.timestamp > NONCE_EXPIRY_MS) {
|
|
91
|
+
nonceCache.delete(key);
|
|
92
|
+
cleaned++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (cleaned > 0) {
|
|
96
|
+
console.log(`[ReplayProtection] Cleaned ${cleaned} expired nonces`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Get cache statistics (for monitoring)
|
|
101
|
+
*/
|
|
102
|
+
export function getCacheStats() {
|
|
103
|
+
return {
|
|
104
|
+
size: nonceCache.size,
|
|
105
|
+
maxSize: MAX_CACHE_SIZE,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Clear all cached nonces (for testing)
|
|
110
|
+
*/
|
|
111
|
+
export function clearNonceCache() {
|
|
112
|
+
nonceCache.clear();
|
|
113
|
+
}
|
|
114
|
+
// Periodic cleanup (every 5 minutes)
|
|
115
|
+
if (typeof setInterval !== 'undefined') {
|
|
116
|
+
setInterval(cleanupExpiredNonces, 5 * 60 * 1000);
|
|
117
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STVOR libsodium Singleton
|
|
3
|
+
* Ensures sodium.ready is called only ONCE globally
|
|
4
|
+
* Prevents race conditions during concurrent initialization
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Initialize libsodium ONCE globally
|
|
8
|
+
* Safe to call multiple times - returns same promise
|
|
9
|
+
*
|
|
10
|
+
* @throws Never throws - libsodium.ready is infallible
|
|
11
|
+
*/
|
|
12
|
+
export declare function ensureSodiumReady(): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Check if libsodium is ready (synchronous)
|
|
15
|
+
*/
|
|
16
|
+
export declare function isSodiumReady(): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Reset state (ONLY for testing)
|
|
19
|
+
*/
|
|
20
|
+
export declare function _resetSodiumState(): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STVOR libsodium Singleton
|
|
3
|
+
* Ensures sodium.ready is called only ONCE globally
|
|
4
|
+
* Prevents race conditions during concurrent initialization
|
|
5
|
+
*/
|
|
6
|
+
import sodium from 'libsodium-wrappers';
|
|
7
|
+
let sodiumInitialized = false;
|
|
8
|
+
let sodiumInitPromise = null;
|
|
9
|
+
/**
|
|
10
|
+
* Initialize libsodium ONCE globally
|
|
11
|
+
* Safe to call multiple times - returns same promise
|
|
12
|
+
*
|
|
13
|
+
* @throws Never throws - libsodium.ready is infallible
|
|
14
|
+
*/
|
|
15
|
+
export async function ensureSodiumReady() {
|
|
16
|
+
// Already initialized - return immediately
|
|
17
|
+
if (sodiumInitialized) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
// Initialization in progress - return existing promise
|
|
21
|
+
if (sodiumInitPromise) {
|
|
22
|
+
return sodiumInitPromise;
|
|
23
|
+
}
|
|
24
|
+
// Start initialization
|
|
25
|
+
sodiumInitPromise = (async () => {
|
|
26
|
+
await sodium.ready;
|
|
27
|
+
sodiumInitialized = true;
|
|
28
|
+
console.log('[Crypto] libsodium initialized');
|
|
29
|
+
})();
|
|
30
|
+
return sodiumInitPromise;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Check if libsodium is ready (synchronous)
|
|
34
|
+
*/
|
|
35
|
+
export function isSodiumReady() {
|
|
36
|
+
return sodiumInitialized;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Reset state (ONLY for testing)
|
|
40
|
+
*/
|
|
41
|
+
export function _resetSodiumState() {
|
|
42
|
+
sodiumInitialized = false;
|
|
43
|
+
sodiumInitPromise = null;
|
|
44
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
interface FingerprintRecord {
|
|
23
|
+
fingerprint: string;
|
|
24
|
+
firstSeen: Date;
|
|
25
|
+
version: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Generate BLAKE2b-256 fingerprint from identity public key
|
|
29
|
+
*
|
|
30
|
+
* BINDING: Identity key ONLY
|
|
31
|
+
* - SPK rotation does NOT change fingerprint
|
|
32
|
+
* - OPK exhaustion does NOT change fingerprint
|
|
33
|
+
* - Only identity key rotation changes fingerprint
|
|
34
|
+
*/
|
|
35
|
+
export declare function generateFingerprint(identityPublicKey: Uint8Array): string;
|
|
36
|
+
/**
|
|
37
|
+
* Store fingerprint for user (in-memory fallback)
|
|
38
|
+
*/
|
|
39
|
+
export declare function storeFingerprint(userId: string, fingerprint: string): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Verify fingerprint against stored value
|
|
42
|
+
*
|
|
43
|
+
* BEHAVIOR:
|
|
44
|
+
* - First use: stores fingerprint, returns true
|
|
45
|
+
* - Match: returns true
|
|
46
|
+
* - Mismatch: throws error (HARD FAILURE)
|
|
47
|
+
*
|
|
48
|
+
* KEY ROTATION:
|
|
49
|
+
* - Automatic rotation NOT supported
|
|
50
|
+
* - Requires manual trustNewFingerprint() call
|
|
51
|
+
* - Otherwise connection fails on mismatch
|
|
52
|
+
*
|
|
53
|
+
* @throws Error on fingerprint mismatch (possible MITM or key rotation)
|
|
54
|
+
*/
|
|
55
|
+
export declare function verifyFingerprint(userId: string, identityPublicKey: Uint8Array): Promise<boolean>;
|
|
56
|
+
/**
|
|
57
|
+
* Manually trust a new fingerprint (key rotation)
|
|
58
|
+
*
|
|
59
|
+
* USE CASES:
|
|
60
|
+
* - User reinstalled app and lost keys
|
|
61
|
+
* - Legitimate key rotation after compromise
|
|
62
|
+
* - Migration from old device
|
|
63
|
+
*
|
|
64
|
+
* SECURITY: Should be called ONLY after out-of-band verification
|
|
65
|
+
*/
|
|
66
|
+
export declare function trustNewFingerprint(userId: string, identityPublicKey: Uint8Array): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Get stored fingerprint record for user
|
|
69
|
+
*/
|
|
70
|
+
export declare function getStoredFingerprint(userId: string): FingerprintRecord | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Format fingerprint for display (groups of 4 hex chars)
|
|
73
|
+
* Example: "a3f2 d8c1 5e90 7b4a ..."
|
|
74
|
+
*/
|
|
75
|
+
export declare function formatFingerprint(fingerprint: string): string;
|
|
76
|
+
/**
|
|
77
|
+
* Clear all stored fingerprints (TESTING ONLY)
|
|
78
|
+
*/
|
|
79
|
+
export declare function clearFingerprints(): void;
|
|
80
|
+
export {};
|