@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.
@@ -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
+ }
@@ -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';
@@ -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 {};