Files
relicario/extension/src/service-worker/sshsig.ts

153 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<Uint8Array>,
): Promise<string> {
// 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 (AZ az 09 + /) 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');
}