// Run-flow cryptography (crypto.md, extension/claude.md "Cryptographic // responsibilities"). Pure functions — no chrome.* APIs — so they're usable // unchanged from the companion test harness (scripts/) for cross-checking, // and testable in isolation. import { x25519 } from '@noble/curves/ed25519.js'; import { ml_kem768 } from '@noble/post-quantum/ml-kem.js'; export interface X25519Keypair { secretKey: Uint8Array; publicKey: Uint8Array; } export interface MlKem768Keypair { secretKey: Uint8Array; publicKey: Uint8Array; } export function generateX25519Keypair(): X25519Keypair { return x25519.keygen(); } export function generateMlKem768Keypair(): MlKem768Keypair { return ml_kem768.keygen(); } export function x25519SharedSecret(secretKey: Uint8Array, peerPublicKey: Uint8Array): Uint8Array { return x25519.getSharedSecret(secretKey, peerPublicKey); } export function mlKem768Encapsulate(publicKey: Uint8Array): { cipherText: Uint8Array; sharedSecret: Uint8Array } { return ml_kem768.encapsulate(publicKey); } export function mlKem768Decapsulate(cipherText: Uint8Array, secretKey: Uint8Array): Uint8Array { return ml_kem768.decapsulate(cipherText, secretKey); } function concat(...parts: Uint8Array[]): Uint8Array { const total = parts.reduce((sum, p) => sum + p.length, 0); const out = new Uint8Array(total); let offset = 0; for (const part of parts) { out.set(part, offset); offset += part.length; } return out; } /** * alpha_hash = SHA-256(x25519_pub || kem_pub) — classical precedes * post-quantum per crypto.md's normative hybrid-construction-ordering rule. */ export async function alphaHash(x25519Pub: Uint8Array, kemPub: Uint8Array): Promise { const digest = await crypto.subtle.digest('SHA-256', concat(x25519Pub, kemPub) as BufferSource); return new Uint8Array(digest); } async function hkdfSha256(ikm: Uint8Array, salt: Uint8Array, info: string, lengthBytes: number): Promise { const key = await crypto.subtle.importKey('raw', ikm as BufferSource, 'HKDF', false, ['deriveBits']); const bits = await crypto.subtle.deriveBits( { name: 'HKDF', hash: 'SHA-256', salt: salt as BufferSource, info: new TextEncoder().encode(info) }, key, lengthBytes * 8, ); return new Uint8Array(bits); } /** * run_key = HKDF-SHA256(ikm = x25519_shared || kem_shared_secret, * salt = runId_utf8, info = "locqr-run-key-v1", length = 32) — crypto.md. */ export async function deriveRunKey(x25519Shared: Uint8Array, kemSharedSecret: Uint8Array, runId: string): Promise { const ikm = concat(x25519Shared, kemSharedSecret); const salt = new TextEncoder().encode(runId); return hkdfSha256(ikm, salt, 'locqr-run-key-v1', 32); } /** * PIN = HKDF-SHA256(ikm=run_key, salt=[], info="locqr-pin-v1", length=4) * (mobile/claude.md — not restated in crypto.md itself), interpreted as a * big-endian uint32, mod 1,000,000, zero-padded to 6 digits. Both the * extension and the companion (here, the test harness) must implement this * identically or the two sides' PINs will never match. */ export async function derivePin(runKey: Uint8Array): Promise { const bits = await hkdfSha256(runKey, new Uint8Array(0), 'locqr-pin-v1', 4); const value = new DataView(bits.buffer, bits.byteOffset, bits.byteLength).getUint32(0, false); return (value % 1_000_000).toString().padStart(6, '0'); } export interface Credential { username: string; password: string; } /** * Wire framing (interfaces.md): nonce[12] || ciphertext[N] || tag[16]. * WebCrypto's AES-GCM expects ciphertext+tag concatenated as one buffer * (tag last, tagLength defaults to 128 bits) — which is exactly this wire * format already, once the nonce is split off. No manual tag separation * needed. */ export async function decryptCredential(runKey: Uint8Array, payload: Uint8Array): Promise { const nonce = payload.slice(0, 12); const ciphertextAndTag = payload.slice(12); const key = await crypto.subtle.importKey('raw', runKey as BufferSource, 'AES-GCM', false, ['decrypt']); const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce as BufferSource }, key, ciphertextAndTag as BufferSource); return JSON.parse(new TextDecoder().decode(plaintext)) as Credential; }