krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

35d07f32e12d86484a77b0c4674877dfe64298f2

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T22:48:23Z

M4: commit signature verification

- internal/sig: byte-exact payload reconstruction from raw commit
  objects; OpenPGP verification via ProtonMail/go-crypto; native SSHSIG
  parse/verify/sign (PROTOCOL.sshsig, namespace git, sha256/sha512)
- six distinct states; author email drives the badge, committer email
  recorded separately and surfaced only when it differs
- key-state policy (revoked/expired from the DB) decided before the
  crypto check; crypto verified at the signature's own creation time
- epoch cache: commit_signatures rows below settings.key_epoch are
  recomputed; epoch bumps on ssh/pgp key add/remove and on email
  verification (store.VerifyEmail, AddEmail-verified)
- pgp add/list/remove (armored key on stdin; stores UID emails, expiry,
  revocation) and repo log --limit with per-commit signature state
- e2e fixture chain covering all states incl. backdated-expired and
  hard-revoked keys; asserts unknown->verified upgrade on later key
  registration, email-verify upgrade, remove/re-add downgrade cycle;
  cross-checks payload reconstruction with git verify-commit under a
  throwaway GNUPGHOME
 cmd/forged/main.go           |   8 +-
 e2e/sig_test.go              | 363 +++++++++++++++++++++++++++++++++++++++++++
 go.mod                       |   2 +
 go.sum                       |   4 +
 internal/control/sig.go      | 203 ++++++++++++++++++++++++
 internal/gitutil/gitutil.go  |  26 ++++
 internal/sig/commit.go       | 111 +++++++++++++
 internal/sig/commit_test.go  |  62 ++++++++
 internal/sig/keys.go         |  59 +++++++
 internal/sig/sshsig.go       | 180 +++++++++++++++++++++
 internal/sig/verify.go       | 148 ++++++++++++++++++
 internal/store/signatures.go | 214 +++++++++++++++++++++++++
 internal/store/users.go      |  19 ++-
 13 files changed, 1390 insertions(+), 9 deletions(-)

