feat(ext/sw): universal commitSigned — isomorphic-git receive-pack push + SSHSIG

Add CommitFile/CommitSigner types + commitSigned to GitHost interface.
Implement git-push.ts (clone→add→commit({onSign=SSHSIG})→push over http/web)
and wire GiteaHost.commitSigned / GitHubHost.commitSigned. Tests: 7/7 incl.
golden-vector tie against the sshsig.test.ts byte-exact vector.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKBbrAjmpVXMEK15pNi3Ha
This commit is contained in:
adlee-was-taken
2026-06-26 00:08:09 -04:00
parent 0bc1f53b48
commit 56dec78f44
7 changed files with 1064 additions and 9 deletions

View File

@@ -0,0 +1,192 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { createPrivateKey, sign as nodeSign } from 'node:crypto';
import * as git from 'isomorphic-git';
import { commitSignedPush } from '../git-push';
import { GiteaHost } from '../gitea';
import { GitHubHost } from '../github';
import type { CommitFile, CommitSigner } from '../git-host';
// ---------------------------------------------------------------------------
// Mock isomorphic-git so no network or filesystem I/O occurs.
// The vi.mock calls are hoisted by Vitest and run before any imports.
// ---------------------------------------------------------------------------
vi.mock('isomorphic-git', () => ({
clone: vi.fn(async () => {}),
add: vi.fn(async () => {}),
commit: vi.fn(async () => 'oid'),
push: vi.fn(async () => ({ ok: true, errors: [] })),
}));
vi.mock('isomorphic-git/http/web', () => ({ default: {} }));
// ---------------------------------------------------------------------------
// Golden-vector constants — byte-exact, identical to sshsig.test.ts.
// Tied to the SSHSIG spec proven against `git verify-commit`.
// ---------------------------------------------------------------------------
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';
const GOLDEN_ARMOR =
'-----BEGIN SSH SIGNATURE-----\n' +
'U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgA6EHv/POEL4dcN0Y50vAmWfk1j\n' +
'CbpQ1fHdyGZBJVMbgAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\n' +
'AAAAQFLDkEyykOw6Z+I9qLKtTDToj2pXIfgvn+PoNDa5PfE6DQ/BmetpymL2aVdQXSpQ4k\n' +
'i4fjWKgx63Q43NsyR/cAs=\n' +
'-----END SSH SIGNATURE-----';
// ---------------------------------------------------------------------------
// Fixed-seed deterministic ed25519 signer (Node.js only; test-only).
// The injected rawSign approach mirrors the production SW wiring, where
// rawSign wraps wasm.sign_for_git; here we use node:crypto for the seed.
// ---------------------------------------------------------------------------
function fixedSigner(): (data: Uint8Array) => Uint8Array {
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));
}
// ---------------------------------------------------------------------------
// Shared test fixtures
// ---------------------------------------------------------------------------
const GIT_URL = 'https://git.example.com/org/vault.git';
const BRANCH = 'main';
const AUTH = { username: 'relicario', password: 'test-token' };
function makeSigner(): CommitSigner {
return {
author: { name: 'Test User', email: 'test@example.com' },
signingPubOpenssh: PUB_OPENSSH,
rawSign: fixedSigner(),
};
}
// ---------------------------------------------------------------------------
// Engine tests
// ---------------------------------------------------------------------------
describe('commitSignedPush engine', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('1 — clone called once with correct url / ref / singleBranch / depth / onAuth', async () => {
const files: CommitFile[] = [{ path: 'vault/item.enc', content: new Uint8Array([1, 2, 3]) }];
await commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const args = (vi.mocked(git.clone).mock.calls[0][0] as any);
expect(vi.mocked(git.clone)).toHaveBeenCalledTimes(1);
expect(args.url).toBe(GIT_URL);
expect(args.ref).toBe(BRANCH);
expect(args.singleBranch).toBe(true);
expect(args.depth).toBe(1);
expect(typeof args.onAuth).toBe('function');
expect(args.onAuth()).toEqual(AUTH);
});
test('2 — add called once per file with the right filepath', async () => {
const files: CommitFile[] = [
{ path: 'a/b.enc', content: new Uint8Array([1]) },
{ path: 'c.enc', content: new Uint8Array([2]) },
];
await commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() });
expect(vi.mocked(git.add)).toHaveBeenCalledTimes(2);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((vi.mocked(git.add).mock.calls[0][0] as any).filepath).toBe('a/b.enc');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((vi.mocked(git.add).mock.calls[1][0] as any).filepath).toBe('c.enc');
});
test('3 — golden-vector tie: onSign wires buildSshSig correctly', async () => {
// Use the fixed vector signer (same seed/key as sshsig.test.ts).
const vectorSigner: CommitSigner = {
author: { name: 'Owner', email: 'o@acme' },
signingPubOpenssh: PUB_OPENSSH,
rawSign: fixedSigner(),
};
await commitSignedPush({
gitUrl: GIT_URL, branch: BRANCH, auth: AUTH,
files: [],
message: 'org add',
signer: vectorSigner,
});
// Capture the onSign that was passed to git.commit and invoke it with the
// fixed PAYLOAD. The returned signature MUST equal GOLDEN_ARMOR byte-for-byte.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const commitArgs = (vi.mocked(git.commit).mock.calls[0][0] as any);
const { signature } = await commitArgs.onSign({ payload: PAYLOAD, secretKey: 'device' });
expect(signature).toBe(GOLDEN_ARMOR);
});
test('4 — push called with correct url / ref / remoteRef / onAuth', async () => {
const files: CommitFile[] = [{ path: 'vault/item.enc', content: new Uint8Array([1]) }];
await commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() });
expect(vi.mocked(git.push)).toHaveBeenCalledTimes(1);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const args = (vi.mocked(git.push).mock.calls[0][0] as any);
expect(args.url).toBe(GIT_URL);
expect(args.ref).toBe(BRANCH);
expect(args.remoteRef).toBe(BRANCH);
expect(typeof args.onAuth).toBe('function');
expect(args.onAuth()).toEqual(AUTH);
});
test('5 — push rejection throws with server detail', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(git.push).mockResolvedValueOnce({ ok: false, errors: ['pre-receive hook declined'] } as any);
const files: CommitFile[] = [{ path: 'vault/item.enc', content: new Uint8Array([1]) }];
await expect(
commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() }),
).rejects.toThrow('pre-receive hook declined');
});
});
// ---------------------------------------------------------------------------
// Host wrapper tests — verifies commitSigned delegates correctly to the engine.
// The mocked git.clone captures the gitUrl and auth shape for each host.
// ---------------------------------------------------------------------------
describe('GitHost wrappers — commitSigned wires commitSignedPush correctly', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('GiteaHost: builds https://<host>/<repo>.git gitUrl; auth.password === apiToken', async () => {
// LIVE RESIDUAL: exact Basic-auth acceptance confirmed only by a live push against Gitea.
const signer = makeSigner();
await new GiteaHost('https://git.example', 'o/r', 'tok').commitSigned([], 'm', signer);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const args = (vi.mocked(git.clone).mock.calls[0][0] as any);
expect(args.url).toBe('https://git.example/o/r.git');
expect(args.onAuth()).toEqual({ username: 'relicario', password: 'tok' });
});
test('GitHubHost: builds https://github.com/<repo>.git; auth.username === apiToken', async () => {
// LIVE RESIDUAL: GitHub PAT-over-Basic (username=token, password=x-oauth-basic) unconfirmed live.
const signer = makeSigner();
await new GitHubHost('o/r', 'tok').commitSigned([], 'm', signer);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const args = (vi.mocked(git.clone).mock.calls[0][0] as any);
expect(args.url).toBe('https://github.com/o/r.git');
expect(args.onAuth()).toEqual({ username: 'tok', password: 'x-oauth-basic' });
});
});

