Files
Shade/packages/shade-sdk/tests/backup.test.ts
Sterister 467dd5b065
Some checks failed
Test / test (push) Has been cancelled
feat(advanced): M-Adv 1-3 — multi-device, backup/restore, group messaging
Phase D complete. Shade is now at parity with Signal libsignal's core
feature set.

M-Adv 1: Multi-device support (simplified Sesame)
- DeviceListManager tracks per-user device lists ("user:deviceId" addresses)
- fanOutEncrypt() sends one message to all known devices via independent
  1:1 Double Ratchet sessions
- observeIncoming() auto-registers new devices from received messages
- JSON serialization for persistence
- userOfDevice/deviceIdOf address parsers

M-Adv 2: Backup and restore
- @shade/sdk exports BackupBlob format: version + salt + nonce + ciphertext
- Passphrase-derived key via HKDF (note: upgrade path to Argon2id documented)
- exportBackup()/importBackup() handle identity, prekeys, sessions, trust
- backupToString/backupFromString for single-string transport (copy/paste, QR)
- shade.exportBackup()/importBackup() convenience methods on SDK
- CLI: shade backup export <file> / shade backup restore <file>
- Rebuilds manager + transport after restore so ratchet state is consistent

M-Adv 3: Group messaging (Sender Keys)
- Per-sender chain key + Ed25519 signing key per group
- createSenderKey / buildDistribution / installDistribution for key distribution
- senderKeyEncrypt advances chain and signs ciphertext+header
- senderKeyDecrypt verifies signature then advances the sender's chain
- Out-of-order handling with bounded skip
- O(1) per message (once distributions are installed)
- Defensive ByteArray copies in distribution to prevent zeroize-across-refs

276 tests passing, 0 failures. All 13 SDK/tooling/platform/advanced
milestones complete. Shade is feature-complete for v2.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:51:34 +02:00

108 lines
3.9 KiB
TypeScript

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/);
});
});