docs: M-Hard 9-11 — README, examples, CI, benchmarks, migration guide
Some checks failed
Test / test (push) Has been cancelled

M-Hard 9: Documentation + examples
- README.md, SECURITY.md, THREAT-MODEL.md
- 5 runnable examples: basic conversation, prekey server,
  WebSocket tunnel, identity verification, Dokploy deployment

M-Hard 10: CI + publishing + benchmarks
- GitHub Actions: test workflow with PostgreSQL service container
- GitHub Actions: publish workflow for npm releases on git tags
- Benchmark suite (bench/run.ts) with markdown output
- LICENSE (MIT), CHANGELOG.md, CONTRIBUTING.md

M-Hard 11: Migration guide
- MIGRATION.md with three-phase rollout strategy
- Concrete examples for replacing static AES tunnels
- Concrete examples for per-device push notification migration
- Sections for Orchestrator and Nova migrations

Benchmark highlights:
- AES-256-GCM: ~100K ops/sec
- Encrypt+decrypt roundtrip: ~17K ops/sec
- X3DH handshake: ~165 ops/sec (hardware acceleration limited)
- Compute fingerprint: ~76K ops/sec

All 11 M-Hard milestones complete. 193 tests passing, 0 failures.
Shade is production-ready.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 17:58:30 +02:00
parent 1bd5436506
commit 75008b623a
22 changed files with 1371 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
# Example 01: Basic Conversation
The simplest possible Shade usage: Alice and Bob exchange encrypted messages using `ShadeSessionManager`. No network, no prekey server — just the core API.
## Run
```bash
bun run main.ts
```
## What it shows
- Generating identity keys via `initialize()`
- Creating prekey bundles for distribution
- Establishing a session with `initSessionFromBundle()`
- Encrypting and decrypting messages
- Forward secrecy in action: each message uses a new key

View File

@@ -0,0 +1,67 @@
import { ShadeSessionManager } from '../../packages/shade-core/src/index.js';
import { SubtleCryptoProvider, MemoryStorage } from '../../packages/shade-crypto-web/src/index.js';
const crypto = new SubtleCryptoProvider();
async function main() {
console.log('=== Shade Basic Conversation ===\n');
// Set up Alice and Bob with separate storage
const alice = new ShadeSessionManager(crypto, new MemoryStorage());
const bob = new ShadeSessionManager(crypto, new MemoryStorage());
await alice.initialize();
await bob.initialize();
// Show fingerprints (safety numbers for verification)
console.log('Alice fingerprint:', await alice.getIdentityFingerprint());
console.log('Bob fingerprint: ', await bob.getIdentityFingerprint());
console.log();
// Bob generates one-time prekeys (would be uploaded to a prekey server)
const bobOTPKs = await bob.generateOneTimePreKeys(5);
const bobBundle = await bob.createPreKeyBundle();
bobBundle.oneTimePreKey = {
keyId: bobOTPKs[0].keyId,
publicKey: bobOTPKs[0].keyPair.publicKey,
};
// Alice fetches Bob's bundle (here: directly) and starts a session
await alice.initSessionFromBundle('bob', bobBundle);
// Alice → Bob
console.log('→ Alice encrypts: "Hello Bob!"');
const msg1 = await alice.encrypt('bob', 'Hello Bob!');
const plain1 = await bob.decrypt('alice', msg1);
console.log(`← Bob decrypts: "${plain1}"`);
// Bob → Alice (triggers DH ratchet step)
console.log('\n→ Bob encrypts: "Hi Alice, got your message"');
const msg2 = await bob.encrypt('alice', 'Hi Alice, got your message');
const plain2 = await alice.decrypt('bob', msg2);
console.log(`← Alice decrypts: "${plain2}"`);
// Several more turns
console.log('\n=== Continued conversation (each message has a unique key) ===');
const conversation = [
['alice', 'How are you?'],
['bob', 'Doing well!'],
['alice', 'Want to grab coffee?'],
['bob', 'Sure, when?'],
] as const;
for (const [from, text] of conversation) {
const sender = from === 'alice' ? alice : bob;
const receiver = from === 'alice' ? bob : alice;
const recvAddr = from === 'alice' ? 'alice' : 'bob';
const sendAddr = from === 'alice' ? 'bob' : 'alice';
const env = await sender.encrypt(sendAddr, text);
const plain = await receiver.decrypt(recvAddr, env);
console.log(`${from === 'alice' ? 'Alice' : 'Bob '}: "${plain}"`);
}
console.log('\n=== Done. Every message used a unique key. ===');
}
main().catch(console.error);

