diff --git a/extension/src/service-worker/__tests__/sshsig.test.ts b/extension/src/service-worker/__tests__/sshsig.test.ts new file mode 100644 index 0000000..f914807 --- /dev/null +++ b/extension/src/service-worker/__tests__/sshsig.test.ts @@ -0,0 +1,114 @@ +import { createPrivateKey, sign as nodeSign } from 'node:crypto'; +import { describe, expect, test } from 'vitest'; +import { buildSshSig } from '../sshsig'; + +// --------------------------------------------------------------------------- +// Fixed-seed deterministic ed25519 signer (Node-only; test-only). +// sshsig.ts itself uses only Web APIs — the signer is injected. +// --------------------------------------------------------------------------- + +const SEED_HEX = + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + +const PUB_OPENSSH = + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAOhB7/zzhC+HXDdGOdLwJln5NYwm6UNXx3chmQSVTG4'; + +const PAYLOAD = + 'tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor Owner 1750000000 +0000\ncommitter Owner 1750000000 +0000\n\norg add\n'; + +// Byte-exact golden vector — already confirmed against real `git verify-commit`. +const GOLDEN_ARMOR = + '-----BEGIN SSH SIGNATURE-----\n' + + 'U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgA6EHv/POEL4dcN0Y50vAmWfk1j\n' + + 'CbpQ1fHdyGZBJVMbgAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\n' + + 'AAAAQFLDkEyykOw6Z+I9qLKtTDToj2pXIfgvn+PoNDa5PfE6DQ/BmetpymL2aVdQXSpQ4k\n' + + 'i4fjWKgx63Q43NsyR/cAs=\n' + + '-----END SSH SIGNATURE-----'; + +function fixedSigner() { + const pkcs8 = Buffer.concat([ + Buffer.from('302e020100300506032b657004220420', 'hex'), + Buffer.from(SEED_HEX, 'hex'), + ]); + const key = createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' }); + return (data: Uint8Array) => + new Uint8Array(nodeSign(null, Buffer.from(data), key)); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('buildSshSig', () => { + test('matches the golden vector byte-for-byte (synchronous rawSign)', async () => { + const armor = await buildSshSig( + new TextEncoder().encode(PAYLOAD), + PUB_OPENSSH, + fixedSigner(), + ); + expect(armor).toBe(GOLDEN_ARMOR); + }); + + test('matches the golden vector when rawSign returns a Promise (async rawSign)', async () => { + const syncSigner = fixedSigner(); + const asyncSigner = (data: Uint8Array): Promise => + Promise.resolve(syncSigner(data)); + const armor = await buildSshSig( + new TextEncoder().encode(PAYLOAD), + PUB_OPENSSH, + asyncSigner, + ); + expect(armor).toBe(GOLDEN_ARMOR); + }); + + test('structural: armor has correct PEM markers and SSHSIG binary header', async () => { + const armor = await buildSshSig( + new TextEncoder().encode(PAYLOAD), + PUB_OPENSSH, + fixedSigner(), + ); + + expect(armor.startsWith('-----BEGIN SSH SIGNATURE-----')).toBe(true); + expect(armor.endsWith('-----END SSH SIGNATURE-----')).toBe(true); + + // Extract and decode the base64 body (lines between the markers). + const lines = armor.split('\n'); + const body = lines.slice(1, lines.length - 1).join(''); + const decoded = Uint8Array.from(atob(body), (c) => c.charCodeAt(0)); + + // First 6 bytes must be ASCII "SSHSIG". + const header = new TextDecoder().decode(decoded.slice(0, 6)); + expect(header).toBe('SSHSIG'); + + // Bytes 6-9: u32be version = 1. + const version = new DataView(decoded.buffer).getUint32(6, false); + expect(version).toBe(1); + + // The namespace string "git" must appear as a length-prefixed field. + // str("git") = [0,0,0,3,0x67,0x69,0x74] + const gitBytes = new Uint8Array([0, 0, 0, 3, 0x67, 0x69, 0x74]); + let foundGit = false; + for (let i = 0; i <= decoded.length - gitBytes.length; i++) { + if (gitBytes.every((b, j) => decoded[i + j] === b)) { + foundGit = true; + break; + } + } + expect(foundGit).toBe(true); + }); + + test('determinism/sensitivity: different payload produces different armor', async () => { + const signer = fixedSigner(); + const armor1 = await buildSshSig( + new TextEncoder().encode(PAYLOAD), + PUB_OPENSSH, + signer, + ); + const armor2 = await buildSshSig( + new TextEncoder().encode(PAYLOAD + ' modified'), + PUB_OPENSSH, + signer, + ); + expect(armor1).not.toBe(armor2); + }); +}); diff --git a/extension/src/service-worker/sshsig.ts b/extension/src/service-worker/sshsig.ts new file mode 100644 index 0000000..8bf4e0c --- /dev/null +++ b/extension/src/service-worker/sshsig.ts @@ -0,0 +1,149 @@ +/** + * SSHSIG framing for Relicario git commit signing. + * + * Turns a raw ed25519 signature (as returned by WASM `sign_for_git`) into the + * armored "-----BEGIN SSH SIGNATURE-----" block that git `gpgsig` expects. + * + * Pure Web APIs only: no `node:*`, no `chrome.*`, no WASM import, no Buffer. + * This module must bundle cleanly into the extension service worker and run + * under any standard browser JS environment (happy-dom, Chrome SW, etc.). + * + * Framing spec (proven end-to-end in docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md): + * + * Let str(x) = u32be(len(x)) || x. All integers are big-endian. + * 1. H = SHA-512(payload) (64 bytes) + * 2. signedBlob = "SSHSIG" || str("git") || str("") || str("sha512") || str(H) + * — the leading "SSHSIG" has NO length prefix. + * 3. rawSig = rawSign(signedBlob) (64 bytes) + * 4. pubWire = base64decode(signingPubOpenssh.trim().split(/\s+/)[1]) + * 5. sigWire = str("ssh-ed25519") || str(rawSig) + * 6. armored = "SSHSIG" || u32be(1) || str(pubWire) || str("git") || str("") || str("sha512") || str(sigWire) + * 7. Output: PEM-ish block with body wrapped at 70 chars per line. + */ + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Build a git-compatible SSHSIG armored block. + * + * @param payload - The raw commit object bytes to sign. + * @param signingPubOpenssh - The signer's public key in OpenSSH authorized-keys + * format, e.g. "ssh-ed25519 AAAA... [comment]". + * @param rawSign - Injected raw ed25519 signer: receives the SSHSIG + * blob (already hashed per the framing spec) and + * returns the 64-byte raw signature synchronously or + * as a Promise. In production the SW passes a thin + * wrapper around `wasm.sign_for_git`. + * @returns The armored SSH signature string. + */ +export async function buildSshSig( + payload: Uint8Array, + signingPubOpenssh: string, + rawSign: (data: Uint8Array) => Uint8Array | Promise, +): Promise { + // Step 1: H = SHA-512(payload). + // SubtleCrypto requires a plain ArrayBuffer (not ArrayBufferLike) in TS 5.9+; + // callers always pass a freshly allocated Uint8Array so .buffer is the full buffer. + const H = new Uint8Array( + await crypto.subtle.digest('SHA-512', payload.buffer as ArrayBuffer), + ); + + // Step 2: signedBlob — "SSHSIG" (no length prefix) || str("git") || str("") || str("sha512") || str(H) + const signedBlob = concat([ + enc('SSHSIG'), + str(enc('git')), + str(EMPTY), + str(enc('sha512')), + str(H), + ]); + + // Step 3: rawSig (must be 64 bytes for ed25519) + const rawSig = new Uint8Array(await rawSign(signedBlob)); + + // Step 4: pubWire — already in SSH wire format: str("ssh-ed25519") || str(rawPub32) + const pubWire = b64Decode(signingPubOpenssh.trim().split(/\s+/)[1]); + + // Step 5: sigWire + const sigWire = concat([str(enc('ssh-ed25519')), str(rawSig)]); + + // Step 6: armored binary + const armoredBytes = concat([ + enc('SSHSIG'), + u32be(1), + str(pubWire), + str(enc('git')), + str(EMPTY), + str(enc('sha512')), + str(sigWire), + ]); + + // Step 7: PEM-ish output with 70-char line wrapping + const body = wrapAt(b64Encode(armoredBytes), 70); + return `-----BEGIN SSH SIGNATURE-----\n${body}\n-----END SSH SIGNATURE-----`; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Empty byte array (reused for str("") calls). */ +const EMPTY = new Uint8Array(0); + +/** Encode a plain ASCII string as UTF-8 bytes (Relicario uses only ASCII names). */ +function enc(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +/** 4-byte big-endian unsigned integer. */ +function u32be(n: number): Uint8Array { + const buf = new Uint8Array(4); + new DataView(buf.buffer).setUint32(0, n, false); + return buf; +} + +/** Length-prefixed byte string: u32be(len(data)) || data. */ +function str(data: Uint8Array): Uint8Array { + return concat([u32be(data.length), data]); +} + +/** Concatenate multiple Uint8Arrays into one. */ +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 p of parts) { + out.set(p, offset); + offset += p.length; + } + return out; +} + +/** Standard base64 encode (A–Z a–z 0–9 + /) with = padding. Uses btoa. */ +function b64Encode(data: Uint8Array): string { + let binary = ''; + for (let i = 0; i < data.length; i++) { + binary += String.fromCharCode(data[i]); + } + return btoa(binary); +} + +/** Standard base64 decode. Uses atob. */ +function b64Decode(b64: string): Uint8Array { + const binary = atob(b64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; +} + +/** Wrap a flat base64 string into lines of at most `width` chars, joined by "\n". */ +function wrapAt(s: string, width: number): string { + const lines: string[] = []; + for (let i = 0; i < s.length; i += width) { + lines.push(s.slice(i, i + width)); + } + return lines.join('\n'); +}