153 lines
5.5 KiB
TypeScript
153 lines
5.5 KiB
TypeScript
/**
|
||
* 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 (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');
|
||
}
|