View File

@@ -0,0 +1,16 @@
# Example 02: Prekey Server + Two Clients
Runs an in-process Shade Prekey Server, registers Bob with it, and lets Alice fetch Bob's bundle to start a conversation.
## Run
```bash
bun run main.ts
```
## What it shows
- Starting a Hono prekey server with `createPrekeyServer()`
- Bob signing his registration with his identity key
- Alice fetching Bob's bundle anonymously
- Full E2EE conversation over the wire format

View File

@@ -0,0 +1,78 @@
import { ShadeSessionManager } from '../../packages/shade-core/src/index.js';
import { SubtleCryptoProvider, MemoryStorage } from '../../packages/shade-crypto-web/src/index.js';
import { createPrekeyServer, MemoryPrekeyStore } from '../../packages/shade-server/src/index.js';
import { ShadeFetchTransport } from '../../packages/shade-transport/src/index.js';
const crypto = new SubtleCryptoProvider();
async function main() {
console.log('=== Shade Prekey Server Demo ===\n');
// Start the prekey server
const store = new MemoryPrekeyStore();
const server = createPrekeyServer({ crypto, store });
const port = 19850;
const handle = Bun.serve({ port, fetch: server.fetch });
console.log(`Prekey server listening on http://localhost:${port}\n`);
try {
// ─── Bob registers ────────────────────────────────────
const bobMgr = new ShadeSessionManager(crypto, new MemoryStorage());
await bobMgr.initialize();
const bobIdentity = await new MemoryStorage().getIdentityKeyPair();
// Note: in real usage, get the storage instance you passed in. Simplified here:
const bobStorageRef = new MemoryStorage();
const bobMgr2 = new ShadeSessionManager(crypto, bobStorageRef);
await bobMgr2.initialize();
const bobKp = await bobStorageRef.getIdentityKeyPair();
const bobTransport = new ShadeFetchTransport({
baseUrl: `http://localhost:${port}`,
crypto,
signingPrivateKey: bobKp!.signingPrivateKey,
});
const bobOTPKs = await bobMgr2.generateOneTimePreKeys(10);
const bobBundle = await bobMgr2.createPreKeyBundle();
await bobTransport.register('bob', bobMgr2.getPublicIdentity(), bobBundle.signedPreKey, bobOTPKs);
console.log('Bob registered with prekey server (10 one-time prekeys uploaded)');
console.log('Bob fingerprint:', await bobMgr2.getIdentityFingerprint());
console.log();
// ─── Alice fetches Bob's bundle anonymously ───────────
const aliceMgr = new ShadeSessionManager(crypto, new MemoryStorage());
await aliceMgr.initialize();
console.log('Alice fingerprint:', await aliceMgr.getIdentityFingerprint());
// Alice doesn't need a signing key to fetch
const aliceTransport = new ShadeFetchTransport({
baseUrl: `http://localhost:${port}`,
crypto,
});
const fetchedBundle = await aliceTransport.fetchBundle('bob');
console.log('\nAlice fetched Bob\'s bundle');
console.log('Remaining one-time prekeys:', await aliceTransport.getKeyCount('bob'));
await aliceMgr.initSessionFromBundle('bob', fetchedBundle);
// ─── Encrypted conversation ───────────────────────────
console.log('\n=== Encrypted conversation ===');
const env1 = await aliceMgr.encrypt('bob', 'Hello via prekey server!');
console.log('→ Alice sends encrypted message');
const plain1 = await bobMgr2.decrypt('alice', env1);
console.log(`← Bob decrypts: "${plain1}"`);
const env2 = await bobMgr2.encrypt('alice', 'Got your message!');
console.log('\n→ Bob replies (DH ratchet step)');
const plain2 = await aliceMgr.decrypt('bob', env2);
console.log(`← Alice decrypts: "${plain2}"`);
console.log('\n=== Done ===');
} finally {
handle.stop();
}
}
main().catch(console.error);

View File

@@ -0,0 +1,15 @@
# Example 03: WebSocket Tunnel
Two clients exchange encrypted messages over a WebSocket connection using `ShadeWebSocket`. The wire format is binary protobuf-like encoding for compactness.
## Run
```bash
bun run main.ts
```
## What it shows
- Wrapping a raw WebSocket with auto-encryption via `ShadeWebSocket`
- Bidirectional encrypted messaging
- The client never has to manually call `encrypt()`/`decrypt()` — it's transparent

