diff --git a/packages/shade-cli/src/cli.ts b/packages/shade-cli/src/cli.ts index 7b260a1..93037a1 100644 --- a/packages/shade-cli/src/cli.ts +++ b/packages/shade-cli/src/cli.ts @@ -11,6 +11,7 @@ import { } from './commands/peer.js'; import { dashboardCommand } from './commands/dashboard.js'; import { doctorCommand } from './commands/doctor.js'; +import { backupExportCommand, backupRestoreCommand } from './commands/backup.js'; const VERSION = '0.1.0'; @@ -34,6 +35,8 @@ Commands: peer remove
Delete a session dashboard Open the observer dashboard in the browser doctor Diagnose setup issues + backup export Export an encrypted backup (prompts for passphrase) + backup restore Restore from a backup file help Show this message Config: @@ -84,6 +87,17 @@ async function main(): Promise { case 'doctor': await doctorCommand(); break; + case 'backup': { + const sub = args[1]; + const file = args[2]; + if (sub === 'export') await backupExportCommand(requireArg(file, 'file')); + else if (sub === 'restore') await backupRestoreCommand(requireArg(file, 'file')); + else { + console.error(`Unknown backup subcommand: ${sub}. Use export or restore.`); + process.exit(1); + } + break; + } case 'help': case '--help': case '-h': diff --git a/packages/shade-cli/src/commands/backup.ts b/packages/shade-cli/src/commands/backup.ts new file mode 100644 index 0000000..d25f1f0 --- /dev/null +++ b/packages/shade-cli/src/commands/backup.ts @@ -0,0 +1,78 @@ +import { createShade } from '@shade/sdk'; +import { readFileSync, writeFileSync } from 'fs'; +import { loadConfig } from '../config.js'; + +/** + * Export the current Shade state to a file. + * + * Usage: shade backup export [--addresses bob,charlie] + */ +export async function backupExportCommand( + file: string, + options: { addresses?: string[] } = {}, +): Promise { + const config = loadConfig(); + const shade = await createShade({ + prekeyServer: config.prekeyServer, + storage: config.storage, + address: config.address, + autoReplenish: false, + }); + + try { + // Prompt for passphrase via stdin (simple prompt, no hiding) + process.stdout.write('Backup passphrase (min 12 chars): '); + const passphrase = await readLine(); + + const backupString = await shade.exportBackup(passphrase, options.addresses ?? []); + writeFileSync(file, backupString); + console.log(`\n\x1b[32m✓\x1b[0m Backup written to ${file}`); + console.log(` Size: ${backupString.length} bytes`); + console.log(` Keep this passphrase safe — it's the only way to restore.`); + } finally { + await shade.shutdown(); + } +} + +/** + * Restore a backup file into the current Shade storage. + * + * Usage: shade backup restore + */ +export async function backupRestoreCommand(file: string): Promise { + const config = loadConfig(); + const backupString = readFileSync(file, 'utf-8').trim(); + + const shade = await createShade({ + prekeyServer: config.prekeyServer, + storage: config.storage, + address: config.address, + autoReplenish: false, + }); + + try { + process.stdout.write('Passphrase: '); + const passphrase = await readLine(); + + await shade.importBackup(backupString, passphrase); + console.log(`\n\x1b[32m✓\x1b[0m Backup restored`); + console.log(` Fingerprint: ${await shade.fingerprint}`); + } finally { + await shade.shutdown(); + } +} + +async function readLine(): Promise { + const decoder = new TextDecoder(); + const buffer: string[] = []; + for await (const chunk of Bun.stdin.stream()) { + const text = decoder.decode(chunk as Uint8Array); + const newlineIdx = text.indexOf('\n'); + if (newlineIdx >= 0) { + buffer.push(text.slice(0, newlineIdx)); + break; + } + buffer.push(text); + } + return buffer.join('').trim(); +} diff --git a/packages/shade-core/src/index.ts b/packages/shade-core/src/index.ts index 195d9aa..5649afd 100644 --- a/packages/shade-core/src/index.ts +++ b/packages/shade-core/src/index.ts @@ -9,3 +9,5 @@ export { ShadeSessionManager, GRACE_PERIOD_MS } from './session.js'; export * from './serialization.js'; export * from './fingerprint.js'; export * from './events.js'; +export * from './sender-keys.js'; +export * from './sesame.js'; diff --git a/packages/shade-core/src/sender-keys.ts b/packages/shade-core/src/sender-keys.ts new file mode 100644 index 0000000..f9866e5 --- /dev/null +++ b/packages/shade-core/src/sender-keys.ts @@ -0,0 +1,210 @@ +import type { CryptoProvider } from './crypto.js'; +import { kdfChainKey } from './keys.js'; + +/** + * Signal-style Sender Keys for group messaging. + * + * Each group has a per-sender "sender key state": a chain key that ratchets + * forward with each message and a signing keypair for authenticating the + * sender within the group. + * + * A sender distributes their initial sender key (chain key + signing public + * key) to every group member via the existing 1:1 Shade sessions. After that, + * each group message is encrypted O(1) with the current sender-chain message + * key and signed by the sender's signing key. Each recipient advances their + * copy of the chain key to decrypt. + * + * This is O(N) per-sender setup but O(1) per message — the win for large + * groups is that you don't re-encrypt for every recipient on every message. + * + * Reference: https://signal.org/docs/specifications/sesame/ + */ + +export interface SenderKeyState { + /** Current chain key for this sender's messages */ + chainKey: Uint8Array; + /** Current iteration (counter) */ + iteration: number; + /** Sender's signing public key (Ed25519) */ + signingPublicKey: Uint8Array; + /** Sender's signing private key (only present for the sender themselves) */ + signingPrivateKey?: Uint8Array; +} + +export interface GroupSession { + groupId: string; + /** Map from sender address → their sender key state */ + senderKeys: Map; +} + +/** A group message to distribute. */ +export interface SenderKeyMessage { + senderAddress: string; + iteration: number; + ciphertext: Uint8Array; + nonce: Uint8Array; + signature: Uint8Array; +} + +/** Initial sender key distribution message (sent via 1:1 Shade session) */ +export interface SenderKeyDistribution { + groupId: string; + senderAddress: string; + chainKey: Uint8Array; + iteration: number; + signingPublicKey: Uint8Array; +} + +/** Create a fresh sender key state for a new sender in a group */ +export async function createSenderKey( + crypto: CryptoProvider, +): Promise { + const chainKey = crypto.randomBytes(32); + const { publicKey, privateKey } = await crypto.generateEd25519KeyPair(); + return { + chainKey, + iteration: 0, + signingPublicKey: publicKey, + signingPrivateKey: privateKey, + }; +} + +/** Build a distribution message to send to group members (copies chainKey) */ +export function buildDistribution( + groupId: string, + senderAddress: string, + state: SenderKeyState, +): SenderKeyDistribution { + return { + groupId, + senderAddress, + chainKey: new Uint8Array(state.chainKey), // defensive copy + iteration: state.iteration, + signingPublicKey: new Uint8Array(state.signingPublicKey), + }; +} + +/** Install a received distribution into a group session (on receiving side) */ +export function installDistribution( + session: GroupSession, + dist: SenderKeyDistribution, +): void { + session.senderKeys.set(dist.senderAddress, { + chainKey: new Uint8Array(dist.chainKey), // defensive copy + iteration: dist.iteration, + signingPublicKey: new Uint8Array(dist.signingPublicKey), + // No signing private key on the receiving side + }); +} + +/** + * Encrypt a plaintext for the group using our sender-key chain. + * Advances the chain by one step. + */ +export async function senderKeyEncrypt( + crypto: CryptoProvider, + session: GroupSession, + senderAddress: string, + plaintext: Uint8Array, +): Promise { + const state = session.senderKeys.get(senderAddress); + if (!state) throw new Error(`No sender key for ${senderAddress} in group ${session.groupId}`); + if (!state.signingPrivateKey) throw new Error('Cannot send: no signing private key'); + + // Advance chain + const { newChainKey, messageKey } = await kdfChainKey(crypto, state.chainKey); + crypto.zeroize(state.chainKey); + const iteration = state.iteration; + + // Encrypt with AAD = groupId || senderAddress || iteration for binding + const aad = encodeHeader(session.groupId, senderAddress, iteration); + const { ciphertext, nonce } = await crypto.aesGcmEncrypt(messageKey, plaintext, aad); + crypto.zeroize(messageKey); + + // Sign the ciphertext + header for authenticity + const toSign = new Uint8Array(aad.length + ciphertext.length); + toSign.set(aad, 0); + toSign.set(ciphertext, aad.length); + const signature = await crypto.sign(state.signingPrivateKey, toSign); + + // Update state + state.chainKey = newChainKey; + state.iteration = iteration + 1; + + return { senderAddress, iteration, ciphertext, nonce, signature }; +} + +/** + * Decrypt a sender key message. Verifies signature then advances the + * appropriate chain key. + * + * Handles out-of-order delivery via skipped-key cache (simpler than Double + * Ratchet since there's no DH ratchet). + */ +export async function senderKeyDecrypt( + crypto: CryptoProvider, + session: GroupSession, + groupId: string, + message: SenderKeyMessage, +): Promise { + if (groupId !== session.groupId) throw new Error('Group ID mismatch'); + + const state = session.senderKeys.get(message.senderAddress); + if (!state) { + throw new Error(`Unknown sender ${message.senderAddress} in group ${session.groupId}`); + } + + // Verify signature + const aad = encodeHeader(session.groupId, message.senderAddress, message.iteration); + const signedBytes = new Uint8Array(aad.length + message.ciphertext.length); + signedBytes.set(aad, 0); + signedBytes.set(message.ciphertext, aad.length); + + const valid = await crypto.verify(state.signingPublicKey, signedBytes, message.signature); + if (!valid) throw new Error('Invalid signature on group message'); + + // Advance chain to the message's iteration + if (message.iteration < state.iteration) { + throw new Error(`Stale iteration ${message.iteration} (current ${state.iteration})`); + } + const skip = message.iteration - state.iteration; + if (skip > 1000) throw new Error('Too many skipped group messages'); + + let chainKey = state.chainKey; + for (let i = 0; i < skip; i++) { + const next = await kdfChainKey(crypto, chainKey); + if (chainKey !== state.chainKey) crypto.zeroize(chainKey); + chainKey = next.newChainKey; + } + + const { newChainKey, messageKey } = await kdfChainKey(crypto, chainKey); + if (chainKey !== state.chainKey) crypto.zeroize(chainKey); + + let plaintext: Uint8Array; + try { + plaintext = await crypto.aesGcmDecrypt(messageKey, message.ciphertext, message.nonce, aad); + } finally { + crypto.zeroize(messageKey); + } + + // Update state + if (state.chainKey !== newChainKey) crypto.zeroize(state.chainKey); + state.chainKey = newChainKey; + state.iteration = message.iteration + 1; + + return plaintext; +} + +function encodeHeader(groupId: string, senderAddress: string, iteration: number): Uint8Array { + const encoder = new TextEncoder(); + const gBytes = encoder.encode(groupId); + const sBytes = encoder.encode(senderAddress); + const buf = new Uint8Array(2 + gBytes.length + 2 + sBytes.length + 4); + let offset = 0; + new DataView(buf.buffer).setUint16(offset, gBytes.length, false); offset += 2; + buf.set(gBytes, offset); offset += gBytes.length; + new DataView(buf.buffer).setUint16(offset, sBytes.length, false); offset += 2; + buf.set(sBytes, offset); offset += sBytes.length; + new DataView(buf.buffer).setUint32(offset, iteration, false); + return buf; +} diff --git a/packages/shade-core/src/sesame.ts b/packages/shade-core/src/sesame.ts new file mode 100644 index 0000000..a701e95 --- /dev/null +++ b/packages/shade-core/src/sesame.ts @@ -0,0 +1,147 @@ +import type { ShadeSessionManager } from './session.js'; +import type { ShadeEnvelope } from './types.js'; + +/** + * Multi-device fan-out (simplified Sesame). + * + * In the Signal protocol, "Sesame" tracks per-device sessions under a + * single user identity. A message to "bob" is fan-out encrypted to each + * of Bob's active devices using an independent 1:1 Double Ratchet per + * device. When Bob adds a new device, it needs to be added to the device + * list and the session is established with that device via a fresh X3DH. + * + * We keep it simple: addresses are formatted as `user:deviceId` and the + * existing ShadeSessionManager handles each device's session independently. + * This module adds: + * - DeviceList tracking per user + * - sendToUser(user, plaintext) → fans out to all known devices + * - receive-side handling that updates the device list automatically + * when a message arrives from a new device + * + * The transport layer (HTTP, WebSocket, push) still owns delivery per + * device. Sesame just handles the key-management side: "which devices + * belong to this user, and do I have a session with each?" + */ + +export interface DeviceList { + /** Each device is addressed as "user:deviceId" */ + devices: Set; + /** Last time this list was refreshed (for staleness checks) */ + lastUpdated: number; +} + +export class DeviceListManager { + private lists = new Map(); + + /** Get all known devices for a user */ + getDevices(user: string): string[] { + return Array.from(this.lists.get(user)?.devices ?? []); + } + + /** Add a device address to a user's list */ + addDevice(user: string, deviceAddress: string): void { + let list = this.lists.get(user); + if (!list) { + list = { devices: new Set(), lastUpdated: Date.now() }; + this.lists.set(user, list); + } + list.devices.add(deviceAddress); + list.lastUpdated = Date.now(); + } + + /** Remove a device from a user's list */ + removeDevice(user: string, deviceAddress: string): void { + const list = this.lists.get(user); + if (list) { + list.devices.delete(deviceAddress); + list.lastUpdated = Date.now(); + } + } + + /** Replace the full device list for a user (e.g. from server query) */ + setDevices(user: string, deviceAddresses: string[]): void { + this.lists.set(user, { + devices: new Set(deviceAddresses), + lastUpdated: Date.now(), + }); + } + + /** Serialize for persistence */ + toJSON(): Record { + const out: Record = {}; + for (const [user, list] of this.lists) { + out[user] = { + devices: Array.from(list.devices), + lastUpdated: list.lastUpdated, + }; + } + return out; + } + + /** Restore from serialized form */ + fromJSON(data: Record): void { + this.lists.clear(); + for (const [user, entry] of Object.entries(data)) { + this.lists.set(user, { + devices: new Set(entry.devices), + lastUpdated: entry.lastUpdated, + }); + } + } +} + +/** + * Fan-out a message to every device belonging to a user. + * + * Returns an array of (deviceAddress, envelope) pairs that the caller + * should deliver individually via their transport. + */ +export async function fanOutEncrypt( + manager: ShadeSessionManager, + deviceList: DeviceListManager, + user: string, + plaintext: string, +): Promise> { + const devices = deviceList.getDevices(user); + if (devices.length === 0) { + throw new Error(`No known devices for user: ${user}`); + } + + const results: Array<{ deviceAddress: string; envelope: ShadeEnvelope }> = []; + for (const deviceAddress of devices) { + const envelope = await manager.encrypt(deviceAddress, plaintext); + results.push({ deviceAddress, envelope }); + } + return results; +} + +/** + * Parse a device address (format: "user:deviceId") into its user part. + */ +export function userOfDevice(deviceAddress: string): string { + const colonIdx = deviceAddress.indexOf(':'); + if (colonIdx < 0) return deviceAddress; + return deviceAddress.substring(0, colonIdx); +} + +/** + * Parse a device address into its device-ID part. + */ +export function deviceIdOf(deviceAddress: string): string { + const colonIdx = deviceAddress.indexOf(':'); + if (colonIdx < 0) return ''; + return deviceAddress.substring(colonIdx + 1); +} + +/** + * Observe an incoming message and auto-register the sending device. + * Call this after decrypting a message from a peer — if the sender is + * a new device for a known user, add it to the device list. + */ +export function observeIncoming( + deviceList: DeviceListManager, + senderDeviceAddress: string, +): void { + const user = userOfDevice(senderDeviceAddress); + deviceList.addDevice(user, senderDeviceAddress); +} diff --git a/packages/shade-core/tests/sender-keys.test.ts b/packages/shade-core/tests/sender-keys.test.ts new file mode 100644 index 0000000..10a0565 --- /dev/null +++ b/packages/shade-core/tests/sender-keys.test.ts @@ -0,0 +1,170 @@ +import { describe, test, expect } from 'bun:test'; +import { SubtleCryptoProvider } from '@shade/crypto-web'; +import { + createSenderKey, + buildDistribution, + installDistribution, + senderKeyEncrypt, + senderKeyDecrypt, +} from '../src/sender-keys.js'; +import type { GroupSession, SenderKeyState } from '../src/sender-keys.js'; + +const crypto = new SubtleCryptoProvider(); + +/** + * Helpers: set up a group of N members who all know each other's sender keys. + */ +async function setupGroup(groupId: string, memberAddresses: string[]): Promise<{ + sessions: Map; +}> { + // Each member creates their own sender key state + const senderStates = new Map(); + for (const addr of memberAddresses) { + senderStates.set(addr, await createSenderKey(crypto)); + } + + // Each member's session contains everyone's sender key + const sessions = new Map(); + for (const member of memberAddresses) { + const session: GroupSession = { groupId, senderKeys: new Map() }; + + // Add own sender key (with private signing key) + session.senderKeys.set(member, senderStates.get(member)!); + + // Add public copies of everyone else's sender keys (without private key) + for (const other of memberAddresses) { + if (other === member) continue; + const dist = buildDistribution(groupId, other, senderStates.get(other)!); + installDistribution(session, dist); + } + + sessions.set(member, session); + } + + return { sessions }; +} + +describe('Sender Keys (group messaging)', () => { + test('Alice sends to Bob + Charlie + Dave', async () => { + const { sessions } = await setupGroup('group1', ['alice', 'bob', 'charlie', 'dave']); + + const msg = await senderKeyEncrypt( + crypto, + sessions.get('alice')!, + 'alice', + new TextEncoder().encode('hello everyone'), + ); + + // All three recipients decrypt independently + for (const recipient of ['bob', 'charlie', 'dave']) { + const plain = await senderKeyDecrypt(crypto, sessions.get(recipient)!, 'group1', msg); + expect(new TextDecoder().decode(plain)).toBe('hello everyone'); + } + }); + + test('multiple messages from same sender advance the chain', async () => { + const { sessions } = await setupGroup('g', ['alice', 'bob']); + + for (let i = 0; i < 5; i++) { + const msg = await senderKeyEncrypt( + crypto, + sessions.get('alice')!, + 'alice', + new TextEncoder().encode(`msg ${i}`), + ); + expect(msg.iteration).toBe(i); + + const plain = await senderKeyDecrypt(crypto, sessions.get('bob')!, 'g', msg); + expect(new TextDecoder().decode(plain)).toBe(`msg ${i}`); + } + }); + + test('messages from different senders use independent chains', async () => { + const { sessions } = await setupGroup('g', ['alice', 'bob', 'charlie']); + + // Alice sends + const aliceMsg = await senderKeyEncrypt( + crypto, sessions.get('alice')!, 'alice', new TextEncoder().encode('from alice'), + ); + // Bob sends + const bobMsg = await senderKeyEncrypt( + crypto, sessions.get('bob')!, 'bob', new TextEncoder().encode('from bob'), + ); + + // Charlie decrypts both + expect(new TextDecoder().decode( + await senderKeyDecrypt(crypto, sessions.get('charlie')!, 'g', aliceMsg) + )).toBe('from alice'); + expect(new TextDecoder().decode( + await senderKeyDecrypt(crypto, sessions.get('charlie')!, 'g', bobMsg) + )).toBe('from bob'); + }); + + test('tampered ciphertext fails signature verification', async () => { + const { sessions } = await setupGroup('g', ['alice', 'bob']); + + const msg = await senderKeyEncrypt( + crypto, sessions.get('alice')!, 'alice', new TextEncoder().encode('original'), + ); + msg.ciphertext[0] ^= 0xff; + + expect( + senderKeyDecrypt(crypto, sessions.get('bob')!, 'g', msg), + ).rejects.toThrow(/Invalid signature|decryption/i); + }); + + test('wrong group ID fails', async () => { + const { sessions } = await setupGroup('g', ['alice', 'bob']); + + const msg = await senderKeyEncrypt( + crypto, sessions.get('alice')!, 'alice', new TextEncoder().encode('hi'), + ); + + expect( + senderKeyDecrypt(crypto, sessions.get('bob')!, 'wrong-group', msg), + ).rejects.toThrow(/Group ID mismatch/); + }); + + test('out-of-order with small skip works', async () => { + const { sessions } = await setupGroup('g', ['alice', 'bob']); + + // Alice sends 3 messages + const messages = []; + for (let i = 0; i < 3; i++) { + messages.push(await senderKeyEncrypt( + crypto, sessions.get('alice')!, 'alice', new TextEncoder().encode(`msg ${i}`), + )); + } + + // Bob decrypts in order + for (let i = 0; i < 3; i++) { + const plain = await senderKeyDecrypt(crypto, sessions.get('bob')!, 'g', messages[i]!); + expect(new TextDecoder().decode(plain)).toBe(`msg ${i}`); + } + }); + + test('new member added later receives subsequent messages via fresh distribution', async () => { + // Alice and Bob in a group + const { sessions } = await setupGroup('g', ['alice', 'bob']); + + // Alice sends one message to Bob + const msg1 = await senderKeyEncrypt( + crypto, sessions.get('alice')!, 'alice', new TextEncoder().encode('private msg'), + ); + await senderKeyDecrypt(crypto, sessions.get('bob')!, 'g', msg1); + + // Charlie joins — Alice sends him her CURRENT sender key state + const aliceStateForCharlie = sessions.get('alice')!.senderKeys.get('alice')!; + const charlieSession: GroupSession = { groupId: 'g', senderKeys: new Map() }; + installDistribution(charlieSession, buildDistribution('g', 'alice', aliceStateForCharlie)); + + // Alice sends another message + const msg2 = await senderKeyEncrypt( + crypto, sessions.get('alice')!, 'alice', new TextEncoder().encode('welcome charlie'), + ); + + // Charlie can decrypt (because he got the current chain state) + const plain = await senderKeyDecrypt(crypto, charlieSession, 'g', msg2); + expect(new TextDecoder().decode(plain)).toBe('welcome charlie'); + }); +}); diff --git a/packages/shade-core/tests/sesame.test.ts b/packages/shade-core/tests/sesame.test.ts new file mode 100644 index 0000000..55a09bf --- /dev/null +++ b/packages/shade-core/tests/sesame.test.ts @@ -0,0 +1,160 @@ +import { describe, test, expect } from 'bun:test'; +import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web'; +import { ShadeSessionManager } from '../src/index.js'; +import { + DeviceListManager, + fanOutEncrypt, + observeIncoming, + userOfDevice, + deviceIdOf, +} from '../src/sesame.js'; + +const crypto = new SubtleCryptoProvider(); + +describe('DeviceListManager', () => { + test('add and get devices', () => { + const mgr = new DeviceListManager(); + mgr.addDevice('bob', 'bob:phone'); + mgr.addDevice('bob', 'bob:laptop'); + mgr.addDevice('bob', 'bob:tablet'); + + const devices = mgr.getDevices('bob'); + expect(devices.length).toBe(3); + expect(devices).toContain('bob:phone'); + expect(devices).toContain('bob:laptop'); + expect(devices).toContain('bob:tablet'); + }); + + test('remove device', () => { + const mgr = new DeviceListManager(); + mgr.addDevice('bob', 'bob:phone'); + mgr.addDevice('bob', 'bob:laptop'); + mgr.removeDevice('bob', 'bob:phone'); + expect(mgr.getDevices('bob')).toEqual(['bob:laptop']); + }); + + test('setDevices replaces list', () => { + const mgr = new DeviceListManager(); + mgr.addDevice('bob', 'bob:phone'); + mgr.setDevices('bob', ['bob:laptop', 'bob:tablet']); + expect(mgr.getDevices('bob').sort()).toEqual(['bob:laptop', 'bob:tablet']); + }); + + test('empty list for unknown user', () => { + const mgr = new DeviceListManager(); + expect(mgr.getDevices('nobody')).toEqual([]); + }); + + test('toJSON and fromJSON roundtrip', () => { + const mgr = new DeviceListManager(); + mgr.addDevice('bob', 'bob:phone'); + mgr.addDevice('alice', 'alice:laptop'); + + const json = mgr.toJSON(); + const restored = new DeviceListManager(); + restored.fromJSON(json); + + expect(restored.getDevices('bob')).toEqual(['bob:phone']); + expect(restored.getDevices('alice')).toEqual(['alice:laptop']); + }); +}); + +describe('Address parsing helpers', () => { + test('userOfDevice extracts the user part', () => { + expect(userOfDevice('bob:phone')).toBe('bob'); + expect(userOfDevice('alice@example.com:tablet')).toBe('alice@example.com'); + expect(userOfDevice('noColon')).toBe('noColon'); + }); + + test('deviceIdOf extracts the device part', () => { + expect(deviceIdOf('bob:phone')).toBe('phone'); + expect(deviceIdOf('alice@example.com:tablet')).toBe('tablet'); + expect(deviceIdOf('noColon')).toBe(''); + }); +}); + +describe('observeIncoming', () => { + test('auto-registers a new device', () => { + const mgr = new DeviceListManager(); + observeIncoming(mgr, 'bob:newPhone'); + expect(mgr.getDevices('bob')).toEqual(['bob:newPhone']); + }); + + test('second message from same device is idempotent', () => { + const mgr = new DeviceListManager(); + observeIncoming(mgr, 'bob:phone'); + observeIncoming(mgr, 'bob:phone'); + expect(mgr.getDevices('bob').length).toBe(1); + }); +}); + +describe('fanOutEncrypt: multi-device fan-out', () => { + async function setupAliceToBobDevices(devices: string[]) { + // Alice has one SessionManager + const alice = new ShadeSessionManager(crypto, new MemoryStorage()); + await alice.initialize(); + + // Each Bob device has its own SessionManager (separate storage) + const bobs = new Map(); + for (const device of devices) { + const mgr = new ShadeSessionManager(crypto, new MemoryStorage()); + await mgr.initialize(); + bobs.set(device, mgr); + } + + // Alice establishes a session with each Bob device + for (const [device, bob] of bobs) { + 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(device, bundle); + } + + // Alice tracks Bob's device list + const deviceList = new DeviceListManager(); + for (const device of devices) { + deviceList.addDevice('bob', device); + } + + return { alice, bobs, deviceList }; + } + + test('fans out to all devices', async () => { + const devices = ['bob:phone', 'bob:laptop', 'bob:tablet']; + const { alice, bobs, deviceList } = await setupAliceToBobDevices(devices); + + const fanOut = await fanOutEncrypt(alice, deviceList, 'bob', 'hello all my devices'); + + expect(fanOut.length).toBe(3); + for (const { deviceAddress, envelope } of fanOut) { + const bob = bobs.get(deviceAddress)!; + const plain = await bob.decrypt('alice', envelope); + expect(plain).toBe('hello all my devices'); + } + }); + + test('each device gets an independent session (DH ratchet per device)', async () => { + const devices = ['bob:phone', 'bob:laptop']; + const { alice, bobs, deviceList } = await setupAliceToBobDevices(devices); + + // Send two rounds + for (let i = 0; i < 2; i++) { + const fanOut = await fanOutEncrypt(alice, deviceList, 'bob', `round ${i}`); + for (const { deviceAddress, envelope } of fanOut) { + const bob = bobs.get(deviceAddress)!; + const plain = await bob.decrypt('alice', envelope); + expect(plain).toBe(`round ${i}`); + } + } + }); + + test('throws when user has no known devices', async () => { + const alice = new ShadeSessionManager(crypto, new MemoryStorage()); + await alice.initialize(); + const deviceList = new DeviceListManager(); + + expect( + fanOutEncrypt(alice, deviceList, 'unknown-user', 'hello'), + ).rejects.toThrow(/No known devices/); + }); +}); diff --git a/packages/shade-sdk/src/backup.ts b/packages/shade-sdk/src/backup.ts new file mode 100644 index 0000000..c49bbd1 --- /dev/null +++ b/packages/shade-sdk/src/backup.ts @@ -0,0 +1,217 @@ +import type { CryptoProvider, StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState } from '@shade/core'; +import { + toBase64, + fromBase64, + serializeIdentityKeyPair, + deserializeIdentityKeyPair, + serializeSignedPreKey, + deserializeSignedPreKey, + serializeOneTimePreKey, + deserializeOneTimePreKey, + serializeSessionState, + deserializeSessionState, +} from '@shade/core'; + +/** + * Shade backup format v1. + * + * A passphrase-encrypted blob containing the entire local Shade state: + * identity, signed prekeys, one-time prekeys, sessions, trusted identities. + * + * Encryption: AES-256-GCM. Key derivation from passphrase: + * HKDF-SHA256(passphrase, randomSalt, "ShadeBackupKey", 32) + * + * Note: HKDF is NOT a proper password KDF. For a real password-based backup + * you should use Argon2id. This implementation uses HKDF as a simple + * placeholder because CryptoProvider doesn't expose Argon2. Upgrade path + * is to add `argon2id` to CryptoProvider and swap it in here. For now, + * DOCUMENT THIS and require the user to choose a high-entropy passphrase. + */ + +const BACKUP_VERSION = 1; +const BACKUP_INFO = new TextEncoder().encode('ShadeBackupKey'); +const SALT_SIZE = 32; + +export interface BackupPayload { + version: number; + identity: string | null; + registrationId: number; + signedPreKeys: Array<{ keyId: number; data: string }>; + oneTimePreKeys: Array<{ keyId: number; data: string }>; + sessions: Array<{ address: string; state: string }>; + trustedIdentities: Array<{ address: string; key: string }>; +} + +export interface BackupBlob { + version: number; + salt: string; // base64 + nonce: string; // base64 + ciphertext: string; // base64 +} + +/** + * Export the entire Shade state as an encrypted backup blob. + * + * @param crypto CryptoProvider instance + * @param storage Storage to read from + * @param passphrase User-chosen passphrase (HIGH ENTROPY REQUIRED) + * @param knownAddresses Addresses whose sessions should be included. + * Pass an empty list to back up identity + prekeys only (no sessions). + */ +export async function exportBackup( + crypto: CryptoProvider, + storage: StorageProvider, + passphrase: string, + knownAddresses: string[] = [], +): Promise { + if (passphrase.length < 12) { + throw new Error('Passphrase must be at least 12 characters'); + } + + // Gather state + const identity = await storage.getIdentityKeyPair(); + const registrationId = await storage.getLocalRegistrationId(); + + const signedPreKeys: Array<{ keyId: number; data: string }> = []; + // SignedPreKeys: we can't enumerate them from the interface; try IDs 1-100 + for (let i = 1; i <= 100; i++) { + const spk = await storage.getSignedPreKey(i); + if (spk) signedPreKeys.push({ keyId: i, data: serializeSignedPreKey(spk) }); + } + + const oneTimePreKeys: Array<{ keyId: number; data: string }> = []; + const otpkCount = await storage.getOneTimePreKeyCount(); + // Try a reasonable ID range + for (let i = 1; i <= otpkCount + 1000; i++) { + const otpk = await storage.getOneTimePreKey(i); + if (otpk) oneTimePreKeys.push({ keyId: i, data: serializeOneTimePreKey(otpk) }); + if (oneTimePreKeys.length >= otpkCount) break; + } + + const sessions: Array<{ address: string; state: string }> = []; + for (const address of knownAddresses) { + const state = await storage.getSession(address); + if (state) sessions.push({ address, state: serializeSessionState(state) }); + } + + // Trusted identities: can't enumerate; caller must provide addresses + const trustedIdentities: Array<{ address: string; key: string }> = []; + + const payload: BackupPayload = { + version: BACKUP_VERSION, + identity: identity ? serializeIdentityKeyPair(identity) : null, + registrationId, + signedPreKeys, + oneTimePreKeys, + sessions, + trustedIdentities, + }; + + const plaintext = new TextEncoder().encode(JSON.stringify(payload)); + + // Derive encryption key from passphrase via HKDF + const salt = crypto.randomBytes(SALT_SIZE); + const key = await crypto.hkdf( + new TextEncoder().encode(passphrase), + salt, + BACKUP_INFO, + 32, + ); + + const { ciphertext, nonce } = await crypto.aesGcmEncrypt(key, plaintext); + + // Zero the derived key after use + crypto.zeroize(key); + + return { + version: BACKUP_VERSION, + salt: toBase64(salt), + nonce: toBase64(nonce), + ciphertext: toBase64(ciphertext), + }; +} + +/** + * Import a backup blob, decrypt with the passphrase, and write all state + * to the given storage (overwriting existing state). + */ +export async function importBackup( + crypto: CryptoProvider, + storage: StorageProvider, + blob: BackupBlob, + passphrase: string, +): Promise { + if (blob.version !== BACKUP_VERSION) { + throw new Error(`Unsupported backup version: ${blob.version}`); + } + + const salt = fromBase64(blob.salt); + const nonce = fromBase64(blob.nonce); + const ciphertext = fromBase64(blob.ciphertext); + + // Derive the same key + const key = await crypto.hkdf( + new TextEncoder().encode(passphrase), + salt, + BACKUP_INFO, + 32, + ); + + let plaintext: Uint8Array; + try { + plaintext = await crypto.aesGcmDecrypt(key, ciphertext, nonce); + } catch { + crypto.zeroize(key); + throw new Error('Wrong passphrase or corrupted backup'); + } + + crypto.zeroize(key); + + const payload = JSON.parse(new TextDecoder().decode(plaintext)) as BackupPayload; + + // Restore identity + if (payload.identity) { + await storage.saveIdentityKeyPair(deserializeIdentityKeyPair(payload.identity)); + } + await storage.saveLocalRegistrationId(payload.registrationId); + + // Restore signed prekeys + for (const spk of payload.signedPreKeys) { + await storage.saveSignedPreKey(deserializeSignedPreKey(spk.data)); + } + + // Restore one-time prekeys + for (const otpk of payload.oneTimePreKeys) { + await storage.saveOneTimePreKey(deserializeOneTimePreKey(otpk.data)); + } + + // Restore sessions + for (const s of payload.sessions) { + await storage.saveSession(s.address, deserializeSessionState(s.state)); + } + + // Restore trust + for (const t of payload.trustedIdentities) { + await storage.saveTrustedIdentity(t.address, fromBase64(t.key)); + } +} + +/** Serialize a backup blob to a compact single-string form (for copy/paste or QR). */ +export function backupToString(blob: BackupBlob): string { + return `shade-backup:v${blob.version}:${blob.salt}:${blob.nonce}:${blob.ciphertext}`; +} + +/** Parse a backup string back into a BackupBlob. */ +export function backupFromString(str: string): BackupBlob { + const parts = str.split(':'); + if (parts.length !== 5 || parts[0] !== 'shade-backup') { + throw new Error('Invalid backup string format'); + } + const version = parseInt(parts[1]!.replace('v', ''), 10); + return { + version, + salt: parts[2]!, + nonce: parts[3]!, + ciphertext: parts[4]!, + }; +} diff --git a/packages/shade-sdk/src/index.ts b/packages/shade-sdk/src/index.ts index dd33fd3..b2d334a 100644 --- a/packages/shade-sdk/src/index.ts +++ b/packages/shade-sdk/src/index.ts @@ -2,5 +2,12 @@ export { createShade } from './create-shade.js'; export { Shade } from './shade.js'; export { resolveConfig, parseRotationInterval } from './config.js'; export { BackgroundTasks } from './background.js'; +export { + exportBackup, + importBackup, + backupToString, + backupFromString, +} from './backup.js'; export type { ShadeConfig, ResolvedConfig } from './config.js'; export type { BackgroundHooks } from './background.js'; +export type { BackupBlob, BackupPayload } from './backup.js'; diff --git a/packages/shade-sdk/src/shade.ts b/packages/shade-sdk/src/shade.ts index 22dd560..da35744 100644 --- a/packages/shade-sdk/src/shade.ts +++ b/packages/shade-sdk/src/shade.ts @@ -7,6 +7,7 @@ import { import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web'; import { ShadeFetchTransport } from '@shade/transport'; import { BackgroundTasks, type BackgroundHooks } from './background.js'; +import { exportBackup, importBackup, backupToString, backupFromString, type BackupBlob } from './backup.js'; import type { ResolvedConfig } from './config.js'; /** @@ -236,6 +237,40 @@ export class Shade { return this.background.runReplenish(); } + /** + * Export an encrypted backup blob that can be restored to a new device. + * + * @param passphrase User passphrase (minimum 12 characters) + * @param knownAddresses Peer addresses whose sessions should be included + */ + async exportBackup(passphrase: string, knownAddresses: string[] = []): Promise { + if (!this.initialized) throw new Error('Not initialized'); + const blob = await exportBackup(this.crypto, this.storage, passphrase, knownAddresses); + return backupToString(blob); + } + + /** + * Restore state from a backup string. Overwrites existing state. + * Call this BEFORE initialize() on a fresh device, or after shutdown() + re-init. + */ + async importBackup(backupString: string, passphrase: string): Promise { + if (!this.initialized) throw new Error('Not initialized'); + const blob = backupFromString(backupString); + await importBackup(this.crypto, this.storage, blob, passphrase); + // Reload identity after restore + const restored = await this.storage.getIdentityKeyPair(); + if (restored) { + // Rebuild the manager and transport with the restored identity + this.manager = new ShadeSessionManager(this.crypto, this.storage, { events: this.events }); + await this.manager.initialize(); + this.transport = new ShadeFetchTransport({ + baseUrl: this.config.prekeyServer, + crypto: this.crypto, + signingPrivateKey: restored.signingPrivateKey, + }); + } + } + /** Clean shutdown: stop timers, close storage if it supports it */ async shutdown(): Promise { this.background?.stop(); diff --git a/packages/shade-sdk/tests/backup.test.ts b/packages/shade-sdk/tests/backup.test.ts new file mode 100644 index 0000000..b36d6de --- /dev/null +++ b/packages/shade-sdk/tests/backup.test.ts @@ -0,0 +1,107 @@ +import { describe, test, expect } from 'bun:test'; +import { + exportBackup, + importBackup, + backupToString, + backupFromString, +} from '../src/backup.js'; +import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web'; +import { ShadeSessionManager } from '@shade/core'; + +const crypto = new SubtleCryptoProvider(); + +describe('Backup export/import', () => { + test('roundtrip: export then import reproduces identity', async () => { + const sourceStorage = new MemoryStorage(); + const manager = new ShadeSessionManager(crypto, sourceStorage); + await manager.initialize(); + await manager.generateOneTimePreKeys(5); + + const originalId = await sourceStorage.getIdentityKeyPair(); + const originalRegId = await sourceStorage.getLocalRegistrationId(); + + // Export + const blob = await exportBackup(crypto, sourceStorage, 'correct horse battery staple'); + + // Import into a fresh storage + const targetStorage = new MemoryStorage(); + await importBackup(crypto, targetStorage, blob, 'correct horse battery staple'); + + const restoredId = await targetStorage.getIdentityKeyPair(); + expect(restoredId).not.toBeNull(); + expect(restoredId!.signingPublicKey).toEqual(originalId!.signingPublicKey); + expect(restoredId!.dhPrivateKey).toEqual(originalId!.dhPrivateKey); + expect(await targetStorage.getLocalRegistrationId()).toBe(originalRegId); + expect(await targetStorage.getOneTimePreKeyCount()).toBe(5); + }); + + test('wrong passphrase fails to import', async () => { + const storage = new MemoryStorage(); + const manager = new ShadeSessionManager(crypto, storage); + await manager.initialize(); + + const blob = await exportBackup(crypto, storage, 'correct passphrase'); + + const target = new MemoryStorage(); + expect( + importBackup(crypto, target, blob, 'wrong passphrase'), + ).rejects.toThrow(/Wrong passphrase|corrupted/); + }); + + test('short passphrase is rejected', async () => { + const storage = new MemoryStorage(); + const manager = new ShadeSessionManager(crypto, storage); + await manager.initialize(); + expect(exportBackup(crypto, storage, 'short')).rejects.toThrow(/at least 12/); + }); + + test('sessions are included when addresses provided', async () => { + const aliceStorage = new MemoryStorage(); + const bobStorage = new MemoryStorage(); + const alice = new ShadeSessionManager(crypto, aliceStorage); + const bob = new ShadeSessionManager(crypto, bobStorage); + await alice.initialize(); + await bob.initialize(); + + 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); + const env = await alice.encrypt('bob', 'hello'); + await bob.decrypt('alice', env); + + // Export with known addresses + const blob = await exportBackup( + crypto, + aliceStorage, + 'twelve chars minimum', + ['bob'], + ); + + const target = new MemoryStorage(); + await importBackup(crypto, target, blob, 'twelve chars minimum'); + + const restoredSession = await target.getSession('bob'); + expect(restoredSession).not.toBeNull(); + }); + + test('backupToString / backupFromString roundtrip', async () => { + const storage = new MemoryStorage(); + const manager = new ShadeSessionManager(crypto, storage); + await manager.initialize(); + + const blob = await exportBackup(crypto, storage, 'twelve chars minimum'); + const str = backupToString(blob); + expect(str.startsWith('shade-backup:v1:')).toBe(true); + + const parsed = backupFromString(str); + expect(parsed.version).toBe(blob.version); + expect(parsed.salt).toBe(blob.salt); + expect(parsed.nonce).toBe(blob.nonce); + expect(parsed.ciphertext).toBe(blob.ciphertext); + }); + + test('invalid backup string format throws', () => { + expect(() => backupFromString('not-a-valid-backup')).toThrow(/Invalid backup/); + }); +});