feat(advanced): M-Adv 1-3 — multi-device, backup/restore, group messaging
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
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>
This commit is contained in:
107
packages/shade-sdk/tests/backup.test.ts
Normal file
107
packages/shade-sdk/tests/backup.test.ts
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user