Documentation v3.5.4
GitHub npm izahii@protonmail.com ← Home

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.

Zero dependencies. Node.js SDK uses only the built-in node:crypto module. Browser SDK uses Web Crypto API. No WASM, no libsodium, no npm bloat.
FeatureStatus
X3DH + Double RatchetProduction
One-time prekeys (OPK)Production
Group chats (Sender Keys)Production
ML-KEM-768 (NIST FIPS 203)Production
Sealed senderProduction
GDPR complianceProduction
Browser (Web Crypto API)Production
Forward SecrecyProduction
Replay protectionProduction
Multi-device syncNot ready
Multi-instance (distributed)Not ready

Installation

bash
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.

typescript
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();
Hosted relay: https://relay.stvor.xyz is live and accepts any stvor_* token. No account or setup required.
Never hardcode tokens. Use process.env.STVOR_APP_TOKEN in production.

Relay Options

Hosted relay (recommended)

Ready to use. No account, no setup. Accepts any stvor_* token.

typescript
relayUrl: 'https://relay.stvor.xyz'

Local relay (development)

Built into the SDK — no internet needed.

bash
npx @stvor/sdk mock-relay          # port 4444
PORT=9000 npx @stvor/sdk mock-relay
typescript
relayUrl: 'http://localhost:4444'

Self-hosted relay

bash
git clone https://github.com/sapogeth/sdk-relay
cd sdk-relay
node server.js

Module Setup

ESM (recommended)

typescript
import { Stvor } from '@stvor/sdk';

CommonJS

javascript
const { Stvor } = require('@stvor/sdk');

TypeScript config

json
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}

Stvor.connect()

Creates identity keys, registers with the relay, and starts polling — all in one call.

typescript
const client = await Stvor.connect(config: StvorConfig): Promise<StvorClient>
ParameterTypeRequiredDescription
userIdstringYesUser identifier — email, UUID, username
appTokenstringYesToken starting with stvor_
relayUrlstringYesRelay server URL
timeoutnumberNoRequest timeout ms (default: 10000)
pollIntervalMsnumberNoMessage polling interval ms (default: 1000)
typescript
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).

typescript
await client.send(recipientId, data, options?)
ParameterTypeDescription
recipientIdstringRecipient's userId
dataanyAny JS value — see Data Types
options.waitForRecipientbooleanAuto-wait for recipient (default: true)
options.timeoutnumberMax wait ms (default: 10000)
typescript
// 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.

typescript
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.

typescript
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.

typescript
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.

typescript
await alice.createGroup('team-chat', ['bob', 'charlie']);
How it works: Each member gets a copy of Alice's sender key chain, encrypted via their 1-to-1 session. The relay broadcasts one ciphertext to all members — O(1) encryption regardless of group size.

sendToGroup(groupId, data)

Sends any JavaScript value to the group. One encryption — all members receive and can decrypt.

typescript
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.

typescript
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.

typescript
// 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.

typescript
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:

  1. Classical X3DH runs as normal — derives classical_ss
  2. ML-KEM-768 contribution is derived deterministically from both peers' ML-KEM public keys
  3. Hybrid root key = HKDF(classical_ss ‖ pqc_contribution)
  4. All subsequent Double Ratchet messages use this hybrid root key
Hybrid security: If a quantum computer breaks ECDH — the PQC contribution still protects you. If ML-KEM has an unknown flaw — classical X3DH still protects you. Both must be broken simultaneously.

Key sizes

KeySizeNotes
ML-KEM encapsulation key (public)1184 bytesPublished to relay alongside classical keys
ML-KEM decapsulation key (private)2400 bytesNever leaves the device
Hybrid session key32 bytesHKDF 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.

Status: ML-KEM-768 implementation is NIST FIPS 203 compliant. No independent security audit has been performed yet. For regulated environments, wait for an audit before relying solely on the PQC layer.

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.

typescript
const alice = await Stvor.connect({
  userId:       'alice',
  appToken:     'stvor_live_xxx',
  relayUrl:     'https://relay.stvor.xyz',
  sealedSender: true,   // ← enable
});
What relay seesWithout sealed senderWith sealed sender
to✓ plaintext✓ plaintext (needed for routing)
from✓ plaintext✗ hidden inside envelope
ciphertextopaqueopaque

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.

