Files
locqr/extension/src/background/crypto.ts
T
2026-08-16 22:00:08 +02:00

111 lines
4.3 KiB
TypeScript

// 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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
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<string> {
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<Credential> {
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;
}