STVOR SDK
Add production-grade E2EE to any app in minutes. Signal Protocol (X3DH + Double Ratchet + one-time prekeys) with optional ML-KEM-768 post-quantum protection — behind a three-line API. No cryptography expertise required.
node:crypto module. Browser SDK uses Web Crypto API. No WASM, no libsodium, no npm bloat.
| Feature | Status |
|---|---|
| X3DH + Double Ratchet | Production |
| One-time prekeys (OPK) | Production |
| Group chats (Sender Keys) | Production |
| ML-KEM-768 (NIST FIPS 203) | Production |
| Sealed sender | Production |
| GDPR compliance | Production |
| Browser (Web Crypto API) | Production |
| Forward Secrecy | Production |
| Replay protection | Production |
| Multi-device sync | Not ready |
| Multi-instance (distributed) | Not ready |
Installation
npm install @stvor/sdk
Requirements: Node.js ≥ 18 or any modern browser. No other dependencies.
Quick Start
Two users, one encrypted message. Uses the hosted relay — no setup needed.
import { Stvor } from '@stvor/sdk';
const alice = await Stvor.connect({
userId: 'alice@example.com',
appToken: 'stvor_dev_test123', // any stvor_* token
relayUrl: 'https://relay.stvor.xyz', // hosted relay, no setup needed
});
const bob = await Stvor.connect({
userId: 'bob@example.com',
appToken: 'stvor_dev_test123',
relayUrl: 'https://relay.stvor.xyz',
});
bob.onMessage((msg) => {
console.log(msg.from, msg.data);
// "alice@example.com" "Hello!"
});
await alice.send('bob@example.com', 'Hello!');
await alice.disconnect();
await bob.disconnect();
https://relay.stvor.xyz is live and accepts any stvor_* token. No account or setup required.
process.env.STVOR_APP_TOKEN in production.
Relay Options
Hosted relay (recommended)
Ready to use. No account, no setup. Accepts any stvor_* token.
relayUrl: 'https://relay.stvor.xyz'
Local relay (development)
Built into the SDK — no internet needed.
npx @stvor/sdk mock-relay # port 4444
PORT=9000 npx @stvor/sdk mock-relay
relayUrl: 'http://localhost:4444'
Self-hosted relay
git clone https://github.com/sapogeth/sdk-relay
cd sdk-relay
node server.js
Module Setup
ESM (recommended)
import { Stvor } from '@stvor/sdk';
CommonJS
const { Stvor } = require('@stvor/sdk');
TypeScript config
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}
Stvor.connect()
Creates identity keys, registers with the relay, and starts polling — all in one call.
const client = await Stvor.connect(config: StvorConfig): Promise<StvorClient>
| Parameter | Type | Required | Description |
|---|---|---|---|
| userId | string | Yes | User identifier — email, UUID, username |
| appToken | string | Yes | Token starting with stvor_ |
| relayUrl | string | Yes | Relay server URL |
| timeout | number | No | Request timeout ms (default: 10000) |
| pollIntervalMs | number | No | Message polling interval ms (default: 1000) |
const alice = await Stvor.connect({
userId: 'alice@example.com',
appToken: process.env.STVOR_APP_TOKEN,
relayUrl: 'https://relay.stvor.xyz',
timeout: 15_000,
pollIntervalMs: 500,
});
Errors: INVALID_APP_TOKEN if token doesn't start with stvor_. RELAY_UNAVAILABLE if relay is unreachable.
client.send()
Encrypts and sends data to a recipient. Automatically waits for the recipient to register (configurable).
await client.send(recipientId, data, options?)
| Parameter | Type | Description |
|---|---|---|
| recipientId | string | Recipient's userId |
| data | any | Any JS value — see Data Types |
| options.waitForRecipient | boolean | Auto-wait for recipient (default: true) |
| options.timeout | number | Max wait ms (default: 10000) |
// Text
await alice.send('bob', 'Hello!');
// Object
await alice.send('bob', { text: 'Hi', ts: new Date() });
// Binary
await alice.send('bob', new Uint8Array([1, 2, 3]));
// Fail immediately if recipient not registered
await alice.send('bob', 'Hey', { waitForRecipient: false });
client.onMessage()
Subscribes to incoming messages. Returns an unsubscribe function.
interface StvorMessage {
from: string; // Sender's userId
data: unknown; // Original type preserved
timestamp: Date;
id: string;
}
const unsubscribe = client.onMessage((msg: StvorMessage) => {
console.log(msg.from, msg.data);
});
// Stop receiving
unsubscribe();
client.disconnect()
Stops polling and closes the connection. Call on logout or app shutdown.
await alice.disconnect();
client.waitForUser()
Waits for a user to register. Returns true if available, false on timeout. Not needed before send() — it waits automatically.
const online = await alice.waitForUser('bob', 30_000);
if (online) {
await alice.send('bob', 'Hey!');
}
createGroup(groupId, memberIds)
Creates an E2EE group using Sender Keys (same scheme as Signal). Automatically distributes sender keys to each member via their existing 1-to-1 session.
await alice.createGroup('team-chat', ['bob', 'charlie']);
sendToGroup(groupId, data)
Sends any JavaScript value to the group. One encryption — all members receive and can decrypt.
await alice.sendToGroup('team-chat', { text: 'Hello team!' });
await alice.sendToGroup('team-chat', new Uint8Array([1, 2, 3]));
onGroupMessage(handler)
Subscribe to incoming group messages. Returns an unsubscribe function.
interface StvorGroupMessage {
groupId: string; // Which group
from: string; // Sender's userId
data: unknown; // Decrypted value (original type preserved)
timestamp: Date;
id: string;
}
const unsubscribe = bob.onGroupMessage((msg: StvorGroupMessage) => {
console.log(msg.groupId, msg.from, msg.data);
});
unsubscribe(); // stop listening
addGroupMember() / removeGroupMember()
Manage group membership. On removal, the sender key is automatically ratcheted — the removed member cannot decrypt any future messages.
// Add member — sends them the current sender key
await alice.addGroupMember('team-chat', 'dave');
// Remove member — auto-ratchets sender key, dave cannot read future messages
await alice.removeGroupMember('team-chat', 'charlie');
Post-Quantum Cryptography (ML-KEM-768)
When pqc: true, key exchange uses a hybrid scheme combining classical X3DH with ML-KEM-768 (NIST FIPS 203). Secure if either classical or post-quantum is unbroken.
const alice = await Stvor.connect({
userId: 'alice',
appToken: 'stvor_live_xxx',
relayUrl: 'https://relay.stvor.xyz',
pqc: true, // ← enables ML-KEM-768
});
How it works
When both peers have pqc: true, session establishment automatically uses a hybrid key derivation:
- Classical X3DH runs as normal — derives
classical_ss - ML-KEM-768 contribution is derived deterministically from both peers' ML-KEM public keys
- Hybrid root key =
HKDF(classical_ss ‖ pqc_contribution) - All subsequent Double Ratchet messages use this hybrid root key
Key sizes
| Key | Size | Notes |
|---|---|---|
| ML-KEM encapsulation key (public) | 1184 bytes | Published to relay alongside classical keys |
| ML-KEM decapsulation key (private) | 2400 bytes | Never leaves the device |
| Hybrid session key | 32 bytes | HKDF output, same as classical |
Fallback
If one peer has pqc: true and the other doesn't, the session falls back gracefully to classical X3DH only — no error, no incompatibility.
Sealed Sender
By default the relay sees { to: "bob", from: "alice", ciphertext: "..." }. With sealed sender enabled, the relay sees only { to: "bob", ciphertext: "<opaque blob>" } — sender identity is hidden inside the encrypted envelope.
const alice = await Stvor.connect({
userId: 'alice',
appToken: 'stvor_live_xxx',
relayUrl: 'https://relay.stvor.xyz',
sealedSender: true, // ← enable
});
| What relay sees | Without sealed sender | With sealed sender |
|---|---|---|
| to | ✓ plaintext | ✓ plaintext (needed for routing) |
| from | ✓ plaintext | ✗ hidden inside envelope |
| ciphertext | opaque | opaque |
Protocol: For each message, an ephemeral ECDH key pair is generated. The sender's identity is encrypted with ECDH(epk, recipient_IK) → AES-256-GCM. The recipient decrypts the envelope with their identity private key to recover from, then proceeds with normal Double Ratchet decryption.
to must be plaintext for routing). For full anonymity, a mix network (Tor) would be required.
GDPR Compliance
The relay stores only: public keys, queued ciphertexts, and timestamps. Message content is E2EE — the relay cannot access it. Two compliance endpoints are built in:
deleteMyData() — Art. 17 Right to Erasure
const result = await alice.deleteMyData();
// { deletedAt: "2026-04-25T...", messagesDeleted: 3 }
// Removes: public keys, all queued messages, registration record
exportMyData() — Art. 20 Data Portability
const data = await alice.exportMyData();
// {
// userId: "alice",
// data: {
// publicKeys: { ... }, // public — not sensitive
// pendingMessages: 2, // count only, not content
// registeredAt: "2026-04-...",
// lastActivity: "2026-04-...",
// },
// notice: "Message content is E2EE. The relay cannot access or export it."
// }
StvorWebSDK.create() — Browser
Browser equivalent of Stvor.connect(). Uses Web Crypto API — no Node.js, no WASM, no dependencies.
import { StvorWebSDK } from '@stvor/sdk/web';
const alice = await StvorWebSDK.create({
userId: 'alice@example.com',
appToken: 'stvor_live_xxx',
relayUrl: 'https://relay.stvor.xyz',
});
await alice.send('bob@example.com', { text: 'Hello!' });
alice.disconnect();
web.onMessage() — Browser
Browser handler receives from and data as separate arguments.
alice.onMessage((from: string, data: unknown) => {
console.log(from, data);
});
Supported Data Types
The SDK uses a marker-byte codec to preserve the exact JavaScript type end-to-end. No manual serialization needed.
| Type | Example | Status |
|---|---|---|
| string | 'Hello, world!' | Supported |
| number | 42, 3.14, -7 | Supported |
| boolean | true, false | Supported |
| null | null | Supported |
| Uint8Array / Buffer | Binary files, images | Supported |
| object / array | { key: 'val' }, [1,2,3] | Supported |
| Date | new Date() | Supported |
| Set | new Set([1,2,3]) | Supported |
| Map | new Map([['a',1]]) | Supported |
await alice.send('bob', 'text');
await alice.send('bob', 42);
await alice.send('bob', new Uint8Array([1,2,3]));
await alice.send('bob', new Date());
await alice.send('bob', new Map([['key', 'val']]));
// Recipient gets the exact original type:
bob.onMessage((msg) => {
if (msg.data instanceof Map) { /* ... */ }
});
Cryptography
Stvor v3.5.4 implements Signal Protocol + ML-KEM-768 (NIST FIPS 203) using only native crypto APIs — zero external dependencies.
- X3DH — Extended Triple Diffie-Hellman for initial key agreement
- Double Ratchet — DH ratchet + symmetric ratchet for forward secrecy
- ECDH P-256 — Diffie-Hellman key exchange
- ECDSA P-256 — Digital signatures (SPK verification)
- AES-256-GCM — Authenticated encryption, header as AAD
- HKDF-SHA256 — Key derivation
- HMAC-SHA256 — Chain key ratcheting
Security Guarantees
- ✅ End-to-end encryption — plaintext never leaves the device
- ✅ Forward Secrecy — DH ratchet per message
- ✅ Post-Compromise Security — automatic ratchet advancement
- ✅ Authenticated encryption — AES-256-GCM with AEAD
- ✅ Replay protection — nonce + timestamp validation
- ✅ TOFU verification — identity verified on first contact
- ✅ Simultaneous send — both parties can send before receiving
Limitations
| Feature | Status | Notes |
|---|---|---|
| Multi-device sync | Not ready | Each device = separate identity |
| Multi-instance relay | Not ready | Requires distributed state |
| Persistent storage | In-memory | Keys cleared on restart |
| Post-quantum (ML-KEM-768) | Available | Enable with pqc: true — hybrid X3DH+ML-KEM, NIST FIPS 203 verified |
| Metadata privacy (sender) | Available | Enable with sealedSender: true — relay cannot see sender |
| Metadata privacy (recipient) | Not provided | Relay sees to for routing — mix networks required for full anonymity |
Troubleshooting
INVALID_APP_TOKEN
Token must start with stvor_. Use stvor_dev_test123 for local development.
RELAY_UNAVAILABLE
Run npx @stvor/sdk mock-relay for local dev, or check your relayUrl.
RECIPIENT_NOT_FOUND
Recipient hasn't connected yet. send() waits automatically by default — increase timeout if needed.
DELIVERY_FAILED
Decryption error — usually a session state mismatch. Disconnect and reconnect both clients.
ERR_PACKAGE_PATH_NOT_EXPORTED
Use @stvor/sdk for Node.js and @stvor/sdk/web for browser. Update to ^3.5.4.
Migration v3.0 → v3.4
Stvor.init() + app.connect() API is gone. Use Stvor.connect() instead.
// ❌ v3.0 (old)
const app = await Stvor.init({ appToken: '...', relayUrl: '...' });
const alice = await app.connect('alice');
alice.onMessage((msg) => console.log(msg.senderId, msg.content));
// ✅ v3.4 (new)
const alice = await Stvor.connect({
userId: 'alice', appToken: '...', relayUrl: '...',
});
alice.onMessage((msg) => console.log(msg.from, msg.data));
Also update the message handler: msg.senderId → msg.from, msg.content → msg.data.