@stvor/sdk 2.4.0 → 3.0.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.cjs +29 -0
- package/dist/facade/app.d.ts +83 -76
- package/dist/facade/app.js +330 -195
- package/dist/facade/crypto-session.cjs +29 -0
- package/dist/facade/crypto-session.d.ts +49 -54
- package/dist/facade/crypto-session.js +117 -140
- package/dist/facade/errors.cjs +29 -0
- package/dist/facade/errors.d.ts +29 -12
- package/dist/facade/errors.js +49 -8
- package/dist/facade/index.cjs +29 -0
- package/dist/facade/index.d.ts +27 -8
- package/dist/facade/index.js +23 -3
- package/dist/facade/local-storage-identity-store.cjs +29 -0
- package/dist/facade/local-storage-identity-store.d.ts +50 -0
- package/dist/facade/local-storage-identity-store.js +100 -0
- package/dist/facade/metrics-attestation.cjs +29 -0
- package/dist/facade/metrics-attestation.d.ts +209 -0
- package/dist/facade/metrics-attestation.js +333 -0
- package/dist/facade/metrics-engine.cjs +29 -0
- package/dist/facade/metrics-engine.d.ts +91 -0
- package/dist/facade/metrics-engine.js +170 -0
- package/dist/facade/redis-replay-cache.cjs +29 -0
- package/dist/facade/redis-replay-cache.d.ts +88 -0
- package/dist/facade/redis-replay-cache.js +60 -0
- package/dist/facade/relay-client.cjs +29 -0
- package/dist/facade/relay-client.d.ts +22 -23
- package/dist/facade/relay-client.js +107 -128
- package/dist/facade/replay-manager.cjs +29 -0
- package/dist/facade/replay-manager.d.ts +28 -35
- package/dist/facade/replay-manager.js +102 -69
- package/dist/facade/sodium-singleton.cjs +29 -0
- package/dist/facade/tofu-manager.cjs +29 -0
- package/dist/facade/tofu-manager.d.ts +38 -36
- package/dist/facade/tofu-manager.js +109 -77
- package/dist/facade/types.cjs +29 -0
- package/dist/facade/types.d.ts +2 -0
- package/dist/index.cjs +29 -0
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +7 -0
- package/dist/legacy.cjs +29 -0
- package/dist/legacy.d.ts +31 -1
- package/dist/legacy.js +90 -2
- package/dist/ratchet/core-production.cjs +29 -0
- package/dist/ratchet/core-production.d.ts +95 -0
- package/dist/ratchet/core-production.js +286 -0
- package/dist/ratchet/index.cjs +29 -0
- package/dist/ratchet/index.d.ts +49 -78
- package/dist/ratchet/index.js +313 -288
- package/dist/ratchet/key-recovery.cjs +29 -0
- package/dist/ratchet/replay-protection.cjs +29 -0
- package/dist/ratchet/tofu.cjs +29 -0
- package/dist/src/facade/app.cjs +29 -0
- package/dist/src/facade/app.d.ts +105 -0
- package/dist/src/facade/app.js +245 -0
- package/dist/src/facade/crypto.cjs +29 -0
- package/dist/src/facade/errors.cjs +29 -0
- package/dist/src/facade/errors.d.ts +19 -0
- package/dist/src/facade/errors.js +21 -0
- package/dist/src/facade/index.cjs +29 -0
- package/dist/src/facade/index.d.ts +8 -0
- package/dist/src/facade/index.js +5 -0
- package/dist/src/facade/relay-client.cjs +29 -0
- package/dist/src/facade/relay-client.d.ts +36 -0
- package/dist/src/facade/relay-client.js +154 -0
- package/dist/src/facade/types.cjs +29 -0
- package/dist/src/facade/types.d.ts +50 -0
- package/dist/src/facade/types.js +4 -0
- package/dist/src/index.cjs +29 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +2 -0
- package/dist/src/legacy.cjs +29 -0
- package/dist/src/legacy.d.ts +0 -0
- package/dist/src/legacy.js +1 -0
- package/dist/src/mock-relay-server.cjs +29 -0
- package/dist/src/mock-relay-server.d.ts +30 -0
- package/dist/src/mock-relay-server.js +236 -0
- package/package.json +37 -11
- package/dist/ratchet/tests/ratchet.test.d.ts +0 -1
- package/dist/ratchet/tests/ratchet.test.js +0 -160
- /package/dist/{facade → src/facade}/crypto.d.ts +0 -0
- /package/dist/{facade → src/facade}/crypto.js +0 -0
package/dist/facade/app.js
CHANGED
|
@@ -1,241 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STVOR DX Facade - Main Application Classes
|
|
3
|
+
*
|
|
4
|
+
* Security Guarantees:
|
|
5
|
+
* - X3DH + Double Ratchet (Signal Protocol)
|
|
6
|
+
* - Forward Secrecy via automatic DH ratchet rotation
|
|
7
|
+
* - Post-Compromise Security via forced ratchet steps
|
|
8
|
+
* - TOFU (Trust On First Use) for identity verification
|
|
9
|
+
* - Replay protection via nonce validation
|
|
10
|
+
* - Cryptographically verified metrics (HMAC-SHA256)
|
|
11
|
+
* - Node.js crypto for all cryptographic operations
|
|
12
|
+
*/
|
|
1
13
|
import { Errors, StvorError } from './errors.js';
|
|
14
|
+
export { StvorError, Errors };
|
|
2
15
|
import { RelayClient } from './relay-client.js';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
}
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
if (m.type === 'message' && m.to === this.userId && m.payload) {
|
|
47
|
-
const payload = m.payload;
|
|
48
|
-
const sender = m.from;
|
|
49
|
-
try {
|
|
50
|
-
const plain = this.crypto.decrypt(payload, payload.senderPub);
|
|
51
|
-
const text = new TextDecoder().decode(plain);
|
|
52
|
-
for (const h of this.handlers)
|
|
53
|
-
h(sender, text);
|
|
54
|
-
}
|
|
55
|
-
catch (e) {
|
|
56
|
-
// ignore decryption errors
|
|
57
|
-
}
|
|
58
|
-
}
|
|
16
|
+
import { Counter, Gauge, register } from 'prom-client';
|
|
17
|
+
import { CryptoSessionManager } from './crypto-session.js';
|
|
18
|
+
import { verifyFingerprint } from './tofu-manager.js';
|
|
19
|
+
import { validateMessageWithNonce } from './replay-manager.js';
|
|
20
|
+
import { MetricsAttestationEngine } from './metrics-attestation.js';
|
|
21
|
+
// Define Prometheus metrics
|
|
22
|
+
const messagesDeliveredTotal = new Counter({
|
|
23
|
+
name: 'messages_delivered_total',
|
|
24
|
+
help: 'Total number of messages successfully delivered',
|
|
25
|
+
});
|
|
26
|
+
const quotaExceededTotal = new Counter({
|
|
27
|
+
name: 'quota_exceeded_total',
|
|
28
|
+
help: 'Total number of quota exceeded events',
|
|
29
|
+
});
|
|
30
|
+
const rateLimitedTotal = new Counter({
|
|
31
|
+
name: 'rate_limited_total',
|
|
32
|
+
help: 'Total number of rate-limited events',
|
|
33
|
+
});
|
|
34
|
+
const activeTokens = new Gauge({
|
|
35
|
+
name: 'active_tokens',
|
|
36
|
+
help: 'Number of currently active tokens',
|
|
37
|
+
});
|
|
38
|
+
// Export metrics for use in the application
|
|
39
|
+
export { messagesDeliveredTotal, quotaExceededTotal, rateLimitedTotal, activeTokens, register };
|
|
40
|
+
export class StvorApp {
|
|
41
|
+
constructor(config) {
|
|
42
|
+
this.connectedClients = new Map();
|
|
43
|
+
this.config = config;
|
|
44
|
+
this.relay = new RelayClient(config.relayUrl, config.appToken, config.timeout);
|
|
45
|
+
this.metricsAttestation = new MetricsAttestationEngine(config.appToken);
|
|
46
|
+
this.appToken = config.appToken;
|
|
47
|
+
this.backendUrl = config.backendUrl || 'http://localhost:3000';
|
|
59
48
|
}
|
|
60
|
-
|
|
61
|
-
|
|
49
|
+
isReady() {
|
|
50
|
+
return this.relay.isConnected();
|
|
62
51
|
}
|
|
63
52
|
/**
|
|
64
|
-
*
|
|
53
|
+
* Get attestation engine for recording metrics
|
|
65
54
|
*/
|
|
66
|
-
|
|
67
|
-
return this.
|
|
55
|
+
getMetricsAttestationEngine() {
|
|
56
|
+
return this.metricsAttestation;
|
|
68
57
|
}
|
|
69
58
|
/**
|
|
70
|
-
*
|
|
59
|
+
* Periodically send metrics attestations to backend
|
|
60
|
+
* Backend verifies and stores only valid attestations
|
|
71
61
|
*/
|
|
72
|
-
|
|
73
|
-
|
|
62
|
+
async sendMetricsAttestation() {
|
|
63
|
+
const attestation = this.metricsAttestation.createAttestation();
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetch(`${this.backendUrl}/api/metrics/attest`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { 'Content-Type': 'application/json' },
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
appToken: this.appToken,
|
|
70
|
+
attestation,
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
const error = await response.json();
|
|
75
|
+
console.error('[STVOR] Metrics attestation rejected:', error.reason);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
console.debug('[STVOR] Metrics attestation verified and stored');
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
console.error('[STVOR] Failed to send metrics attestation:', error);
|
|
82
|
+
}
|
|
74
83
|
}
|
|
75
84
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
* ```
|
|
85
|
+
* Flush metrics to backend
|
|
86
|
+
* Sends current metrics attestation (if there is any activity)
|
|
87
|
+
* Called explicitly by user or on disconnect
|
|
88
88
|
*/
|
|
89
|
-
async
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
async flushMetrics() {
|
|
90
|
+
await this.sendMetricsAttestation().catch(err => console.debug('[STVOR] Metrics flush failed:', err));
|
|
91
|
+
}
|
|
92
|
+
async connect(userId) {
|
|
93
|
+
const existingClient = this.connectedClients.get(userId);
|
|
94
|
+
if (existingClient) {
|
|
95
|
+
console.warn(`[STVOR] Warning: User "${userId}" is already connected. Returning cached client.`);
|
|
96
|
+
return existingClient;
|
|
93
97
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
clearTimeout(timeout);
|
|
111
|
-
resolve();
|
|
112
|
-
};
|
|
113
|
-
// Add to pending resolvers
|
|
114
|
-
if (!this.pendingKeyResolvers.has(userId)) {
|
|
115
|
-
this.pendingKeyResolvers.set(userId, []);
|
|
98
|
+
const client = new StvorFacadeClient(userId, this.relay, this.metricsAttestation);
|
|
99
|
+
await this.initClient(client);
|
|
100
|
+
this.connectedClients.set(userId, client);
|
|
101
|
+
return client;
|
|
102
|
+
}
|
|
103
|
+
async disconnect(userId) {
|
|
104
|
+
if (userId) {
|
|
105
|
+
const client = this.connectedClients.get(userId);
|
|
106
|
+
if (client) {
|
|
107
|
+
await client.disconnect();
|
|
108
|
+
this.connectedClients.delete(userId);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
for (const client of this.connectedClients.values()) {
|
|
113
|
+
await client.disconnect();
|
|
116
114
|
}
|
|
117
|
-
this.
|
|
118
|
-
|
|
115
|
+
this.connectedClients.clear();
|
|
116
|
+
this.relay.disconnect();
|
|
117
|
+
// Flush any pending metrics before disconnect
|
|
118
|
+
await this.flushMetrics();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async initClient(client) {
|
|
122
|
+
await client.internalInitialize();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export class StvorFacadeClient {
|
|
126
|
+
constructor(userId, relay, metricsAttestation) {
|
|
127
|
+
this.initialized = false;
|
|
128
|
+
this.messageHandlers = new Map();
|
|
129
|
+
this.messageQueue = [];
|
|
130
|
+
this.isReceiving = false;
|
|
131
|
+
this.userId = userId;
|
|
132
|
+
this.relay = relay;
|
|
133
|
+
this.metricsAttestation = metricsAttestation;
|
|
134
|
+
this.cryptoSession = new CryptoSessionManager(userId);
|
|
135
|
+
}
|
|
136
|
+
async internalInitialize() {
|
|
137
|
+
await this.initialize();
|
|
138
|
+
}
|
|
139
|
+
async initialize() {
|
|
140
|
+
if (this.initialized)
|
|
141
|
+
return;
|
|
142
|
+
// Initialize libsodium and generate identity keys
|
|
143
|
+
await this.cryptoSession.initialize();
|
|
144
|
+
// Get serialized public keys for relay registration
|
|
145
|
+
const publicKeys = this.cryptoSession.getPublicKeys();
|
|
146
|
+
// Register with relay server
|
|
147
|
+
await this.relay.register(this.userId, publicKeys);
|
|
148
|
+
this.initialized = true;
|
|
149
|
+
this.startMessagePolling();
|
|
119
150
|
}
|
|
120
151
|
/**
|
|
121
152
|
* Send an encrypted message to a recipient.
|
|
122
153
|
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
|
154
|
+
* By default, if the recipient is not yet registered, the method will
|
|
155
|
+
* poll up to `options.timeout` ms for their keys to appear on the relay.
|
|
156
|
+
* Set `options.waitForRecipient: false` to throw immediately instead.
|
|
130
157
|
*
|
|
131
|
-
* @
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* // Skip waiting (throws immediately if not available)
|
|
137
|
-
* await alice.send('bob@example.com', 'Hello!', { waitForRecipient: false });
|
|
138
|
-
* ```
|
|
158
|
+
* @param recipientId - The recipient's user ID
|
|
159
|
+
* @param content - Message content (string or Uint8Array)
|
|
160
|
+
* @param options - Optional settings:
|
|
161
|
+
* - `timeout` — Max wait time in ms (default: 10 000)
|
|
162
|
+
* - `waitForRecipient` — Auto-wait for recipient keys (default: true)
|
|
139
163
|
*/
|
|
140
164
|
async send(recipientId, content, options) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
165
|
+
if (!this.initialized) {
|
|
166
|
+
throw Errors.clientNotReady();
|
|
167
|
+
}
|
|
168
|
+
const { timeout = 10000, waitForRecipient = true } = options ?? {};
|
|
169
|
+
// Check quota for production tokens (skip for local dev tokens)
|
|
170
|
+
const appToken = this.relay.getAppToken();
|
|
171
|
+
if (!appToken.startsWith('stvor_local_') && !appToken.startsWith('stvor_dev_')) {
|
|
172
|
+
const quota = await this.checkQuota();
|
|
173
|
+
if (quota && quota.used >= quota.limit && quota.limit !== -1) {
|
|
174
|
+
quotaExceededTotal.inc();
|
|
175
|
+
throw Errors.quotaExceeded();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const contentBytes = typeof content === 'string'
|
|
179
|
+
? new TextEncoder().encode(content)
|
|
180
|
+
: content;
|
|
181
|
+
// Ensure session with recipient exists
|
|
182
|
+
if (!this.cryptoSession.hasSession(recipientId)) {
|
|
183
|
+
// Fetch recipient's public keys (with optional polling)
|
|
184
|
+
let recipientPublicKeys = await this.relay.getPublicKeys(recipientId);
|
|
185
|
+
// If not found and waitForRecipient is enabled, poll until timeout
|
|
186
|
+
if (!recipientPublicKeys && waitForRecipient) {
|
|
187
|
+
recipientPublicKeys = await this.waitForRecipientKeys(recipientId, timeout);
|
|
188
|
+
}
|
|
189
|
+
if (!recipientPublicKeys) {
|
|
190
|
+
throw Errors.recipientNotFound(recipientId);
|
|
149
191
|
}
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
192
|
+
// TOFU: Verify fingerprint (throws on mismatch)
|
|
193
|
+
const recipientIdentityKey = Buffer.from(recipientPublicKeys.identityKey, 'base64url');
|
|
194
|
+
await verifyFingerprint(recipientId, recipientIdentityKey);
|
|
195
|
+
// Establish X3DH session
|
|
196
|
+
await this.cryptoSession.establishSessionWithPeer(recipientId, recipientPublicKeys);
|
|
197
|
+
}
|
|
198
|
+
// Encrypt using Double Ratchet
|
|
199
|
+
const plaintext = new TextDecoder().decode(contentBytes);
|
|
200
|
+
const { ciphertext, header } = this.cryptoSession.encryptForPeer(recipientId, plaintext);
|
|
201
|
+
// METRIC: Record successful encryption (AFTER AEAD completes)
|
|
202
|
+
this.metricsAttestation.recordMessageEncrypted();
|
|
203
|
+
try {
|
|
204
|
+
await this.relay.send({
|
|
205
|
+
to: recipientId,
|
|
206
|
+
from: this.userId,
|
|
207
|
+
ciphertext,
|
|
208
|
+
header,
|
|
209
|
+
});
|
|
210
|
+
messagesDeliveredTotal.inc();
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
if (e.code === 'QUOTA_EXCEEDED') {
|
|
214
|
+
quotaExceededTotal.inc();
|
|
215
|
+
throw Errors.quotaExceeded();
|
|
156
216
|
}
|
|
217
|
+
throw e;
|
|
157
218
|
}
|
|
158
|
-
const plain = typeof content === 'string' ? new TextEncoder().encode(content) : content;
|
|
159
|
-
const payload = this.crypto.encrypt(plain, recipientPub);
|
|
160
|
-
const msg = { type: 'message', to: recipientId, from: this.userId, payload };
|
|
161
|
-
this.relay.send(msg);
|
|
162
219
|
}
|
|
163
220
|
/**
|
|
164
|
-
*
|
|
221
|
+
* Check current quota usage from the relay server
|
|
165
222
|
*/
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
223
|
+
async checkQuota() {
|
|
224
|
+
try {
|
|
225
|
+
const response = await fetch(`${this.relay.getBaseUrl()}/usage`, {
|
|
226
|
+
headers: {
|
|
227
|
+
'Authorization': `Bearer ${this.relay.getAppToken()}`
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
if (!response.ok)
|
|
231
|
+
return null;
|
|
232
|
+
return await response.json();
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// If quota check fails, allow the request (fail open for availability)
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
173
238
|
}
|
|
174
239
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
240
|
+
* Wait for a specific recipient's public keys to become available on the relay.
|
|
241
|
+
* Polls the relay at 500ms intervals until the keys appear or timeout expires.
|
|
177
242
|
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* @example
|
|
182
|
-
* ```typescript
|
|
183
|
-
* client.onUserAvailable((userId) => {
|
|
184
|
-
* console.log(`${userId} is now available for messaging`);
|
|
185
|
-
* });
|
|
186
|
-
* ```
|
|
243
|
+
* @param recipientId - The user ID of the recipient
|
|
244
|
+
* @param timeoutMs - Max time to wait in milliseconds (default: 10000)
|
|
245
|
+
* @returns The recipient's serialized public keys, or null if timeout
|
|
187
246
|
*/
|
|
188
|
-
|
|
189
|
-
this.
|
|
247
|
+
async waitForUser(recipientId, timeoutMs = 10000) {
|
|
248
|
+
const keys = await this.waitForRecipientKeys(recipientId, timeoutMs);
|
|
249
|
+
return keys !== null;
|
|
250
|
+
}
|
|
251
|
+
async waitForRecipientKeys(recipientId, timeoutMs) {
|
|
252
|
+
const start = Date.now();
|
|
253
|
+
const pollInterval = 500;
|
|
254
|
+
while (Date.now() - start < timeoutMs) {
|
|
255
|
+
const keys = await this.relay.getPublicKeys(recipientId);
|
|
256
|
+
if (keys)
|
|
257
|
+
return keys;
|
|
258
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
// Note: blocking receive()/seal()/open() APIs are NOT part of SDK v0.1 facade.
|
|
263
|
+
// Use onMessage() for incoming messages and send() to transmit messages.
|
|
264
|
+
onMessage(handler) {
|
|
265
|
+
const id = crypto.randomUUID();
|
|
266
|
+
this.messageHandlers.set(id, handler);
|
|
190
267
|
return () => {
|
|
191
|
-
|
|
192
|
-
if (i >= 0)
|
|
193
|
-
this.userAvailableHandlers.splice(i, 1);
|
|
268
|
+
this.messageHandlers.delete(id);
|
|
194
269
|
};
|
|
195
270
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
constructor(config) {
|
|
199
|
-
this.config = config;
|
|
200
|
-
this.clients = new Map();
|
|
271
|
+
getUserId() {
|
|
272
|
+
return this.userId;
|
|
201
273
|
}
|
|
202
|
-
async
|
|
203
|
-
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
274
|
+
async decryptMessage(msg) {
|
|
275
|
+
// Ensure session with sender exists
|
|
276
|
+
if (!this.cryptoSession.hasSession(msg.from)) {
|
|
277
|
+
// Fetch sender's public keys
|
|
278
|
+
const senderPublicKeys = await this.relay.getPublicKeys(msg.from);
|
|
279
|
+
if (!senderPublicKeys) {
|
|
280
|
+
throw Errors.recipientNotFound(msg.from);
|
|
281
|
+
}
|
|
282
|
+
// TOFU: Verify fingerprint (throws on mismatch)
|
|
283
|
+
const senderIdentityKey = Buffer.from(senderPublicKeys.identityKey, 'base64url');
|
|
284
|
+
await verifyFingerprint(msg.from, senderIdentityKey);
|
|
285
|
+
// Establish X3DH session
|
|
286
|
+
await this.cryptoSession.establishSessionWithPeer(msg.from, senderPublicKeys);
|
|
287
|
+
}
|
|
288
|
+
// Replay protection: extract nonce from header (bytes 73-84)
|
|
289
|
+
const headerBuf = Buffer.from(msg.header, 'base64url');
|
|
290
|
+
const nonce = headerBuf.subarray(73, 85);
|
|
291
|
+
const timestamp = Math.floor(new Date(msg.timestamp).getTime() / 1000);
|
|
292
|
+
try {
|
|
293
|
+
await validateMessageWithNonce(msg.from, nonce, timestamp);
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
// METRIC: Record replay attempt
|
|
297
|
+
this.metricsAttestation.recordReplayAttempt();
|
|
298
|
+
throw e; // Re-throw to prevent decryption
|
|
299
|
+
}
|
|
300
|
+
// Decrypt using Double Ratchet
|
|
301
|
+
try {
|
|
302
|
+
const plaintext = this.cryptoSession.decryptFromPeer(msg.from, msg.ciphertext, msg.header);
|
|
303
|
+
// METRIC: Record successful decryption (AFTER AAD verification)
|
|
304
|
+
this.metricsAttestation.recordMessageDecrypted();
|
|
305
|
+
return {
|
|
306
|
+
id: msg.id || crypto.randomUUID(),
|
|
307
|
+
senderId: msg.from,
|
|
308
|
+
content: plaintext,
|
|
309
|
+
timestamp: new Date(msg.timestamp),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
catch (e) {
|
|
313
|
+
// METRIC: Record failed decryption (auth failure)
|
|
314
|
+
this.metricsAttestation.recordMessageRejected();
|
|
315
|
+
// Decryption failed — surface as delivery failure
|
|
316
|
+
throw Errors.deliveryFailed(msg.from);
|
|
317
|
+
}
|
|
213
318
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
319
|
+
startMessagePolling() {
|
|
320
|
+
const poll = async () => {
|
|
321
|
+
try {
|
|
322
|
+
const messages = await this.relay.fetchMessages(this.userId);
|
|
323
|
+
if (messages.length > 0) {
|
|
324
|
+
const msg = await this.decryptMessage(messages[0]);
|
|
325
|
+
for (const handler of this.messageHandlers.values()) {
|
|
326
|
+
try {
|
|
327
|
+
handler(msg);
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
console.error('[StvorApp] Handler error:', err);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
console.error('[StvorApp] Poll error:', err);
|
|
337
|
+
}
|
|
338
|
+
if (this.initialized) {
|
|
339
|
+
setTimeout(poll, 1000);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
poll();
|
|
219
343
|
}
|
|
220
344
|
/**
|
|
221
|
-
*
|
|
345
|
+
* Disconnect the client from the relay server.
|
|
222
346
|
*/
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (userId) {
|
|
228
|
-
this.clients.delete(userId);
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
this.clients.clear();
|
|
347
|
+
async disconnect() {
|
|
348
|
+
this.initialized = false;
|
|
349
|
+
// Stop message polling
|
|
350
|
+
// Note: The polling interval will naturally stop checking once initialized is false
|
|
232
351
|
}
|
|
233
352
|
}
|
|
234
353
|
export async function init(config) {
|
|
235
|
-
|
|
236
|
-
|
|
354
|
+
const relayUrl = config.relayUrl || 'https://relay.stvor.io';
|
|
355
|
+
const timeout = config.timeout || 10000;
|
|
356
|
+
if (!config.appToken || !config.appToken.startsWith('stvor_')) {
|
|
357
|
+
throw Errors.invalidAppToken();
|
|
358
|
+
}
|
|
359
|
+
const appConfig = {
|
|
360
|
+
appToken: config.appToken,
|
|
361
|
+
relayUrl,
|
|
362
|
+
timeout,
|
|
363
|
+
};
|
|
364
|
+
const app = new StvorApp(appConfig);
|
|
365
|
+
try {
|
|
366
|
+
const relay = new RelayClient(relayUrl, config.appToken, timeout);
|
|
367
|
+
await relay.healthCheck();
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
console.error('[StvorApp] Relay health check failed:', err);
|
|
371
|
+
throw Errors.relayUnavailable();
|
|
237
372
|
}
|
|
238
|
-
return
|
|
373
|
+
return app;
|
|
239
374
|
}
|
|
240
375
|
// Alias for createApp
|
|
241
376
|
export const createApp = init;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Auto-generated CommonJS wrapper for facade/crypto-session.js
|
|
4
|
+
// This allows `require('@stvor/sdk')` to work alongside ESM `import`.
|
|
5
|
+
|
|
6
|
+
const mod = require('module');
|
|
7
|
+
const url = require('url');
|
|
8
|
+
|
|
9
|
+
// Use dynamic import to load the ESM module
|
|
10
|
+
let _cached;
|
|
11
|
+
async function _load() {
|
|
12
|
+
if (!_cached) {
|
|
13
|
+
_cached = await import(url.pathToFileURL(__filename.replace(/\.cjs$/, '.js')).href);
|
|
14
|
+
}
|
|
15
|
+
return _cached;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// For simple CJS usage, expose a promise-based loader
|
|
19
|
+
module.exports = new Proxy({ load: _load }, {
|
|
20
|
+
get(target, prop) {
|
|
21
|
+
if (prop === '__esModule') return true;
|
|
22
|
+
if (prop === 'then') return undefined; // prevent treating as thenable
|
|
23
|
+
if (prop === 'load') return _load;
|
|
24
|
+
if (prop === 'default') {
|
|
25
|
+
return _load().then(m => m.default);
|
|
26
|
+
}
|
|
27
|
+
return _load().then(m => m[prop]);
|
|
28
|
+
}
|
|
29
|
+
});
|