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