173 lines
7.0 KiB
Markdown
173 lines
7.0 KiB
Markdown
|
|
# Migration Guide
|
||
|
|
|
||
|
|
This document describes how to migrate existing systems with ad-hoc encryption to Shade's Signal Protocol implementation.
|
||
|
|
|
||
|
|
## Why migrate?
|
||
|
|
|
||
|
|
If you currently use:
|
||
|
|
- A static AES-256-GCM key per pair (e.g., ECDH at handshake, then never rotated)
|
||
|
|
- Pre-shared keys distributed at registration time
|
||
|
|
- Simple per-device symmetric encryption (like Nova's push notifications)
|
||
|
|
|
||
|
|
…then you're missing **forward secrecy** and **post-compromise recovery**. Shade gives you both with minimal code changes.
|
||
|
|
|
||
|
|
## Migration phases
|
||
|
|
|
||
|
|
The recommended migration is a three-phase rollout that lets you ship without downtime:
|
||
|
|
|
||
|
|
### Phase 1: Dual-write
|
||
|
|
- Set up the Shade prekey server alongside your existing system
|
||
|
|
- New devices register with both systems
|
||
|
|
- Old devices continue using the legacy encryption
|
||
|
|
- Both encrypted formats are accepted on read
|
||
|
|
|
||
|
|
### Phase 2: Switch reads
|
||
|
|
- Once the majority of devices are on Shade, prefer Shade for new sessions
|
||
|
|
- Continue accepting legacy messages for older clients
|
||
|
|
- Monitor decryption failure rates
|
||
|
|
|
||
|
|
### Phase 3: Deprecate
|
||
|
|
- Remove legacy encryption code
|
||
|
|
- Force all devices to re-pair via Shade
|
||
|
|
- Clean up legacy database columns
|
||
|
|
|
||
|
|
## Concrete examples
|
||
|
|
|
||
|
|
### Example A: Replacing a static AES tunnel
|
||
|
|
|
||
|
|
Before (`crypto/e2ee.ts`):
|
||
|
|
```ts
|
||
|
|
import { generateKeyPair, deriveSharedSecret, encrypt, decrypt } from './crypto/e2ee.js';
|
||
|
|
|
||
|
|
// During pairing
|
||
|
|
const myKp = await generateKeyPair();
|
||
|
|
const sharedSecret = await deriveSharedSecret(myKp.privateKey, peerPublicKey);
|
||
|
|
db.serverConnection.insert({ sharedSecret: exportSecret(sharedSecret) });
|
||
|
|
|
||
|
|
// On every message
|
||
|
|
const { ciphertext, nonce } = await encrypt(sharedSecret, plaintext);
|
||
|
|
ws.send({ ciphertext, nonce });
|
||
|
|
```
|
||
|
|
|
||
|
|
After (with Shade):
|
||
|
|
```ts
|
||
|
|
import { ShadeSessionManager } from '@shade/core';
|
||
|
|
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||
|
|
import { SQLiteStorage } from '@shade/storage-sqlite';
|
||
|
|
import { ShadeWebSocket, ShadeFetchTransport } from '@shade/transport';
|
||
|
|
|
||
|
|
const crypto = new SubtleCryptoProvider();
|
||
|
|
const storage = new SQLiteStorage('/data/shade.db');
|
||
|
|
const manager = new ShadeSessionManager(crypto, storage);
|
||
|
|
await manager.initialize();
|
||
|
|
|
||
|
|
// During pairing — fetch peer's bundle and start session
|
||
|
|
const transport = new ShadeFetchTransport({
|
||
|
|
baseUrl: 'https://prekey.example.com',
|
||
|
|
crypto,
|
||
|
|
signingPrivateKey: (await storage.getIdentityKeyPair())!.signingPrivateKey,
|
||
|
|
});
|
||
|
|
const peerBundle = await transport.fetchBundle('peer-id');
|
||
|
|
await manager.initSessionFromBundle('peer-id', peerBundle);
|
||
|
|
|
||
|
|
// On every message — wrap the WebSocket
|
||
|
|
const shadeWs = new ShadeWebSocket(rawWs, manager, 'peer-id');
|
||
|
|
shadeWs.onMessage((plaintext) => handleMessage(plaintext));
|
||
|
|
await shadeWs.send('Hello peer');
|
||
|
|
```
|
||
|
|
|
||
|
|
The key differences:
|
||
|
|
1. **No static shared secret** — keys ratchet forward with each message
|
||
|
|
2. **Identity is persistent** — same identity across reconnects, but session keys regenerate
|
||
|
|
3. **The transport wrapper is transparent** — your application code doesn't change
|
||
|
|
|
||
|
|
### Example B: Replacing per-device push encryption
|
||
|
|
|
||
|
|
Before (per-device static AES key):
|
||
|
|
```ts
|
||
|
|
// Server side
|
||
|
|
const device = db.pushDevices.findFirst({ where: { id } });
|
||
|
|
const key = Buffer.from(device.encryptionKey, 'base64');
|
||
|
|
const encrypted = encryptPayload(notificationJson, key);
|
||
|
|
sendToFCM({ data: { enc: encrypted, v: '1' } });
|
||
|
|
```
|
||
|
|
|
||
|
|
After (Shade per-device session):
|
||
|
|
```ts
|
||
|
|
// Server side
|
||
|
|
const manager = new ShadeSessionManager(crypto, storage);
|
||
|
|
await manager.initialize();
|
||
|
|
|
||
|
|
// First time per device: fetch their bundle and establish session
|
||
|
|
if (!await storage.getSession(`device:${deviceId}`)) {
|
||
|
|
const bundle = await prekeyTransport.fetchBundle(`device:${deviceId}`);
|
||
|
|
await manager.initSessionFromBundle(`device:${deviceId}`, bundle);
|
||
|
|
}
|
||
|
|
|
||
|
|
const envelope = await manager.encrypt(`device:${deviceId}`, notificationJson);
|
||
|
|
sendToFCM({ data: { enc: encodeEnvelope(envelope), v: '2' } });
|
||
|
|
```
|
||
|
|
|
||
|
|
Client side:
|
||
|
|
```kotlin
|
||
|
|
// Decode the envelope, decrypt via Shade
|
||
|
|
val envelope = decodeEnvelope(data["enc"]!!)
|
||
|
|
val plaintext = shadeManager.decrypt("server", envelope)
|
||
|
|
```
|
||
|
|
|
||
|
|
## Database migration
|
||
|
|
|
||
|
|
If your existing system stores symmetric keys in the database:
|
||
|
|
|
||
|
|
### Before
|
||
|
|
```sql
|
||
|
|
CREATE TABLE devices (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
encryption_key TEXT NOT NULL -- base64 AES-256
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
### After
|
||
|
|
```sql
|
||
|
|
CREATE TABLE devices (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
shade_address TEXT NOT NULL -- e.g. "device:abc123"
|
||
|
|
-- Shade tables (created automatically by SQLiteStorage):
|
||
|
|
-- shade_identity, shade_sessions, shade_signed_prekeys, etc.
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
The Shade tables are auto-created when you instantiate the storage backend. No manual migration needed.
|
||
|
|
|
||
|
|
## Migration for Orchestrator
|
||
|
|
|
||
|
|
The Orchestrator project's `orchestrator-shared/src/crypto/e2ee.ts` provides a static ECDH-derived AES-256-GCM key for the workstation↔server sync tunnel. To migrate:
|
||
|
|
|
||
|
|
1. **Add Shade dependencies** to `orchestrator-shared/package.json`
|
||
|
|
2. **Replace `e2ee.ts`** with imports from `@shade/core` and `@shade/transport`
|
||
|
|
3. **Update the pairing flow** in `sync-server.ts` and `sync-client.ts` to exchange Shade prekey bundles instead of raw ECDH public keys
|
||
|
|
4. **Wrap the sync WebSocket** with `ShadeWebSocket` for transparent encryption
|
||
|
|
5. **Migrate the `serverConnection` table** to a `shade_sessions` table (or run dual-write during the rollout)
|
||
|
|
|
||
|
|
The key insight: Shade replaces the static `sharedSecret` column with a full ratcheting session, but the WebSocket transport, message types, and application logic don't change.
|
||
|
|
|
||
|
|
## Migration for Nova (push notifications)
|
||
|
|
|
||
|
|
Nova's `pushDevices.encryptionKey` column is a per-device static AES key. To migrate:
|
||
|
|
|
||
|
|
1. **Run a Shade prekey server** (Docker container, see `examples/05-dokploy-deployment`)
|
||
|
|
2. **On Android device registration**, generate Shade identity + upload prekey bundle to the server (instead of generating a raw AES key)
|
||
|
|
3. **In the Nova backend**, fetch the device's bundle and establish a Shade session per device
|
||
|
|
4. **Encrypt notifications via the Shade session** instead of `encryptPayload()`
|
||
|
|
5. **On the Android client**, decrypt with Shade instead of the static key
|
||
|
|
6. **Cross-platform interop**: this requires the `shade-android` Kotlin module (not yet built — planned for the M8 milestone)
|
||
|
|
|
||
|
|
During the rollout, send notifications with a `v: 1` (legacy) or `v: 2` (Shade) field so old and new clients coexist.
|
||
|
|
|
||
|
|
## Common pitfalls
|
||
|
|
|
||
|
|
1. **Don't store private keys in shared databases without encryption at rest** — Shade trusts the storage layer to be secure. Use filesystem encryption or PostgreSQL TDE if the database is on shared infrastructure.
|
||
|
|
2. **Don't skip identity verification** — Shade gives you fingerprints (`getIdentityFingerprint()`), but it's the user's responsibility to compare them out-of-band on first contact.
|
||
|
|
3. **Don't reuse session storage between identities** — each user/device should have its own Shade storage. Mixing identities in one storage will corrupt the ratchet state.
|
||
|
|
4. **Keep prekey stocks topped up** — call `ensurePreKeyStock()` periodically (e.g., on app start or every hour). When the server runs out of one-time prekeys, new sessions will fall back to using just the signed prekey, which is slightly less secure.
|