diff --git a/cmd/forged/main.go b/cmd/forged/main.go
index 9c38553..86c3aa4 100644
--- a/cmd/forged/main.go
+++ b/cmd/forged/main.go
@@ -298,13 +298,7 @@ func adminEmailVerifyCmd() *cobra.Command {
 			if err != nil {
 				return fmt.Errorf("user %s: %w", args[0], err)
 			}
-			res, err := st.DB.Exec(
-				`UPDATE emails SET verified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), verified_by = 'admin'
-				 WHERE user_id = ? AND address = ?`, u.ID, args[1])
-			if err != nil {
-				return err
-			}
-			if n, _ := res.RowsAffected(); n == 0 {
+			if err := st.VerifyEmail(u.ID, args[1], "admin"); err != nil {
 				return fmt.Errorf("no address %s on user %s", args[1], args[0])
 			}
 			fmt.Println("verified", args[1])
diff --git a/e2e/sig_test.go b/e2e/sig_test.go
new file mode 100644
index 0000000..741296f
--- /dev/null
+++ b/e2e/sig_test.go
@@ -0,0 +1,363 @@
+package e2e
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/ProtonMail/go-crypto/openpgp"
+	"github.com/ProtonMail/go-crypto/openpgp/armor"
+	"github.com/ProtonMail/go-crypto/openpgp/packet"
+	"golang.org/x/crypto/ssh"
+
+	"github.com/krazywarez/forge/internal/sig"
+)
+
+// --- fixture key helpers -------------------------------------------------
+
+func newPGPKey(t *testing.T, name, email string, cfg *packet.Config) *openpgp.Entity {
+	t.Helper()
+	e, err := openpgp.NewEntity(name, "", email, cfg)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return e
+}
+
+func armorPub(t *testing.T, e *openpgp.Entity) string {
+	t.Helper()
+	var buf bytes.Buffer
+	w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := e.Serialize(w); err != nil {
+		t.Fatal(err)
+	}
+	w.Close()
+	return buf.String()
+}
+
+func pgpSign(t *testing.T, e *openpgp.Entity, payload []byte, cfg *packet.Config) string {
+	t.Helper()
+	var buf bytes.Buffer
+	if err := openpgp.ArmoredDetachSign(&buf, e, bytes.NewReader(payload), cfg); err != nil {
+		t.Fatal(err)
+	}
+	return buf.String()
+}
+
+// --- fixture commit construction ----------------------------------------
+
+type commitSpec struct {
+	authorEmail    string
+	committerEmail string
+	subject        string
+	sign           func(payload []byte) string // "" = unsigned
+}
+
+// buildCommits writes a chain of hand-constructed commit objects into the
+// clone at dir and points refs/heads/main at the tip.
+func buildCommits(t *testing.T, dir string, env []string, specs []commitSpec) []string {
+	t.Helper()
+	tree := strings.TrimSpace(mustGit(t, dir, env, "mktree"))
+	parent := ""
+	base := time.Now().Add(-time.Duration(len(specs)) * time.Minute).Unix()
+	var shas []string
+	for i, spec := range specs {
+		if spec.committerEmail == "" {
+			spec.committerEmail = spec.authorEmail
+		}
+		ts := base + int64(i)*60
+		var b strings.Builder
+		fmt.Fprintf(&b, "tree %s\n", tree)
+		if parent != "" {
+			fmt.Fprintf(&b, "parent %s\n", parent)
+		}
+		fmt.Fprintf(&b, "author T <%s> %d +0000\n", spec.authorEmail, ts)
+		fmt.Fprintf(&b, "committer T <%s> %d +0000\n", spec.committerEmail, ts)
+		payloadTail := fmt.Sprintf("\n%s\n", spec.subject)
+		payload := b.String() + payloadTail
+
+		full := payload
+		if spec.sign != nil {
+			sigText := spec.sign([]byte(payload))
+			var sigHeader strings.Builder
+			for j, line := range strings.Split(strings.TrimSuffix(sigText, "\n"), "\n") {
+				if j == 0 {
+					sigHeader.WriteString("gpgsig " + line + "\n")
+				} else {
+					sigHeader.WriteString(" " + line + "\n")
+				}
+			}
+			full = b.String() + sigHeader.String() + payloadTail
+		}
+
+		cmd := exec.Command("git", "hash-object", "-t", "commit", "-w", "--stdin")
+		cmd.Dir = dir
+		cmd.Env = env
+		cmd.Stdin = strings.NewReader(full)
+		out, err := cmd.Output()
+		if err != nil {
+			t.Fatalf("hash-object: %v", err)
+		}
+		parent = strings.TrimSpace(string(out))
+		shas = append(shas, parent)
+	}
+	mustGit(t, dir, env, "update-ref", "refs/heads/main", parent)
+	return shas
+}
+
+// --- the M4 milestone test ----------------------------------------------
+
+type logEntry struct {
+	SHA            string `json:"sha"`
+	Subject        string `json:"subject"`
+	AuthorEmail    string `json:"author_email"`
+	CommitterEmail string `json:"committer_email"`
+	Signature      struct {
+		State  string `json:"state"`
+		Signer string `json:"signer"`
+	} `json:"signature"`
+}
+
+func (i *instance) repoLog(t *testing.T, key, repo string) map[string]logEntry {
+	t.Helper()
+	out, errOut, code := i.ssh(t, key, "", "repo", "log", repo, "--json")
+	if code != 0 {
+		t.Fatalf("repo log: exit %d, %s", code, errOut)
+	}
+	var env struct {
+		Data []logEntry `json:"data"`
+	}
+	if err := json.Unmarshal([]byte(out), &env); err != nil {
+		t.Fatalf("repo log JSON: %v\n%s", err, out)
+	}
+	byShaOrSubject := map[string]logEntry{}
+	for _, e := range env.Data {
+		byShaOrSubject[e.Subject] = e
+	}
+	return byShaOrSubject
+}
+
+func TestSignatureVerification(t *testing.T) {
+	inst := startInstance(t)
+
+	aliceKey := inst.newKey(t, "alice")
+	inst.admin(t, "admin", "user", "create", "alice",
+		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
+
+	// bob: registered SSH key, email NOT yet verified.
+	bobKey := inst.newKey(t, "bob")
+	inst.admin(t, "admin", "user", "create", "bob",
+		"--key", bobKey+".pub", "--email", "bob@example.test")
+
+	// PGP keys.
+	now := time.Now()
+	aliceEnt := newPGPKey(t, "Alice", "alice@example.test", nil)
+	malloryEnt := newPGPKey(t, "Mallory", "mallory@example.test", nil)
+
+	past := now.Add(-2 * time.Hour)
+	expiredCfg := &packet.Config{Time: func() time.Time { return past }, KeyLifetimeSecs: 3600}
+	expiredEnt := newPGPKey(t, "Alice Old", "alice@example.test", expiredCfg)
+
+	// The "revoked" key signs its commit first and is revoked before
+	// registration: go-crypto (correctly) refuses to sign with a revoked key.
+	revokedEnt := newPGPKey(t, "Alice Revoked", "alice@example.test", nil)
+
+	// Register alice's current and expired keys on her account.
+	for _, ent := range []*openpgp.Entity{aliceEnt, expiredEnt} {
+		_, errOut, code := inst.ssh(t, aliceKey, armorPub(t, ent), "pgp", "add")
+		if code != 0 {
+			t.Fatalf("pgp add: %s", errOut)
+		}
+	}
+
+	// Alice's SSH signer for SSHSIG commits; bob's too.
+	aliceSSHRaw, _ := os.ReadFile(aliceKey)
+	aliceSigner, err := ssh.ParsePrivateKey(aliceSSHRaw)
+	if err != nil {
+		t.Fatal(err)
+	}
+	bobSSHRaw, _ := os.ReadFile(bobKey)
+	bobSigner, err := ssh.ParsePrivateKey(bobSSHRaw)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Repo + working clone.
+	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/sig"); code != 0 {
+		t.Fatalf("repo create: %s", errOut)
+	}
+	work := t.TempDir()
+	env := inst.gitEnv(aliceKey)
+	mustGit(t, work, env, "clone", inst.sshURL("alice/sig"), "w")
+	dir := filepath.Join(work, "w")
+
+	sigCfg := &packet.Config{}
+	expiredSigCfg := &packet.Config{Time: func() time.Time { return past.Add(10 * time.Minute) }}
+	var verifiedPayloadSig string // captured to build the bad-signature commit
+
+	specs := []commitSpec{
+		{authorEmail: "alice@example.test", subject: "unsigned"},
+		{authorEmail: "alice@example.test", subject: "verified-pgp", sign: func(p []byte) string {
+			verifiedPayloadSig = pgpSign(t, aliceEnt, p, sigCfg)
+			return verifiedPayloadSig
+		}},
+		{authorEmail: "mallory@example.test", subject: "unknown-key", sign: func(p []byte) string {
+			return pgpSign(t, malloryEnt, p, sigCfg)
+		}},
+		{authorEmail: "eve@example.test", subject: "email-mismatch", sign: func(p []byte) string {
+			return pgpSign(t, aliceEnt, p, sigCfg)
+		}},
+		{authorEmail: "alice@example.test", subject: "expired-key", sign: func(p []byte) string {
+			return pgpSign(t, expiredEnt, p, expiredSigCfg)
+		}},
+		{authorEmail: "alice@example.test", subject: "revoked-key", sign: func(p []byte) string {
+			return pgpSign(t, revokedEnt, p, sigCfg)
+		}},
+		{authorEmail: "alice@example.test", subject: "bad-signature", sign: func(p []byte) string {
+			return verifiedPayloadSig // valid armor, wrong payload
+		}},
+		{authorEmail: "alice@example.test", committerEmail: "other@example.test", subject: "verified-sshsig", sign: func(p []byte) string {
+			s, err := sig.MarshalSSHSig(aliceSigner, p)
+			if err != nil {
+				t.Fatal(err)
+			}
+			return string(s)
+		}},
+		{authorEmail: "bob@example.test", subject: "sshsig-unverified-email", sign: func(p []byte) string {
+			s, err := sig.MarshalSSHSig(bobSigner, p)
+			if err != nil {
+				t.Fatal(err)
+			}
+			return string(s)
+		}},
+	}
+	buildCommits(t, dir, env, specs)
+	mustGit(t, dir, env, "push", "-q", "origin", "main")
+
+	// Now revoke the key and register it: revocation predates verification,
+	// which is what the revoked state is about.
+	if err := revokedEnt.RevokeKey(packet.KeyCompromised, "test", nil); err != nil {
+		t.Fatal(err)
+	}
+	if _, errOut, code := inst.ssh(t, aliceKey, armorPub(t, revokedEnt), "pgp", "add"); code != 0 {
+		t.Fatalf("pgp add revoked: %s", errOut)
+	}
+
+	// Golden state check: one commit per state.
+	want := map[string]struct {
+		state  string
+		signer string
+	}{
+		"unsigned":                {"unsigned", ""},
+		"verified-pgp":            {"verified", "alice"},
+		"unknown-key":             {"signed_unknown_key", ""},
+		"email-mismatch":          {"signed_email_mismatch", "alice"},
+		"expired-key":             {"signed_key_expired", "alice"},
+		"revoked-key":             {"signed_key_revoked", "alice"},
+		"bad-signature":           {"bad_signature", "alice"},
+		"verified-sshsig":         {"verified", "alice"},
+		"sshsig-unverified-email": {"signed_email_mismatch", "bob"},
+	}
+	check := func(log map[string]logEntry, subjects ...string) {
+		t.Helper()
+		for _, subj := range subjects {
+			e, ok := log[subj]
+			if !ok {
+				t.Fatalf("commit %q missing from log", subj)
+			}
+			w := want[subj]
+			if e.Signature.State != w.state || e.Signature.Signer != w.signer {
+				t.Errorf("%s: state=%s signer=%q, want state=%s signer=%q",
+					subj, e.Signature.State, e.Signature.Signer, w.state, w.signer)
+			}
+		}
+	}
+	log := inst.repoLog(t, aliceKey, "alice/sig")
+	subjects := make([]string, 0, len(want))
+	for s := range want {
+		subjects = append(subjects, s)
+	}
+	check(log, subjects...)
+
+	// Committer email surfaces only when it differs from the author.
+	if log["verified-sshsig"].CommitterEmail != "other@example.test" {
+		t.Errorf("differing committer email not surfaced: %+v", log["verified-sshsig"])
+	}
+	if log["unsigned"].CommitterEmail != "" {
+		t.Errorf("identical committer email should be omitted: %+v", log["unsigned"])
+	}
+
+	// Epoch transition 1: registering mallory (key + verified email)
+	// upgrades the cached signed_unknown_key row to verified.
+	malloryKey := inst.newKey(t, "mallory")
+	inst.admin(t, "admin", "user", "create", "mallory",
+		"--key", malloryKey+".pub", "--email", "mallory@example.test", "--verified")
+	if _, errOut, code := inst.ssh(t, malloryKey, armorPub(t, malloryEnt), "pgp", "add"); code != 0 {
+		t.Fatalf("mallory pgp add: %s", errOut)
+	}
+	want["unknown-key"] = struct {
+		state  string
+		signer string
+	}{"verified", "mallory"}
+	check(inst.repoLog(t, aliceKey, "alice/sig"), "unknown-key")
+
+	// Epoch transition 2: verifying bob's email upgrades his SSHSIG commit.
+	inst.admin(t, "admin", "email", "verify", "bob", "bob@example.test")
+	want["sshsig-unverified-email"] = struct {
+		state  string
+		signer string
+	}{"verified", "bob"}
+	check(inst.repoLog(t, aliceKey, "alice/sig"), "sshsig-unverified-email")
+
+	// Epoch transition 3: removing alice's PGP key downgrades her verified
+	// commit; re-adding restores it.
+	fpr := fmt.Sprintf("%x", aliceEnt.PrimaryKey.Fingerprint)
+	if _, errOut, code := inst.ssh(t, aliceKey, "", "pgp", "remove", fpr); code != 0 {
+		t.Fatalf("pgp remove: %s", errOut)
+	}
+	if got := inst.repoLog(t, aliceKey, "alice/sig")["verified-pgp"].Signature.State; got != "signed_unknown_key" {
+		t.Errorf("after key removal: verified-pgp state = %s, want signed_unknown_key", got)
+	}
+	if _, errOut, code := inst.ssh(t, aliceKey, armorPub(t, aliceEnt), "pgp", "add"); code != 0 {
+		t.Fatalf("pgp re-add: %s", errOut)
+	}
+	check(inst.repoLog(t, aliceKey, "alice/sig"), "verified-pgp")
+
+	// Cross-check payload reconstruction against git itself, when gpg is
+	// available: git verify-commit must agree the signature is valid.
+	if gpgPath, err := exec.LookPath("gpg"); err == nil {
+		gnupgHome := t.TempDir()
+		gpgEnv := append(env, "GNUPGHOME="+gnupgHome)
+		imp := exec.Command(gpgPath, "--batch", "--import")
+		imp.Env = gpgEnv
+		imp.Stdin = strings.NewReader(armorPub(t, aliceEnt))
+		// gpg exits nonzero if it cannot reach its agent, even when the
+		// import itself succeeded; trust the summary line instead.
+		if out, err := imp.CombinedOutput(); err != nil && !strings.Contains(string(out), "imported: 1") {
+			t.Fatalf("gpg import: %v\n%s", err, out)
+		}
+		var sha string
+		for _, e := range inst.repoLog(t, aliceKey, "alice/sig") {
+			if e.Subject == "verified-pgp" {
+				sha = e.SHA
+			}
+		}
+		vc := exec.Command("git", "verify-commit", sha)
+		vc.Dir = dir
+		vc.Env = gpgEnv
+		if out, err := vc.CombinedOutput(); err != nil {
+			t.Errorf("git verify-commit disagrees with forge verification: %v\n%s", err, out)
+		}
+	} else {
+		t.Log("gpg not installed; skipping git verify-commit cross-check")
+	}
+}
diff --git a/go.mod b/go.mod
index 06b4318..ba01536 100644
--- a/go.mod
+++ b/go.mod
@@ -4,12 +4,14 @@ go 1.27.0
 
 require (
 	github.com/BurntSushi/toml v1.6.0
+	github.com/ProtonMail/go-crypto v1.4.1
 	github.com/spf13/cobra v1.10.2
 	golang.org/x/crypto v0.55.0
 	modernc.org/sqlite v1.57.0
 )
 
 require (
+	github.com/cloudflare/circl v1.6.2 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
diff --git a/go.sum b/go.sum
index 1ee1ac1..c424a7b 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,9 @@
 github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
+github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
+github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
+github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
diff --git a/internal/control/sig.go b/internal/control/sig.go
new file mode 100644
index 0000000..d295fc6
--- /dev/null
+++ b/internal/control/sig.go
@@ -0,0 +1,203 @@
+package control
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"strconv"
+	"time"
+
+	"github.com/krazywarez/forge/internal/gitutil"
+	"github.com/krazywarez/forge/internal/policy"
+	"github.com/krazywarez/forge/internal/protocol"
+	"github.com/krazywarez/forge/internal/sig"
+	"github.com/krazywarez/forge/internal/store"
+)
+
+func init() {
+	register(Command{Path: []string{"pgp", "add"},
+		Summary: "register an OpenPGP public key (armored, on stdin)", ReadsStdin: true, Run: runPGPAdd})
+	register(Command{Path: []string{"pgp", "list"},
+		Summary: "list registered OpenPGP keys", Run: runPGPList})
+	register(Command{Path: []string{"pgp", "remove"},
+		Summary: "remove an OpenPGP key by fingerprint", Run: runPGPRemove})
+	register(Command{Path: []string{"repo", "log"},
+		Summary: "commit log with signature states: repo log <owner/name> [--limit n]", Run: runRepoLog})
+}
+
+func runPGPAdd(c *Ctx, args []string) int {
+	if len(args) != 0 {
+		return c.fail(protocol.ExitUsage, "usage: pgp add < key.asc")
+	}
+	raw, err := io.ReadAll(io.LimitReader(c.Stdin, 1<<20))
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "reading key: %v", err)
+	}
+	meta, err := sig.ParsePGPKey(raw)
+	if err != nil {
+		return c.fail(protocol.ExitUsage, "%v", err)
+	}
+	uids, _ := json.Marshal(meta.Emails)
+	if err := c.Store.AddPGPKey(c.User.ID, meta.Fingerprint, string(raw), string(uids), meta.ExpiresAt, meta.RevokedAt); err != nil {
+		if errors.Is(err, store.ErrDuplicateKey) {
+			return c.fail(protocol.ExitUsage, "%v", err)
+		}
+		return c.fail(protocol.ExitFailure, "adding key: %v", err)
+	}
+	type out struct {
+		Fingerprint string   `json:"fingerprint"`
+		Emails      []string `json:"emails"`
+	}
+	d := out{meta.Fingerprint, meta.Emails}
+	return c.emit(d, func(w io.Writer) {
+		fmt.Fprintf(w, "added %s (%v)\n", d.Fingerprint, d.Emails)
+	})
+}
+
+func runPGPList(c *Ctx, args []string) int {
+	keys, err := c.Store.ListPGPKeys(c.User.ID)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	type out struct {
+		Fingerprint string     `json:"fingerprint"`
+		Emails      string     `json:"emails"`
+		ExpiresAt   *time.Time `json:"expires_at,omitempty"`
+		RevokedAt   *time.Time `json:"revoked_at,omitempty"`
+	}
+	var ds []out
+	for _, k := range keys {
+		ds = append(ds, out{k.Fingerprint, k.UIDsJSON, k.ExpiresAt, k.RevokedAt})
+	}
+	return c.emit(ds, func(w io.Writer) {
+		for _, d := range ds {
+			fmt.Fprintf(w, "%s\t%s\n", d.Fingerprint, d.Emails)
+		}
+	})
+}
+
+func runPGPRemove(c *Ctx, args []string) int {
+	if len(args) != 1 {
+		return c.fail(protocol.ExitUsage, "usage: pgp remove <fingerprint>")
+	}
+	if err := c.Store.RemovePGPKey(c.User.ID, args[0]); err != nil {
+		if errors.Is(err, store.ErrNotFound) {
+			return c.fail(protocol.ExitNotFound, "no key %s on your account", args[0])
+		}
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) {
+		fmt.Fprintf(w, "removed %s\n", args[0])
+	})
+}
+
+// VerifyCommitCached verifies one commit with the epoch cache. Shared with
+// the web UI.
+func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sha string) (sig.Result, error) {
+	epoch, err := st.KeyEpoch()
+	if err != nil {
+		return sig.Result{}, err
+	}
+	if res, ok, err := st.CachedSignature(repo.ID, sha, epoch); err != nil {
+		return sig.Result{}, err
+	} else if ok {
+		return res, nil
+	}
+	res, err := sig.VerifyCommit(store.SigDB{Store: st}, parsed)
+	if err != nil {
+		return sig.Result{}, err
+	}
+	if err := st.StoreSignature(repo.ID, sha, res, epoch); err != nil {
+		return sig.Result{}, err
+	}
+	return res, nil
+}
+
+func runRepoLog(c *Ctx, args []string) int {
+	limit := 30
+	var path string
+	for i := 0; i < len(args); i++ {
+		switch args[i] {
+		case "--limit":
+			if i+1 >= len(args) {
+				return c.fail(protocol.ExitUsage, "--limit requires a value")
+			}
+			n, err := strconv.Atoi(args[i+1])
+			if err != nil || n < 1 || n > 1000 {
+				return c.fail(protocol.ExitUsage, "--limit must be 1..1000")
+			}
+			limit = n
+			i++
+		default:
+			if path != "" {
+				return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--limit n]")
+			}
+			path = args[i]
+		}
+	}
+	if path == "" {
+		return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--limit n]")
+	}
+	repo, code := resolveRepo(c, path, policy.CanRead)
+	if code >= 0 {
+		return code
+	}
+	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
+	shas, err := gitutil.RevList(dir, repo.DefaultBranch, limit)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "reading log: %v", err)
+	}
+
+	type sigOut struct {
+		State       string `json:"state"`
+		Signer      string `json:"signer,omitempty"`
+		Fingerprint string `json:"key_fingerprint,omitempty"`
+	}
+	type out struct {
+		SHA            string `json:"sha"`
+		Subject        string `json:"subject"`
+		AuthorName     string `json:"author_name"`
+		AuthorEmail    string `json:"author_email"`
+		CommitterEmail string `json:"committer_email,omitempty"` // only when it differs
+		Date           string `json:"date"`
+		Signature      sigOut `json:"signature"`
+	}
+	var ds []out
+	for _, sha := range shas {
+		raw, err := gitutil.ReadCommit(dir, sha)
+		if err != nil {
+			return c.fail(protocol.ExitFailure, "%v", err)
+		}
+		parsed, err := sig.ParseCommit(raw)
+		if err != nil {
+			return c.fail(protocol.ExitFailure, "parsing %s: %v", sha, err)
+		}
+		res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
+		if err != nil {
+			return c.fail(protocol.ExitFailure, "verifying %s: %v", sha, err)
+		}
+		d := out{
+			SHA:         sha,
+			Subject:     parsed.Subject,
+			AuthorName:  parsed.AuthorName,
+			AuthorEmail: parsed.AuthorEmail,
+			Date:        time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339),
+			Signature:   sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint},
+		}
+		if parsed.CommitterEmail != parsed.AuthorEmail {
+			d.CommitterEmail = parsed.CommitterEmail
+		}
+		if res.SignerUserID != 0 {
+			if u, err := c.Store.UserByID(res.SignerUserID); err == nil {
+				d.Signature.Signer = u.Username
+			}
+		}
+		ds = append(ds, d)
+	}
+	return c.emit(ds, func(w io.Writer) {
+		for _, d := range ds {
+			fmt.Fprintf(w, "%.10s  %-22s %s (%s <%s>)\n", d.SHA, d.Signature.State, d.Subject, d.AuthorName, d.AuthorEmail)
+		}
+	})
+}
diff --git a/internal/gitutil/gitutil.go b/internal/gitutil/gitutil.go
index 99c5370..639189b 100644
--- a/internal/gitutil/gitutil.go
+++ b/internal/gitutil/gitutil.go
@@ -74,3 +74,29 @@ func ZeroSHA(s string) bool {
 	}
 	return true
 }