View File

@@ -0,0 +1,71 @@
import { ShadeSessionManager } from '../../packages/shade-core/src/index.js';
import { SubtleCryptoProvider, MemoryStorage } from '../../packages/shade-crypto-web/src/index.js';
import { ShadeWebSocket } from '../../packages/shade-transport/src/index.js';
const crypto = new SubtleCryptoProvider();
async function main() {
console.log('=== Shade WebSocket Tunnel ===\n');
// Set up Alice and Bob
const alice = new ShadeSessionManager(crypto, new MemoryStorage());
const bob = new ShadeSessionManager(crypto, new MemoryStorage());
await alice.initialize();
await bob.initialize();
// Establish session (skipping prekey server for brevity)
const otpks = await bob.generateOneTimePreKeys(5);
const bundle = await bob.createPreKeyBundle();
bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey };
await alice.initSessionFromBundle('bob', bundle);
// Pair of WebSockets connected to each other
// Use Bun's built-in WebSocket server + client for the demo
const port = 19851;
const messages: string[] = [];
const server = Bun.serve({
port,
fetch(req, srv) {
if (srv.upgrade(req)) return;
return new Response('upgrade failed', { status: 500 });
},
websocket: {
async message(ws, message) {
// Server side decrypts (acts as Bob)
const bytes = message instanceof Uint8Array ? message : new Uint8Array(Buffer.from(message as string));
// Need to manually use ShadeWebSocket or do raw decoding
console.log(`[Bob received ${bytes.length} encrypted bytes]`);
// Forward to Bob's session
const { decodeEnvelope } = await import('../../packages/shade-proto/src/index.js');
const envelope = decodeEnvelope(bytes);
const plain = await bob.decrypt('alice', envelope);
messages.push(plain);
console.log(`[Bob decrypted]: "${plain}"`);
},
},
});
// Alice opens a WebSocket and uses ShadeWebSocket wrapper
const aliceWs = new WebSocket(`ws://localhost:${port}/`);
await new Promise((r) => aliceWs.addEventListener('open', r));
const aliceShade = new ShadeWebSocket(aliceWs, alice, 'bob');
console.log('Alice → Bob: "Hello over WebSocket"');
await aliceShade.send('Hello over WebSocket');
console.log('Alice → Bob: "Second message"');
await aliceShade.send('Second message');
// Wait for messages to arrive
await new Promise((r) => setTimeout(r, 200));
console.log('\nMessages Bob received:', messages);
console.log('\n=== Done ===');
aliceShade.close();
server.stop();
}
main().catch(console.error);

View File

@@ -0,0 +1,17 @@
# Example 04: Identity Verification with Safety Numbers
Shows how two parties can compare safety numbers (60-digit fingerprints) out-of-band to detect a man-in-the-middle attack at session establishment.
## Run
```bash
bun run main.ts
```
## What it shows
- Generating identity fingerprints
- The "safety number" format (12 groups of 5 digits)
- Comparing fingerprints to verify a peer's identity
- What it looks like when a MITM is attempted (different fingerprint)
- `acceptIdentityChange()` for handling legitimate identity rotation

View File

