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
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(-) @@ -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]) new file mode 100644 @@ -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") + } +} @@ -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 @@ -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= new file mode 100644 @@ -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) + } + }) +} @@ -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 +} new file mode 100644 @@ -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 + } +} new file mode 100644 @@ -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") + } +} new file mode 100644 @@ -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 +} new file mode 100644 @@ -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()) +} new file mode 100644 @@ -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 +} new file mode 100644 @@ -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 +} @@ -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) {