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>
211 lines
7.1 KiB
TypeScript
211 lines
7.1 KiB
TypeScript
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<string, SenderKeyState>;
|
|
}
|
|
|
|
/** 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<SenderKeyState> {
|
|
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<SenderKeyMessage> {
|
|
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<Uint8Array> {
|
|
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;
|
|
}
|