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);
});
});