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);
|
||||
});
|
||||
});
|
||||
149
extension/src/service-worker/sshsig.ts
Normal file
149
extension/src/service-worker/sshsig.ts
Normal 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 (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');
|
||||
}
|
||||
Reference in New Issue
Block a user