@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.
- package/dist/facade/app.d.ts +75 -2
- package/dist/facade/app.js +152 -5
- package/dist/facade/crypto-session.d.ts +76 -0
- package/dist/facade/crypto-session.js +175 -0
- package/dist/facade/errors.d.ts +2 -0
- package/dist/facade/errors.js +3 -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
package/dist/facade/app.d.ts
CHANGED
|
@@ -1,23 +1,96 @@
|
|
|
1
1
|
import type { StvorAppConfig, UserId, MessageContent } from './types.js';
|
|
2
2
|
import { RelayClient } from './relay-client.js';
|
|
3
3
|
type MessageHandler = (from: UserId, msg: string | Uint8Array) => void;
|
|
4
|
+
type UserAvailableHandler = (userId: UserId) => void;
|
|
4
5
|
export declare class StvorFacadeClient {
|
|
5
6
|
readonly userId: UserId;
|
|
6
7
|
private readonly relay;
|
|
8
|
+
private readonly defaultTimeout;
|
|
7
9
|
private crypto;
|
|
8
10
|
private handlers;
|
|
11
|
+
private userAvailableHandlers;
|
|
9
12
|
private knownPubKeys;
|
|
10
|
-
|
|
13
|
+
private pendingKeyResolvers;
|
|
14
|
+
constructor(userId: UserId, relay: RelayClient, defaultTimeout?: number);
|
|
11
15
|
private handleRelayMessage;
|
|
12
16
|
internalInitialize(): Promise<void>;
|
|
13
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Check if a user's public key is available locally
|
|
19
|
+
*/
|
|
20
|
+
isUserAvailable(userId: UserId): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Get list of all known users (whose public keys we have)
|
|
23
|
+
*/
|
|
24
|
+
getAvailableUsers(): UserId[];
|
|
25
|
+
/**
|
|
26
|
+
* Wait until a specific user's public key becomes available.
|
|
27
|
+
* This is the recommended way to ensure you can send messages.
|
|
28
|
+
*
|
|
29
|
+
* @param userId - The user to wait for
|
|
30
|
+
* @param timeoutMs - Maximum time to wait (default: 10000ms)
|
|
31
|
+
* @throws StvorError with RECIPIENT_TIMEOUT if timeout expires
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```typescript
|
|
35
|
+
* await alice.waitForUser('bob@example.com');
|
|
36
|
+
* await alice.send('bob@example.com', 'Hello!');
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
waitForUser(userId: UserId, timeoutMs?: number): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Send an encrypted message to a recipient.
|
|
42
|
+
*
|
|
43
|
+
* If the recipient's public key is not yet available, this method will
|
|
44
|
+
* automatically wait up to `timeoutMs` for the key to arrive via the relay.
|
|
45
|
+
*
|
|
46
|
+
* @param recipientId - The recipient's user ID
|
|
47
|
+
* @param content - Message content (string or Uint8Array)
|
|
48
|
+
* @param options - Optional: { timeout: number, waitForRecipient: boolean }
|
|
49
|
+
* @throws StvorError with RECIPIENT_TIMEOUT if recipient key doesn't arrive in time
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* // Auto-waits for recipient (recommended)
|
|
54
|
+
* await alice.send('bob@example.com', 'Hello!');
|
|
55
|
+
*
|
|
56
|
+
* // Skip waiting (throws immediately if not available)
|
|
57
|
+
* await alice.send('bob@example.com', 'Hello!', { waitForRecipient: false });
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
send(recipientId: UserId, content: MessageContent, options?: {
|
|
61
|
+
timeout?: number;
|
|
62
|
+
waitForRecipient?: boolean;
|
|
63
|
+
}): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Register a handler for incoming messages
|
|
66
|
+
*/
|
|
14
67
|
onMessage(handler: MessageHandler): () => void;
|
|
68
|
+
/**
|
|
69
|
+
* Register a handler that fires when a new user becomes available.
|
|
70
|
+
* This is triggered when we receive a user's public key announcement.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```typescript
|
|
74
|
+
* client.onUserAvailable((userId) => {
|
|
75
|
+
* console.log(`${userId} is now available for messaging`);
|
|
76
|
+
* });
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
onUserAvailable(handler: UserAvailableHandler): () => void;
|
|
15
80
|
}
|
|
16
81
|
export declare class StvorApp {
|
|
17
82
|
private readonly config;
|
|
18
83
|
private clients;
|
|
19
84
|
constructor(config: StvorAppConfig);
|
|
20
85
|
connect(userId: UserId): Promise<StvorFacadeClient>;
|
|
86
|
+
/**
|
|
87
|
+
* Get a connected client by user ID
|
|
88
|
+
*/
|
|
89
|
+
getClient(userId: UserId): StvorFacadeClient | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* Check if a user is connected locally
|
|
92
|
+
*/
|
|
93
|
+
isConnected(userId: UserId): boolean;
|
|
21
94
|
disconnect(userId?: UserId): Promise<void>;
|
|
22
95
|
}
|
|
23
96
|
export declare function init(config: StvorAppConfig): Promise<StvorApp>;
|
package/dist/facade/app.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { Errors, StvorError } from './errors.js';
|
|
2
2
|
import { RelayClient } from './relay-client.js';
|
|
3
3
|
import { CryptoSession } from './crypto.js';
|
|
4
|
+
/** Default timeout for waiting for recipient keys (ms) */
|
|
5
|
+
const DEFAULT_RECIPIENT_TIMEOUT = 10000;
|
|
6
|
+
/** Polling interval for key resolution (ms) */
|
|
7
|
+
const KEY_POLL_INTERVAL = 100;
|
|
4
8
|
export class StvorFacadeClient {
|
|
5
|
-
constructor(userId, relay) {
|
|
9
|
+
constructor(userId, relay, defaultTimeout = DEFAULT_RECIPIENT_TIMEOUT) {
|
|
6
10
|
this.userId = userId;
|
|
7
11
|
this.relay = relay;
|
|
12
|
+
this.defaultTimeout = defaultTimeout;
|
|
8
13
|
this.handlers = [];
|
|
14
|
+
this.userAvailableHandlers = [];
|
|
9
15
|
this.knownPubKeys = new Map();
|
|
16
|
+
this.pendingKeyResolvers = new Map();
|
|
10
17
|
this.crypto = new CryptoSession();
|
|
11
18
|
// listen relay messages
|
|
12
19
|
this.relay.onMessage((m) => this.handleRelayMessage(m));
|
|
@@ -17,7 +24,23 @@ export class StvorFacadeClient {
|
|
|
17
24
|
if (!m || typeof m !== 'object')
|
|
18
25
|
return;
|
|
19
26
|
if (m.type === 'announce' && m.user && m.pub) {
|
|
27
|
+
const wasKnown = this.knownPubKeys.has(m.user);
|
|
20
28
|
this.knownPubKeys.set(m.user, m.pub);
|
|
29
|
+
// Notify pending resolvers
|
|
30
|
+
const resolvers = this.pendingKeyResolvers.get(m.user);
|
|
31
|
+
if (resolvers) {
|
|
32
|
+
resolvers.forEach(resolve => resolve());
|
|
33
|
+
this.pendingKeyResolvers.delete(m.user);
|
|
34
|
+
}
|
|
35
|
+
// Notify user available handlers (only for new users)
|
|
36
|
+
if (!wasKnown) {
|
|
37
|
+
for (const h of this.userAvailableHandlers) {
|
|
38
|
+
try {
|
|
39
|
+
h(m.user);
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
21
44
|
return;
|
|
22
45
|
}
|
|
23
46
|
if (m.type === 'message' && m.to === this.userId && m.payload) {
|
|
@@ -37,16 +60,109 @@ export class StvorFacadeClient {
|
|
|
37
60
|
async internalInitialize() {
|
|
38
61
|
// nothing for now; announce already sent in constructor
|
|
39
62
|
}
|
|
40
|
-
|
|
41
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Check if a user's public key is available locally
|
|
65
|
+
*/
|
|
66
|
+
isUserAvailable(userId) {
|
|
67
|
+
return this.knownPubKeys.has(userId);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Get list of all known users (whose public keys we have)
|
|
71
|
+
*/
|
|
72
|
+
getAvailableUsers() {
|
|
73
|
+
return Array.from(this.knownPubKeys.keys()).filter(id => id !== this.userId);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Wait until a specific user's public key becomes available.
|
|
77
|
+
* This is the recommended way to ensure you can send messages.
|
|
78
|
+
*
|
|
79
|
+
* @param userId - The user to wait for
|
|
80
|
+
* @param timeoutMs - Maximum time to wait (default: 10000ms)
|
|
81
|
+
* @throws StvorError with RECIPIENT_TIMEOUT if timeout expires
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```typescript
|
|
85
|
+
* await alice.waitForUser('bob@example.com');
|
|
86
|
+
* await alice.send('bob@example.com', 'Hello!');
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
async waitForUser(userId, timeoutMs = this.defaultTimeout) {
|
|
90
|
+
// Already available
|
|
91
|
+
if (this.knownPubKeys.has(userId)) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const timeout = setTimeout(() => {
|
|
96
|
+
// Remove from pending
|
|
97
|
+
const resolvers = this.pendingKeyResolvers.get(userId);
|
|
98
|
+
if (resolvers) {
|
|
99
|
+
const idx = resolvers.indexOf(resolveHandler);
|
|
100
|
+
if (idx >= 0)
|
|
101
|
+
resolvers.splice(idx, 1);
|
|
102
|
+
if (resolvers.length === 0)
|
|
103
|
+
this.pendingKeyResolvers.delete(userId);
|
|
104
|
+
}
|
|
105
|
+
reject(new StvorError(Errors.RECIPIENT_TIMEOUT, `Timed out waiting for user "${userId}" after ${timeoutMs}ms. ` +
|
|
106
|
+
`The user may not be connected to the relay. ` +
|
|
107
|
+
`Ensure both parties are online before sending messages.`, 'Verify the recipient is connected, or increase timeout', true));
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
const resolveHandler = () => {
|
|
110
|
+
clearTimeout(timeout);
|
|
111
|
+
resolve();
|
|
112
|
+
};
|
|
113
|
+
// Add to pending resolvers
|
|
114
|
+
if (!this.pendingKeyResolvers.has(userId)) {
|
|
115
|
+
this.pendingKeyResolvers.set(userId, []);
|
|
116
|
+
}
|
|
117
|
+
this.pendingKeyResolvers.get(userId).push(resolveHandler);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Send an encrypted message to a recipient.
|
|
122
|
+
*
|
|
123
|
+
* If the recipient's public key is not yet available, this method will
|
|
124
|
+
* automatically wait up to `timeoutMs` for the key to arrive via the relay.
|
|
125
|
+
*
|
|
126
|
+
* @param recipientId - The recipient's user ID
|
|
127
|
+
* @param content - Message content (string or Uint8Array)
|
|
128
|
+
* @param options - Optional: { timeout: number, waitForRecipient: boolean }
|
|
129
|
+
* @throws StvorError with RECIPIENT_TIMEOUT if recipient key doesn't arrive in time
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```typescript
|
|
133
|
+
* // Auto-waits for recipient (recommended)
|
|
134
|
+
* await alice.send('bob@example.com', 'Hello!');
|
|
135
|
+
*
|
|
136
|
+
* // Skip waiting (throws immediately if not available)
|
|
137
|
+
* await alice.send('bob@example.com', 'Hello!', { waitForRecipient: false });
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
async send(recipientId, content, options) {
|
|
141
|
+
const { timeout = this.defaultTimeout, waitForRecipient = true } = options ?? {};
|
|
142
|
+
// Try to resolve recipient key
|
|
143
|
+
let recipientPub = this.knownPubKeys.get(recipientId);
|
|
42
144
|
if (!recipientPub) {
|
|
43
|
-
|
|
145
|
+
if (!waitForRecipient) {
|
|
146
|
+
throw new StvorError(Errors.RECIPIENT_NOT_FOUND, `Recipient "${recipientId}" is not available. ` +
|
|
147
|
+
`Their public key has not been announced to the relay. ` +
|
|
148
|
+
`Use waitForUser() or enable waitForRecipient option.`, 'Call waitForUser(recipientId) before sending, or ensure recipient is connected', false);
|
|
149
|
+
}
|
|
150
|
+
// Wait for recipient key with timeout
|
|
151
|
+
await this.waitForUser(recipientId, timeout);
|
|
152
|
+
recipientPub = this.knownPubKeys.get(recipientId);
|
|
153
|
+
if (!recipientPub) {
|
|
154
|
+
// Should not happen, but safety check
|
|
155
|
+
throw new StvorError(Errors.RECIPIENT_NOT_FOUND, `Recipient "${recipientId}" key resolution failed unexpectedly.`, 'This is an internal error, please report it', false);
|
|
156
|
+
}
|
|
44
157
|
}
|
|
45
158
|
const plain = typeof content === 'string' ? new TextEncoder().encode(content) : content;
|
|
46
159
|
const payload = this.crypto.encrypt(plain, recipientPub);
|
|
47
160
|
const msg = { type: 'message', to: recipientId, from: this.userId, payload };
|
|
48
161
|
this.relay.send(msg);
|
|
49
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* Register a handler for incoming messages
|
|
165
|
+
*/
|
|
50
166
|
onMessage(handler) {
|
|
51
167
|
this.handlers.push(handler);
|
|
52
168
|
return () => {
|
|
@@ -55,6 +171,25 @@ export class StvorFacadeClient {
|
|
|
55
171
|
this.handlers.splice(i, 1);
|
|
56
172
|
};
|
|
57
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Register a handler that fires when a new user becomes available.
|
|
176
|
+
* This is triggered when we receive a user's public key announcement.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```typescript
|
|
180
|
+
* client.onUserAvailable((userId) => {
|
|
181
|
+
* console.log(`${userId} is now available for messaging`);
|
|
182
|
+
* });
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
onUserAvailable(handler) {
|
|
186
|
+
this.userAvailableHandlers.push(handler);
|
|
187
|
+
return () => {
|
|
188
|
+
const i = this.userAvailableHandlers.indexOf(handler);
|
|
189
|
+
if (i >= 0)
|
|
190
|
+
this.userAvailableHandlers.splice(i, 1);
|
|
191
|
+
};
|
|
192
|
+
}
|
|
58
193
|
}
|
|
59
194
|
export class StvorApp {
|
|
60
195
|
constructor(config) {
|
|
@@ -66,11 +201,23 @@ export class StvorApp {
|
|
|
66
201
|
if (existing)
|
|
67
202
|
return existing;
|
|
68
203
|
const relay = new RelayClient(this.config.relayUrl ?? 'wss://stvor.xyz/relay', this.config.appToken, this.config.timeout ?? 10000);
|
|
69
|
-
const client = new StvorFacadeClient(userId, relay);
|
|
204
|
+
const client = new StvorFacadeClient(userId, relay, this.config.timeout ?? 10000);
|
|
70
205
|
await client.internalInitialize();
|
|
71
206
|
this.clients.set(userId, client);
|
|
72
207
|
return client;
|
|
73
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Get a connected client by user ID
|
|
211
|
+
*/
|
|
212
|
+
getClient(userId) {
|
|
213
|
+
return this.clients.get(userId);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Check if a user is connected locally
|
|
217
|
+
*/
|
|
218
|
+
isConnected(userId) {
|
|
219
|
+
return this.clients.has(userId);
|
|
220
|
+
}
|
|
74
221
|
async disconnect(userId) {
|
|
75
222
|
if (userId) {
|
|
76
223
|
this.clients.delete(userId);
|
|
@@ -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/errors.d.ts
CHANGED
|
@@ -3,9 +3,11 @@ export declare const Errors: {
|
|
|
3
3
|
readonly RELAY_UNAVAILABLE: "RELAY_UNAVAILABLE";
|
|
4
4
|
readonly DELIVERY_FAILED: "DELIVERY_FAILED";
|
|
5
5
|
readonly RECIPIENT_NOT_FOUND: "RECIPIENT_NOT_FOUND";
|
|
6
|
+
readonly RECIPIENT_TIMEOUT: "RECIPIENT_TIMEOUT";
|
|
6
7
|
readonly MESSAGE_INTEGRITY_FAILED: "MESSAGE_INTEGRITY_FAILED";
|
|
7
8
|
readonly RECEIVE_TIMEOUT: "RECEIVE_TIMEOUT";
|
|
8
9
|
readonly RECEIVE_IN_PROGRESS: "RECEIVE_IN_PROGRESS";
|
|
10
|
+
readonly NOT_CONNECTED: "NOT_CONNECTED";
|
|
9
11
|
};
|
|
10
12
|
export type ErrorCode = (typeof Errors)[keyof typeof Errors];
|
|
11
13
|
export declare class StvorError extends Error {
|
package/dist/facade/errors.js
CHANGED
|
@@ -3,9 +3,11 @@ export const Errors = {
|
|
|
3
3
|
RELAY_UNAVAILABLE: 'RELAY_UNAVAILABLE',
|
|
4
4
|
DELIVERY_FAILED: 'DELIVERY_FAILED',
|
|
5
5
|
RECIPIENT_NOT_FOUND: 'RECIPIENT_NOT_FOUND',
|
|
6
|
+
RECIPIENT_TIMEOUT: 'RECIPIENT_TIMEOUT',
|
|
6
7
|
MESSAGE_INTEGRITY_FAILED: 'MESSAGE_INTEGRITY_FAILED',
|
|
7
8
|
RECEIVE_TIMEOUT: 'RECEIVE_TIMEOUT',
|
|
8
9
|
RECEIVE_IN_PROGRESS: 'RECEIVE_IN_PROGRESS',
|
|
10
|
+
NOT_CONNECTED: 'NOT_CONNECTED',
|
|
9
11
|
};
|
|
10
12
|
export class StvorError extends Error {
|
|
11
13
|
constructor(code, message, action, retryable) {
|
|
@@ -13,5 +15,6 @@ export class StvorError extends Error {
|
|
|
13
15
|
this.code = code;
|
|
14
16
|
this.action = action;
|
|
15
17
|
this.retryable = retryable;
|
|
18
|
+
this.name = 'StvorError';
|
|
16
19
|
}
|
|
17
20
|
}
|
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;
|