+
+// RevList returns up to limit commit SHAs reachable from ref, newest first.
+func RevList(dir, ref string, limit int) ([]string, error) {
+	cmd := exec.Command("git", "-C", dir, "rev-list", fmt.Sprintf("--max-count=%d", limit), ref)
+	out, err := cmd.Output()
+	if err != nil {
+		return nil, fmt.Errorf("rev-list %s: %w", ref, err)
+	}
+	var shas []string
+	for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+		if l != "" {
+			shas = append(shas, l)
+		}
+	}
+	return shas, nil
+}
+
+// ReadCommit returns the raw commit object bytes.
+func ReadCommit(dir, sha string) ([]byte, error) {
+	cmd := exec.Command("git", "-C", dir, "cat-file", "commit", sha)
+	out, err := cmd.Output()
+	if err != nil {
+		return nil, fmt.Errorf("cat-file commit %s: %w", sha, err)
+	}
+	return out, nil
+}
diff --git a/internal/sig/commit.go b/internal/sig/commit.go
new file mode 100644
index 0000000..9f1ad74
--- /dev/null
+++ b/internal/sig/commit.go
@@ -0,0 +1,111 @@
+// Package sig verifies OpenPGP and SSHSIG signatures on git commits and
+// tags, and maps them to the forge's trust states.
+package sig
+
+import (
+	"bytes"
+	"fmt"
+	"strings"
+)
+
+// Commit is a parsed raw commit object.
+type Commit struct {
+	Raw            []byte
+	Payload        []byte // Raw with the gpgsig header removed, byte-exact
+	Signature      []byte // armored signature block, nil if unsigned
+	AuthorName     string
+	AuthorEmail    string
+	CommitterEmail string
+	Subject        string
+	AuthorUnix     int64
+}
+
+// ParseCommit splits a raw commit object (as printed by `git cat-file
+// commit`) into its signed payload and signature. The payload must be
+// byte-exact: it is the original object minus the gpgsig header line and its
+// continuation lines, nothing else.
+func ParseCommit(raw []byte) (*Commit, error) {
+	c := &Commit{Raw: raw}
+
+	headerEnd := bytes.Index(raw, []byte("\n\n"))
+	if headerEnd < 0 {
+		return nil, fmt.Errorf("malformed commit: no header/body separator")
+	}
+	headers := raw[:headerEnd+1] // include trailing newline of last header
+	body := raw[headerEnd+2:]
+
+	var payload bytes.Buffer
+	lines := bytes.SplitAfter(headers, []byte("\n"))
+	for i := 0; i < len(lines); i++ {
+		line := lines[i]
+		if sigBody, ok := bytes.CutPrefix(line, []byte("gpgsig ")); ok {
+			// The signature value continues on lines starting with a space.
+			var sig bytes.Buffer
+			sig.Write(sigBody)
+			for i+1 < len(lines) && bytes.HasPrefix(lines[i+1], []byte(" ")) {
+				sig.Write(lines[i+1][1:])
+				i++
+			}
+			c.Signature = bytes.TrimSuffix(sig.Bytes(), []byte("\n"))
+			continue
+		}
+		payload.Write(line)
+
+		switch {
+		case bytes.HasPrefix(line, []byte("author ")):
+			c.AuthorName, c.AuthorEmail, c.AuthorUnix = parseIdent(string(line[len("author "):]))
+		case bytes.HasPrefix(line, []byte("committer ")):
+			_, c.CommitterEmail, _ = parseIdent(string(line[len("committer "):]))
+		}
+	}
+	payload.WriteByte('\n')
+	payload.Write(body)
+	c.Payload = payload.Bytes()
+
+	if i := bytes.IndexByte(body, '\n'); i >= 0 {
+		c.Subject = string(body[:i])
+	} else {
+		c.Subject = strings.TrimRight(string(body), "\n")
+	}
+	return c, nil
+}
+
+// parseIdent parses "Name <email> unix tz".
+func parseIdent(s string) (name, email string, unix int64) {
+	s = strings.TrimSuffix(s, "\n")
+	lt := strings.IndexByte(s, '<')
+	gt := strings.IndexByte(s, '>')
+	if lt < 0 || gt < lt {
+		return s, "", 0
+	}
+	name = strings.TrimSpace(s[:lt])
+	email = s[lt+1 : gt]
+	rest := strings.Fields(s[gt+1:])
+	if len(rest) >= 1 {
+		fmt.Sscanf(rest[0], "%d", &unix)
+	}
+	return name, email, unix
+}
+
+// SigKind reports which signature format a gpgsig block holds.
+type SigKind int
+
+const (
+	SigNone SigKind = iota
+	SigOpenPGP
+	SigSSH
+	SigUnknown
+)
+
+func KindOf(sig []byte) SigKind {
+	switch {
+	case sig == nil:
+		return SigNone
+	case bytes.Contains(sig, []byte("BEGIN PGP SIGNATURE")):
+		return SigOpenPGP
+	case bytes.Contains(sig, []byte("BEGIN SSH SIGNATURE")):
+		return SigSSH
+	default:
+		return SigUnknown
+	}
+}
diff --git a/internal/sig/commit_test.go b/internal/sig/commit_test.go
new file mode 100644
index 0000000..c9b3772
--- /dev/null
+++ b/internal/sig/commit_test.go
@@ -0,0 +1,62 @@
+package sig
+
+import (
+	"bytes"
+	"testing"
+)
+
+var signedCommit = []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n" +
+	"parent 0123456789012345678901234567890123456789\n" +
+	"author T <a@example.test> 1700000000 +0000\n" +
+	"committer T <c@example.test> 1700000000 +0000\n" +
+	"gpgsig -----BEGIN PGP SIGNATURE-----\n" +
+	" \n" +
+	" base64base64\n" +
+	" =abcd\n" +
+	" -----END PGP SIGNATURE-----\n" +
+	"\n" +
+	"subject line\n\nbody\n")
+
+func TestParseCommitSigned(t *testing.T) {
+	c, err := ParseCommit(signedCommit)
+	if err != nil {
+		t.Fatal(err)
+	}
+	wantPayload := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n" +
+		"parent 0123456789012345678901234567890123456789\n" +
+		"author T <a@example.test> 1700000000 +0000\n" +
+		"committer T <c@example.test> 1700000000 +0000\n" +
+		"\n" +
+		"subject line\n\nbody\n")
+	if !bytes.Equal(c.Payload, wantPayload) {
+		t.Errorf("payload not byte-exact:\ngot  %q\nwant %q", c.Payload, wantPayload)
+	}
+	wantSig := "-----BEGIN PGP SIGNATURE-----\n\nbase64base64\n=abcd\n-----END PGP SIGNATURE-----"
+	if string(c.Signature) != wantSig {
+		t.Errorf("signature reconstruction:\ngot  %q\nwant %q", c.Signature, wantSig)
+	}
+	if c.AuthorEmail != "a@example.test" || c.CommitterEmail != "c@example.test" ||
+		c.Subject != "subject line" || c.AuthorUnix != 1700000000 {
+		t.Errorf("fields: %+v", c)
+	}
+	if KindOf(c.Signature) != SigOpenPGP {
+		t.Errorf("kind = %v", KindOf(c.Signature))
+	}
+}
+
+func TestParseCommitUnsigned(t *testing.T) {
+	raw := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n" +
+		"author T <a@example.test> 1700000000 +0000\n" +
+		"committer T <a@example.test> 1700000000 +0000\n" +
+		"\nsubject\n")
+	c, err := ParseCommit(raw)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if c.Signature != nil {
+		t.Errorf("unsigned commit has signature %q", c.Signature)
+	}
+	if !bytes.Equal(c.Payload, raw) {
+		t.Errorf("unsigned payload must equal raw object")
+	}
+}
diff --git a/internal/sig/keys.go b/internal/sig/keys.go
new file mode 100644
index 0000000..61f5aa5
--- /dev/null
+++ b/internal/sig/keys.go
@@ -0,0 +1,59 @@
+package sig
+
+import (
+	"bytes"
+	"encoding/hex"
+	"fmt"
+	"time"
+
+	"github.com/ProtonMail/go-crypto/openpgp"
+)
+
+func keyIDHex(id uint64) string {
+	var b [8]byte
+	for i := 7; i >= 0; i-- {
+		b[i] = byte(id)
+		id >>= 8
+	}
+	return hex.EncodeToString(b[:])
+}
+
+// PGPKeyMeta is what `pgp add` needs to persist about an imported key.
+type PGPKeyMeta struct {
+	Fingerprint string // primary key, lowercase hex
+	Emails      []string
+	ExpiresAt   *time.Time
+	RevokedAt   *time.Time
+}
+
+// ParsePGPKey extracts registration metadata from an armored public key.
+func ParsePGPKey(armored []byte) (PGPKeyMeta, error) {
+	ring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(armored))
+	if err != nil {
+		return PGPKeyMeta{}, fmt.Errorf("not a valid armored OpenPGP key: %w", err)
+	}
+	if len(ring) != 1 {
+		return PGPKeyMeta{}, fmt.Errorf("expected exactly one key, got %d", len(ring))
+	}
+	e := ring[0]
+	meta := PGPKeyMeta{
+		Fingerprint: hex.EncodeToString(e.PrimaryKey.Fingerprint),
+	}
+	for _, id := range e.Identities {
+		if id.UserId != nil && id.UserId.Email != "" {
+			meta.Emails = append(meta.Emails, id.UserId.Email)
+		}
+	}
+	// Primary key expiry from the self-signature.
+	if selfSig, _ := e.PrimarySelfSignature(); selfSig != nil && selfSig.KeyLifetimeSecs != nil && *selfSig.KeyLifetimeSecs > 0 {
+		t := e.PrimaryKey.CreationTime.Add(time.Duration(*selfSig.KeyLifetimeSecs) * time.Second)
+		meta.ExpiresAt = &t
+	}
+	for _, rev := range e.Revocations {
+		t := rev.CreationTime
+		if meta.RevokedAt == nil || t.Before(*meta.RevokedAt) {
+			meta.RevokedAt = &t
+		}
+	}
+	return meta, nil
+}
diff --git a/internal/sig/sshsig.go b/internal/sig/sshsig.go
new file mode 100644
index 0000000..2dee88b
--- /dev/null
+++ b/internal/sig/sshsig.go
@@ -0,0 +1,180 @@
+package sig
+
+import (
+	"bytes"
+	"crypto/sha256"
+	"crypto/sha512"
+	"encoding/base64"
+	"encoding/binary"
+	"fmt"
+	"slices"
+	"strings"
+
+	"golang.org/x/crypto/ssh"
+)
+
+// SSHSIG armored signature format, per openssh PROTOCOL.sshsig.
+const sshsigMagic = "SSHSIG"
+
+type sshsigBlob struct {
+	Version       uint32
+	PublicKey     []byte
+	Namespace     string
+	Reserved      string
+	HashAlgorithm string
+	Signature     []byte
+}
+
+func verifySSH(db DB, c *Commit) (Result, error) {
+	blob, err := decodeArmor(c.Signature, "SSH SIGNATURE")
+	if err != nil {
+		return Result{State: BadSignature}, nil
+	}
+	sb, err := parseSSHSig(blob)
+	if err != nil {
+		return Result{State: BadSignature}, nil
+	}
+	pub, err := ssh.ParsePublicKey(sb.PublicKey)
+	if err != nil {
+		return Result{State: BadSignature}, nil
+	}
+
+	fp := ssh.FingerprintSHA256(pub)
+	key, found, err := db.SSHSignerByFingerprint(fp)
+	if err != nil {
+		return Result{}, err
+	}
+	if !found {
+		return Result{State: SignedUnknownKey, KeyFingerprint: fp}, nil
+	}
+	res := Result{SignerUserID: key.UserID, KeyFingerprint: fp}
+
+	// Reconstruct the signed blob: MAGIC || namespace || reserved ||
+	// hash_algorithm || H(payload).
+	var h []byte
+	switch sb.HashAlgorithm {
+	case "sha512":
+		d := sha512.Sum512(c.Payload)
+		h = d[:]
+	case "sha256":
+		d := sha256.Sum256(c.Payload)
+		h = d[:]
+	default:
+		res.State = BadSignature
+		return res, nil
+	}
+	if sb.Namespace != "git" {
+		res.State = BadSignature
+		return res, nil
+	}
+	signed := buildSSHSignedData(sb.Namespace, sb.Reserved, sb.HashAlgorithm, h)
+
+	var sshSig ssh.Signature
+	if err := ssh.Unmarshal(sb.Signature, &sshSig); err != nil {
+		res.State = BadSignature
+		return res, nil
+	}
+	if err := pub.Verify(signed, &sshSig); err != nil {
+		res.State = BadSignature
+		return res, nil
+	}
+
+	// SSH keys carry no identities: the principal set is the owning
+	// account's verified emails.
+	verified, err := db.VerifiedEmails(key.UserID)
+	if err != nil {
+		return Result{}, err
+	}
+	if !slices.Contains(verified, c.AuthorEmail) {
+		res.State = SignedEmailMismatch
+		return res, nil
+	}
+	res.State = Verified
+	return res, nil
+}
+
+func parseSSHSig(blob []byte) (*sshsigBlob, error) {
+	if !bytes.HasPrefix(blob, []byte(sshsigMagic)) {
+		return nil, fmt.Errorf("missing SSHSIG magic")
+	}
+	var sb sshsigBlob
+	if err := ssh.Unmarshal(blob[len(sshsigMagic):], &sb); err != nil {
+		return nil, err
+	}
+	if sb.Version != 1 {
+		return nil, fmt.Errorf("unsupported sshsig version %d", sb.Version)
+	}
+	return &sb, nil
+}
+
+func buildSSHSignedData(namespace, reserved, hashAlg string, hash []byte) []byte {
+	var b bytes.Buffer
+	b.WriteString(sshsigMagic)
+	writeSSHString(&b, []byte(namespace))
+	writeSSHString(&b, []byte(reserved))
+	writeSSHString(&b, []byte(hashAlg))
+	writeSSHString(&b, hash)
+	return b.Bytes()
+}
+
+func writeSSHString(b *bytes.Buffer, s []byte) {
+	var l [4]byte
+	binary.BigEndian.PutUint32(l[:], uint32(len(s)))
+	b.Write(l[:])
+	b.Write(s)
+}
+
+// MarshalSSHSig builds an armored SSHSIG over payload with the given signer.
+// Used by fixture generation and (later) forge-side tooling.
+func MarshalSSHSig(signer ssh.Signer, payload []byte) ([]byte, error) {
+	d := sha512.Sum512(payload)
+	signed := buildSSHSignedData("git", "", "sha512", d[:])
+	sshSig, err := signer.Sign(nil, signed)
+	if err != nil {
+		return nil, err
+	}
+	var body bytes.Buffer
+	body.WriteString(sshsigMagic)
+	blob := sshsigBlob{
+		Version:       1,
+		PublicKey:     signer.PublicKey().Marshal(),
+		Namespace:     "git",
+		Reserved:      "",
+		HashAlgorithm: "sha512",
+		Signature:     ssh.Marshal(sshSig),
+	}
+	body.Write(ssh.Marshal(blob))
+
+	b64 := base64.StdEncoding.EncodeToString(body.Bytes())
+	var out strings.Builder
+	out.WriteString("-----BEGIN SSH SIGNATURE-----\n")
+	for len(b64) > 70 {
+		out.WriteString(b64[:70] + "\n")
+		b64 = b64[70:]
+	}
+	out.WriteString(b64 + "\n-----END SSH SIGNATURE-----\n")
+	return []byte(out.String()), nil
+}
+
+// decodeArmor extracts the base64 body between BEGIN/END markers for the
+// given label. Works for both SSHSIG and OpenPGP armor (checksum lines and
+// armor headers are skipped).
+func decodeArmor(armored []byte, label string) ([]byte, error) {
+	begin := "-----BEGIN " + label + "-----"
+	end := "-----END " + label + "-----"
+	s := string(armored)
+	i := strings.Index(s, begin)
+	j := strings.Index(s, end)
+	if i < 0 || j < i {
+		return nil, fmt.Errorf("no %s armor", label)
+	}
+	var b64 strings.Builder
+	for _, line := range strings.Split(s[i+len(begin):j], "\n") {
+		line = strings.TrimSpace(line)
+		if line == "" || strings.HasPrefix(line, "=") || strings.Contains(line, ":") {
+			continue // armor checksum or header line
+		}
+		b64.WriteString(line)
+	}
+	return base64.StdEncoding.DecodeString(b64.String())
+}
diff --git a/internal/sig/verify.go b/internal/sig/verify.go
new file mode 100644
index 0000000..ed9cb7e
--- /dev/null
+++ b/internal/sig/verify.go
@@ -0,0 +1,148 @@
+package sig
+
+import (
+	"bytes"
+	"slices"
+	"time"
+
+	"github.com/ProtonMail/go-crypto/openpgp"
+	"github.com/ProtonMail/go-crypto/openpgp/packet"
+)
+
+type State string
+
+const (
+	Verified            State = "verified"
+	SignedUnknownKey    State = "signed_unknown_key"
+	SignedEmailMismatch State = "signed_email_mismatch"
+	SignedKeyExpired    State = "signed_key_expired"
+	SignedKeyRevoked    State = "signed_key_revoked"
+	BadSignature        State = "bad_signature"
+	Unsigned            State = "unsigned"
+)
+
+type Result struct {
+	State          State
+	SignerUserID   int64 // 0 when unknown
+	KeyFingerprint string
+}
+
+// PGPKeyInfo is a registered OpenPGP key as the verifier needs it.
+type PGPKeyInfo struct {
+	UserID    int64
+	Armored   string
+	ExpiresAt *time.Time
+	RevokedAt *time.Time
+}
+
+// SSHKeyInfo is a registered SSH key as the verifier needs it.
+type SSHKeyInfo struct {
+	UserID      int64
+	Fingerprint string
+}
+
+// DB is the store surface the verifier depends on. Implemented by
+// store.SigDB.
+type DB interface {
+	// PGPKeyByIssuer finds a registered key whose fingerprint ends with the
+	// issuer key id (16 hex chars, lowercase).
+	PGPKeyByIssuer(keyIDHex string) (PGPKeyInfo, string, bool, error) // info, fingerprint, found
+	SSHSignerByFingerprint(fp string) (SSHKeyInfo, bool, error)
+	VerifiedEmails(userID int64) ([]string, error)
+}
+
+// VerifyCommit classifies one parsed commit. The commit's author email
+// drives the identity check, per the plan.
+func VerifyCommit(db DB, c *Commit) (Result, error) {
+	switch KindOf(c.Signature) {
+	case SigNone:
+		return Result{State: Unsigned}, nil
+	case SigOpenPGP:
+		return verifyOpenPGP(db, c)
+	case SigSSH:
+		return verifySSH(db, c)
+	default:
+		return Result{State: BadSignature}, nil
+	}
+}
+
+func verifyOpenPGP(db DB, c *Commit) (Result, error) {
+	issuer, sigTime, ok := openpgpIssuer(c.Signature)
+	if !ok {
+		return Result{State: BadSignature}, nil
+	}
+	key, fpr, found, err := db.PGPKeyByIssuer(issuer)
+	if err != nil {
+		return Result{}, err
+	}
+	if !found {
+		return Result{State: SignedUnknownKey, KeyFingerprint: issuer}, nil
+	}
+	res := Result{SignerUserID: key.UserID, KeyFingerprint: fpr}
+
+	// Key-state policy comes before cryptography: a revoked or expired key
+	// invalidates the trust claim no matter what the signature says, and
+	// hard revocations make the library's own verdict on such keys
+	// unpredictable.
+	now := time.Now()
+	if key.RevokedAt != nil && key.RevokedAt.Before(now) {
+		res.State = SignedKeyRevoked
+		return res, nil
+	}
+	if key.ExpiresAt != nil && key.ExpiresAt.Before(now) {
+		res.State = SignedKeyExpired
+		return res, nil
+	}
+
+	ring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(key.Armored)))
+	if err != nil {
+		return Result{}, err
+	}
+	// Verify the cryptography at the signature's own creation time: key
+	// expiry is our policy decision (above), not the library's.
+	cfg := &packet.Config{Time: func() time.Time { return sigTime }}
+	signer, err := openpgp.CheckArmoredDetachedSignature(
+		ring, bytes.NewReader(c.Payload), bytes.NewReader(c.Signature), cfg)
+	if err != nil || signer == nil {
+		res.State = BadSignature
+		return res, nil
+	}
+
+	// Author email must appear in a UID on the signing key AND be a
+	// verified address on the owning account.
+	uidMatch := false
+	for _, id := range signer.Identities {
+		if id.UserId != nil && id.UserId.Email == c.AuthorEmail {
+			uidMatch = true
+			break
+		}
+	}
+	verified, err := db.VerifiedEmails(key.UserID)
+	if err != nil {
+		return Result{}, err
+	}
+	if !uidMatch || !slices.Contains(verified, c.AuthorEmail) {
+		res.State = SignedEmailMismatch
+		return res, nil
+	}
+	res.State = Verified
+	return res, nil
+}
+
+// openpgpIssuer extracts the issuer key id (lowercase hex) and creation time
+// from an armored signature without verifying it.
+func openpgpIssuer(armored []byte) (string, time.Time, bool) {
+	block, err := decodeArmor(armored, "PGP SIGNATURE")
+	if err != nil {
+		return "", time.Time{}, false
+	}
+	p, err := packet.Read(bytes.NewReader(block))
+	if err != nil {
+		return "", time.Time{}, false
+	}
+	sig, ok := p.(*packet.Signature)
+	if !ok || sig.IssuerKeyId == nil {
+		return "", time.Time{}, false
+	}
+	return keyIDHex(*sig.IssuerKeyId), sig.CreationTime, true
+}
diff --git a/internal/store/signatures.go b/internal/store/signatures.go
new file mode 100644
index 0000000..b962ad6
--- /dev/null
+++ b/internal/store/signatures.go
@@ -0,0 +1,214 @@
+package store
+
+import (
+	"database/sql"
+	"errors"
+	"time"
+
+	"github.com/krazywarez/forge/internal/sig"
+)
+
+// AddPGPKey registers an OpenPGP key and bumps the key epoch.
+func (s *Store) AddPGPKey(userID int64, fingerprint, armored, uidsJSON string, expiresAt, revokedAt *time.Time) error {
+	tx, err := s.DB.Begin()
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback()
+	if _, err := tx.Exec(
+		"INSERT INTO pgp_keys (user_id, fingerprint, armored, uids_json, expires_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?)",
+		userID, fingerprint, armored, uidsJSON, timePtr(expiresAt), timePtr(revokedAt)); err != nil {
+		if isUniqueErr(err) {
+			return ErrDuplicateKey
+		}
+		return err
+	}
+	if err := bumpKeyEpoch(tx); err != nil {
+		return err
+	}
+	return tx.Commit()
+}
+
+func (s *Store) RemovePGPKey(userID int64, fingerprint string) error {
+	tx, err := s.DB.Begin()
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback()
+	res, err := tx.Exec("DELETE FROM pgp_keys WHERE user_id = ? AND fingerprint = ?", userID, fingerprint)
+	if err != nil {
+		return err
+	}
+	if n, _ := res.RowsAffected(); n == 0 {
+		return ErrNotFound
+	}
+	if err := bumpKeyEpoch(tx); err != nil {
+		return err
+	}
+	return tx.Commit()
+}
+
+type PGPKey struct {
+	Fingerprint string
+	UIDsJSON    string
+	ExpiresAt   *time.Time
+	RevokedAt   *time.Time
+}
+
+func (s *Store) ListPGPKeys(userID int64) ([]PGPKey, error) {
+	rows, err := s.DB.Query(
+		"SELECT fingerprint, uids_json, expires_at, revoked_at FROM pgp_keys WHERE user_id = ? ORDER BY id", userID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []PGPKey
+	for rows.Next() {
+		var k PGPKey
+		var exp, rev sql.NullString
+		if err := rows.Scan(&k.Fingerprint, &k.UIDsJSON, &exp, &rev); err != nil {
+			return nil, err
+		}
+		k.ExpiresAt = parseTime(exp)
+		k.RevokedAt = parseTime(rev)
+		out = append(out, k)
+	}
+	return out, rows.Err()
+}
+
+// VerifyEmail marks an address verified and bumps the key epoch (email
+// verification is a trust input for signature states).
+func (s *Store) VerifyEmail(userID int64, address, by string) error {
+	tx, err := s.DB.Begin()
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback()
+	res, err := tx.Exec(
+		`UPDATE emails SET verified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), verified_by = ?
+		 WHERE user_id = ? AND address = ?`, by, userID, address)
+	if err != nil {
+		return err
+	}
+	if n, _ := res.RowsAffected(); n == 0 {
+		return ErrNotFound
+	}
+	if err := bumpKeyEpoch(tx); err != nil {
+		return err
+	}
+	return tx.Commit()
+}
+
+// SigDB adapts Store to the verifier's interface and owns the epoch cache.
+type SigDB struct{ *Store }
+
+func (d SigDB) PGPKeyByIssuer(keyIDHex string) (sig.PGPKeyInfo, string, bool, error) {
+	var info sig.PGPKeyInfo
+	var fpr string
+	var exp, rev sql.NullString
+	err := d.DB.QueryRow(
+		"SELECT user_id, fingerprint, armored, expires_at, revoked_at FROM pgp_keys WHERE fingerprint LIKE '%' || ?",
+		keyIDHex).Scan(&info.UserID, &fpr, &info.Armored, &exp, &rev)
+	if errors.Is(err, sql.ErrNoRows) {
+		return info, "", false, nil
+	}
+	if err != nil {
+		return info, "", false, err
+	}
+	info.ExpiresAt = parseTime(exp)
+	info.RevokedAt = parseTime(rev)
+	return info, fpr, true, nil
+}
+
+func (d SigDB) SSHSignerByFingerprint(fp string) (sig.SSHKeyInfo, bool, error) {
+	k, err := d.SSHKeyByFingerprint(fp)
+	if errors.Is(err, ErrNotFound) {
+		return sig.SSHKeyInfo{}, false, nil
+	}
+	if err != nil {
+		return sig.SSHKeyInfo{}, false, err
+	}
+	return sig.SSHKeyInfo{UserID: k.UserID, Fingerprint: k.Fingerprint}, true, nil
+}
+
+func (d SigDB) VerifiedEmails(userID int64) ([]string, error) {
+	rows, err := d.DB.Query(
+		"SELECT address FROM emails WHERE user_id = ? AND verified_at IS NOT NULL", userID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []string
+	for rows.Next() {
+		var a string
+		if err := rows.Scan(&a); err != nil {
+			return nil, err
+		}
+		out = append(out, a)
+	}
+	return out, rows.Err()
+}
+
+// CachedSignature returns a cached result and whether it is current at the
+// given epoch.
+func (s *Store) CachedSignature(repoID int64, sha string, epoch int64) (sig.Result, bool, error) {
+	var r sig.Result
+	var state string
+	var signer sql.NullInt64
+	var fpr sql.NullString
+	var rowEpoch int64
+	err := s.DB.QueryRow(
+		"SELECT state, signer_user_id, key_fingerprint, key_epoch FROM commit_signatures WHERE repo_id = ? AND commit_sha = ?",
+		repoID, sha).Scan(&state, &signer, &fpr, &rowEpoch)
+	if errors.Is(err, sql.ErrNoRows) {
+		return r, false, nil
+	}
+	if err != nil {
+		return r, false, err
+	}
+	if rowEpoch < epoch {
+		return r, false, nil // stale: trust inputs changed since this was computed
+	}
+	r.State = sig.State(state)
+	r.SignerUserID = signer.Int64
+	r.KeyFingerprint = fpr.String
+	return r, true, nil
+}
+
+func (s *Store) StoreSignature(repoID int64, sha string, r sig.Result, epoch int64) error {
+	var signer any
+	if r.SignerUserID != 0 {
+		signer = r.SignerUserID
+	}
+	var fpr any
+	if r.KeyFingerprint != "" {
+		fpr = r.KeyFingerprint
+	}
+	_, err := s.DB.Exec(`
+		INSERT INTO commit_signatures (repo_id, commit_sha, state, signer_user_id, key_fingerprint, key_epoch)
+		VALUES (?, ?, ?, ?, ?, ?)
+		ON CONFLICT (repo_id, commit_sha) DO UPDATE SET
+			state = excluded.state, signer_user_id = excluded.signer_user_id,
+			key_fingerprint = excluded.key_fingerprint, key_epoch = excluded.key_epoch,
+			checked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
+		repoID, sha, string(r.State), signer, fpr, epoch)
+	return err
+}
+
+func timePtr(t *time.Time) any {
+	if t == nil {
+		return nil
+	}
+	return t.UTC().Format("2006-01-02T15:04:05.000Z")
+}
+
+func parseTime(s sql.NullString) *time.Time {
+	if !s.Valid {
+		return nil
+	}
+	t, err := time.Parse("2006-01-02T15:04:05.000Z", s.String)
+	if err != nil {
+		return nil
+	}
+	return &t
+}
diff --git a/internal/store/users.go b/internal/store/users.go
index 7350bbd..93f847a 100644
--- a/internal/store/users.go
+++ b/internal/store/users.go
@@ -142,20 +142,35 @@ func (s *Store) TouchSSHKey(id int64) error {
 }
 
 // AddEmail adds an address; verifiedBy is "" (unverified), "smtp", or "admin".
+// Adding an already-verified address bumps the key epoch: it is a trust input
+// for signature states.
 func (s *Store) AddEmail(userID int64, address, verifiedBy string, primary bool) error {
+	tx, err := s.DB.Begin()
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback()
 	var vAt, vBy any
 	if verifiedBy != "" {
 		vAt = "now"
 		vBy = verifiedBy
 	}
-	_, err := s.DB.Exec(
+	_, err = tx.Exec(
 		`INSERT INTO emails (user_id, address, verified_at, verified_by, is_primary)
 		 VALUES (?, ?, CASE WHEN ? IS NULL THEN NULL ELSE strftime('%Y-%m-%dT%H:%M:%fZ','now') END, ?, ?)`,
 		userID, address, vAt, vBy, boolInt(primary))
 	if isUniqueErr(err) {
 		return fmt.Errorf("address %q is already in use", address)
 	}
-	return err
+	if err != nil {
+		return err
+	}
+	if verifiedBy != "" {
+		if err := bumpKeyEpoch(tx); err != nil {
+			return err
+		}
+	}
+	return tx.Commit()
 }
 
 func (s *Store) KeyEpoch() (int64, error) {