@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/index.cjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Auto-generated CommonJS wrapper for index.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
|
+
});
|
package/dist/index.d.cts
ADDED
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/legacy.cjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Auto-generated CommonJS wrapper for legacy.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
|
+
});
|
package/dist/legacy.d.ts
CHANGED
|
@@ -1 +1,31 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* STVOR SDK - Legacy Core API
|
|
3
|
+
* Kept for backwards compatibility
|
|
4
|
+
*/
|
|
5
|
+
export interface StvorConfig {
|
|
6
|
+
apiKey: string;
|
|
7
|
+
serverUrl?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface Peer {
|
|
10
|
+
id: string;
|
|
11
|
+
publicKey: any;
|
|
12
|
+
}
|
|
13
|
+
export interface EncryptedMessage {
|
|
14
|
+
ciphertext: string;
|
|
15
|
+
nonce: string;
|
|
16
|
+
from: string;
|
|
17
|
+
}
|
|
18
|
+
export declare class StvorClient {
|
|
19
|
+
private config;
|
|
20
|
+
private myKeyPair;
|
|
21
|
+
private myId;
|
|
22
|
+
private peers;
|
|
23
|
+
constructor(config: StvorConfig);
|
|
24
|
+
ready(): Promise<void>;
|
|
25
|
+
createPeer(name: string): Promise<Peer>;
|
|
26
|
+
send({ to, message }: {
|
|
27
|
+
to: string;
|
|
28
|
+
message: string;
|
|
29
|
+
}): Promise<EncryptedMessage>;
|
|
30
|
+
receive(encrypted: EncryptedMessage): Promise<string>;
|
|
31
|
+
}
|
package/dist/legacy.js
CHANGED
|
@@ -1,2 +1,90 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* STVOR SDK - Legacy Core API
|
|
3
|
+
* Kept for backwards compatibility
|
|
4
|
+
*/
|
|
5
|
+
export class StvorClient {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.myKeyPair = null;
|
|
8
|
+
this.myId = '';
|
|
9
|
+
this.peers = new Map();
|
|
10
|
+
this.config = {
|
|
11
|
+
serverUrl: 'http://localhost:3001',
|
|
12
|
+
...config,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
async ready() {
|
|
16
|
+
if (!this.config.apiKey) {
|
|
17
|
+
throw new Error('API key is required');
|
|
18
|
+
}
|
|
19
|
+
this.myKeyPair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits']);
|
|
20
|
+
this.myId = this.config.apiKey.substring(0, 8);
|
|
21
|
+
}
|
|
22
|
+
async createPeer(name) {
|
|
23
|
+
if (!this.myKeyPair)
|
|
24
|
+
throw new Error('Call ready() first');
|
|
25
|
+
const publicKey = await crypto.subtle.exportKey('jwk', this.myKeyPair.publicKey);
|
|
26
|
+
const res = await fetch(`${this.config.serverUrl}/register`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({ user_id: name, publicKey }),
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const err = await res.json();
|
|
33
|
+
throw new Error(`Registration failed: ${JSON.stringify(err)}`);
|
|
34
|
+
}
|
|
35
|
+
return { id: name, publicKey };
|
|
36
|
+
}
|
|
37
|
+
async send({ to, message }) {
|
|
38
|
+
if (!this.myKeyPair)
|
|
39
|
+
throw new Error('Call ready() first');
|
|
40
|
+
let recipientKey = this.peers.get(to);
|
|
41
|
+
if (!recipientKey) {
|
|
42
|
+
const res = await fetch(`${this.config.serverUrl}/public-key/${to}`);
|
|
43
|
+
if (!res.ok)
|
|
44
|
+
throw new Error(`Peer ${to} not found`);
|
|
45
|
+
const { publicKey } = await res.json();
|
|
46
|
+
recipientKey = await crypto.subtle.importKey('jwk', publicKey, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
|
|
47
|
+
this.peers.set(to, recipientKey);
|
|
48
|
+
}
|
|
49
|
+
const sharedKey = await crypto.subtle.deriveKey({ name: 'ECDH', public: recipientKey }, this.myKeyPair.privateKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
50
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
51
|
+
const encoder = new TextEncoder();
|
|
52
|
+
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, sharedKey, encoder.encode(message));
|
|
53
|
+
const sendRes = await fetch(`${this.config.serverUrl}/message`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
from: this.myId,
|
|
58
|
+
to,
|
|
59
|
+
ciphertext: Buffer.from(encrypted).toString('base64'),
|
|
60
|
+
nonce: Buffer.from(iv).toString('base64'),
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
if (!sendRes.ok) {
|
|
64
|
+
throw new Error('Failed to send message');
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
ciphertext: Buffer.from(encrypted).toString('base64'),
|
|
68
|
+
nonce: Buffer.from(iv).toString('base64'),
|
|
69
|
+
from: this.myId
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
async receive(encrypted) {
|
|
73
|
+
if (!this.myKeyPair)
|
|
74
|
+
throw new Error('Call ready() first');
|
|
75
|
+
let senderKey = this.peers.get(encrypted.from);
|
|
76
|
+
if (!senderKey) {
|
|
77
|
+
const res = await fetch(`${this.config.serverUrl}/public-key/${encrypted.from}`);
|
|
78
|
+
if (!res.ok)
|
|
79
|
+
throw new Error(`Sender ${encrypted.from} not found`);
|
|
80
|
+
const { publicKey } = await res.json();
|
|
81
|
+
senderKey = await crypto.subtle.importKey('jwk', publicKey, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
|
|
82
|
+
this.peers.set(encrypted.from, senderKey);
|
|
83
|
+
}
|
|
84
|
+
const sharedKey = await crypto.subtle.deriveKey({ name: 'ECDH', public: senderKey }, this.myKeyPair.privateKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
85
|
+
const iv = Buffer.from(encrypted.nonce, 'base64');
|
|
86
|
+
const ciphertext = Buffer.from(encrypted.ciphertext, 'base64');
|
|
87
|
+
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, sharedKey, ciphertext);
|
|
88
|
+
return new TextDecoder().decode(decrypted);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Auto-generated CommonJS wrapper for ratchet/core-production.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,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STVOR SDK v2.4.0 - Production-Ready Core Ratchet
|
|
3
|
+
* Key changes from v2:
|
|
4
|
+
* - Header AAD authentication
|
|
5
|
+
* - Immutable state transitions
|
|
6
|
+
* - Functional design (pure functions)
|
|
7
|
+
* - Explicit error codes
|
|
8
|
+
*/
|
|
9
|
+
export interface EncryptedMessage {
|
|
10
|
+
ciphertext: Uint8Array;
|
|
11
|
+
header: {
|
|
12
|
+
publicKey: Uint8Array;
|
|
13
|
+
nonce: Uint8Array;
|
|
14
|
+
sendCounter: number;
|
|
15
|
+
receiveCounter: number;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface SessionState {
|
|
20
|
+
peerId: string;
|
|
21
|
+
peerIdentityKey: Uint8Array;
|
|
22
|
+
rootKey: Uint8Array;
|
|
23
|
+
sendingChainKey: Uint8Array;
|
|
24
|
+
receivingChainKey: Uint8Array;
|
|
25
|
+
sendCounter: number;
|
|
26
|
+
receiveCounter: number;
|
|
27
|
+
skippedMessageKeys: Map<string, {
|
|
28
|
+
key: Uint8Array;
|
|
29
|
+
timestamp: number;
|
|
30
|
+
counter: number;
|
|
31
|
+
}>;
|
|
32
|
+
state: SessionFSMState;
|
|
33
|
+
lastRatchetTime: number;
|
|
34
|
+
lastRatchetCounter: number;
|
|
35
|
+
createdAt: number;
|
|
36
|
+
metadata: Record<string, any>;
|
|
37
|
+
}
|
|
38
|
+
export type SessionFSMState = 'INIT' | 'ESTABLISHED' | 'RATCHETING' | 'COMPROMISED';
|
|
39
|
+
export declare const ErrorCode: {
|
|
40
|
+
readonly DECRYPT_FAILED: "DECRYPT_FAILED";
|
|
41
|
+
readonly AUTH_FAILED: "AUTH_FAILED";
|
|
42
|
+
readonly INVALID_KEY_FORMAT: "INVALID_KEY_FORMAT";
|
|
43
|
+
readonly SPK_SIGNATURE_INVALID: "SPK_SIGNATURE_INVALID";
|
|
44
|
+
readonly REPLAY_DETECTED: "REPLAY_DETECTED";
|
|
45
|
+
readonly TOFU_MISMATCH: "TOFU_MISMATCH";
|
|
46
|
+
readonly INVALID_STATE_TRANSITION: "INVALID_STATE_TRANSITION";
|
|
47
|
+
readonly SESSION_COMPROMISED: "SESSION_COMPROMISED";
|
|
48
|
+
readonly STORAGE_UNAVAILABLE: "STORAGE_UNAVAILABLE";
|
|
49
|
+
readonly STORAGE_WRITE_FAILED: "STORAGE_WRITE_FAILED";
|
|
50
|
+
readonly SKIPPED_KEYS_LIMIT_EXCEEDED: "SKIPPED_KEYS_LIMIT_EXCEEDED";
|
|
51
|
+
readonly REPLAY_WINDOW_EXPIRED: "REPLAY_WINDOW_EXPIRED";
|
|
52
|
+
};
|
|
53
|
+
export declare class StvorSDKError extends Error {
|
|
54
|
+
readonly code: keyof typeof ErrorCode;
|
|
55
|
+
readonly metadata?: Record<string, any>;
|
|
56
|
+
constructor(code: keyof typeof ErrorCode, message: string, metadata?: Record<string, any>);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Decrypt message with full validation
|
|
60
|
+
* ATOMICALLY: validate ALL, then update session
|
|
61
|
+
*/
|
|
62
|
+
export declare function decryptMessageWithValidation(ciphertext: Uint8Array, header: EncryptedMessage['header'], session: SessionState, validators: {
|
|
63
|
+
replayCache: IReplayCache;
|
|
64
|
+
tofuStore?: ITofuStore;
|
|
65
|
+
}): Promise<{
|
|
66
|
+
plaintext: string;
|
|
67
|
+
updatedSession: SessionState;
|
|
68
|
+
}>;
|
|
69
|
+
/**
|
|
70
|
+
* Encrypt message with policy enforcement
|
|
71
|
+
*/
|
|
72
|
+
export declare function encryptMessageWithPolicy(plaintext: string, session: SessionState): {
|
|
73
|
+
message: EncryptedMessage;
|
|
74
|
+
updatedSession: SessionState;
|
|
75
|
+
};
|
|
76
|
+
export interface IReplayCache {
|
|
77
|
+
/**
|
|
78
|
+
* Check if nonce already seen
|
|
79
|
+
* Returns true if REPLAY detected
|
|
80
|
+
* MUST be atomic
|
|
81
|
+
*/
|
|
82
|
+
checkAndMark(peerId: string, nonceHex: string, timestamp: number): Promise<boolean>;
|
|
83
|
+
}
|
|
84
|
+
export interface ITofuStore {
|
|
85
|
+
storeFingerprint(peerId: string, fingerprint: string): Promise<void>;
|
|
86
|
+
getFingerprint(peerId: string): Promise<string | null>;
|
|
87
|
+
}
|
|
88
|
+
export interface ISessionStore {
|
|
89
|
+
saveSession(peerId: string, session: SessionState): Promise<void>;
|
|
90
|
+
loadSession(peerId: string): Promise<SessionState | null>;
|
|
91
|
+
}
|
|
92
|
+
export interface IIdentityStore {
|
|
93
|
+
saveIdentityKeys(userId: string, keys: any): Promise<void>;
|
|
94
|
+
loadIdentityKeys(userId: string): Promise<any | null>;
|
|
95
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STVOR SDK v2.4.0 - Production-Ready Core Ratchet
|
|
3
|
+
* Key changes from v2:
|
|
4
|
+
* - Header AAD authentication
|
|
5
|
+
* - Immutable state transitions
|
|
6
|
+
* - Functional design (pure functions)
|
|
7
|
+
* - Explicit error codes
|
|
8
|
+
*/
|
|
9
|
+
import sodium from 'libsodium-wrappers';
|
|
10
|
+
// Constants for invariants
|
|
11
|
+
const DH_RATCHET_POLICY = {
|
|
12
|
+
maxMessages: 50,
|
|
13
|
+
maxTimeMs: 10 * 60 * 1000, // 10 minutes
|
|
14
|
+
};
|
|
15
|
+
const MAX_SKIPPED_KEYS_PER_SESSION = 50;
|
|
16
|
+
const MAX_TOTAL_SKIPPED_KEYS = 500;
|
|
17
|
+
const SKIPPED_KEY_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// PART 2: ERROR CODES (EXPLICIT)
|
|
20
|
+
// ============================================================================
|
|
21
|
+
export const ErrorCode = {
|
|
22
|
+
// Crypto errors
|
|
23
|
+
DECRYPT_FAILED: 'DECRYPT_FAILED',
|
|
24
|
+
AUTH_FAILED: 'AUTH_FAILED', // AAD verification failed
|
|
25
|
+
INVALID_KEY_FORMAT: 'INVALID_KEY_FORMAT',
|
|
26
|
+
SPK_SIGNATURE_INVALID: 'SPK_SIGNATURE_INVALID',
|
|
27
|
+
// Replay / TOFU
|
|
28
|
+
REPLAY_DETECTED: 'REPLAY_DETECTED',
|
|
29
|
+
TOFU_MISMATCH: 'TOFU_MISMATCH',
|
|
30
|
+
// State machine
|
|
31
|
+
INVALID_STATE_TRANSITION: 'INVALID_STATE_TRANSITION',
|
|
32
|
+
SESSION_COMPROMISED: 'SESSION_COMPROMISED',
|
|
33
|
+
// Storage
|
|
34
|
+
STORAGE_UNAVAILABLE: 'STORAGE_UNAVAILABLE',
|
|
35
|
+
STORAGE_WRITE_FAILED: 'STORAGE_WRITE_FAILED',
|
|
36
|
+
// DoS protection
|
|
37
|
+
SKIPPED_KEYS_LIMIT_EXCEEDED: 'SKIPPED_KEYS_LIMIT_EXCEEDED',
|
|
38
|
+
REPLAY_WINDOW_EXPIRED: 'REPLAY_WINDOW_EXPIRED',
|
|
39
|
+
};
|
|
40
|
+
export class StvorSDKError extends Error {
|
|
41
|
+
constructor(code, message, metadata) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.metadata = metadata;
|
|
45
|
+
this.name = 'StvorSDKError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// PART 3: IMMUTABLE STATE MANAGEMENT
|
|
50
|
+
// ============================================================================
|
|
51
|
+
/**
|
|
52
|
+
* Pure function: compute new session state WITHOUT mutating input
|
|
53
|
+
* Returns new state object on success, throws on error
|
|
54
|
+
*
|
|
55
|
+
* CRITICAL: This function has NO SIDE EFFECTS
|
|
56
|
+
* It only computes, doesn't update globals or storage
|
|
57
|
+
*/
|
|
58
|
+
function tryDecryptMessage(ciphertext, header, session) {
|
|
59
|
+
// Attempt to use skipped key first
|
|
60
|
+
const skippedKeyEntry = findAndValidateSkippedKey(session, header);
|
|
61
|
+
if (skippedKeyEntry) {
|
|
62
|
+
const plaintext = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertext, constructAAD(header), header.nonce, skippedKeyEntry.key);
|
|
63
|
+
// New state: remove used skipped key, increment receive counter
|
|
64
|
+
const newSession = structuredClone(session);
|
|
65
|
+
const skippedKeyId = generateSkippedKeyId(header);
|
|
66
|
+
newSession.skippedMessageKeys.delete(skippedKeyId);
|
|
67
|
+
newSession.receiveCounter = header.receiveCounter + 1;
|
|
68
|
+
return newSession;
|
|
69
|
+
}
|
|
70
|
+
// Standard ratchet decryption
|
|
71
|
+
const sharedSecret = sodium.crypto_kx_client_session_keys(session.peerIdentityKey, session.sendingChainKey, // Recipient's SPK
|
|
72
|
+
header.publicKey);
|
|
73
|
+
// Compute new root key
|
|
74
|
+
const newRootKey = deriveRootKeyFromDH(session.rootKey, sharedSecret.sharedTx, 'receive');
|
|
75
|
+
// Derive new chain key
|
|
76
|
+
const newReceivingChainKey = deriveChainKey(newRootKey, 'receiving');
|
|
77
|
+
// Derive message key
|
|
78
|
+
const messageKey = deriveMessageKey(newReceivingChainKey);
|
|
79
|
+
// Decrypt with AAD verification
|
|
80
|
+
const plaintext = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertext, constructAAD(header), // ← NEW: authenticate header
|
|
81
|
+
header.nonce, messageKey);
|
|
82
|
+
// Create new state (not mutating input)
|
|
83
|
+
const newSession = structuredClone(session);
|
|
84
|
+
newSession.rootKey = newRootKey;
|
|
85
|
+
newSession.receivingChainKey = newReceivingChainKey;
|
|
86
|
+
newSession.receiveCounter = header.receiveCounter + 1;
|
|
87
|
+
return newSession;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Pure function: compute new session state for encryption
|
|
91
|
+
* Returns { ciphertext, header, newSession }
|
|
92
|
+
*/
|
|
93
|
+
function tryEncryptMessage(plaintext, session) {
|
|
94
|
+
// Check if ratchet needed (pure predicate)
|
|
95
|
+
const shouldRatchet = checkDHRatchetPolicy(session);
|
|
96
|
+
let currentSession = structuredClone(session);
|
|
97
|
+
// Apply ratchet if needed
|
|
98
|
+
if (shouldRatchet) {
|
|
99
|
+
currentSession = performDHRatchet(currentSession);
|
|
100
|
+
}
|
|
101
|
+
// Encrypt
|
|
102
|
+
const ratchetKeyPair = sodium.crypto_kx_keypair();
|
|
103
|
+
const sharedSecret = sodium.crypto_kx_client_session_keys(ratchetKeyPair.publicKey, ratchetKeyPair.privateKey, currentSession.peerIdentityKey);
|
|
104
|
+
// Derive new root key
|
|
105
|
+
const newRootKey = deriveRootKeyFromDH(currentSession.rootKey, sharedSecret.sharedTx, 'send');
|
|
106
|
+
// Derive chain key
|
|
107
|
+
const newSendingChainKey = deriveChainKey(newRootKey, 'sending');
|
|
108
|
+
const messageKey = deriveMessageKey(newSendingChainKey);
|
|
109
|
+
// Build header
|
|
110
|
+
const nonce = sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
111
|
+
const header = {
|
|
112
|
+
publicKey: ratchetKeyPair.publicKey,
|
|
113
|
+
nonce,
|
|
114
|
+
sendCounter: currentSession.sendCounter,
|
|
115
|
+
receiveCounter: currentSession.receiveCounter,
|
|
116
|
+
timestamp: Date.now(),
|
|
117
|
+
};
|
|
118
|
+
// Encrypt with AAD
|
|
119
|
+
const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(sodium.from_string(plaintext), constructAAD(header), // ← NEW: authenticate header
|
|
120
|
+
null, nonce, messageKey);
|
|
121
|
+
// New state
|
|
122
|
+
const newSession = structuredClone(currentSession);
|
|
123
|
+
newSession.rootKey = newRootKey;
|
|
124
|
+
newSession.sendingChainKey = newSendingChainKey;
|
|
125
|
+
newSession.sendCounter++;
|
|
126
|
+
newSession.lastRatchetCounter = shouldRatchet
|
|
127
|
+
? currentSession.sendCounter
|
|
128
|
+
: currentSession.lastRatchetCounter;
|
|
129
|
+
return { ciphertext, header, newSession };
|
|
130
|
+
}
|
|
131
|
+
// ============================================================================
|
|
132
|
+
// PART 4: STATE MACHINE ENFORCEMENT
|
|
133
|
+
// ============================================================================
|
|
134
|
+
/**
|
|
135
|
+
* Validate state transition
|
|
136
|
+
* Prevents invalid transitions and enforces FSM invariants
|
|
137
|
+
*/
|
|
138
|
+
function validateStateTransition(fromState, toState) {
|
|
139
|
+
const validTransitions = {
|
|
140
|
+
'INIT': ['ESTABLISHED'],
|
|
141
|
+
'ESTABLISHED': ['RATCHETING', 'COMPROMISED'],
|
|
142
|
+
'RATCHETING': ['ESTABLISHED', 'COMPROMISED'],
|
|
143
|
+
'COMPROMISED': [], // Terminal state
|
|
144
|
+
};
|
|
145
|
+
if (!validTransitions[fromState]?.includes(toState)) {
|
|
146
|
+
throw new StvorSDKError('INVALID_STATE_TRANSITION', `Cannot transition from ${fromState} to ${toState}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function checkDHRatchetPolicy(session) {
|
|
150
|
+
const elapsed = Date.now() - session.lastRatchetTime;
|
|
151
|
+
const messagesSinceRatchet = session.sendCounter - session.lastRatchetCounter;
|
|
152
|
+
return (messagesSinceRatchet >= DH_RATCHET_POLICY.maxMessages ||
|
|
153
|
+
elapsed >= DH_RATCHET_POLICY.maxTimeMs);
|
|
154
|
+
}
|
|
155
|
+
function performDHRatchet(session) {
|
|
156
|
+
// Generate new ephemeral
|
|
157
|
+
const ephemeralKeyPair = sodium.crypto_kx_keypair();
|
|
158
|
+
// Perform DH
|
|
159
|
+
const dhOutput = sodium.crypto_kx_client_session_keys(ephemeralKeyPair.publicKey, ephemeralKeyPair.privateKey, session.peerIdentityKey);
|
|
160
|
+
// Derive new root key
|
|
161
|
+
const newRootKey = deriveRootKeyFromDH(session.rootKey, dhOutput.sharedTx, 'ratchet');
|
|
162
|
+
// New state
|
|
163
|
+
const newSession = structuredClone(session);
|
|
164
|
+
newSession.rootKey = newRootKey;
|
|
165
|
+
newSession.sendingChainKey = deriveChainKey(newRootKey, 'sending');
|
|
166
|
+
newSession.receivingChainKey = deriveChainKey(newRootKey, 'receiving');
|
|
167
|
+
newSession.skippedMessageKeys.clear(); // Clear old keys
|
|
168
|
+
newSession.lastRatchetTime = Date.now();
|
|
169
|
+
newSession.state = 'RATCHETING';
|
|
170
|
+
return newSession;
|
|
171
|
+
}
|
|
172
|
+
// ============================================================================
|
|
173
|
+
// PART 5: KEY DERIVATION (PURE)
|
|
174
|
+
// ============================================================================
|
|
175
|
+
function deriveRootKeyFromDH(oldRootKey, dhOutput, context) {
|
|
176
|
+
const info = sodium.from_string(`stvor:dh:${context}`);
|
|
177
|
+
return sodium.crypto_generichash(32, sodium.from_string('HKDF-Extract'), new Uint8Array([...oldRootKey, ...dhOutput, ...info]));
|
|
178
|
+
}
|
|
179
|
+
function deriveChainKey(rootKey, direction) {
|
|
180
|
+
const info = sodium.from_string(`stvor:chain:${direction}`);
|
|
181
|
+
return sodium.crypto_generichash(32, rootKey, info);
|
|
182
|
+
}
|
|
183
|
+
function deriveMessageKey(chainKey) {
|
|
184
|
+
const info = sodium.from_string('stvor:message-key');
|
|
185
|
+
return sodium.crypto_generichash(32, chainKey, info);
|
|
186
|
+
}
|
|
187
|
+
// ============================================================================
|
|
188
|
+
// PART 6: AAD CONSTRUCTION (AUTHENTICATED ADDITIONAL DATA)
|
|
189
|
+
// ============================================================================
|
|
190
|
+
/**
|
|
191
|
+
* Construct AAD from message header
|
|
192
|
+
* This ensures header cannot be tampered without detection
|
|
193
|
+
*/
|
|
194
|
+
function constructAAD(header) {
|
|
195
|
+
return sodium.crypto_generichash(32, sodium.from_string('AAD'), new Uint8Array([
|
|
196
|
+
...header.publicKey,
|
|
197
|
+
...header.nonce,
|
|
198
|
+
...(new Uint32Array([header.sendCounter])),
|
|
199
|
+
...(new Uint32Array([header.receiveCounter])),
|
|
200
|
+
...(new Uint32Array([header.timestamp])),
|
|
201
|
+
]));
|
|
202
|
+
}
|
|
203
|
+
// ============================================================================
|
|
204
|
+
// PART 7: SKIPPED MESSAGE KEYS
|
|
205
|
+
// ============================================================================
|
|
206
|
+
function generateSkippedKeyId(header) {
|
|
207
|
+
return sodium.to_hex(sodium.crypto_generichash(32, header.nonce));
|
|
208
|
+
}
|
|
209
|
+
function findAndValidateSkippedKey(session, header) {
|
|
210
|
+
const keyId = generateSkippedKeyId(header);
|
|
211
|
+
const entry = session.skippedMessageKeys.get(keyId);
|
|
212
|
+
if (!entry) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
// Check TTL
|
|
216
|
+
if (Date.now() - entry.timestamp > SKIPPED_KEY_TTL_MS) {
|
|
217
|
+
return null; // Expired
|
|
218
|
+
}
|
|
219
|
+
return entry;
|
|
220
|
+
}
|
|
221
|
+
function addSkippedKey(session, nonce, key) {
|
|
222
|
+
// Check limits
|
|
223
|
+
if (session.skippedMessageKeys.size >= MAX_SKIPPED_KEYS_PER_SESSION) {
|
|
224
|
+
throw new StvorSDKError('SKIPPED_KEYS_LIMIT_EXCEEDED', `Per-session skipped keys limit (${MAX_SKIPPED_KEYS_PER_SESSION}) exceeded`);
|
|
225
|
+
}
|
|
226
|
+
const keyId = sodium.to_hex(sodium.crypto_generichash(32, nonce));
|
|
227
|
+
session.skippedMessageKeys.set(keyId, {
|
|
228
|
+
key,
|
|
229
|
+
timestamp: Date.now(),
|
|
230
|
+
counter: session.receiveCounter,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// ============================================================================
|
|
234
|
+
// PART 8: PUBLIC API (FACADE)
|
|
235
|
+
// ============================================================================
|
|
236
|
+
/**
|
|
237
|
+
* Decrypt message with full validation
|
|
238
|
+
* ATOMICALLY: validate ALL, then update session
|
|
239
|
+
*/
|
|
240
|
+
export async function decryptMessageWithValidation(ciphertext, header, session, validators) {
|
|
241
|
+
// PHASE 1: Validation (no state changes)
|
|
242
|
+
// Check state machine
|
|
243
|
+
if (session.state === 'COMPROMISED') {
|
|
244
|
+
throw new StvorSDKError('SESSION_COMPROMISED', 'Session is compromised, recovery required');
|
|
245
|
+
}
|
|
246
|
+
// Check replay
|
|
247
|
+
const isReplay = await validators.replayCache.checkAndMark(session.peerId, sodium.to_hex(header.nonce), header.timestamp);
|
|
248
|
+
if (isReplay) {
|
|
249
|
+
throw new StvorSDKError('REPLAY_DETECTED', 'Message is a replay', { nonce: sodium.to_hex(header.nonce) });
|
|
250
|
+
}
|
|
251
|
+
// Compute new state (pure, no mutations)
|
|
252
|
+
let newSession;
|
|
253
|
+
try {
|
|
254
|
+
newSession = tryDecryptMessage(ciphertext, header, session);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
if (error.code === 'EBADMSG') {
|
|
258
|
+
throw new StvorSDKError('AUTH_FAILED', 'AAD authentication failed');
|
|
259
|
+
}
|
|
260
|
+
throw new StvorSDKError('DECRYPT_FAILED', error.message);
|
|
261
|
+
}
|
|
262
|
+
// PHASE 2: Commit (all validations passed)
|
|
263
|
+
// Update session state atomically
|
|
264
|
+
Object.assign(session, newSession);
|
|
265
|
+
return {
|
|
266
|
+
plaintext: newSession.toString(), // Placeholder
|
|
267
|
+
updatedSession: session,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Encrypt message with policy enforcement
|
|
272
|
+
*/
|
|
273
|
+
export function encryptMessageWithPolicy(plaintext, session) {
|
|
274
|
+
// Check state
|
|
275
|
+
if (session.state === 'COMPROMISED') {
|
|
276
|
+
throw new StvorSDKError('SESSION_COMPROMISED', 'Cannot encrypt: session is compromised');
|
|
277
|
+
}
|
|
278
|
+
// Compute new state (pure)
|
|
279
|
+
const { ciphertext, header, newSession } = tryEncryptMessage(plaintext, session);
|
|
280
|
+
// Update session (atomic)
|
|
281
|
+
Object.assign(session, newSession);
|
|
282
|
+
return {
|
|
283
|
+
message: { ciphertext, header },
|
|
284
|
+
updatedSession: session,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Auto-generated CommonJS wrapper for ratchet/index.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
|
+
});
|