Commit 35d07f32e1

35d07f32e12d86484a77b0c4674877dfe64298f2

parent: a831d1ab09

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 22:48 UTC

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

Layout: unified · split

cmd/forged/main.go +1 −7
@@ -298,13 +298,7 @@ func adminEmailVerifyCmd() *cobra.Command {
298298 if err != nil {
299299 return fmt.Errorf("user %s: %w", args[0], err)
300300 }
301 res, err := st.DB.Exec(
302 `UPDATE emails SET verified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), verified_by = 'admin'
303 WHERE user_id = ? AND address = ?`, u.ID, args[1])
304 if err != nil {
305 return err
306 }
307 if n, _ := res.RowsAffected(); n == 0 {
301 if err := st.VerifyEmail(u.ID, args[1], "admin"); err != nil {
308302 return fmt.Errorf("no address %s on user %s", args[1], args[0])
309303 }
310304 fmt.Println("verified", args[1])
e2e/sig_test.go added +363
@@ -0,0 +1,363 @@
1package e2e
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/ProtonMail/go-crypto/openpgp"
15 "github.com/ProtonMail/go-crypto/openpgp/armor"
16 "github.com/ProtonMail/go-crypto/openpgp/packet"
17 "golang.org/x/crypto/ssh"
18
19 "github.com/krazywarez/forge/internal/sig"
20)
21
22// --- fixture key helpers -------------------------------------------------
23
24func newPGPKey(t *testing.T, name, email string, cfg *packet.Config) *openpgp.Entity {
25 t.Helper()
26 e, err := openpgp.NewEntity(name, "", email, cfg)
27 if err != nil {
28 t.Fatal(err)
29 }
30 return e
31}
32
33func armorPub(t *testing.T, e *openpgp.Entity) string {
34 t.Helper()
35 var buf bytes.Buffer
36 w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
37 if err != nil {
38 t.Fatal(err)
39 }
40 if err := e.Serialize(w); err != nil {
41 t.Fatal(err)
42 }
43 w.Close()
44 return buf.String()
45}
46
47func pgpSign(t *testing.T, e *openpgp.Entity, payload []byte, cfg *packet.Config) string {
48 t.Helper()
49 var buf bytes.Buffer
50 if err := openpgp.ArmoredDetachSign(&buf, e, bytes.NewReader(payload), cfg); err != nil {
51 t.Fatal(err)
52 }
53 return buf.String()
54}
55
56// --- fixture commit construction ----------------------------------------
57
58type commitSpec struct {
59 authorEmail string
60 committerEmail string
61 subject string
62 sign func(payload []byte) string // "" = unsigned
63}
64
65// buildCommits writes a chain of hand-constructed commit objects into the
66// clone at dir and points refs/heads/main at the tip.
67func buildCommits(t *testing.T, dir string, env []string, specs []commitSpec) []string {
68 t.Helper()
69 tree := strings.TrimSpace(mustGit(t, dir, env, "mktree"))
70 parent := ""
71 base := time.Now().Add(-time.Duration(len(specs)) * time.Minute).Unix()
72 var shas []string
73 for i, spec := range specs {
74 if spec.committerEmail == "" {
75 spec.committerEmail = spec.authorEmail
76 }
77 ts := base + int64(i)*60
78 var b strings.Builder
79 fmt.Fprintf(&b, "tree %s\n", tree)
80 if parent != "" {
81 fmt.Fprintf(&b, "parent %s\n", parent)
82 }
83 fmt.Fprintf(&b, "author T <%s> %d +0000\n", spec.authorEmail, ts)
84 fmt.Fprintf(&b, "committer T <%s> %d +0000\n", spec.committerEmail, ts)
85 payloadTail := fmt.Sprintf("\n%s\n", spec.subject)
86 payload := b.String() + payloadTail
87
88 full := payload
89 if spec.sign != nil {
90 sigText := spec.sign([]byte(payload))
91 var sigHeader strings.Builder
92 for j, line := range strings.Split(strings.TrimSuffix(sigText, "\n"), "\n") {
93 if j == 0 {
94 sigHeader.WriteString("gpgsig " + line + "\n")
95 } else {
96 sigHeader.WriteString(" " + line + "\n")
97 }
98 }
99 full = b.String() + sigHeader.String() + payloadTail
100 }
101
102 cmd := exec.Command("git", "hash-object", "-t", "commit", "-w", "--stdin")
103 cmd.Dir = dir
104 cmd.Env = env
105 cmd.Stdin = strings.NewReader(full)
106 out, err := cmd.Output()
107 if err != nil {
108 t.Fatalf("hash-object: %v", err)
109 }
110 parent = strings.TrimSpace(string(out))
111 shas = append(shas, parent)
112 }
113 mustGit(t, dir, env, "update-ref", "refs/heads/main", parent)
114 return shas
115}
116
117// --- the M4 milestone test ----------------------------------------------
118
119type logEntry struct {
120 SHA string `json:"sha"`
121 Subject string `json:"subject"`
122 AuthorEmail string `json:"author_email"`
123 CommitterEmail string `json:"committer_email"`
124 Signature struct {
125 State string `json:"state"`
126 Signer string `json:"signer"`
127 } `json:"signature"`
128}
129
130func (i *instance) repoLog(t *testing.T, key, repo string) map[string]logEntry {
131 t.Helper()
132 out, errOut, code := i.ssh(t, key, "", "repo", "log", repo, "--json")
133 if code != 0 {
134 t.Fatalf("repo log: exit %d, %s", code, errOut)
135 }
136 var env struct {
137 Data []logEntry `json:"data"`
138 }
139 if err := json.Unmarshal([]byte(out), &env); err != nil {
140 t.Fatalf("repo log JSON: %v\n%s", err, out)
141 }
142 byShaOrSubject := map[string]logEntry{}
143 for _, e := range env.Data {
144 byShaOrSubject[e.Subject] = e
145 }
146 return byShaOrSubject
147}
148
149func TestSignatureVerification(t *testing.T) {
150 inst := startInstance(t)
151
152 aliceKey := inst.newKey(t, "alice")
153 inst.admin(t, "admin", "user", "create", "alice",
154 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
155
156 // bob: registered SSH key, email NOT yet verified.
157 bobKey := inst.newKey(t, "bob")
158 inst.admin(t, "admin", "user", "create", "bob",
159 "--key", bobKey+".pub", "--email", "bob@example.test")
160
161 // PGP keys.
162 now := time.Now()
163 aliceEnt := newPGPKey(t, "Alice", "alice@example.test", nil)
164 malloryEnt := newPGPKey(t, "Mallory", "mallory@example.test", nil)
165
166 past := now.Add(-2 * time.Hour)
167 expiredCfg := &packet.Config{Time: func() time.Time { return past }, KeyLifetimeSecs: 3600}
168 expiredEnt := newPGPKey(t, "Alice Old", "alice@example.test", expiredCfg)
169
170 // The "revoked" key signs its commit first and is revoked before
171 // registration: go-crypto (correctly) refuses to sign with a revoked key.
172 revokedEnt := newPGPKey(t, "Alice Revoked", "alice@example.test", nil)
173
174 // Register alice's current and expired keys on her account.
175 for _, ent := range []*openpgp.Entity{aliceEnt, expiredEnt} {
176 _, errOut, code := inst.ssh(t, aliceKey, armorPub(t, ent), "pgp", "add")
177 if code != 0 {
178 t.Fatalf("pgp add: %s", errOut)
179 }
180 }
181
182 // Alice's SSH signer for SSHSIG commits; bob's too.
183 aliceSSHRaw, _ := os.ReadFile(aliceKey)
184 aliceSigner, err := ssh.ParsePrivateKey(aliceSSHRaw)
185 if err != nil {
186 t.Fatal(err)
187 }
188 bobSSHRaw, _ := os.ReadFile(bobKey)
189 bobSigner, err := ssh.ParsePrivateKey(bobSSHRaw)
190 if err != nil {
191 t.Fatal(err)
192 }
193
194 // Repo + working clone.
195 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/sig"); code != 0 {
196 t.Fatalf("repo create: %s", errOut)
197 }
198 work := t.TempDir()
199 env := inst.gitEnv(aliceKey)
200 mustGit(t, work, env, "clone", inst.sshURL("alice/sig"), "w")
201 dir := filepath.Join(work, "w")
202
203 sigCfg := &packet.Config{}
204 expiredSigCfg := &packet.Config{Time: func() time.Time { return past.Add(10 * time.Minute) }}
205 var verifiedPayloadSig string // captured to build the bad-signature commit
206
207 specs := []commitSpec{
208 {authorEmail: "alice@example.test", subject: "unsigned"},
209 {authorEmail: "alice@example.test", subject: "verified-pgp", sign: func(p []byte) string {
210 verifiedPayloadSig = pgpSign(t, aliceEnt, p, sigCfg)
211 return verifiedPayloadSig
212 }},
213 {authorEmail: "mallory@example.test", subject: "unknown-key", sign: func(p []byte) string {
214 return pgpSign(t, malloryEnt, p, sigCfg)
215 }},
216 {authorEmail: "eve@example.test", subject: "email-mismatch", sign: func(p []byte) string {
217 return pgpSign(t, aliceEnt, p, sigCfg)
218 }},
219 {authorEmail: "alice@example.test", subject: "expired-key", sign: func(p []byte) string {
220 return pgpSign(t, expiredEnt, p, expiredSigCfg)
221 }},
222 {authorEmail: "alice@example.test", subject: "revoked-key", sign: func(p []byte) string {
223 return pgpSign(t, revokedEnt, p, sigCfg)
224 }},
225 {authorEmail: "alice@example.test", subject: "bad-signature", sign: func(p []byte) string {
226 return verifiedPayloadSig // valid armor, wrong payload
227 }},
228 {authorEmail: "alice@example.test", committerEmail: "other@example.test", subject: "verified-sshsig", sign: func(p []byte) string {
229 s, err := sig.MarshalSSHSig(aliceSigner, p)
230 if err != nil {
231 t.Fatal(err)
232 }
233 return string(s)
234 }},
235 {authorEmail: "bob@example.test", subject: "sshsig-unverified-email", sign: func(p []byte) string {
236 s, err := sig.MarshalSSHSig(bobSigner, p)
237 if err != nil {
238 t.Fatal(err)
239 }
240 return string(s)
241 }},
242 }
243 buildCommits(t, dir, env, specs)
244 mustGit(t, dir, env, "push", "-q", "origin", "main")
245
246 // Now revoke the key and register it: revocation predates verification,
247 // which is what the revoked state is about.
248 if err := revokedEnt.RevokeKey(packet.KeyCompromised, "test", nil); err != nil {
249 t.Fatal(err)
250 }
251 if _, errOut, code := inst.ssh(t, aliceKey, armorPub(t, revokedEnt), "pgp", "add"); code != 0 {
252 t.Fatalf("pgp add revoked: %s", errOut)
253 }
254
255 // Golden state check: one commit per state.
256 want := map[string]struct {
257 state string
258 signer string
259 }{
260 "unsigned": {"unsigned", ""},
261 "verified-pgp": {"verified", "alice"},
262 "unknown-key": {"signed_unknown_key", ""},
263 "email-mismatch": {"signed_email_mismatch", "alice"},
264 "expired-key": {"signed_key_expired", "alice"},
265 "revoked-key": {"signed_key_revoked", "alice"},
266 "bad-signature": {"bad_signature", "alice"},
267 "verified-sshsig": {"verified", "alice"},
268 "sshsig-unverified-email": {"signed_email_mismatch", "bob"},
269 }
270 check := func(log map[string]logEntry, subjects ...string) {
271 t.Helper()
272 for _, subj := range subjects {
273 e, ok := log[subj]
274 if !ok {
275 t.Fatalf("commit %q missing from log", subj)
276 }
277 w := want[subj]
278 if e.Signature.State != w.state || e.Signature.Signer != w.signer {
279 t.Errorf("%s: state=%s signer=%q, want state=%s signer=%q",
280 subj, e.Signature.State, e.Signature.Signer, w.state, w.signer)
281 }
282 }
283 }
284 log := inst.repoLog(t, aliceKey, "alice/sig")
285 subjects := make([]string, 0, len(want))
286 for s := range want {
287 subjects = append(subjects, s)
288 }
289 check(log, subjects...)
290
291 // Committer email surfaces only when it differs from the author.
292 if log["verified-sshsig"].CommitterEmail != "other@example.test" {
293 t.Errorf("differing committer email not surfaced: %+v", log["verified-sshsig"])
294 }
295 if log["unsigned"].CommitterEmail != "" {
296 t.Errorf("identical committer email should be omitted: %+v", log["unsigned"])
297 }
298
299 // Epoch transition 1: registering mallory (key + verified email)
300 // upgrades the cached signed_unknown_key row to verified.
301 malloryKey := inst.newKey(t, "mallory")
302 inst.admin(t, "admin", "user", "create", "mallory",
303 "--key", malloryKey+".pub", "--email", "mallory@example.test", "--verified")
304 if _, errOut, code := inst.ssh(t, malloryKey, armorPub(t, malloryEnt), "pgp", "add"); code != 0 {
305 t.Fatalf("mallory pgp add: %s", errOut)
306 }
307 want["unknown-key"] = struct {
308 state string
309 signer string
310 }{"verified", "mallory"}
311 check(inst.repoLog(t, aliceKey, "alice/sig"), "unknown-key")
312
313 // Epoch transition 2: verifying bob's email upgrades his SSHSIG commit.
314 inst.admin(t, "admin", "email", "verify", "bob", "bob@example.test")
315 want["sshsig-unverified-email"] = struct {
316 state string
317 signer string
318 }{"verified", "bob"}
319 check(inst.repoLog(t, aliceKey, "alice/sig"), "sshsig-unverified-email")
320
321 // Epoch transition 3: removing alice's PGP key downgrades her verified
322 // commit; re-adding restores it.
323 fpr := fmt.Sprintf("%x", aliceEnt.PrimaryKey.Fingerprint)
324 if _, errOut, code := inst.ssh(t, aliceKey, "", "pgp", "remove", fpr); code != 0 {
325 t.Fatalf("pgp remove: %s", errOut)
326 }
327 if got := inst.repoLog(t, aliceKey, "alice/sig")["verified-pgp"].Signature.State; got != "signed_unknown_key" {
328 t.Errorf("after key removal: verified-pgp state = %s, want signed_unknown_key", got)
329 }
330 if _, errOut, code := inst.ssh(t, aliceKey, armorPub(t, aliceEnt), "pgp", "add"); code != 0 {
331 t.Fatalf("pgp re-add: %s", errOut)
332 }
333 check(inst.repoLog(t, aliceKey, "alice/sig"), "verified-pgp")
334
335 // Cross-check payload reconstruction against git itself, when gpg is
336 // available: git verify-commit must agree the signature is valid.
337 if gpgPath, err := exec.LookPath("gpg"); err == nil {
338 gnupgHome := t.TempDir()
339 gpgEnv := append(env, "GNUPGHOME="+gnupgHome)
340 imp := exec.Command(gpgPath, "--batch", "--import")
341 imp.Env = gpgEnv
342 imp.Stdin = strings.NewReader(armorPub(t, aliceEnt))
343 // gpg exits nonzero if it cannot reach its agent, even when the
344 // import itself succeeded; trust the summary line instead.
345 if out, err := imp.CombinedOutput(); err != nil && !strings.Contains(string(out), "imported: 1") {
346 t.Fatalf("gpg import: %v\n%s", err, out)
347 }
348 var sha string
349 for _, e := range inst.repoLog(t, aliceKey, "alice/sig") {
350 if e.Subject == "verified-pgp" {
351 sha = e.SHA
352 }
353 }
354 vc := exec.Command("git", "verify-commit", sha)
355 vc.Dir = dir
356 vc.Env = gpgEnv
357 if out, err := vc.CombinedOutput(); err != nil {
358 t.Errorf("git verify-commit disagrees with forge verification: %v\n%s", err, out)
359 }
360 } else {
361 t.Log("gpg not installed; skipping git verify-commit cross-check")
362 }
363}
go.mod +2
@@ -4,12 +4,14 @@ go 1.27.0
44
55require (
66 github.com/BurntSushi/toml v1.6.0
7 github.com/ProtonMail/go-crypto v1.4.1
78 github.com/spf13/cobra v1.10.2
89 golang.org/x/crypto v0.55.0
910 modernc.org/sqlite v1.57.0
1011)
1112
1213require (
14 github.com/cloudflare/circl v1.6.2 // indirect
1315 github.com/dustin/go-humanize v1.0.1 // indirect
1416 github.com/google/uuid v1.6.0 // indirect
1517 github.com/inconshreveable/mousetrap v1.1.0 // indirect
go.sum +4
@@ -1,5 +1,9 @@
11github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
22github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
4github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
5github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
6github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
37github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
48github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
59github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
internal/control/sig.go added +203
@@ -0,0 +1,203 @@
1package control
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "strconv"
9 "time"
10
11 "github.com/krazywarez/forge/internal/gitutil"
12 "github.com/krazywarez/forge/internal/policy"
13 "github.com/krazywarez/forge/internal/protocol"
14 "github.com/krazywarez/forge/internal/sig"
15 "github.com/krazywarez/forge/internal/store"
16)
17
18func init() {
19 register(Command{Path: []string{"pgp", "add"},
20 Summary: "register an OpenPGP public key (armored, on stdin)", ReadsStdin: true, Run: runPGPAdd})
21 register(Command{Path: []string{"pgp", "list"},
22 Summary: "list registered OpenPGP keys", Run: runPGPList})
23 register(Command{Path: []string{"pgp", "remove"},
24 Summary: "remove an OpenPGP key by fingerprint", Run: runPGPRemove})
25 register(Command{Path: []string{"repo", "log"},
26 Summary: "commit log with signature states: repo log <owner/name> [--limit n]", Run: runRepoLog})
27}
28
29func runPGPAdd(c *Ctx, args []string) int {
30 if len(args) != 0 {
31 return c.fail(protocol.ExitUsage, "usage: pgp add < key.asc")
32 }
33 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 1<<20))
34 if err != nil {
35 return c.fail(protocol.ExitFailure, "reading key: %v", err)
36 }
37 meta, err := sig.ParsePGPKey(raw)
38 if err != nil {
39 return c.fail(protocol.ExitUsage, "%v", err)
40 }
41 uids, _ := json.Marshal(meta.Emails)
42 if err := c.Store.AddPGPKey(c.User.ID, meta.Fingerprint, string(raw), string(uids), meta.ExpiresAt, meta.RevokedAt); err != nil {
43 if errors.Is(err, store.ErrDuplicateKey) {
44 return c.fail(protocol.ExitUsage, "%v", err)
45 }
46 return c.fail(protocol.ExitFailure, "adding key: %v", err)
47 }
48 type out struct {
49 Fingerprint string `json:"fingerprint"`
50 Emails []string `json:"emails"`
51 }
52 d := out{meta.Fingerprint, meta.Emails}
53 return c.emit(d, func(w io.Writer) {
54 fmt.Fprintf(w, "added %s (%v)\n", d.Fingerprint, d.Emails)
55 })
56}
57
58func runPGPList(c *Ctx, args []string) int {
59 keys, err := c.Store.ListPGPKeys(c.User.ID)
60 if err != nil {
61 return c.fail(protocol.ExitFailure, "%v", err)
62 }
63 type out struct {
64 Fingerprint string `json:"fingerprint"`
65 Emails string `json:"emails"`
66 ExpiresAt *time.Time `json:"expires_at,omitempty"`
67 RevokedAt *time.Time `json:"revoked_at,omitempty"`
68 }
69 var ds []out
70 for _, k := range keys {
71 ds = append(ds, out{k.Fingerprint, k.UIDsJSON, k.ExpiresAt, k.RevokedAt})
72 }
73 return c.emit(ds, func(w io.Writer) {
74 for _, d := range ds {
75 fmt.Fprintf(w, "%s\t%s\n", d.Fingerprint, d.Emails)
76 }
77 })
78}
79
80func runPGPRemove(c *Ctx, args []string) int {
81 if len(args) != 1 {
82 return c.fail(protocol.ExitUsage, "usage: pgp remove <fingerprint>")
83 }
84 if err := c.Store.RemovePGPKey(c.User.ID, args[0]); err != nil {
85 if errors.Is(err, store.ErrNotFound) {
86 return c.fail(protocol.ExitNotFound, "no key %s on your account", args[0])
87 }
88 return c.fail(protocol.ExitFailure, "%v", err)
89 }
90 return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) {
91 fmt.Fprintf(w, "removed %s\n", args[0])
92 })
93}
94
95// VerifyCommitCached verifies one commit with the epoch cache. Shared with
96// the web UI.
97func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sha string) (sig.Result, error) {
98 epoch, err := st.KeyEpoch()
99 if err != nil {
100 return sig.Result{}, err
101 }
102 if res, ok, err := st.CachedSignature(repo.ID, sha, epoch); err != nil {
103 return sig.Result{}, err
104 } else if ok {
105 return res, nil
106 }
107 res, err := sig.VerifyCommit(store.SigDB{Store: st}, parsed)
108 if err != nil {
109 return sig.Result{}, err
110 }
111 if err := st.StoreSignature(repo.ID, sha, res, epoch); err != nil {
112 return sig.Result{}, err
113 }
114 return res, nil
115}
116
117func runRepoLog(c *Ctx, args []string) int {
118 limit := 30
119 var path string
120 for i := 0; i < len(args); i++ {
121 switch args[i] {
122 case "--limit":
123 if i+1 >= len(args) {
124 return c.fail(protocol.ExitUsage, "--limit requires a value")
125 }
126 n, err := strconv.Atoi(args[i+1])
127 if err != nil || n < 1 || n > 1000 {
128 return c.fail(protocol.ExitUsage, "--limit must be 1..1000")
129 }
130 limit = n
131 i++
132 default:
133 if path != "" {
134 return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--limit n]")
135 }
136 path = args[i]
137 }
138 }
139 if path == "" {
140 return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--limit n]")
141 }
142 repo, code := resolveRepo(c, path, policy.CanRead)
143 if code >= 0 {
144 return code
145 }
146 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
147 shas, err := gitutil.RevList(dir, repo.DefaultBranch, limit)
148 if err != nil {
149 return c.fail(protocol.ExitFailure, "reading log: %v", err)
150 }
151
152 type sigOut struct {
153 State string `json:"state"`
154 Signer string `json:"signer,omitempty"`
155 Fingerprint string `json:"key_fingerprint,omitempty"`
156 }
157 type out struct {
158 SHA string `json:"sha"`
159 Subject string `json:"subject"`
160 AuthorName string `json:"author_name"`
161 AuthorEmail string `json:"author_email"`
162 CommitterEmail string `json:"committer_email,omitempty"` // only when it differs
163 Date string `json:"date"`
164 Signature sigOut `json:"signature"`
165 }
166 var ds []out
167 for _, sha := range shas {
168 raw, err := gitutil.ReadCommit(dir, sha)
169 if err != nil {
170 return c.fail(protocol.ExitFailure, "%v", err)
171 }
172 parsed, err := sig.ParseCommit(raw)
173 if err != nil {
174 return c.fail(protocol.ExitFailure, "parsing %s: %v", sha, err)
175 }
176 res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
177 if err != nil {
178 return c.fail(protocol.ExitFailure, "verifying %s: %v", sha, err)
179 }
180 d := out{
181 SHA: sha,
182 Subject: parsed.Subject,
183 AuthorName: parsed.AuthorName,
184 AuthorEmail: parsed.AuthorEmail,
185 Date: time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339),
186 Signature: sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint},
187 }
188 if parsed.CommitterEmail != parsed.AuthorEmail {
189 d.CommitterEmail = parsed.CommitterEmail
190 }
191 if res.SignerUserID != 0 {
192 if u, err := c.Store.UserByID(res.SignerUserID); err == nil {
193 d.Signature.Signer = u.Username
194 }
195 }
196 ds = append(ds, d)
197 }
198 return c.emit(ds, func(w io.Writer) {
199 for _, d := range ds {
200 fmt.Fprintf(w, "%.10s %-22s %s (%s <%s>)\n", d.SHA, d.Signature.State, d.Subject, d.AuthorName, d.AuthorEmail)
201 }
202 })
203}
internal/gitutil/gitutil.go +26
@@ -74,3 +74,29 @@ func ZeroSHA(s string) bool {
7474 }
7575 return true
7676}
77
78// RevList returns up to limit commit SHAs reachable from ref, newest first.
79func RevList(dir, ref string, limit int) ([]string, error) {
80 cmd := exec.Command("git", "-C", dir, "rev-list", fmt.Sprintf("--max-count=%d", limit), ref)
81 out, err := cmd.Output()
82 if err != nil {
83 return nil, fmt.Errorf("rev-list %s: %w", ref, err)
84 }
85 var shas []string
86 for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
87 if l != "" {
88 shas = append(shas, l)
89 }
90 }
91 return shas, nil
92}
93
94// ReadCommit returns the raw commit object bytes.
95func ReadCommit(dir, sha string) ([]byte, error) {
96 cmd := exec.Command("git", "-C", dir, "cat-file", "commit", sha)
97 out, err := cmd.Output()
98 if err != nil {
99 return nil, fmt.Errorf("cat-file commit %s: %w", sha, err)
100 }
101 return out, nil
102}
internal/sig/commit.go added +111
@@ -0,0 +1,111 @@
1// Package sig verifies OpenPGP and SSHSIG signatures on git commits and
2// tags, and maps them to the forge's trust states.
3package sig
4
5import (
6 "bytes"
7 "fmt"
8 "strings"
9)
10
11// Commit is a parsed raw commit object.
12type Commit struct {
13 Raw []byte
14 Payload []byte // Raw with the gpgsig header removed, byte-exact
15 Signature []byte // armored signature block, nil if unsigned
16 AuthorName string
17 AuthorEmail string
18 CommitterEmail string
19 Subject string
20 AuthorUnix int64
21}
22
23// ParseCommit splits a raw commit object (as printed by `git cat-file
24// commit`) into its signed payload and signature. The payload must be
25// byte-exact: it is the original object minus the gpgsig header line and its
26// continuation lines, nothing else.
27func ParseCommit(raw []byte) (*Commit, error) {
28 c := &Commit{Raw: raw}
29
30 headerEnd := bytes.Index(raw, []byte("\n\n"))
31 if headerEnd < 0 {
32 return nil, fmt.Errorf("malformed commit: no header/body separator")
33 }
34 headers := raw[:headerEnd+1] // include trailing newline of last header
35 body := raw[headerEnd+2:]
36
37 var payload bytes.Buffer
38 lines := bytes.SplitAfter(headers, []byte("\n"))
39 for i := 0; i < len(lines); i++ {
40 line := lines[i]
41 if sigBody, ok := bytes.CutPrefix(line, []byte("gpgsig ")); ok {
42 // The signature value continues on lines starting with a space.
43 var sig bytes.Buffer
44 sig.Write(sigBody)
45 for i+1 < len(lines) && bytes.HasPrefix(lines[i+1], []byte(" ")) {
46 sig.Write(lines[i+1][1:])
47 i++
48 }
49 c.Signature = bytes.TrimSuffix(sig.Bytes(), []byte("\n"))
50 continue
51 }
52 payload.Write(line)
53
54 switch {
55 case bytes.HasPrefix(line, []byte("author ")):
56 c.AuthorName, c.AuthorEmail, c.AuthorUnix = parseIdent(string(line[len("author "):]))
57 case bytes.HasPrefix(line, []byte("committer ")):
58 _, c.CommitterEmail, _ = parseIdent(string(line[len("committer "):]))
59 }
60 }
61 payload.WriteByte('\n')
62 payload.Write(body)
63 c.Payload = payload.Bytes()
64
65 if i := bytes.IndexByte(body, '\n'); i >= 0 {
66 c.Subject = string(body[:i])
67 } else {
68 c.Subject = strings.TrimRight(string(body), "\n")
69 }
70 return c, nil
71}
72
73// parseIdent parses "Name <email> unix tz".
74func parseIdent(s string) (name, email string, unix int64) {
75 s = strings.TrimSuffix(s, "\n")
76 lt := strings.IndexByte(s, '<')
77 gt := strings.IndexByte(s, '>')
78 if lt < 0 || gt < lt {
79 return s, "", 0
80 }
81 name = strings.TrimSpace(s[:lt])
82 email = s[lt+1 : gt]
83 rest := strings.Fields(s[gt+1:])
84 if len(rest) >= 1 {
85 fmt.Sscanf(rest[0], "%d", &unix)
86 }
87 return name, email, unix
88}
89
90// SigKind reports which signature format a gpgsig block holds.
91type SigKind int
92
93const (
94 SigNone SigKind = iota
95 SigOpenPGP
96 SigSSH
97 SigUnknown
98)
99
100func KindOf(sig []byte) SigKind {
101 switch {
102 case sig == nil:
103 return SigNone
104 case bytes.Contains(sig, []byte("BEGIN PGP SIGNATURE")):
105 return SigOpenPGP
106 case bytes.Contains(sig, []byte("BEGIN SSH SIGNATURE")):
107 return SigSSH
108 default:
109 return SigUnknown
110 }
111}
internal/sig/commit_test.go added +62
@@ -0,0 +1,62 @@
1package sig
2
3import (
4 "bytes"
5 "testing"
6)
7
8var signedCommit = []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n" +
9 "parent 0123456789012345678901234567890123456789\n" +
10 "author T <a@example.test> 1700000000 +0000\n" +
11 "committer T <c@example.test> 1700000000 +0000\n" +
12 "gpgsig -----BEGIN PGP SIGNATURE-----\n" +
13 " \n" +
14 " base64base64\n" +
15 " =abcd\n" +
16 " -----END PGP SIGNATURE-----\n" +
17 "\n" +
18 "subject line\n\nbody\n")
19
20func TestParseCommitSigned(t *testing.T) {
21 c, err := ParseCommit(signedCommit)
22 if err != nil {
23 t.Fatal(err)
24 }
25 wantPayload := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n" +
26 "parent 0123456789012345678901234567890123456789\n" +
27 "author T <a@example.test> 1700000000 +0000\n" +
28 "committer T <c@example.test> 1700000000 +0000\n" +
29 "\n" +
30 "subject line\n\nbody\n")
31 if !bytes.Equal(c.Payload, wantPayload) {
32 t.Errorf("payload not byte-exact:\ngot %q\nwant %q", c.Payload, wantPayload)
33 }
34 wantSig := "-----BEGIN PGP SIGNATURE-----\n\nbase64base64\n=abcd\n-----END PGP SIGNATURE-----"
35 if string(c.Signature) != wantSig {
36 t.Errorf("signature reconstruction:\ngot %q\nwant %q", c.Signature, wantSig)
37 }
38 if c.AuthorEmail != "a@example.test" || c.CommitterEmail != "c@example.test" ||
39 c.Subject != "subject line" || c.AuthorUnix != 1700000000 {
40 t.Errorf("fields: %+v", c)
41 }
42 if KindOf(c.Signature) != SigOpenPGP {
43 t.Errorf("kind = %v", KindOf(c.Signature))
44 }
45}
46
47func TestParseCommitUnsigned(t *testing.T) {
48 raw := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n" +
49 "author T <a@example.test> 1700000000 +0000\n" +
50 "committer T <a@example.test> 1700000000 +0000\n" +
51 "\nsubject\n")
52 c, err := ParseCommit(raw)
53 if err != nil {
54 t.Fatal(err)
55 }
56 if c.Signature != nil {
57 t.Errorf("unsigned commit has signature %q", c.Signature)
58 }
59 if !bytes.Equal(c.Payload, raw) {
60 t.Errorf("unsigned payload must equal raw object")
61 }
62}
internal/sig/keys.go added +59
@@ -0,0 +1,59 @@
1package sig
2
3import (
4 "bytes"
5 "encoding/hex"
6 "fmt"
7 "time"
8
9 "github.com/ProtonMail/go-crypto/openpgp"
10)
11
12func keyIDHex(id uint64) string {
13 var b [8]byte
14 for i := 7; i >= 0; i-- {
15 b[i] = byte(id)
16 id >>= 8
17 }
18 return hex.EncodeToString(b[:])
19}
20
21// PGPKeyMeta is what `pgp add` needs to persist about an imported key.
22type PGPKeyMeta struct {
23 Fingerprint string // primary key, lowercase hex
24 Emails []string
25 ExpiresAt *time.Time
26 RevokedAt *time.Time
27}
28
29// ParsePGPKey extracts registration metadata from an armored public key.
30func ParsePGPKey(armored []byte) (PGPKeyMeta, error) {
31 ring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(armored))
32 if err != nil {
33 return PGPKeyMeta{}, fmt.Errorf("not a valid armored OpenPGP key: %w", err)
34 }
35 if len(ring) != 1 {
36 return PGPKeyMeta{}, fmt.Errorf("expected exactly one key, got %d", len(ring))
37 }
38 e := ring[0]
39 meta := PGPKeyMeta{
40 Fingerprint: hex.EncodeToString(e.PrimaryKey.Fingerprint),
41 }
42 for _, id := range e.Identities {
43 if id.UserId != nil && id.UserId.Email != "" {
44 meta.Emails = append(meta.Emails, id.UserId.Email)
45 }
46 }
47 // Primary key expiry from the self-signature.
48 if selfSig, _ := e.PrimarySelfSignature(); selfSig != nil && selfSig.KeyLifetimeSecs != nil && *selfSig.KeyLifetimeSecs > 0 {
49 t := e.PrimaryKey.CreationTime.Add(time.Duration(*selfSig.KeyLifetimeSecs) * time.Second)
50 meta.ExpiresAt = &t
51 }
52 for _, rev := range e.Revocations {
53 t := rev.CreationTime
54 if meta.RevokedAt == nil || t.Before(*meta.RevokedAt) {
55 meta.RevokedAt = &t
56 }
57 }
58 return meta, nil
59}
internal/sig/sshsig.go added +180
@@ -0,0 +1,180 @@
1package sig
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "crypto/sha512"
7 "encoding/base64"
8 "encoding/binary"
9 "fmt"
10 "slices"
11 "strings"
12
13 "golang.org/x/crypto/ssh"
14)
15
16// SSHSIG armored signature format, per openssh PROTOCOL.sshsig.
17const sshsigMagic = "SSHSIG"
18
19type sshsigBlob struct {
20 Version uint32
21 PublicKey []byte
22 Namespace string
23 Reserved string
24 HashAlgorithm string
25 Signature []byte
26}
27
28func verifySSH(db DB, c *Commit) (Result, error) {
29 blob, err := decodeArmor(c.Signature, "SSH SIGNATURE")
30 if err != nil {
31 return Result{State: BadSignature}, nil
32 }
33 sb, err := parseSSHSig(blob)
34 if err != nil {
35 return Result{State: BadSignature}, nil
36 }
37 pub, err := ssh.ParsePublicKey(sb.PublicKey)
38 if err != nil {
39 return Result{State: BadSignature}, nil
40 }
41
42 fp := ssh.FingerprintSHA256(pub)
43 key, found, err := db.SSHSignerByFingerprint(fp)
44 if err != nil {
45 return Result{}, err
46 }
47 if !found {
48 return Result{State: SignedUnknownKey, KeyFingerprint: fp}, nil
49 }
50 res := Result{SignerUserID: key.UserID, KeyFingerprint: fp}
51
52 // Reconstruct the signed blob: MAGIC || namespace || reserved ||
53 // hash_algorithm || H(payload).
54 var h []byte
55 switch sb.HashAlgorithm {
56 case "sha512":
57 d := sha512.Sum512(c.Payload)
58 h = d[:]
59 case "sha256":
60 d := sha256.Sum256(c.Payload)
61 h = d[:]
62 default:
63 res.State = BadSignature
64 return res, nil
65 }
66 if sb.Namespace != "git" {
67 res.State = BadSignature
68 return res, nil
69 }
70 signed := buildSSHSignedData(sb.Namespace, sb.Reserved, sb.HashAlgorithm, h)
71
72 var sshSig ssh.Signature
73 if err := ssh.Unmarshal(sb.Signature, &sshSig); err != nil {
74 res.State = BadSignature
75 return res, nil
76 }
77 if err := pub.Verify(signed, &sshSig); err != nil {
78 res.State = BadSignature
79 return res, nil
80 }
81
82 // SSH keys carry no identities: the principal set is the owning
83 // account's verified emails.
84 verified, err := db.VerifiedEmails(key.UserID)
85 if err != nil {
86 return Result{}, err
87 }
88 if !slices.Contains(verified, c.AuthorEmail) {
89 res.State = SignedEmailMismatch
90 return res, nil
91 }
92 res.State = Verified
93 return res, nil
94}
95
96func parseSSHSig(blob []byte) (*sshsigBlob, error) {
97 if !bytes.HasPrefix(blob, []byte(sshsigMagic)) {
98 return nil, fmt.Errorf("missing SSHSIG magic")
99 }
100 var sb sshsigBlob
101 if err := ssh.Unmarshal(blob[len(sshsigMagic):], &sb); err != nil {
102 return nil, err
103 }
104 if sb.Version != 1 {
105 return nil, fmt.Errorf("unsupported sshsig version %d", sb.Version)
106 }
107 return &sb, nil
108}
109
110func buildSSHSignedData(namespace, reserved, hashAlg string, hash []byte) []byte {
111 var b bytes.Buffer
112 b.WriteString(sshsigMagic)
113 writeSSHString(&b, []byte(namespace))
114 writeSSHString(&b, []byte(reserved))
115 writeSSHString(&b, []byte(hashAlg))
116 writeSSHString(&b, hash)
117 return b.Bytes()
118}
119
120func writeSSHString(b *bytes.Buffer, s []byte) {
121 var l [4]byte
122 binary.BigEndian.PutUint32(l[:], uint32(len(s)))
123 b.Write(l[:])
124 b.Write(s)
125}
126
127// MarshalSSHSig builds an armored SSHSIG over payload with the given signer.
128// Used by fixture generation and (later) forge-side tooling.
129func MarshalSSHSig(signer ssh.Signer, payload []byte) ([]byte, error) {
130 d := sha512.Sum512(payload)
131 signed := buildSSHSignedData("git", "", "sha512", d[:])
132 sshSig, err := signer.Sign(nil, signed)
133 if err != nil {
134 return nil, err
135 }
136 var body bytes.Buffer
137 body.WriteString(sshsigMagic)
138 blob := sshsigBlob{
139 Version: 1,
140 PublicKey: signer.PublicKey().Marshal(),
141 Namespace: "git",
142 Reserved: "",
143 HashAlgorithm: "sha512",
144 Signature: ssh.Marshal(sshSig),
145 }
146 body.Write(ssh.Marshal(blob))
147
148 b64 := base64.StdEncoding.EncodeToString(body.Bytes())
149 var out strings.Builder
150 out.WriteString("-----BEGIN SSH SIGNATURE-----\n")
151 for len(b64) > 70 {
152 out.WriteString(b64[:70] + "\n")
153 b64 = b64[70:]
154 }
155 out.WriteString(b64 + "\n-----END SSH SIGNATURE-----\n")
156 return []byte(out.String()), nil
157}
158
159// decodeArmor extracts the base64 body between BEGIN/END markers for the
160// given label. Works for both SSHSIG and OpenPGP armor (checksum lines and
161// armor headers are skipped).
162func decodeArmor(armored []byte, label string) ([]byte, error) {
163 begin := "-----BEGIN " + label + "-----"
164 end := "-----END " + label + "-----"
165 s := string(armored)
166 i := strings.Index(s, begin)
167 j := strings.Index(s, end)
168 if i < 0 || j < i {
169 return nil, fmt.Errorf("no %s armor", label)
170 }
171 var b64 strings.Builder
172 for _, line := range strings.Split(s[i+len(begin):j], "\n") {
173 line = strings.TrimSpace(line)
174 if line == "" || strings.HasPrefix(line, "=") || strings.Contains(line, ":") {
175 continue // armor checksum or header line
176 }
177 b64.WriteString(line)
178 }
179 return base64.StdEncoding.DecodeString(b64.String())
180}
internal/sig/verify.go added +148
@@ -0,0 +1,148 @@
1package sig
2
3import (
4 "bytes"
5 "slices"
6 "time"
7
8 "github.com/ProtonMail/go-crypto/openpgp"
9 "github.com/ProtonMail/go-crypto/openpgp/packet"
10)
11
12type State string
13
14const (
15 Verified State = "verified"
16 SignedUnknownKey State = "signed_unknown_key"
17 SignedEmailMismatch State = "signed_email_mismatch"
18 SignedKeyExpired State = "signed_key_expired"
19 SignedKeyRevoked State = "signed_key_revoked"
20 BadSignature State = "bad_signature"
21 Unsigned State = "unsigned"
22)
23
24type Result struct {
25 State State
26 SignerUserID int64 // 0 when unknown
27 KeyFingerprint string
28}
29
30// PGPKeyInfo is a registered OpenPGP key as the verifier needs it.
31type PGPKeyInfo struct {
32 UserID int64
33 Armored string
34 ExpiresAt *time.Time
35 RevokedAt *time.Time
36}
37
38// SSHKeyInfo is a registered SSH key as the verifier needs it.
39type SSHKeyInfo struct {
40 UserID int64
41 Fingerprint string
42}
43
44// DB is the store surface the verifier depends on. Implemented by
45// store.SigDB.
46type DB interface {
47 // PGPKeyByIssuer finds a registered key whose fingerprint ends with the
48 // issuer key id (16 hex chars, lowercase).
49 PGPKeyByIssuer(keyIDHex string) (PGPKeyInfo, string, bool, error) // info, fingerprint, found
50 SSHSignerByFingerprint(fp string) (SSHKeyInfo, bool, error)
51 VerifiedEmails(userID int64) ([]string, error)
52}
53
54// VerifyCommit classifies one parsed commit. The commit's author email
55// drives the identity check, per the plan.
56func VerifyCommit(db DB, c *Commit) (Result, error) {
57 switch KindOf(c.Signature) {
58 case SigNone:
59 return Result{State: Unsigned}, nil
60 case SigOpenPGP:
61 return verifyOpenPGP(db, c)
62 case SigSSH:
63 return verifySSH(db, c)
64 default:
65 return Result{State: BadSignature}, nil
66 }
67}
68
69func verifyOpenPGP(db DB, c *Commit) (Result, error) {
70 issuer, sigTime, ok := openpgpIssuer(c.Signature)
71 if !ok {
72 return Result{State: BadSignature}, nil
73 }
74 key, fpr, found, err := db.PGPKeyByIssuer(issuer)
75 if err != nil {
76 return Result{}, err
77 }
78 if !found {
79 return Result{State: SignedUnknownKey, KeyFingerprint: issuer}, nil
80 }
81 res := Result{SignerUserID: key.UserID, KeyFingerprint: fpr}
82
83 // Key-state policy comes before cryptography: a revoked or expired key
84 // invalidates the trust claim no matter what the signature says, and
85 // hard revocations make the library's own verdict on such keys
86 // unpredictable.
87 now := time.Now()
88 if key.RevokedAt != nil && key.RevokedAt.Before(now) {
89 res.State = SignedKeyRevoked
90 return res, nil
91 }
92 if key.ExpiresAt != nil && key.ExpiresAt.Before(now) {
93 res.State = SignedKeyExpired
94 return res, nil
95 }
96
97 ring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(key.Armored)))
98 if err != nil {
99 return Result{}, err
100 }
101 // Verify the cryptography at the signature's own creation time: key
102 // expiry is our policy decision (above), not the library's.
103 cfg := &packet.Config{Time: func() time.Time { return sigTime }}
104 signer, err := openpgp.CheckArmoredDetachedSignature(
105 ring, bytes.NewReader(c.Payload), bytes.NewReader(c.Signature), cfg)
106 if err != nil || signer == nil {
107 res.State = BadSignature
108 return res, nil
109 }
110
111 // Author email must appear in a UID on the signing key AND be a
112 // verified address on the owning account.
113 uidMatch := false
114 for _, id := range signer.Identities {
115 if id.UserId != nil && id.UserId.Email == c.AuthorEmail {
116 uidMatch = true
117 break
118 }
119 }
120 verified, err := db.VerifiedEmails(key.UserID)
121 if err != nil {
122 return Result{}, err
123 }
124 if !uidMatch || !slices.Contains(verified, c.AuthorEmail) {
125 res.State = SignedEmailMismatch
126 return res, nil
127 }
128 res.State = Verified
129 return res, nil
130}
131
132// openpgpIssuer extracts the issuer key id (lowercase hex) and creation time
133// from an armored signature without verifying it.
134func openpgpIssuer(armored []byte) (string, time.Time, bool) {
135 block, err := decodeArmor(armored, "PGP SIGNATURE")
136 if err != nil {
137 return "", time.Time{}, false
138 }
139 p, err := packet.Read(bytes.NewReader(block))
140 if err != nil {
141 return "", time.Time{}, false
142 }
143 sig, ok := p.(*packet.Signature)
144 if !ok || sig.IssuerKeyId == nil {
145 return "", time.Time{}, false
146 }
147 return keyIDHex(*sig.IssuerKeyId), sig.CreationTime, true
148}
internal/store/signatures.go added +214
@@ -0,0 +1,214 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "time"
7
8 "github.com/krazywarez/forge/internal/sig"
9)
10
11// AddPGPKey registers an OpenPGP key and bumps the key epoch.
12func (s *Store) AddPGPKey(userID int64, fingerprint, armored, uidsJSON string, expiresAt, revokedAt *time.Time) error {
13 tx, err := s.DB.Begin()
14 if err != nil {
15 return err
16 }
17 defer tx.Rollback()
18 if _, err := tx.Exec(
19 "INSERT INTO pgp_keys (user_id, fingerprint, armored, uids_json, expires_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?)",
20 userID, fingerprint, armored, uidsJSON, timePtr(expiresAt), timePtr(revokedAt)); err != nil {
21 if isUniqueErr(err) {
22 return ErrDuplicateKey
23 }
24 return err
25 }
26 if err := bumpKeyEpoch(tx); err != nil {
27 return err
28 }
29 return tx.Commit()
30}
31
32func (s *Store) RemovePGPKey(userID int64, fingerprint string) error {
33 tx, err := s.DB.Begin()
34 if err != nil {
35 return err
36 }
37 defer tx.Rollback()
38 res, err := tx.Exec("DELETE FROM pgp_keys WHERE user_id = ? AND fingerprint = ?", userID, fingerprint)
39 if err != nil {
40 return err
41 }
42 if n, _ := res.RowsAffected(); n == 0 {
43 return ErrNotFound
44 }
45 if err := bumpKeyEpoch(tx); err != nil {
46 return err
47 }
48 return tx.Commit()
49}
50
51type PGPKey struct {
52 Fingerprint string
53 UIDsJSON string
54 ExpiresAt *time.Time
55 RevokedAt *time.Time
56}
57
58func (s *Store) ListPGPKeys(userID int64) ([]PGPKey, error) {
59 rows, err := s.DB.Query(
60 "SELECT fingerprint, uids_json, expires_at, revoked_at FROM pgp_keys WHERE user_id = ? ORDER BY id", userID)
61 if err != nil {
62 return nil, err
63 }
64 defer rows.Close()
65 var out []PGPKey
66 for rows.Next() {
67 var k PGPKey
68 var exp, rev sql.NullString
69 if err := rows.Scan(&k.Fingerprint, &k.UIDsJSON, &exp, &rev); err != nil {
70 return nil, err
71 }
72 k.ExpiresAt = parseTime(exp)
73 k.RevokedAt = parseTime(rev)
74 out = append(out, k)
75 }
76 return out, rows.Err()
77}
78
79// VerifyEmail marks an address verified and bumps the key epoch (email
80// verification is a trust input for signature states).
81func (s *Store) VerifyEmail(userID int64, address, by string) error {
82 tx, err := s.DB.Begin()
83 if err != nil {
84 return err
85 }
86 defer tx.Rollback()
87 res, err := tx.Exec(
88 `UPDATE emails SET verified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), verified_by = ?
89 WHERE user_id = ? AND address = ?`, by, userID, address)
90 if err != nil {
91 return err
92 }
93 if n, _ := res.RowsAffected(); n == 0 {
94 return ErrNotFound
95 }
96 if err := bumpKeyEpoch(tx); err != nil {
97 return err
98 }
99 return tx.Commit()
100}
101
102// SigDB adapts Store to the verifier's interface and owns the epoch cache.
103type SigDB struct{ *Store }
104
105func (d SigDB) PGPKeyByIssuer(keyIDHex string) (sig.PGPKeyInfo, string, bool, error) {
106 var info sig.PGPKeyInfo
107 var fpr string
108 var exp, rev sql.NullString
109 err := d.DB.QueryRow(
110 "SELECT user_id, fingerprint, armored, expires_at, revoked_at FROM pgp_keys WHERE fingerprint LIKE '%' || ?",
111 keyIDHex).Scan(&info.UserID, &fpr, &info.Armored, &exp, &rev)
112 if errors.Is(err, sql.ErrNoRows) {
113 return info, "", false, nil
114 }
115 if err != nil {
116 return info, "", false, err
117 }
118 info.ExpiresAt = parseTime(exp)
119 info.RevokedAt = parseTime(rev)
120 return info, fpr, true, nil
121}
122
123func (d SigDB) SSHSignerByFingerprint(fp string) (sig.SSHKeyInfo, bool, error) {
124 k, err := d.SSHKeyByFingerprint(fp)
125 if errors.Is(err, ErrNotFound) {
126 return sig.SSHKeyInfo{}, false, nil
127 }
128 if err != nil {
129 return sig.SSHKeyInfo{}, false, err
130 }
131 return sig.SSHKeyInfo{UserID: k.UserID, Fingerprint: k.Fingerprint}, true, nil
132}
133
134func (d SigDB) VerifiedEmails(userID int64) ([]string, error) {
135 rows, err := d.DB.Query(
136 "SELECT address FROM emails WHERE user_id = ? AND verified_at IS NOT NULL", userID)
137 if err != nil {
138 return nil, err
139 }
140 defer rows.Close()
141 var out []string
142 for rows.Next() {
143 var a string
144 if err := rows.Scan(&a); err != nil {
145 return nil, err
146 }
147 out = append(out, a)
148 }
149 return out, rows.Err()
150}
151
152// CachedSignature returns a cached result and whether it is current at the
153// given epoch.
154func (s *Store) CachedSignature(repoID int64, sha string, epoch int64) (sig.Result, bool, error) {
155 var r sig.Result
156 var state string
157 var signer sql.NullInt64
158 var fpr sql.NullString
159 var rowEpoch int64
160 err := s.DB.QueryRow(
161 "SELECT state, signer_user_id, key_fingerprint, key_epoch FROM commit_signatures WHERE repo_id = ? AND commit_sha = ?",
162 repoID, sha).Scan(&state, &signer, &fpr, &rowEpoch)
163 if errors.Is(err, sql.ErrNoRows) {
164 return r, false, nil
165 }
166 if err != nil {
167 return r, false, err
168 }
169 if rowEpoch < epoch {
170 return r, false, nil // stale: trust inputs changed since this was computed
171 }
172 r.State = sig.State(state)
173 r.SignerUserID = signer.Int64
174 r.KeyFingerprint = fpr.String
175 return r, true, nil
176}
177
178func (s *Store) StoreSignature(repoID int64, sha string, r sig.Result, epoch int64) error {
179 var signer any
180 if r.SignerUserID != 0 {
181 signer = r.SignerUserID
182 }
183 var fpr any
184 if r.KeyFingerprint != "" {
185 fpr = r.KeyFingerprint
186 }
187 _, err := s.DB.Exec(`
188 INSERT INTO commit_signatures (repo_id, commit_sha, state, signer_user_id, key_fingerprint, key_epoch)
189 VALUES (?, ?, ?, ?, ?, ?)
190 ON CONFLICT (repo_id, commit_sha) DO UPDATE SET
191 state = excluded.state, signer_user_id = excluded.signer_user_id,
192 key_fingerprint = excluded.key_fingerprint, key_epoch = excluded.key_epoch,
193 checked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
194 repoID, sha, string(r.State), signer, fpr, epoch)
195 return err
196}
197
198func timePtr(t *time.Time) any {
199 if t == nil {
200 return nil
201 }
202 return t.UTC().Format("2006-01-02T15:04:05.000Z")
203}
204
205func parseTime(s sql.NullString) *time.Time {
206 if !s.Valid {
207 return nil
208 }
209 t, err := time.Parse("2006-01-02T15:04:05.000Z", s.String)
210 if err != nil {
211 return nil
212 }
213 return &t
214}
internal/store/users.go +17 −2
@@ -142,20 +142,35 @@ func (s *Store) TouchSSHKey(id int64) error {
142142}
143143
144144// AddEmail adds an address; verifiedBy is "" (unverified), "smtp", or "admin".
145// Adding an already-verified address bumps the key epoch: it is a trust input
146// for signature states.
145147func (s *Store) AddEmail(userID int64, address, verifiedBy string, primary bool) error {
148 tx, err := s.DB.Begin()
149 if err != nil {
150 return err
151 }
152 defer tx.Rollback()
146153 var vAt, vBy any
147154 if verifiedBy != "" {
148155 vAt = "now"
149156 vBy = verifiedBy
150157 }
151 _, err := s.DB.Exec(
158 _, err = tx.Exec(
152159 `INSERT INTO emails (user_id, address, verified_at, verified_by, is_primary)
153160 VALUES (?, ?, CASE WHEN ? IS NULL THEN NULL ELSE strftime('%Y-%m-%dT%H:%M:%fZ','now') END, ?, ?)`,
154161 userID, address, vAt, vBy, boolInt(primary))
155162 if isUniqueErr(err) {
156163 return fmt.Errorf("address %q is already in use", address)
157164 }
158 return err
165 if err != nil {
166 return err
167 }
168 if verifiedBy != "" {
169 if err := bumpKeyEpoch(tx); err != nil {
170 return err
171 }
172 }
173 return tx.Commit()
159174}
160175
161176func (s *Store) KeyEpoch() (int64, error) {