add patch commit signing, show signatures in commit list and detail views

AuthorKonata <konata@posteo.jp>
Date
Commit7353a64831551c74b9493739f311ffaecbcf4f37
Parent1949c20
15 files changed, 333 insertions(+), 65 deletions(-)
▾MREADME.md
@@ -13,6 +13,7 @@ Frontend works without any JS at all enabled, just required for WebAuthn (with g
- **Releases** — releases with source archives, extra uploaded assets, and optional tag creation
- **SSH push/pull** — built-in SSH server, no external git daemon needed
- **Auth** — password login or passkeys (WebAuthn/FIDO2)
- **Commit signing** — merged patches automatically signed and verification badges are shown in the commit list view
- **Optional registration** — others can create accounts to file issues and patches; can be disabled
## Stack
@@ -95,4 +96,5 @@ bun run test # Playwright E2E tests (don't use bun test, it doesn't res
- Issue labels
- Repository list reordering (e.g. last committed) and starring
- redirect image urls in readme
- commit signing
- simple file editor
- generic diff viewer (show diff for a given path between two refs)
▾Msrc/config.ts
@@ -22,3 +22,5 @@ export const COMMITTER_NAME = process.env.COMMITTER_NAME ?? OWNER_DISPLAY_NAME;
export const COMMITTER_EMAIL =
process.env.COMMITTER_EMAIL ??
`${OWNER_DISPLAY_NAME}@${new URL(BASE_URL).hostname}`;
export const EXTRA_ALLOWED_SIGNERS_PATH =
process.env.EXTRA_ALLOWED_SIGNERS_PATH ?? null;
▾Msrc/constants.ts
@@ -28,7 +28,7 @@ export const CHALLENGE_TTL_MS = 5 * 60 * 1000;
// Pagination
export const REPOS_PER_PAGE = 20;
export const COMMITS_PER_PAGE = 30;
export const COMMITS_PER_PAGE = 20;
export const ISSUES_PER_PAGE = 20;
export const PATCHES_PER_PAGE = 20;
export const RELEASES_PER_PAGE = 20;
@@ -44,4 +44,6 @@ export const DB_PATH = path.join(DATA_DIR, "hearthforge.db");
export const REPOS_DIR = path.join(DATA_DIR, "repos");
export const AVATARS_DIR = path.join(DATA_DIR, "avatars");
export const RELEASES_DIR = path.join(DATA_DIR, "releases");
export const SSH_HOST_KEY_PATH = path.join(DATA_DIR, "ssh_host_key");
export const SSH_HOST_KEY_PATH =
process.env.SSH_HOST_KEY_PATH ?? path.join(DATA_DIR, "ssh_host_key");
export const ALLOWED_SIGNERS_PATH = path.join(DATA_DIR, "allowed_signers");
▾Msrc/routes/issues.tsx
@@ -108,7 +108,13 @@ export const issueRoutes = new Elysia()
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
return html(<NewIssue user={user!} repo={repo} template={repo.issue_template ?? undefined} />);
return html(
<NewIssue
user={user!}
repo={repo}
template={repo.issue_template ?? undefined}
/>,
);
})
.post(
▾Msrc/routes/patches.tsx
@@ -144,7 +144,13 @@ export const patchRoutes = new Elysia()
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
return html(<NewPatch user={user!} repo={repo} template={repo.patch_template ?? undefined} />);
return html(
<NewPatch
user={user!}
repo={repo}
template={repo.patch_template ?? undefined}
/>,
);
})
.post(
▾Msrc/routes/repos.tsx
@@ -1,8 +1,9 @@
import { rmSync } from "node:fs";
import { readFileSync, rmSync } from "node:fs";
import path from "node:path";
import { Elysia, t } from "elysia";
import { fileTypeFromBuffer } from "file-type";
import {
ALLOWED_SIGNERS_PATH,
COMMITS_PER_PAGE,
REPOS_PER_PAGE,
VALID_REPO_NAME_RE,
@@ -58,6 +59,11 @@ async function readReadme(
}
export const repoRoutes = new Elysia()
.get("/allowed_signers", () => {
return new Response(readFileSync(ALLOWED_SIGNERS_PATH), {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
})
.guard({
cookie: t.Cookie({ session: t.Optional(t.String()) }),
})
@@ -569,7 +575,13 @@ export const repoRoutes = new Elysia()
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const { description, is_private, default_branch, issue_template, patch_template } = body;
const {
description,
is_private,
default_branch,
issue_template,
patch_template,
} = body;
const branches = await git.branches(repo.name);
const newBranch = default_branch?.trim() || repo.default_branch;
▾Msrc/services/git.ts
@@ -1,7 +1,11 @@
import path from "node:path";
import { $ as _$ } from "bun";
import { REPOS_DIR } from "../constants.ts";
import {
ALLOWED_SIGNERS_PATH,
REPOS_DIR,
SSH_HOST_KEY_PATH,
} from "../constants.ts";
const gitEnv = {
...process.env,
@@ -116,6 +120,7 @@ export interface CommitEntry {
subject: string;
author: string;
date: string;
sigStatus: "good" | "bad" | "none";
}
export interface CommitMeta {
@@ -129,6 +134,7 @@ export interface CommitMeta {
committerEmail: string;
committerDate: string;
parents: string[];
sigStatus: "good" | "bad" | "none";
}
export interface TreeEntry {
@@ -139,6 +145,13 @@ export interface TreeEntry {
name: string;
}
function parseSigStatus(code: string): "good" | "bad" | "none" {
if (code === "G" || code === "X" || code === "Y" || code === "R")
return "good";
if (code === "B" || code === "U" || code === "E") return "bad";
return "none";
}
function parseLog(out: string): CommitEntry[] {
return out
.split("\n")
@@ -150,6 +163,7 @@ function parseLog(out: string): CommitEntry[] {
subject: parts[1] ?? "",
author: parts[2] ?? "",
date: parts[3] ?? "",
sigStatus: parseSigStatus(parts[4] ?? ""),
};
});
}
@@ -250,9 +264,15 @@ export const git = {
skip = 0,
): Promise<CommitEntry[]> {
const p = repoPath(name);
const sigArgs = [
"-c",
"gpg.format=ssh",
"-c",
`gpg.ssh.allowedSignersFile=${ALLOWED_SIGNERS_PATH}`,
];
try {
const out =
await $`git -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai --max-count=${limit} --skip=${skip}`.text();
await $`git ${sigArgs} -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai%x1f%G? --max-count=${limit} --skip=${skip}`.text();
return parseLog(out);
} catch {
return [];
@@ -441,12 +461,21 @@ export const git = {
await $`git -C ${p} rev-parse HEAD`.text()
).trim();
const msg = extractPatchSubject(patchContent);
const sigArgs = [
"-c",
"gpg.format=ssh",
"-c",
`user.signingKey=${SSH_HOST_KEY_PATH}`,
];
const commit = (
await $`git -C ${p} commit-tree ${tree} -p ${parent} -m ${msg}`
await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${msg}`
.env({
...process.env,
LC_ALL: "C",
LANG: "C",
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_SYSTEM: "/dev/null",
GIT_CONFIG_COUNT: "0",
GIT_AUTHOR_NAME: authorName,
GIT_AUTHOR_EMAIL: authorEmail,
GIT_COMMITTER_NAME: committerName,
@@ -474,9 +503,15 @@ export const git = {
): Promise<"ok" | "already_exists" | "bad_ref" | "error"> {
return withRepoLock(repoName, async () => {
const p = repoPath(repoName);
const sigArgs = [
"-c",
"gpg.format=ssh",
"-c",
`user.signingKey=${SSH_HOST_KEY_PATH}`,
];
const result =
message !== undefined
? await $`git -C ${p} tag -a ${tagName} ${ref} -m ${message}`
? await $`git ${sigArgs} -C ${p} tag -s ${tagName} ${ref} -m ${message}`
.env({
...gitEnv,
GIT_COMMITTER_NAME: taggerName!,
@@ -524,9 +559,15 @@ export const git = {
async commitMeta(name: string, sha: string): Promise<CommitMeta | null> {
const p = repoPath(name);
const sigArgs = [
"-c",
"gpg.format=ssh",
"-c",
`gpg.ssh.allowedSignersFile=${ALLOWED_SIGNERS_PATH}`,
];
try {
const [metaOut, msgOut] = await Promise.all([
$`git -C ${p} show --no-patch --format=%H%x1f%an%x1f%ae%x1f%ai%x1f%cn%x1f%ce%x1f%ci%x1f%P ${sha}`.text(),
$`git ${sigArgs} -C ${p} show --no-patch --format=%H%x1f%an%x1f%ae%x1f%ai%x1f%cn%x1f%ce%x1f%ci%x1f%P%x1f%G? ${sha}`.text(),
$`git -C ${p} log --format=%B -1 ${sha}`.text(),
]);
const parts = metaOut.trim().split("\x1f");
@@ -551,6 +592,7 @@ export const git = {
committerEmail: parts[5] ?? "",
committerDate: parts[6] ?? "",
parents: (parts[7] ?? "").trim().split(/\s+/).filter(Boolean),
sigStatus: parseSigStatus(parts[8] ?? ""),
};
} catch {
return null;
▾Msrc/services/repoSync.ts
@@ -2,12 +2,20 @@ import {
type Dirent,
existsSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
} from "node:fs";
import path from "node:path";
import { $ } from "bun";
import { RELEASES_DIR, REPOS_DIR, VALID_REPO_NAME_RE } from "../constants.ts";
import { BASE_URL, EXTRA_ALLOWED_SIGNERS_PATH } from "../config.ts";
import {
ALLOWED_SIGNERS_PATH,
RELEASES_DIR,
REPOS_DIR,
SSH_HOST_KEY_PATH,
VALID_REPO_NAME_RE,
} from "../constants.ts";
import type { RepositoryRow } from "../db/index.ts";
import { db } from "../db/index.ts";
import { git, repoPath } from "../services/git.ts";
@@ -106,7 +114,67 @@ export async function ensureRepoRecord(name: string): Promise<RepositoryRow> {
.executeTakeFirstOrThrow();
}
async function ensureSigningSetup(): Promise<void> {
const hostname = new URL(BASE_URL).hostname;
const pubKeyPath = `${SSH_HOST_KEY_PATH}.pub`;
if (!existsSync(SSH_HOST_KEY_PATH)) {
await Bun.spawn([
"ssh-keygen",
"-t",
"ed25519",
"-N",
"",
"-f",
SSH_HOST_KEY_PATH,
"-C",
hostname,
]).exited;
console.log("Generated SSH host key at", SSH_HOST_KEY_PATH);
} else {
try {
const existing = readFileSync(pubKeyPath, "utf8").trim();
const keyHostname = existing.split(/\s+/)[2] ?? "";
if (keyHostname !== hostname) {
console.warn(
`Warning: SSH host key comment "${keyHostname}" does not match` +
` current hostname "${hostname}". The key was likely generated` +
` for a different BASE_URL. Commit signatures may show an` +
` unexpected identity.`,
);
}
} catch {
// .pub file missing or unreadable — handled below
}
}
let pubKey: string;
try {
pubKey = readFileSync(pubKeyPath, "utf8").trim();
} catch {
console.warn("Could not read SSH public key at", pubKeyPath);
return;
}
let content = `* namespaces="git" ${pubKey}\n`;
if (EXTRA_ALLOWED_SIGNERS_PATH) {
try {
const extra = readFileSync(EXTRA_ALLOWED_SIGNERS_PATH, "utf8");
content += extra.endsWith("\n") ? extra : `${extra}\n`;
} catch {
console.warn(
"Could not read EXTRA_ALLOWED_SIGNERS_PATH:",
EXTRA_ALLOWED_SIGNERS_PATH,
);
}
}
await Bun.write(ALLOWED_SIGNERS_PATH, content);
}
export async function syncStartup(): Promise<void> {
await ensureSigningSetup();
await convertNonBareRepos();
const diskNames = new Set(listDiskRepoNames());
const dbRepos = await db
▾Msrc/services/sshServer.ts
@@ -1,6 +1,6 @@
import { type ChildProcess, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import path from "node:path";
import { Server, utils } from "ssh2";
import { SSH_PORT } from "../config.ts";
@@ -33,21 +33,6 @@ export function fingerprintFromLine(pubkeyLine: string): string | null {
}
export async function startSshServer() {
if (!existsSync(SSH_HOST_KEY_PATH)) {
await Bun.spawn([
"ssh-keygen",
"-t",
"ed25519",
"-N",
"",
"-f",
SSH_HOST_KEY_PATH,
"-C",
"hearthforge-host",
]).exited;
console.log("Generated SSH host key at", SSH_HOST_KEY_PATH);
}
const hostKey = readFileSync(SSH_HOST_KEY_PATH);
const server = new Server({ hostKeys: [hostKey] }, (client) => {
▾Msrc/styles/main.css
@@ -741,6 +741,22 @@
background: var(--color-merged-bg);
color: var(--color-merged);
}
.sig-badge {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
}
.sig-badge.verified {
background: var(--color-success-bg);
color: var(--color-success);
}
.sig-badge.unverified {
background: var(--color-danger-bg);
color: var(--color-danger);
}
/* --- Empty state --- */
.empty-state {
@@ -939,20 +955,14 @@
}
.commit-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-direction: column;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border-muted);
gap: var(--space-4);
flex-wrap: wrap;
gap: var(--space-2);
}
.commit-item:last-child {
border-bottom: none;
}
.commit-main {
flex: 1;
min-width: 0;
}
.commit-subject {
color: var(--color-text);
text-decoration: none;
@@ -965,9 +975,15 @@
.commit-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.commit-meta-right {
display: flex;
align-items: center;
gap: var(--space-3);
white-space: nowrap;
}
.commit-hash {
@@ -2392,6 +2408,14 @@
/* ============================================================
Pagination
============================================================ */
.commit-verify-hint {
margin-bottom: var(--space-4);
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.commit-verify-hint code {
word-break: break-all;
}
.commit-cursor-nav {
display: flex;
justify-content: space-between;
▾Msrc/views/patches/NewPatch.tsx
@@ -42,11 +42,7 @@ export function NewPatch({ user, repo, error, template }: NewPatchProps) {
(Markdown supported, optional)
</span>
</label>
<textarea
id="description"
name="description"
rows="5"
>
<textarea id="description" name="description" rows="5">
{template ?? ""}
</textarea>
</div>
▾Msrc/views/repos/CommitDetail.tsx
@@ -118,6 +118,22 @@ export function CommitDetail({
</span>
</div>
)}
{meta.sigStatus !== "none" && (
<div class="commit-card-meta-row">
<span class="commit-meta-label">Signature</span>
<span class="commit-meta-value">
{meta.sigStatus === "good" ? (
<span class="sig-badge verified">
verified
</span>
) : (
<span class="sig-badge unverified">
unverified
</span>
)}
</span>
</div>
)}
</div>
</div>
▾Msrc/views/repos/CommitLog.tsx
@@ -46,34 +46,59 @@ export function CommitLog({
{commits.length === 0 ? (
<p class="text-muted">No commits yet.</p>
) : (
<ul class="commit-list commit-log">
{commits.map((c) => (
<li class="commit-item">
<div class="commit-main">
<>
<p class="commit-verify-hint">
Note: To verify signed commits locally, download the{" "}
<a href="/allowed_signers">allowed signers file</a>{" "}
and run:{" "}
<code class="mono">
git -c gpg.format=ssh -c
gpg.ssh.allowedSignersFile=allowed_signers
verify-commit &lt;hash&gt;
</code>
</p>
<ul class="commit-list commit-log">
{commits.map((c) => (
<li class="commit-item">
<a
href={`/${repo.name}/commit/${c.hash}`}
class="commit-subject"
>
{c.subject}
</a>
</div>
<div class="commit-meta">
<span class="commit-author">
{c.author}
</span>
<a
href={`/${repo.name}/commit/${c.hash}`}
class="commit-hash mono"
>
{c.hash.slice(0, 7)}
</a>
<time class="commit-date" datetime={c.date}>
{formatDateTime(c.date)}
</time>
</div>
</li>
))}
</ul>
<div class="commit-meta">
<span class="commit-author">
{c.author}
</span>
<span class="commit-meta-right">
{c.sigStatus === "good" && (
<span class="sig-badge verified">
verified
</span>
)}
{c.sigStatus === "bad" && (
<span class="sig-badge unverified">
unverified
</span>
)}
<a
href={`/${repo.name}/commit/${c.hash}`}
class="commit-hash mono"
>
{c.hash.slice(0, 7)}
</a>
<time
class="commit-date"
datetime={c.date}
>
{formatDateTime(c.date)}
</time>
</span>
</div>
</li>
))}
</ul>
</>
)}
{(newerUrl || olderUrl) && (
<nav
▾Msrc/views/repos/RepoSettings.tsx
@@ -100,7 +100,8 @@ export function RepoSettings({
<label for="patch_template">
Patch template{" "}
<span class="text-muted">
(Markdown, prefilled when submitting a new patch)
(Markdown, prefilled when submitting a new
patch)
</span>
</label>
<textarea
▾Mtests/e2e.test.ts
@@ -2,8 +2,10 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import { $ } from 'bun';
import { existsSync, readFileSync } from 'node:fs';
import {
BASE,
DATA_DIR,
ADMIN_PASS,
setupTestEnv,
spawnServer,
@@ -2363,3 +2365,82 @@ describe('issue and patch templates', () => {
} finally { await page.close(); }
});
});
// ─── Commit signing ───────────────────────────────────────────────────────────
// Depends on the 'patches' block having already merged CLEAN_PATCH into my-repo.
describe('commit signing', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('allowed_signers file is generated at startup', () => {
const allowedSignersPath = `${process.cwd()}/${DATA_DIR}/allowed_signers`;
expect(existsSync(allowedSignersPath)).toBe(true);
const content = readFileSync(allowedSignersPath, 'utf8');
expect(content).toContain('namespaces="git"');
expect(content).toContain('ssh-ed25519');
});
test('merged commit has a gpgsig header', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
// Find the patch commit specifically by subject
const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim();
expect(hash).toBeTruthy();
const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text();
expect(obj).toContain('gpgsig');
});
test('unsigned commits have no gpgsig header', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
// Initial commit was created by seedRepo (plain git commit, not hearthforge)
const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim();
expect(hash).toBeTruthy();
const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text();
expect(obj).not.toContain('gpgsig');
});
test('commit log shows verified badge on signed commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
// Find the commit-item for the merged patch by subject text
const patchItem = page.locator('.commit-item').filter({ hasText: 'Add patch-test.txt' });
expect(await patchItem.locator('.sig-badge.verified').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('commit log shows no sig badge on unsigned commit', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commits/main`);
// Initial commit was not signed via hearthforge
const initialItem = page.locator('.commit-item').filter({ hasText: 'Initial commit' });
expect(await initialItem.locator('.sig-badge').count()).toBe(0);
} finally { await page.close(); }
});
test('commit detail shows verified signature row for signed commit', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim();
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commit/${hash}`);
const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
expect(await sigRow.isVisible()).toBe(true);
expect(await sigRow.locator('.sig-badge.verified').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('commit detail shows no signature row for unsigned commit', async () => {
const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim();
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/commit/${hash}`);
const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
expect(await sigRow.count()).toBe(0);
} finally { await page.close(); }
});
});