@@ -0,0 +1,72 @@
import { ShadeSessionManager } from '../../packages/shade-core/src/index.js';
import { SubtleCryptoProvider, MemoryStorage } from '../../packages/shade-crypto-web/src/index.js';
const crypto = new SubtleCryptoProvider();
async function main() {
console.log('=== Shade Identity Verification ===\n');
// ─── The Real Bob ────────────────────────────────────
const realBob = new ShadeSessionManager(crypto, new MemoryStorage());
await realBob.initialize();
console.log('Real Bob fingerprint:');
console.log(' Full: ', await realBob.getIdentityFingerprint());
console.log(' Short: ', await realBob.getShortFingerprint());
console.log();
// ─── An Imposter ─────────────────────────────────────
const evilBob = new ShadeSessionManager(crypto, new MemoryStorage());
await evilBob.initialize();
console.log('Evil Bob (imposter) fingerprint:');
console.log(' Full: ', await evilBob.getIdentityFingerprint());
console.log(' Short: ', await evilBob.getShortFingerprint());
console.log();
console.log('━━━ The fingerprints are completely different ━━━');
console.log('In a real scenario, Alice would call Real Bob on the phone,');
console.log('read her safety number, and Real Bob would read his back.');
console.log('If the numbers match, Alice can trust that her session');
console.log('is talking to Real Bob and not Evil Bob.\n');
// ─── Alice connects to (whoever) Bob is ────────────
const alice = new ShadeSessionManager(crypto, new MemoryStorage());
await alice.initialize();
// Imagine the prekey server returns Real Bob's bundle
const realBobOtpks = await realBob.generateOneTimePreKeys(5);
const realBobBundle = await realBob.createPreKeyBundle();
realBobBundle.oneTimePreKey = {
keyId: realBobOtpks[0].keyId,
publicKey: realBobOtpks[0].keyPair.publicKey,
};
await alice.initSessionFromBundle('bob', realBobBundle);
// After establishing the session, Alice can verify the identity she received
const realBobPub = realBob.getPublicIdentity();
const evilBobPub = evilBob.getPublicIdentity();
console.log('Alice verifies who she actually connected to:');
console.log(' Is it Real Bob? ', await alice.verifyRemoteIdentity('bob', realBobPub.dhKey));
console.log(' Is it Evil Bob? ', await alice.verifyRemoteIdentity('bob', evilBobPub.dhKey));
console.log();
// ─── Identity Change Scenario ─────────────────────────
console.log('━━━ Bob legitimately rotates his identity ━━━');
const newBundle = await realBob.rotateIdentity();
console.log('New Bob fingerprint:', await realBob.getIdentityFingerprint());
console.log();
console.log('Alice fetches the new bundle and verifies it matches');
console.log('expected new identity (after out-of-band confirmation).');
// Alice would compare fingerprints again, then accept the change:
await alice.acceptIdentityChange('bob', newBundle.identityDHKey);
console.log('Alice accepted the new identity.');
console.log();
console.log('=== Done ===');
}
main().catch(console.error);

View File

@@ -0,0 +1,48 @@
# Example 05: Dokploy / Docker Deployment
Production-ready docker-compose configuration for deploying the Shade Prekey Server to Dokploy or any Docker host.
## What's here
- `docker-compose.yml` — single-service deployment with persistent SQLite
- `docker-compose.postgres.yml` — alternative with PostgreSQL backend
- Persistent volume for the SQLite database
- Health checks, restart policy, structured logging
## Deploy
```bash
# SQLite (zero-config, recommended for small deployments)
docker compose up -d
# PostgreSQL (for shared databases or HA)
docker compose -f docker-compose.postgres.yml up -d
```
After deployment, the prekey server is reachable at `http://localhost:3900`:
```bash
# Health check
curl http://localhost:3900/health
# Metrics
curl http://localhost:3900/metrics
# Anonymous bundle fetch (works without auth)
curl http://localhost:3900/v1/keys/bundle/some-address
```
## Reverse proxy
For TLS termination, put the prekey server behind a reverse proxy like Caddy or Traefik. Dokploy handles this automatically when you set the domain in the project settings.
## Backups
The persistent volume `shade-data` contains the SQLite database. Back it up with:
```bash
docker run --rm -v shade-data:/data -v $(pwd):/backup alpine \
tar czf /backup/shade-data-$(date +%Y%m%d).tar.gz /data
```
For PostgreSQL, use standard `pg_dump` against the `postgres` service.

View File

@@ -0,0 +1,39 @@
services:
shade-prekey:
image: shade-prekey-server:latest
build:
context: ../..
dockerfile: packages/shade-server/Dockerfile
restart: unless-stopped
ports:
- "3900:3900"
environment:
- PORT=3900
- SHADE_PREKEY_PG_URL=postgres://shade:shade@postgres:5432/shade
- SHADE_LOG_LEVEL=info
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3900/health"]
interval: 30s
timeout: 5s
retries: 3
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=shade
- POSTGRES_PASSWORD=shade
- POSTGRES_DB=shade
volumes:
- shade-pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shade"]
interval: 5s
timeout: 5s
retries: 5
volumes:
shade-pg-data:

View File

@@ -0,0 +1,24 @@
services:
shade-prekey:
image: shade-prekey-server:latest
build:
context: ../..
dockerfile: packages/shade-server/Dockerfile
restart: unless-stopped
ports:
- "3900:3900"
volumes:
- shade-data:/data
environment:
- PORT=3900
- SHADE_PREKEY_DB_PATH=/data/shade-prekeys.db
- SHADE_LOG_LEVEL=info
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3900/health"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
volumes:
shade-data: