feat(ext/sw): sshsig — SSHSIG framing over sign_for_git for git-valid commit signatures

This commit is contained in:
adlee-was-taken
2026-06-25 23:33:42 -04:00
parent c6abcf8da7
commit b3db292474
2 changed files with 263 additions and 0 deletions

View File

@@ -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 <o@acme> 1750000000 +0000\ncommitter Owner <o@acme> 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<Uint8Array> =>
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);
});
});

View File

@@ -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<Uint8Array>,
): Promise<string> {
// 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 (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');
}