Files
Shade/packages/shade-core/src/sender-keys.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

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