247 lines
8.4 KiB
TypeScript
247 lines
8.4 KiB
TypeScript
|
|
import type { CryptoProvider } from './crypto.js';
|
||
|
|
import type { StorageProvider } from './storage.js';
|
||
|
|
import type {
|
||
|
|
IdentityKeyPair,
|
||
|
|
KeyPair,
|
||
|
|
SignedPreKey,
|
||
|
|
OneTimePreKey,
|
||
|
|
PreKeyBundle,
|
||
|
|
PreKeyMessage,
|
||
|
|
RatchetMessage,
|
||
|
|
SessionState,
|
||
|
|
} from './types.js';
|
||
|
|
import { deriveInitialRootKey } from './keys.js';
|
||
|
|
import { InvalidSignatureError, PreKeyNotFoundError, UntrustedIdentityError } from './errors.js';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* X3DH — Extended Triple Diffie-Hellman key agreement.
|
||
|
|
*
|
||
|
|
* Establishes a shared secret between two parties (Alice and Bob) even when
|
||
|
|
* Bob is offline, using prekey bundles published to a server.
|
||
|
|
*
|
||
|
|
* Reference: https://signal.org/docs/specifications/x3dh/
|
||
|
|
*/
|
||
|
|
|
||
|
|
// ─── Key Generation ──────────────────────────────────────────
|
||
|
|
|
||
|
|
/** Generate a new identity keypair: Ed25519 (signing) + X25519 (DH) */
|
||
|
|
export async function generateIdentityKeyPair(crypto: CryptoProvider): Promise<IdentityKeyPair> {
|
||
|
|
const [signing, dh] = await Promise.all([
|
||
|
|
crypto.generateEd25519KeyPair(),
|
||
|
|
crypto.generateX25519KeyPair(),
|
||
|
|
]);
|
||
|
|
return {
|
||
|
|
signingPublicKey: signing.publicKey,
|
||
|
|
signingPrivateKey: signing.privateKey,
|
||
|
|
dhPublicKey: dh.publicKey,
|
||
|
|
dhPrivateKey: dh.privateKey,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Generate a signed prekey: X25519 keypair signed by the identity key */
|
||
|
|
export async function generateSignedPreKey(
|
||
|
|
crypto: CryptoProvider,
|
||
|
|
identityKey: IdentityKeyPair,
|
||
|
|
keyId: number,
|
||
|
|
): Promise<SignedPreKey> {
|
||
|
|
const keyPair = await crypto.generateX25519KeyPair();
|
||
|
|
const signature = await crypto.sign(identityKey.signingPrivateKey, keyPair.publicKey);
|
||
|
|
return {
|
||
|
|
keyId,
|
||
|
|
keyPair,
|
||
|
|
signature,
|
||
|
|
timestamp: Date.now(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Generate a batch of one-time prekeys */
|
||
|
|
export async function generateOneTimePreKeys(
|
||
|
|
crypto: CryptoProvider,
|
||
|
|
startId: number,
|
||
|
|
count: number,
|
||
|
|
): Promise<OneTimePreKey[]> {
|
||
|
|
const keys: OneTimePreKey[] = [];
|
||
|
|
for (let i = 0; i < count; i++) {
|
||
|
|
const keyPair = await crypto.generateX25519KeyPair();
|
||
|
|
keys.push({ keyId: startId + i, keyPair });
|
||
|
|
}
|
||
|
|
return keys;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Assemble a prekey bundle for publishing to the prekey server */
|
||
|
|
export function createPreKeyBundle(
|
||
|
|
registrationId: number,
|
||
|
|
identityKey: IdentityKeyPair,
|
||
|
|
signedPreKey: SignedPreKey,
|
||
|
|
oneTimePreKey?: OneTimePreKey,
|
||
|
|
): PreKeyBundle {
|
||
|
|
return {
|
||
|
|
registrationId,
|
||
|
|
identitySigningKey: identityKey.signingPublicKey,
|
||
|
|
identityDHKey: identityKey.dhPublicKey,
|
||
|
|
signedPreKey: {
|
||
|
|
keyId: signedPreKey.keyId,
|
||
|
|
publicKey: signedPreKey.keyPair.publicKey,
|
||
|
|
signature: signedPreKey.signature,
|
||
|
|
},
|
||
|
|
oneTimePreKey: oneTimePreKey
|
||
|
|
? { keyId: oneTimePreKey.keyId, publicKey: oneTimePreKey.keyPair.publicKey }
|
||
|
|
: undefined,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Alice: Initiate Session ─────────────────────────────────
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Process a prekey bundle to establish a new session (Alice's side).
|
||
|
|
*
|
||
|
|
* Steps:
|
||
|
|
* 1. Verify the signed prekey's signature using Bob's identity signing key
|
||
|
|
* 2. Generate an ephemeral X25519 keypair
|
||
|
|
* 3. Compute 3 or 4 DH shared secrets:
|
||
|
|
* DH1 = DH(Alice identity DH, Bob signed prekey)
|
||
|
|
* DH2 = DH(Alice ephemeral, Bob identity DH)
|
||
|
|
* DH3 = DH(Alice ephemeral, Bob signed prekey)
|
||
|
|
* DH4 = DH(Alice ephemeral, Bob one-time prekey) — if available
|
||
|
|
* 4. Derive the initial root key from the concatenated DH outputs
|
||
|
|
* 5. Return the X3DH result with all info needed to create a session + PreKeyMessage
|
||
|
|
*/
|
||
|
|
export interface X3DHInitResult {
|
||
|
|
/** Initial root key (32 bytes) — seeds the Double Ratchet */
|
||
|
|
rootKey: Uint8Array;
|
||
|
|
/** Alice's ephemeral X25519 public key (sent to Bob in the PreKeyMessage) */
|
||
|
|
ephemeralPublicKey: Uint8Array;
|
||
|
|
/** Bob's signed prekey ID (so Bob can look up the key) */
|
||
|
|
signedPreKeyId: number;
|
||
|
|
/** Bob's one-time prekey ID if consumed */
|
||
|
|
preKeyId?: number;
|
||
|
|
/** Bob's identity DH public key */
|
||
|
|
remoteIdentityKey: Uint8Array;
|
||
|
|
/** Bob's signed prekey public key (used as initial DH ratchet remote key) */
|
||
|
|
remoteSignedPreKey: Uint8Array;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function processPreKeyBundle(
|
||
|
|
crypto: CryptoProvider,
|
||
|
|
storage: StorageProvider,
|
||
|
|
bundle: PreKeyBundle,
|
||
|
|
): Promise<X3DHInitResult> {
|
||
|
|
// 1. Verify signed prekey signature
|
||
|
|
const valid = await crypto.verify(
|
||
|
|
bundle.identitySigningKey,
|
||
|
|
bundle.signedPreKey.publicKey,
|
||
|
|
bundle.signedPreKey.signature,
|
||
|
|
);
|
||
|
|
if (!valid) {
|
||
|
|
throw new InvalidSignatureError('Signed prekey signature is invalid');
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. Check trust (TOFU or pinned)
|
||
|
|
// We trust based on the DH key since that's what we use in the protocol
|
||
|
|
const identityKey = await storage.getIdentityKeyPair();
|
||
|
|
if (!identityKey) throw new Error('No local identity key — call initialize() first');
|
||
|
|
|
||
|
|
// 3. Generate ephemeral keypair
|
||
|
|
const ephemeral = await crypto.generateX25519KeyPair();
|
||
|
|
|
||
|
|
// 4. Compute DH shared secrets
|
||
|
|
const dh1 = await crypto.x25519(identityKey.dhPrivateKey, bundle.signedPreKey.publicKey);
|
||
|
|
const dh2 = await crypto.x25519(ephemeral.privateKey, bundle.identityDHKey);
|
||
|
|
const dh3 = await crypto.x25519(ephemeral.privateKey, bundle.signedPreKey.publicKey);
|
||
|
|
|
||
|
|
const secrets = [dh1, dh2, dh3];
|
||
|
|
|
||
|
|
let preKeyId: number | undefined;
|
||
|
|
if (bundle.oneTimePreKey) {
|
||
|
|
const dh4 = await crypto.x25519(ephemeral.privateKey, bundle.oneTimePreKey.publicKey);
|
||
|
|
secrets.push(dh4);
|
||
|
|
preKeyId = bundle.oneTimePreKey.keyId;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 5. Derive initial root key
|
||
|
|
const rootKey = await deriveInitialRootKey(crypto, secrets);
|
||
|
|
|
||
|
|
// 6. Save trust for remote identity
|
||
|
|
await storage.saveTrustedIdentity('pending', bundle.identityDHKey);
|
||
|
|
|
||
|
|
return {
|
||
|
|
rootKey,
|
||
|
|
ephemeralPublicKey: ephemeral.publicKey,
|
||
|
|
signedPreKeyId: bundle.signedPreKey.keyId,
|
||
|
|
preKeyId,
|
||
|
|
remoteIdentityKey: bundle.identityDHKey,
|
||
|
|
remoteSignedPreKey: bundle.signedPreKey.publicKey,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Bob: Respond to PreKeyMessage ───────────────────────────
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Process an incoming PreKeyMessage to establish a session (Bob's side).
|
||
|
|
*
|
||
|
|
* Steps:
|
||
|
|
* 1. Look up the signed prekey and optionally the one-time prekey
|
||
|
|
* 2. Compute the same 3 or 4 DH shared secrets (from Bob's perspective)
|
||
|
|
* 3. Derive the same initial root key
|
||
|
|
* 4. Delete the consumed one-time prekey
|
||
|
|
* 5. Return the X3DH result to seed the Double Ratchet
|
||
|
|
*/
|
||
|
|
export interface X3DHResponseResult {
|
||
|
|
/** Initial root key (32 bytes) — must match Alice's */
|
||
|
|
rootKey: Uint8Array;
|
||
|
|
/** Alice's identity DH public key */
|
||
|
|
remoteIdentityKey: Uint8Array;
|
||
|
|
/** Alice's ephemeral public key (used as initial DH ratchet remote key) */
|
||
|
|
remoteEphemeralKey: Uint8Array;
|
||
|
|
/** The embedded first ratchet message to decrypt */
|
||
|
|
initialMessage: RatchetMessage;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function processPreKeyMessage(
|
||
|
|
crypto: CryptoProvider,
|
||
|
|
storage: StorageProvider,
|
||
|
|
message: PreKeyMessage,
|
||
|
|
): Promise<X3DHResponseResult> {
|
||
|
|
const identityKey = await storage.getIdentityKeyPair();
|
||
|
|
if (!identityKey) throw new Error('No local identity key — call initialize() first');
|
||
|
|
|
||
|
|
// 1. Look up signed prekey
|
||
|
|
const signedPreKey = await storage.getSignedPreKey(message.signedPreKeyId);
|
||
|
|
if (!signedPreKey) {
|
||
|
|
throw new PreKeyNotFoundError(message.signedPreKeyId, 'signed');
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. Compute DH shared secrets (Bob's perspective — mirrored from Alice)
|
||
|
|
const dh1 = await crypto.x25519(signedPreKey.keyPair.privateKey, message.identityDHKey);
|
||
|
|
const dh2 = await crypto.x25519(identityKey.dhPrivateKey, message.ephemeralKey);
|
||
|
|
const dh3 = await crypto.x25519(signedPreKey.keyPair.privateKey, message.ephemeralKey);
|
||
|
|
|
||
|
|
const secrets = [dh1, dh2, dh3];
|
||
|
|
|
||
|
|
// 3. If a one-time prekey was used, include DH4
|
||
|
|
if (message.preKeyId != null) {
|
||
|
|
const oneTimePreKey = await storage.getOneTimePreKey(message.preKeyId);
|
||
|
|
if (!oneTimePreKey) {
|
||
|
|
throw new PreKeyNotFoundError(message.preKeyId, 'one-time');
|
||
|
|
}
|
||
|
|
const dh4 = await crypto.x25519(oneTimePreKey.keyPair.privateKey, message.ephemeralKey);
|
||
|
|
secrets.push(dh4);
|
||
|
|
|
||
|
|
// 4. Consume (delete) the one-time prekey
|
||
|
|
await storage.removeOneTimePreKey(message.preKeyId);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 5. Derive the initial root key (should match Alice's)
|
||
|
|
const rootKey = await deriveInitialRootKey(crypto, secrets);
|
||
|
|
|
||
|
|
// 6. Save trust for remote identity
|
||
|
|
await storage.saveTrustedIdentity('pending', message.identityDHKey);
|
||
|
|
|
||
|
|
return {
|
||
|
|
rootKey,
|
||
|
|
remoteIdentityKey: message.identityDHKey,
|
||
|
|
remoteEphemeralKey: message.ephemeralKey,
|
||
|
|
initialMessage: message.message,
|
||
|
|
};
|
||
|
|
}
|