Commit 35d07f32e1
Verified · cmc
Layout: unified · split
cmd/forged/main.go +1 −7
| @@ -298,13 +298,7 @@ func adminEmailVerifyCmd() *cobra.Command { | ||
| 298 | 298 | if err != nil { |
| 299 | 299 | return fmt.Errorf("user %s: %w", args[0], err) |
| 300 | 300 | } |
| 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 { | |
| 308 | 302 | return fmt.Errorf("no address %s on user %s", args[1], args[0]) |
| 309 | 303 | } |
| 310 | 304 | fmt.Println("verified", args[1]) |
e2e/sig_test.go added +363
| @@ -0,0 +1,363 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 24 | func 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 | ||
| 33 | func 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 | ||
| 47 | func 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 | ||
| 58 | type 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. | |
| 67 | func 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 | ||
| 119 | type 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 | ||
| 130 | func (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 | ||
| 149 | func 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 | ||
| 4 | 4 | |
| 5 | 5 | require ( |
| 6 | 6 | github.com/BurntSushi/toml v1.6.0 |
| 7 | github.com/ProtonMail/go-crypto v1.4.1 | |
| 7 | 8 | github.com/spf13/cobra v1.10.2 |
| 8 | 9 | golang.org/x/crypto v0.55.0 |
| 9 | 10 | modernc.org/sqlite v1.57.0 |
| 10 | 11 | ) |
| 11 | 12 | |
| 12 | 13 | require ( |
| 14 | github.com/cloudflare/circl v1.6.2 // indirect | |
| 13 | 15 | github.com/dustin/go-humanize v1.0.1 // indirect |
| 14 | 16 | github.com/google/uuid v1.6.0 // indirect |
| 15 | 17 | github.com/inconshreveable/mousetrap v1.1.0 // indirect |
go.sum +4
| @@ -1,5 +1,9 @@ | ||
| 1 | 1 | github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= |
| 2 | 2 | github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| 3 | github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= | |
| 4 | github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= | |
| 5 | github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= | |
| 6 | github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= | |
| 3 | 7 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= |
| 4 | 8 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| 5 | 9 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
internal/control/sig.go added +203
| @@ -0,0 +1,203 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 18 | func 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 | ||
| 29 | func 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 | ||
| 58 | func 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 | ||
| 80 | func 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. | |
| 97 | func 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 | ||
| 117 | func 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 { | ||
| 74 | 74 | } |
| 75 | 75 | return true |
| 76 | 76 | } |
| 77 | ||
| 78 | // RevList returns up to limit commit SHAs reachable from ref, newest first. | |
| 79 | func 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. | |
| 95 | func 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. | |
| 3 | package sig | |
| 4 | ||
| 5 | import ( | |
| 6 | "bytes" | |
| 7 | "fmt" | |
| 8 | "strings" | |
| 9 | ) | |
| 10 | ||
| 11 | // Commit is a parsed raw commit object. | |
| 12 | type 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. | |
| 27 | func 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". | |
| 74 | func 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. | |
| 91 | type SigKind int | |
| 92 | ||
| 93 | const ( | |
| 94 | SigNone SigKind = iota | |
| 95 | SigOpenPGP | |
| 96 | SigSSH | |
| 97 | SigUnknown | |
| 98 | ) | |
| 99 | ||
| 100 | func 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 @@ | ||
| 1 | package sig | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | var 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 | ||
| 20 | func 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 | ||
| 47 | func 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 @@ | ||
| 1 | package sig | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "encoding/hex" | |
| 6 | "fmt" | |
| 7 | "time" | |
| 8 | ||
| 9 | "github.com/ProtonMail/go-crypto/openpgp" | |
| 10 | ) | |
| 11 | ||
| 12 | func 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. | |
| 22 | type 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. | |
| 30 | func 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 @@ | ||
| 1 | package sig | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 17 | const sshsigMagic = "SSHSIG" | |
| 18 | ||
| 19 | type sshsigBlob struct { | |
| 20 | Version uint32 | |
| 21 | PublicKey []byte | |
| 22 | Namespace string | |
| 23 | Reserved string | |
| 24 | HashAlgorithm string | |
| 25 | Signature []byte | |
| 26 | } | |
| 27 | ||
| 28 | func 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 | ||
| 96 | func 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 | ||
| 110 | func 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 | ||
| 120 | func 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. | |
| 129 | func 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). | |
| 162 | func 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 @@ | ||
| 1 | package sig | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "slices" | |
| 6 | "time" | |
| 7 | ||
| 8 | "github.com/ProtonMail/go-crypto/openpgp" | |
| 9 | "github.com/ProtonMail/go-crypto/openpgp/packet" | |
| 10 | ) | |
| 11 | ||
| 12 | type State string | |
| 13 | ||
| 14 | const ( | |
| 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 | ||
| 24 | type 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. | |
| 31 | type 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. | |
| 39 | type 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. | |
| 46 | type 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. | |
| 56 | func 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 | ||
| 69 | func 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. | |
| 134 | func 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 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 12 | func (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 | ||
| 32 | func (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 | ||
| 51 | type PGPKey struct { | |
| 52 | Fingerprint string | |
| 53 | UIDsJSON string | |
| 54 | ExpiresAt *time.Time | |
| 55 | RevokedAt *time.Time | |
| 56 | } | |
| 57 | ||
| 58 | func (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). | |
| 81 | func (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. | |
| 103 | type SigDB struct{ *Store } | |
| 104 | ||
| 105 | func (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 | ||
| 123 | func (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 | ||
| 134 | func (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. | |
| 154 | func (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 | ||
| 178 | func (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 | ||
| 198 | func 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 | ||
| 205 | func 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 { | ||
| 142 | 142 | } |
| 143 | 143 | |
| 144 | 144 | // 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. | |
| 145 | 147 | func (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() | |
| 146 | 153 | var vAt, vBy any |
| 147 | 154 | if verifiedBy != "" { |
| 148 | 155 | vAt = "now" |
| 149 | 156 | vBy = verifiedBy |
| 150 | 157 | } |
| 151 | _, err := s.DB.Exec( | |
| 158 | _, err = tx.Exec( | |
| 152 | 159 | `INSERT INTO emails (user_id, address, verified_at, verified_by, is_primary) |
| 153 | 160 | VALUES (?, ?, CASE WHEN ? IS NULL THEN NULL ELSE strftime('%Y-%m-%dT%H:%M:%fZ','now') END, ?, ?)`, |
| 154 | 161 | userID, address, vAt, vBy, boolInt(primary)) |
| 155 | 162 | if isUniqueErr(err) { |
| 156 | 163 | return fmt.Errorf("address %q is already in use", address) |
| 157 | 164 | } |
| 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() | |
| 159 | 174 | } |
| 160 | 175 | |
| 161 | 176 | func (s *Store) KeyEpoch() (int64, error) { |