/** * 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). // slice(0) allocates a fresh backing buffer whose extent exactly matches the // view bytes, making this correct even when `payload` is a subarray/offset view. const H = new Uint8Array( await crypto.subtle.digest('SHA-512', payload.slice(0).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); /** Module-scope encoder — avoids allocating a new TextEncoder on every call. */ const ENC = new TextEncoder(); /** Encode a plain ASCII string as UTF-8 bytes (Relicario uses only ASCII names). */ function enc(s: string): Uint8Array { return ENC.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'); }