View File

@@ -4,6 +4,27 @@
/// read, write, and delete individual files without cloning the repo.
/// This interface captures just the operations the vault needs.
/// A single file to include in a commitSigned batch.
export interface CommitFile {
path: string;
content: Uint8Array;
}
/// Ed25519 signing identity used by commitSigned.
///
/// In production the SW passes a thin wrapper around `wasm.sign_for_git`;
/// in tests, a fixed-seed Node.js signer is injected for determinism.
export interface CommitSigner {
author: { name: string; email: string };
/// The signer's public key in OpenSSH authorized-keys format,
/// e.g. "ssh-ed25519 AAAA... [comment]".
signingPubOpenssh: string;
/// Raw ed25519 signer — equivalent to base64decode(sign_for_git(data).signature).
/// Receives the SSHSIG blob (pre-hashed per the framing spec) and returns
/// the 64-byte raw ed25519 signature.
rawSign: (data: Uint8Array) => Promise<Uint8Array> | Uint8Array;
}
export interface GitHost {
/// Read a single file from the repo, returning its raw bytes.
readFile(path: string): Promise<Uint8Array>;
@@ -42,6 +63,13 @@ export interface GitHost {
/// kept distinct for symmetry with putBlob.
deleteBlob(path: string, message: string): Promise<void>;
/// Sign a batch of file changes as a single git commit and push to the host.
///
/// Uses isomorphic-git over the HTTP/web transport (clone→add→commit→push).
/// Commit is signed with SSHSIG using the provided CommitSigner. Throws if
/// the push is rejected by the server or a pre-receive hook.
commitSigned(files: CommitFile[], message: string, signer: CommitSigner): Promise<void>;
/// Cached sync metadata, populated by the `sync` handler — get_vault_status
/// reads these without any network call. lastSyncAt is unix SECONDS (or null
/// until the first sync). ahead/behind exist for parity with `relicario

View File

@@ -0,0 +1,128 @@
/**
* git-push.ts — universal isomorphic-git clone→add→commit→push engine for
* Relicario org-vault writes.
*
* Signs commits with SSHSIG (Relicario's commit-signing format) and pushes
* over git-receive-pack using the HTTP/web transport (fetch-based, required in
* the extension service worker — isomorphic-git/http/node is NOT available and
* MUST NOT be substituted here).
*
* The extension's host_permissions grant the SW fetch() access to org git
* hosts, so no CORS proxy is needed.
*
* The clone→sign→push flow is proven end-to-end against the Relicario org
* pre-receive hook (docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md).
* Build this engine faithfully; do not alter the onSign wiring or the
* push-rejection check.
*/
import * as git from 'isomorphic-git';
import type { FsClient } from 'isomorphic-git';
import http from 'isomorphic-git/http/web';
import { createMemFs } from './mem-fs';
import { buildSshSig } from './sshsig';
import type { CommitFile, CommitSigner } from './git-host';
// Runtime push results from isomorphic-git include an `errors` array that is
// not reflected in the TypeScript PushResult type. Intersect it here so the
// rejection check compiles under strict mode.
type PushResultRuntime = { ok: boolean; errors?: string[] };
/**
* Clone `gitUrl` at depth 1, write `files`, sign the commit with SSHSIG, and
* push back to the same URL. Throws if the server or a pre-receive hook
* rejects the push.
*/
export async function commitSignedPush(opts: {
gitUrl: string;
branch: string;
auth: { username: string; password: string };
files: CommitFile[];
message: string;
signer: CommitSigner;
}): Promise<void> {
// mem-fs.ts declares a local PromiseFsClient to avoid pulling in the
// isomorphic-git package. Keep both a typed handle (for our own writeFile /
// mkdir calls) and a FsClient cast (for git.* functions) from the same object.
const memFs = createMemFs();
const fs = memFs as unknown as FsClient; // structural cast — proven compatible
const dir = '/repo';
const onAuth = () => opts.auth;
// Shallow clone — we only need HEAD to build the new commit on top of it.
await git.clone({
fs, http, dir,
url: opts.gitUrl,
ref: opts.branch,
singleBranch: true,
depth: 1,
onAuth,
});
// Write each file into the in-memory tree and stage it.
for (const f of opts.files) {
await mkdirp(memFs, dir, f.path);
await memFs.promises.writeFile(`${dir}/${f.path}`, f.content);
await git.add({ fs, dir, filepath: f.path });
}
const ident = {
name: opts.signer.author.name,
email: opts.signer.author.email,
timestamp: Math.floor(Date.now() / 1000),
timezoneOffset: 0,
};
// Commit with SSHSIG signing.
// `signingKey: 'device'` is a truthy sentinel — isomorphic-git passes it as
// `secretKey` to `onSign`. We ignore secretKey and use our own signer.
// `payload` is the serialised commit object (exactly what `git verify-commit`
// checks); we TextEncoder-encode it before handing it to buildSshSig.
await git.commit({
fs, dir,
message: opts.message,
author: ident,
committer: ident,
signingKey: 'device',
onSign: async ({ payload }) => ({
signature: await buildSshSig(
new TextEncoder().encode(payload),
opts.signer.signingPubOpenssh,
opts.signer.rawSign,
),
}),
});
// Push over git-receive-pack. The runtime result includes `errors[]` when a
// pre-receive hook declines — cast to PushResultRuntime to access it.
const res = await git.push({
fs, http, dir,
url: opts.gitUrl,
ref: opts.branch,
remoteRef: opts.branch,
onAuth,
}) as unknown as PushResultRuntime;
const ok = res?.ok && (!res.errors || res.errors.length === 0);
if (!ok) {
throw new Error(
`org push rejected by server/hook: ${JSON.stringify(res?.errors ?? res)}`,
);
}
}
/**
* Ensure all parent directories of `filePath` exist inside `dir` in the
* given mem-fs. mem-fs.mkdir never throws, so no EEXIST guard is needed.
*/
async function mkdirp(
memFs: ReturnType<typeof createMemFs>,
dir: string,
filePath: string,
): Promise<void> {
const segments = filePath.split('/').slice(0, -1); // drop the filename
for (let i = 0; i < segments.length; i++) {
const prefix = segments.slice(0, i + 1).join('/');
await memFs.promises.mkdir(`${dir}/${prefix}`);
}
}

View File

@@ -1,5 +1,6 @@
import type { GitHost } from './git-host';
import type { GitHost, CommitFile, CommitSigner } from './git-host';
import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './git-host';
import { commitSignedPush } from './git-push';
/// Gitea Contents API implementation.
///
@@ -20,6 +21,8 @@ export class GiteaHost implements GitHost {
private keysUrl: string;
private branch: string = 'main';
private headers: Record<string, string>;
private gitUrl: string;
private apiToken: string;
lastSyncAt: number | null = null;
ahead = 0;
behind = 0;
@@ -37,6 +40,8 @@ export class GiteaHost implements GitHost {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
this.gitUrl = `${host}/${repoPath}.git`;
this.apiToken = apiToken;
}
async readFile(path: string): Promise<Uint8Array> {
@@ -250,6 +255,24 @@ export class GiteaHost implements GitHost {
return this.deleteFile(path, message);
}
/// Sign a batch of changes as a single SSHSIG-signed commit and push to
/// the Gitea repo over git-receive-pack.
///
/// Auth: Gitea accepts a PAT as the Basic-auth password; the username field
/// is ignored by Gitea token auth. Pending live-push confirmation.
async commitSigned(files: CommitFile[], message: string, signer: CommitSigner): Promise<void> {
return commitSignedPush({
gitUrl: this.gitUrl,
branch: this.branch,
// LIVE RESIDUAL: Gitea PAT-over-Basic (username ignored, password=token)
// — accepted in spike but not yet confirmed from a production Gitea push.
auth: { username: 'relicario', password: this.apiToken },
files,
message,
signer,
});
}
/// Create a deploy key for this repo, returning its numeric ID.
async createDeployKey(title: string, publicKey: string): Promise<number> {
const resp = await fetch(this.keysUrl, {

View File

@@ -1,5 +1,6 @@
import type { GitHost } from './git-host';
import type { GitHost, CommitFile, CommitSigner } from './git-host';
import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './git-host';
import { commitSignedPush } from './git-push';
/// GitHub Contents API implementation.
///
@@ -17,6 +18,8 @@ export class GitHubHost implements GitHost {
private commitsUrl: string;
private branch: string = 'main';
private headers: Record<string, string>;
private repoPath: string;
private apiToken: string;
lastSyncAt: number | null = null;
ahead = 0;
behind = 0;
@@ -31,6 +34,8 @@ export class GitHubHost implements GitHost {
'Accept': 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': '2022-11-28',
};
this.repoPath = repoPath;
this.apiToken = apiToken;
}
async readFile(path: string): Promise<Uint8Array> {
@@ -240,4 +245,23 @@ export class GitHubHost implements GitHost {
async deleteBlob(path: string, message: string): Promise<void> {
return this.deleteFile(path, message);
}
/// Sign a batch of changes as a single SSHSIG-signed commit and push to
/// GitHub over git-receive-pack.
///
/// Auth: classic PAT-over-Basic form that git uses for GitHub: username is
/// the token itself, password is the literal string "x-oauth-basic".
/// Pending live-push confirmation.
async commitSigned(files: CommitFile[], message: string, signer: CommitSigner): Promise<void> {
return commitSignedPush({
gitUrl: `https://github.com/${this.repoPath}.git`,
branch: this.branch,
// LIVE RESIDUAL: GitHub PAT-over-Basic (username=token, password=x-oauth-basic)
// — standard git protocol; not yet confirmed from a production GitHub push.
auth: { username: this.apiToken, password: 'x-oauth-basic' },
files,
message,
signer,
});
}
}