111 lines
4.3 KiB
TypeScript
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;
|
|
}
|