@stvor/sdk 2.2.1 → 2.3.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.
@@ -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 {};
@@ -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;