/** * T5 — ephemeral X25519 keygen + ECDH shared secret via WebCrypto. * * The private key is a NON-EXTRACTABLE `CryptoKey` (INV5): it never leaves as bytes — * `exportKey('raw', priv)` rejects. Fresh per handshake (forward secrecy). */ import { getWebCrypto } from './crypto-provider.js' import { E2EError } from './errors.js' const ALG = 'X25519' const SHARED_SECRET_BITS = 256 export interface EphemeralKeyPair { readonly publicKey: Uint8Array readonly privateKey: CryptoKey } /** Generate a fresh X25519 keypair; private key is non-extractable. */ export async function generateEphemeralKeyPair(): Promise { const subtle = getWebCrypto() const pair = (await subtle.generateKey({ name: ALG }, false, ['deriveBits'])) as CryptoKeyPair const rawPub = await subtle.exportKey('raw', pair.publicKey) return { publicKey: new Uint8Array(rawPub), privateKey: pair.privateKey } } /** Derive the 32-byte raw ECDH shared secret from our private key + the peer's raw public key. */ export async function deriveSharedSecret( privateKey: CryptoKey, peerPublicKey: Uint8Array, ): Promise { const subtle = getWebCrypto() // Copy into a fresh ArrayBuffer-backed view so it satisfies DOM `BufferSource` (not SharedArrayBuffer). const peerRaw = new Uint8Array(peerPublicKey.length) peerRaw.set(peerPublicKey) let peer: CryptoKey try { peer = await subtle.importKey('raw', peerRaw, { name: ALG }, false, []) } catch { throw new E2EError('E2E_BAD_PEER_KEY', 'peer X25519 public key is invalid') } try { const bits = await subtle.deriveBits({ name: ALG, public: peer }, privateKey, SHARED_SECRET_BITS) return new Uint8Array(bits) } catch { throw new E2EError('E2E_ECDH_FAILED', 'X25519 ECDH derivation failed') } }