feat(ext/sw): sshsig — SSHSIG framing over sign_for_git for git-valid commit signatures
This commit is contained in:
114
extension/src/service-worker/__tests__/sshsig.test.ts
Normal file
114
extension/src/service-worker/__tests__/sshsig.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user