Note: Sealed sender hides the sender from the relay, but does not hide the recipient (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

typescript
const result = await alice.deleteMyData();
// { deletedAt: "2026-04-25T...", messagesDeleted: 3 }
// Removes: public keys, all queued messages, registration record

exportMyData() — Art. 20 Data Portability

typescript
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.

typescript
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.

typescript
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.

TypeExampleStatus
string'Hello, world!'Supported
number42, 3.14, -7Supported
booleantrue, falseSupported
nullnullSupported
Uint8Array / BufferBinary files, imagesSupported
object / array{ key: 'val' }, [1,2,3]Supported
Datenew Date()Supported
Setnew Set([1,2,3])Supported
Mapnew Map([['a',1]])Supported
typescript
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
What STVOR does NOT protect: metadata (relay sees sender/recipient/timing), multi-device sync, post-quantum attacks, persistent storage.

Limitations

FeatureStatusNotes
Multi-device syncNot readyEach device = separate identity
Multi-instance relayNot readyRequires distributed state
Persistent storageIn-memoryKeys cleared on restart
Post-quantum (ML-KEM-768)AvailableEnable with pqc: true — hybrid X3DH+ML-KEM, NIST FIPS 203 verified
Metadata privacy (sender)AvailableEnable with sealedSender: true — relay cannot see sender
Metadata privacy (recipient)Not providedRelay 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

Breaking change: The two-step Stvor.init() + app.connect() API is gone. Use Stvor.connect() instead.
typescript
// ❌ 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.senderIdmsg.from, msg.contentmsg.data.

# Stvor SDK v3.5.4 — Full Documentation
Signal Protocol (X3DH + Double Ratchet) · AES-256-GCM · HKDF-SHA256 · Zero runtime dependencies
Overview
EncryptionX3DH + Double Ratchet (Signal Protocol)
AEADAES-256-GCM with header as AAD
Key exchangeECDH P-256
SignaturesECDSA P-256
KDFHKDF-SHA256 + HMAC-SHA256
Node.js runtimenode:crypto only — zero npm deps
Browser runtimeWeb Crypto API — zero deps, no WASM
FeatureStatus
X3DH + Double RatchetProduction
Forward SecrecyProduction
Browser (Web Crypto API)Production
All JS data typesProduction
Replay protectionProduction
Simultaneous sendProduction
Multi-device syncNot ready
Post-quantum (ML-KEM-768)Production — pqc:true
Installation
npm install @stvor/sdk
Node.js>= 18.0.0
BrowserAny modern browser with Web Crypto API
Quick Start
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, 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();
Relay Options
Hosted (recommended)https://relay.stvor.xyz — no account, any stvor_* token
relayUrl: 'https://relay.stvor.xyz'
Local devBuilt 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-hostedgithub.com/sapogeth/sdk-relay
git clone https://github.com/sapogeth/sdk-relay cd sdk-relay node server.js
Module Setup
ESMrecommended
import { Stvor } from '@stvor/sdk';
CommonJS
const { Stvor } = require('@stvor/sdk');
tsconfig.jsonrequired for ESM
{ "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext" } }
Stvor.connect(config)
const client = await Stvor.connect(config: StvorConfig): Promise<StvorClient>
ParameterTypeRequiredDefault
userIdstringYes
appTokenstringYesstarts with stvor_
relayUrlstringYes
timeoutnumberNo10000 ms
pollIntervalMsnumberNo1000 ms
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, });
Throws INVALID_APP_TOKENif token doesn't start with stvor_
Throws RELAY_UNAVAILABLEif relay is unreachable
client.send(recipientId, data, options?)
await client.send(recipientId: string, data: unknown, options?): Promise<void>
ParameterTypeDefault
recipientIdstring
dataany JS value
options.waitForRecipientbooleantrue
options.timeoutnumber10000 ms
// 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])); // Wait up to 30s for recipient to come online await alice.send('bob', 'Hey', { timeout: 30_000 }); // Fail immediately if not registered await alice.send('bob', 'Hey', { waitForRecipient: false });
client.onMessage(handler)
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); }); unsubscribe(); // stop receiving
client.waitForUser(userId, timeoutMs?)
const online = await alice.waitForUser('bob', 30_000); // true = registered, false = timeout // Note: send() waits automatically — use this only to check without sending if (online) { await alice.send('bob', 'Hey!'); }
client.disconnect()
await alice.disconnect(); // stops polling, clears handlers, closes connection
Browser SDK — StvorWebSDK.create()
import { StvorWebSDK } from '@stvor/sdk/web'; const alice = await StvorWebSDK.create({ userId: 'alice@example.com', appToken: 'stvor_live_xxx', relayUrl: 'https://relay.stvor.xyz', }); // Browser handler: (from, data) — not msg object alice.onMessage((from: string, data: unknown) => { console.log(from, data); }); await alice.send('bob@example.com', { text: 'Hello!' }); alice.disconnect(); // Keys persisted in IndexedDB — identity survives page refreshes
Supported Data Types
TypeExample
string'Hello, world!'
number42, 3.14, -7
booleantrue, false
nullnull
Uint8Array / BufferBinary files, images, audio
object / array{ key: 'val' }, [1, 2, 3]
Datenew Date()
Setnew Set([1, 2, 3])
Mapnew Map([['a', 1]])
await alice.send('bob', new Map([['key', 'val']])); // Recipient gets exact original type: bob.onMessage((msg) => { if (msg.data instanceof Map) { /* ... */ } if (msg.data instanceof Date) { /* ... */ } });
Cryptography
X3DHExtended Triple Diffie-Hellman — initial key agreement
Double RatchetDH ratchet + symmetric ratchet — forward secrecy
ECDH P-256Diffie-Hellman key exchange
ECDSA P-256Digital signatures (SPK verification)
AES-256-GCMAuthenticated encryption, header as AAD
HKDF-SHA256Key derivation
HMAC-SHA256Chain key ratcheting
Security Guarantees
✓ E2EEPlaintext never leaves the device
✓ Forward SecrecyDH ratchet per message — past messages safe
✓ Post-CompromiseAutomatic ratchet advancement
✓ AEADAES-256-GCM — authenticated encryption
✓ Replay protectionNonce + timestamp validation per message
✓ TOFUIdentity verified on first contact, throws on key change
✗ MetadataRelay sees sender/recipient/timing
✗ Post-quantumUses classical P-256 — no PQC yet
✗ Multi-deviceEach device = separate identity
~ StorageIn-memory — keys cleared on restart
Group Chats (Sender Keys)
// Create group — distributes sender keys to all members await alice.createGroup('team-chat', ['bob', 'charlie']); // Send — one encryption, all members receive await alice.sendToGroup('team-chat', { text: 'Hello team!' }); // Receive group messages bob.onGroupMessage(msg => { console.log(msg.groupId, msg.from, msg.data); }); // Manage members (auto-ratchets on removal) await alice.addGroupMember('team-chat', 'dave'); await alice.removeGroupMember('team-chat', 'charlie'); // charlie can't read future msgs
StvorGroupMessage.groupIdstring — group identifier
StvorGroupMessage.fromstring — sender userId
StvorGroupMessage.dataunknown — original type preserved
Post-Quantum (ML-KEM-768)
const alice = await Stvor.connect({ userId: 'alice', appToken: 'stvor_live_xxx', relayUrl: 'https://relay.stvor.xyz', pqc: true, // ML-KEM-768 hybrid key exchange });
AlgorithmML-KEM-768 (NIST FIPS 203) — zero external dependencies
Hybrid schemeHKDF(X3DH_ss ‖ ML-KEM_contribution) → 32-byte root key
SecuritySecure if either classical OR post-quantum holds
ek size1184 bytes (ML-KEM-768 public key)
dk size2400 bytes (never leaves device)
FallbackIf peer has no PQC — falls back to classical X3DH silently
Sealed Sender
const alice = await Stvor.connect({ userId: 'alice', appToken: 'stvor_live_xxx', relayUrl: 'https://relay.stvor.xyz', sealedSender: true, // relay sees `to` but never `from` });
Without sealedSenderrelay sees: { to, from, ciphertext }
With sealedSenderrelay sees: { to, ciphertext: <opaque envelope> }
Protocolephemeral ECDH + AES-256-GCM — fresh key per message
GDPR Compliance
// Art. 17 — Right to erasure const result = await alice.deleteMyData(); // { deletedAt, messagesDeleted } // Art. 20 — Data portability const data = await alice.exportMyData(); // { publicKeys, pendingMessages, registeredAt, lastActivity } // Note: message content is E2EE — relay cannot export it
Troubleshooting
INVALID_APP_TOKENToken must start with stvor_ — use stvor_dev_test123 locally
RELAY_UNAVAILABLERun npx @stvor/sdk mock-relay or check relayUrl
RECIPIENT_NOT_FOUNDIncrease timeout — send() waits by default
DELIVERY_FAILEDSession mismatch — disconnect and reconnect both clients
ERR_PACKAGE_PATH_NOT_EXPORTEDUse @stvor/sdk/web for browser, update to ^3.5.4
Migration v3.0 → v3.4
// ❌ v3.0 (old — removed) 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)); // msg.senderId → msg.from // msg.content → msg.data