@stvor/sdk 2.4.1 → 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.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 +71 -0
- package/dist/facade/crypto-session.js +152 -0
- package/dist/facade/errors.d.ts +29 -12
- package/dist/facade/errors.js +49 -8
- 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.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 +51 -0
- package/dist/facade/replay-manager.js +150 -0
- package/dist/facade/sodium-singleton.cjs +29 -0
- package/dist/facade/sodium-singleton.d.ts +20 -0
- package/dist/facade/sodium-singleton.js +44 -0
- package/dist/facade/tofu-manager.cjs +29 -0
- package/dist/facade/tofu-manager.d.ts +82 -0
- package/dist/facade/tofu-manager.js +166 -0
- package/dist/facade/types.d.ts +2 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +7 -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/{facade/crypto.cjs → ratchet/index.cjs} +1 -1
- package/dist/ratchet/index.d.ts +59 -0
- package/dist/ratchet/index.js +343 -0
- package/dist/ratchet/key-recovery.cjs +29 -0
- package/dist/ratchet/key-recovery.d.ts +45 -0
- package/dist/ratchet/key-recovery.js +148 -0
- package/dist/ratchet/replay-protection.cjs +29 -0
- package/dist/ratchet/replay-protection.d.ts +21 -0
- package/dist/ratchet/replay-protection.js +50 -0
- package/dist/{mock-relay-server.cjs → ratchet/tofu.cjs} +1 -1
- package/dist/ratchet/tofu.d.ts +27 -0
- package/dist/ratchet/tofu.js +62 -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/package.json +16 -5
- /package/dist/{facade → src/facade}/crypto.d.ts +0 -0
- /package/dist/{facade → src/facade}/crypto.js +0 -0
- /package/dist/{mock-relay-server.d.ts → src/mock-relay-server.d.ts} +0 -0
- /package/dist/{mock-relay-server.js → src/mock-relay-server.js} +0 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STVOR v2.4.0 - Cryptographically Verified Metrics Engine
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for E2EE metrics.
|
|
5
|
+
* NO UI-side generation. NO localStorage counters. ONLY verified activity.
|
|
6
|
+
*/
|
|
7
|
+
import { createHmac } from 'crypto';
|
|
8
|
+
/**
|
|
9
|
+
* MetricsEngine: Runtime counter for real E2EE events only
|
|
10
|
+
*
|
|
11
|
+
* INVARIANT: Can only increment from SDK internals after cryptographic success
|
|
12
|
+
*/
|
|
13
|
+
export class MetricsEngine {
|
|
14
|
+
constructor(appToken, analyticsUrl = 'http://localhost:3001') {
|
|
15
|
+
this.appToken = appToken;
|
|
16
|
+
this.analyticsUrl = analyticsUrl;
|
|
17
|
+
this.metrics = {
|
|
18
|
+
messagesEncrypted: 0,
|
|
19
|
+
messagesDecrypted: 0,
|
|
20
|
+
messagesRejected: 0,
|
|
21
|
+
replayAttempts: 0,
|
|
22
|
+
authFailures: 0,
|
|
23
|
+
timestamp: Date.now(),
|
|
24
|
+
appToken: appToken
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Called ONLY after successful encrypt with AEAD
|
|
29
|
+
*/
|
|
30
|
+
recordMessageEncrypted() {
|
|
31
|
+
this.metrics.messagesEncrypted++;
|
|
32
|
+
this.updateTimestamp();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Called ONLY after successful decrypt with AAD verification
|
|
36
|
+
* Cannot be called externally
|
|
37
|
+
*/
|
|
38
|
+
recordMessageDecrypted() {
|
|
39
|
+
this.metrics.messagesDecrypted++;
|
|
40
|
+
this.updateTimestamp();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Called when message fails AAD check or other auth failures
|
|
44
|
+
*/
|
|
45
|
+
recordMessageRejected() {
|
|
46
|
+
this.metrics.messagesRejected++;
|
|
47
|
+
this.updateTimestamp();
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Called when replay cache detects duplicate nonce
|
|
51
|
+
*/
|
|
52
|
+
recordReplayAttempt() {
|
|
53
|
+
this.metrics.replayAttempts++;
|
|
54
|
+
this.updateTimestamp();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Called when signature verification fails
|
|
58
|
+
*/
|
|
59
|
+
recordAuthFailure() {
|
|
60
|
+
this.metrics.authFailures++;
|
|
61
|
+
this.updateTimestamp();
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Get current metrics snapshot (immutable)
|
|
65
|
+
*/
|
|
66
|
+
getMetrics() {
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
...this.metrics,
|
|
69
|
+
timestamp: Date.now()
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Reset metrics (for testing only, not accessible in production)
|
|
74
|
+
*/
|
|
75
|
+
updateTimestamp() {
|
|
76
|
+
this.metrics.timestamp = Date.now();
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Get metrics with cryptographic proof
|
|
80
|
+
* proof = HMAC-SHA256(JSON(metrics), derived_key)
|
|
81
|
+
*
|
|
82
|
+
* Derived key = HKDF(appToken, "stvor-metrics-v3")
|
|
83
|
+
*/
|
|
84
|
+
getSignedMetrics() {
|
|
85
|
+
const metrics = this.getMetrics();
|
|
86
|
+
const payload = JSON.stringify(metrics);
|
|
87
|
+
// Derive key from appToken
|
|
88
|
+
// appToken = "sk_live_" or "stvor_" prefix
|
|
89
|
+
const derivedKey = this.deriveMetricsKey();
|
|
90
|
+
// HMAC-SHA256
|
|
91
|
+
const hmac = createHmac('sha256', derivedKey);
|
|
92
|
+
hmac.update(payload);
|
|
93
|
+
const proof = hmac.digest('hex');
|
|
94
|
+
return {
|
|
95
|
+
metrics,
|
|
96
|
+
proof
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Derive metrics signing key from API token
|
|
101
|
+
* Using HKDF pattern for key derivation
|
|
102
|
+
*/
|
|
103
|
+
deriveMetricsKey() {
|
|
104
|
+
// Use libsodium for HKDF
|
|
105
|
+
// info = "stvor-metrics-v3" (domain separation)
|
|
106
|
+
const salt = Buffer.alloc(32, 0); // empty salt
|
|
107
|
+
const info = Buffer.from('stvor-metrics-v3');
|
|
108
|
+
// Extract phase: PRK = HMAC-Hash(salt, IKM)
|
|
109
|
+
const hmacExtract = createHmac('sha256', salt);
|
|
110
|
+
hmacExtract.update(this.appToken);
|
|
111
|
+
const prk = hmacExtract.digest();
|
|
112
|
+
// Expand phase: OKM = HMAC-Hash(PRK, info)
|
|
113
|
+
const hmacExpand = createHmac('sha256', prk);
|
|
114
|
+
hmacExpand.update(info);
|
|
115
|
+
hmacExpand.update(Buffer.from([1])); // counter
|
|
116
|
+
const okm = hmacExpand.digest();
|
|
117
|
+
return okm; // 32 bytes for SHA256
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Verify metrics signature on Dashboard side
|
|
122
|
+
*
|
|
123
|
+
* Takes: payload (JSON string), proof (hex string), apiKey
|
|
124
|
+
* Returns: boolean (valid or not)
|
|
125
|
+
*
|
|
126
|
+
* USAGE:
|
|
127
|
+
* const valid = verifyMetricsSignature(payload, proof, apiKey);
|
|
128
|
+
* if (valid) {
|
|
129
|
+
* const metrics = JSON.parse(payload);
|
|
130
|
+
* display(metrics);
|
|
131
|
+
* } else {
|
|
132
|
+
* display("Unverified");
|
|
133
|
+
* }
|
|
134
|
+
*/
|
|
135
|
+
export function verifyMetricsSignature(payload, proof, apiKey) {
|
|
136
|
+
try {
|
|
137
|
+
// Parse to validate JSON
|
|
138
|
+
JSON.parse(payload);
|
|
139
|
+
// Derive same key from API token
|
|
140
|
+
const derivedKey = deriveMetricsKeyForVerification(apiKey);
|
|
141
|
+
// Compute HMAC
|
|
142
|
+
const hmac = createHmac('sha256', derivedKey);
|
|
143
|
+
hmac.update(payload);
|
|
144
|
+
const computedProof = hmac.digest('hex');
|
|
145
|
+
// Constant-time comparison to prevent timing attacks
|
|
146
|
+
return computedProof === proof;
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
// Any parsing error = invalid
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Derive same key on verification side (Dashboard)
|
|
155
|
+
*/
|
|
156
|
+
function deriveMetricsKeyForVerification(apiKey) {
|
|
157
|
+
const salt = Buffer.alloc(32, 0);
|
|
158
|
+
const info = Buffer.from('stvor-metrics-v3');
|
|
159
|
+
const hmacExtract = createHmac('sha256', salt);
|
|
160
|
+
hmacExtract.update(apiKey);
|
|
161
|
+
const prk = hmacExtract.digest();
|
|
162
|
+
const hmacExpand = createHmac('sha256', prk);
|
|
163
|
+
hmacExpand.update(info);
|
|
164
|
+
hmacExpand.update(Buffer.from([1]));
|
|
165
|
+
return hmacExpand.digest();
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Export MetricsEngine for use in facade/app.ts
|
|
169
|
+
*/
|
|
170
|
+
export default MetricsEngine;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Auto-generated CommonJS wrapper for facade/redis-replay-cache.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
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis-based Replay Protection Cache for SDK
|
|
3
|
+
* Production-ready replay protection for clustered client deployments
|
|
4
|
+
*
|
|
5
|
+
* Use case: When running multiple instances of the same app (e.g., web app with
|
|
6
|
+
* multiple tabs, mobile app with background sync, or server-side SDK usage)
|
|
7
|
+
*/
|
|
8
|
+
import { IReplayCache } from './replay-manager.js';
|
|
9
|
+
/**
|
|
10
|
+
* Redis client interface (compatible with ioredis, node-redis, etc.)
|
|
11
|
+
*/
|
|
12
|
+
export interface RedisClient {
|
|
13
|
+
setEx(key: string, ttl: number, value: string): Promise<void>;
|
|
14
|
+
exists(key: string): Promise<number>;
|
|
15
|
+
keys(pattern: string): Promise<string[]>;
|
|
16
|
+
ping(): Promise<string>;
|
|
17
|
+
quit(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Configuration for Redis replay cache
|
|
21
|
+
*/
|
|
22
|
+
export interface RedisReplayCacheConfig {
|
|
23
|
+
/** Redis client instance (ioredis, node-redis, etc.) */
|
|
24
|
+
client: RedisClient;
|
|
25
|
+
/** Prefix for all keys (default: 'stvor:replay:') */
|
|
26
|
+
keyPrefix?: string;
|
|
27
|
+
/** TTL in seconds (default: 300 = 5 minutes) */
|
|
28
|
+
ttlSeconds?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Redis-based replay cache implementation
|
|
32
|
+
* Works with any Redis client (ioris, node-redis, etc.)
|
|
33
|
+
*/
|
|
34
|
+
export declare class RedisReplayCache implements IReplayCache {
|
|
35
|
+
private client;
|
|
36
|
+
private keyPrefix;
|
|
37
|
+
private ttlSeconds;
|
|
38
|
+
constructor(config: RedisReplayCacheConfig);
|
|
39
|
+
/**
|
|
40
|
+
* Build Redis key for nonce
|
|
41
|
+
*/
|
|
42
|
+
private buildKey;
|
|
43
|
+
/**
|
|
44
|
+
* Add nonce to cache
|
|
45
|
+
*/
|
|
46
|
+
addNonce(userId: string, nonce: string, timestamp: number): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Check if nonce exists in cache
|
|
49
|
+
*/
|
|
50
|
+
hasNonce(userId: string, nonce: string): Promise<boolean>;
|
|
51
|
+
/**
|
|
52
|
+
* Cleanup expired nonces
|
|
53
|
+
* Note: With Redis SETEX, keys auto-expire, so this is a no-op
|
|
54
|
+
*/
|
|
55
|
+
cleanup(userId: string, maxAge: number): Promise<number>;
|
|
56
|
+
/**
|
|
57
|
+
* Get cache statistics
|
|
58
|
+
*/
|
|
59
|
+
getStats(): Promise<{
|
|
60
|
+
size: number;
|
|
61
|
+
}>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Example: Create Redis cache using ioredis
|
|
65
|
+
*
|
|
66
|
+
* ```typescript
|
|
67
|
+
* import Redis from 'ioredis';
|
|
68
|
+
* import { initializeReplayProtection, RedisReplayCache } from '@stvor/sdk';
|
|
69
|
+
*
|
|
70
|
+
* const redis = new Redis(process.env.REDIS_URL!);
|
|
71
|
+
* const cache = new RedisReplayCache({ client: redis });
|
|
72
|
+
* initializeReplayProtection(cache);
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
/**
|
|
76
|
+
* Example: Create Redis cache using node-redis
|
|
77
|
+
*
|
|
78
|
+
* ```typescript
|
|
79
|
+
* import { createClient } from 'redis';
|
|
80
|
+
* import { initializeReplayProtection, RedisReplayCache } from '@stvor/sdk';
|
|
81
|
+
*
|
|
82
|
+
* const redis = createClient({ url: process.env.REDIS_URL });
|
|
83
|
+
* await redis.connect();
|
|
84
|
+
* const cache = new RedisReplayCache({ client: redis });
|
|
85
|
+
* initializeReplayProtection(cache);
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export type { IReplayCache } from './replay-manager.js';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis-based Replay Protection Cache for SDK
|
|
3
|
+
* Production-ready replay protection for clustered client deployments
|
|
4
|
+
*
|
|
5
|
+
* Use case: When running multiple instances of the same app (e.g., web app with
|
|
6
|
+
* multiple tabs, mobile app with background sync, or server-side SDK usage)
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Redis-based replay cache implementation
|
|
10
|
+
* Works with any Redis client (ioris, node-redis, etc.)
|
|
11
|
+
*/
|
|
12
|
+
export class RedisReplayCache {
|
|
13
|
+
constructor(config) {
|
|
14
|
+
this.client = config.client;
|
|
15
|
+
this.keyPrefix = config.keyPrefix || 'stvor:replay:';
|
|
16
|
+
this.ttlSeconds = config.ttlSeconds || 300;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Build Redis key for nonce
|
|
20
|
+
*/
|
|
21
|
+
buildKey(userId, nonce) {
|
|
22
|
+
return `${this.keyPrefix}${userId}:${nonce}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Add nonce to cache
|
|
26
|
+
*/
|
|
27
|
+
async addNonce(userId, nonce, timestamp) {
|
|
28
|
+
const key = this.buildKey(userId, nonce);
|
|
29
|
+
await this.client.setEx(key, this.ttlSeconds, String(timestamp));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Check if nonce exists in cache
|
|
33
|
+
*/
|
|
34
|
+
async hasNonce(userId, nonce) {
|
|
35
|
+
const key = this.buildKey(userId, nonce);
|
|
36
|
+
const result = await this.client.exists(key);
|
|
37
|
+
return result === 1;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Cleanup expired nonces
|
|
41
|
+
* Note: With Redis SETEX, keys auto-expire, so this is a no-op
|
|
42
|
+
*/
|
|
43
|
+
async cleanup(userId, maxAge) {
|
|
44
|
+
// Redis handles TTL automatically via SETEX
|
|
45
|
+
// This is kept for interface compatibility but returns 0
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Get cache statistics
|
|
50
|
+
*/
|
|
51
|
+
async getStats() {
|
|
52
|
+
try {
|
|
53
|
+
const keys = await this.client.keys(`${this.keyPrefix}*`);
|
|
54
|
+
return { size: keys.length };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { size: 0 };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -1,36 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* STVOR DX Facade - Relay Client
|
|
3
3
|
*/
|
|
4
|
-
type
|
|
5
|
-
|
|
4
|
+
import type { SerializedPublicKeys } from './crypto-session';
|
|
5
|
+
interface OutgoingMessage {
|
|
6
|
+
to: string;
|
|
7
|
+
from: string;
|
|
8
|
+
ciphertext: string;
|
|
9
|
+
header: string;
|
|
10
|
+
}
|
|
11
|
+
interface IncomingMessage {
|
|
12
|
+
id?: string;
|
|
13
|
+
from: string;
|
|
14
|
+
ciphertext: string;
|
|
15
|
+
header: string;
|
|
16
|
+
timestamp: string;
|
|
17
|
+
}
|
|
6
18
|
export declare class RelayClient {
|
|
7
19
|
private relayUrl;
|
|
8
20
|
private timeout;
|
|
9
21
|
private appToken;
|
|
10
|
-
private ws?;
|
|
11
22
|
private connected;
|
|
12
|
-
private handshakeComplete;
|
|
13
|
-
private backoff;
|
|
14
|
-
private queue;
|
|
15
|
-
private handlers;
|
|
16
|
-
private reconnecting;
|
|
17
|
-
private connectPromise?;
|
|
18
|
-
private connectResolve?;
|
|
19
|
-
private connectReject?;
|
|
20
|
-
private authFailed;
|
|
21
23
|
constructor(relayUrl: string, appToken: string, timeout?: number);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
* Throws StvorError if API key is rejected.
|
|
25
|
-
*/
|
|
26
|
-
init(): Promise<void>;
|
|
24
|
+
getAppToken(): string;
|
|
25
|
+
getBaseUrl(): string;
|
|
27
26
|
private getAuthHeaders;
|
|
28
|
-
|
|
29
|
-
private scheduleReconnect;
|
|
30
|
-
private doSend;
|
|
31
|
-
send(obj: JSONable): void;
|
|
32
|
-
onMessage(h: RelayHandler): void;
|
|
27
|
+
healthCheck(): Promise<void>;
|
|
33
28
|
isConnected(): boolean;
|
|
34
|
-
|
|
29
|
+
register(userId: string, publicKeys: SerializedPublicKeys): Promise<void>;
|
|
30
|
+
getPublicKeys(userId: string): Promise<SerializedPublicKeys | null>;
|
|
31
|
+
send(message: OutgoingMessage): Promise<void>;
|
|
32
|
+
fetchMessages(userId: string): Promise<IncomingMessage[]>;
|
|
33
|
+
disconnect(): void;
|
|
35
34
|
}
|
|
36
35
|
export {};
|
|
@@ -1,154 +1,133 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* STVOR DX Facade - Relay Client
|
|
3
3
|
*/
|
|
4
|
-
import { Errors
|
|
5
|
-
import * as WS from 'ws';
|
|
4
|
+
import { Errors } from './errors.js';
|
|
6
5
|
export class RelayClient {
|
|
7
6
|
constructor(relayUrl, appToken, timeout = 10000) {
|
|
8
7
|
this.connected = false;
|
|
9
|
-
this.
|
|
10
|
-
this.backoff = 1000;
|
|
11
|
-
this.queue = [];
|
|
12
|
-
this.handlers = [];
|
|
13
|
-
this.reconnecting = false;
|
|
14
|
-
this.authFailed = false;
|
|
15
|
-
this.relayUrl = relayUrl.replace(/^http/, 'ws');
|
|
8
|
+
this.relayUrl = relayUrl;
|
|
16
9
|
this.appToken = appToken;
|
|
17
10
|
this.timeout = timeout;
|
|
18
11
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (this.authFailed) {
|
|
25
|
-
throw new StvorError(Errors.INVALID_API_KEY, 'Relay rejected connection: invalid API key');
|
|
26
|
-
}
|
|
27
|
-
if (this.handshakeComplete)
|
|
28
|
-
return;
|
|
29
|
-
await this.connect();
|
|
12
|
+
getAppToken() {
|
|
13
|
+
return this.appToken;
|
|
14
|
+
}
|
|
15
|
+
getBaseUrl() {
|
|
16
|
+
return this.relayUrl;
|
|
30
17
|
}
|
|
31
18
|
getAuthHeaders() {
|
|
32
19
|
return {
|
|
33
|
-
Authorization: `Bearer ${this.appToken}`,
|
|
20
|
+
'Authorization': `Bearer ${this.appToken}`,
|
|
21
|
+
'Content-Type': 'application/json',
|
|
34
22
|
};
|
|
35
23
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
this.connectReject = reject;
|
|
44
|
-
const WSClass = WS.default ?? WS;
|
|
45
|
-
this.ws = new WSClass(this.relayUrl, { headers: this.getAuthHeaders() });
|
|
46
|
-
// Timeout for handshake
|
|
47
|
-
const handshakeTimeout = setTimeout(() => {
|
|
48
|
-
if (!this.handshakeComplete) {
|
|
49
|
-
this.ws?.close();
|
|
50
|
-
reject(new StvorError(Errors.RELAY_UNAVAILABLE, 'Relay handshake timeout'));
|
|
51
|
-
}
|
|
52
|
-
}, this.timeout);
|
|
53
|
-
this.ws.on('open', () => {
|
|
54
|
-
this.connected = true;
|
|
55
|
-
this.backoff = 1000;
|
|
56
|
-
// Don't flush queue yet - wait for handshake
|
|
24
|
+
async healthCheck() {
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`${this.relayUrl}/health`, {
|
|
29
|
+
method: 'GET',
|
|
30
|
+
signal: controller.signal,
|
|
57
31
|
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
this.handshakeComplete = true;
|
|
66
|
-
// Now flush the queue
|
|
67
|
-
while (this.queue.length) {
|
|
68
|
-
const m = this.queue.shift();
|
|
69
|
-
this.doSend(m);
|
|
70
|
-
}
|
|
71
|
-
this.connectResolve?.();
|
|
72
|
-
}
|
|
73
|
-
else {
|
|
74
|
-
// Handshake rejected
|
|
75
|
-
this.authFailed = true;
|
|
76
|
-
this.ws?.close();
|
|
77
|
-
const err = new StvorError(Errors.INVALID_API_KEY, `Relay rejected connection: ${json.reason || 'invalid API key'}`);
|
|
78
|
-
this.connectReject?.(err);
|
|
79
|
-
}
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
// Regular message
|
|
83
|
-
for (const h of this.handlers)
|
|
84
|
-
h(json);
|
|
85
|
-
}
|
|
86
|
-
catch (e) {
|
|
87
|
-
// ignore parse errors
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
this.ws.on('close', (code) => {
|
|
91
|
-
this.connected = false;
|
|
92
|
-
this.handshakeComplete = false;
|
|
93
|
-
this.ws = undefined;
|
|
94
|
-
this.connectPromise = undefined;
|
|
95
|
-
// If auth failed, don't reconnect
|
|
96
|
-
if (this.authFailed) {
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
// 401/403 close codes mean auth failure
|
|
100
|
-
if (code === 4001 || code === 4003) {
|
|
101
|
-
this.authFailed = true;
|
|
102
|
-
this.connectReject?.(new StvorError(Errors.INVALID_API_KEY, 'Relay rejected connection: invalid API key'));
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
this.scheduleReconnect();
|
|
106
|
-
});
|
|
107
|
-
this.ws.on('error', (err) => {
|
|
108
|
-
this.connected = false;
|
|
109
|
-
this.handshakeComplete = false;
|
|
110
|
-
this.ws = undefined;
|
|
111
|
-
this.connectPromise = undefined;
|
|
112
|
-
if (this.authFailed) {
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
this.scheduleReconnect();
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
return this.connectPromise;
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
throw Errors.relayUnavailable();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
clearTimeout(timeoutId);
|
|
38
|
+
}
|
|
119
39
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return;
|
|
123
|
-
this.reconnecting = true;
|
|
124
|
-
setTimeout(() => {
|
|
125
|
-
this.reconnecting = false;
|
|
126
|
-
this.connect();
|
|
127
|
-
this.backoff = Math.min(this.backoff * 2, 30000);
|
|
128
|
-
}, this.backoff);
|
|
40
|
+
isConnected() {
|
|
41
|
+
return this.connected;
|
|
129
42
|
}
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
43
|
+
async register(userId, publicKeys) {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(`${this.relayUrl}/register`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: this.getAuthHeaders(),
|
|
50
|
+
body: JSON.stringify({ user_id: userId, publicKeys }),
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
const error = await res.json().catch(() => ({}));
|
|
55
|
+
if (error.code === 'AUTH_FAILED') {
|
|
56
|
+
throw Errors.authFailed();
|
|
57
|
+
}
|
|
58
|
+
throw Errors.relayUnavailable();
|
|
59
|
+
}
|
|
60
|
+
this.connected = true;
|
|
134
61
|
}
|
|
135
|
-
|
|
136
|
-
|
|
62
|
+
finally {
|
|
63
|
+
clearTimeout(timeoutId);
|
|
137
64
|
}
|
|
138
65
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
66
|
+
async getPublicKeys(userId) {
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(`${this.relayUrl}/public-key/${userId}`, {
|
|
71
|
+
method: 'GET',
|
|
72
|
+
headers: this.getAuthHeaders(),
|
|
73
|
+
signal: controller.signal,
|
|
74
|
+
});
|
|
75
|
+
if (res.status === 404) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
throw Errors.relayUnavailable();
|
|
80
|
+
}
|
|
81
|
+
const data = await res.json();
|
|
82
|
+
return data.publicKeys;
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
clearTimeout(timeoutId);
|
|
142
86
|
}
|
|
143
|
-
this.doSend(obj);
|
|
144
87
|
}
|
|
145
|
-
|
|
146
|
-
|
|
88
|
+
async send(message) {
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(`${this.relayUrl}/message`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: this.getAuthHeaders(),
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
to: message.to,
|
|
97
|
+
from: message.from,
|
|
98
|
+
ciphertext: message.ciphertext,
|
|
99
|
+
header: message.header,
|
|
100
|
+
}),
|
|
101
|
+
signal: controller.signal,
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
throw Errors.deliveryFailed(message.to);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
clearTimeout(timeoutId);
|
|
109
|
+
}
|
|
147
110
|
}
|
|
148
|
-
|
|
149
|
-
|
|
111
|
+
async fetchMessages(userId) {
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch(`${this.relayUrl}/messages/${userId}`, {
|
|
116
|
+
method: 'GET',
|
|
117
|
+
headers: this.getAuthHeaders(),
|
|
118
|
+
signal: controller.signal,
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
throw Errors.relayUnavailable();
|
|
122
|
+
}
|
|
123
|
+
const data = await res.json();
|
|
124
|
+
return data.messages || [];
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
clearTimeout(timeoutId);
|
|
128
|
+
}
|
|
150
129
|
}
|
|
151
|
-
|
|
152
|
-
|
|
130
|
+
disconnect() {
|
|
131
|
+
this.connected = false;
|
|
153
132
|
}
|
|
154
133
|
}
|