Commit 35d07f32e1
Verified · cmc
Layout: unified · split
cmd/forged/main.go +1 −7
| @@ -298,13 +298,7 @@ func adminEmailVerifyCmd() *cobra.Command { | |||
| 298 | if err != nil { | 298 | if err != nil { |
| 299 | return fmt.Errorf("user %s: %w", args[0], err) | 299 | return fmt.Errorf("user %s: %w", args[0], err) |
| 300 | } | 300 | } |
| 301 | res, err := st.DB.Exec( | 301 | if err := st.VerifyEmail(u.ID, args[1], "admin"); err != nil { |
| 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 { | ||
| 308 | return fmt.Errorf("no address %s on user %s", args[1], args[0]) | 302 | return fmt.Errorf("no address %s on user %s", args[1], args[0]) |
| 309 | } | 303 | } |
| 310 | fmt.Println("verified", args[1]) | 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 | require ( | 5 | require ( |
| 6 | github.com/BurntSushi/toml v1.6.0 | 6 | github.com/BurntSushi/toml v1.6.0 |
| 7 | github.com/ProtonMail/go-crypto v1.4.1 | ||
| 7 | github.com/spf13/cobra v1.10.2 | 8 | github.com/spf13/cobra v1.10.2 |
| 8 | golang.org/x/crypto v0.55.0 | 9 | golang.org/x/crypto v0.55.0 |
| 9 | modernc.org/sqlite v1.57.0 | 10 | modernc.org/sqlite v1.57.0 |
| 10 | ) | 11 | ) |
| 11 | 12 | ||
| 12 | require ( | 13 | require ( |
| 14 | github.com/cloudflare/circl v1.6.2 // indirect | ||
| 13 | github.com/dustin/go-humanize v1.0.1 // indirect | 15 | github.com/dustin/go-humanize v1.0.1 // indirect |
| 14 | github.com/google/uuid v1.6.0 // indirect | 16 | github.com/google/uuid v1.6.0 // indirect |
| 15 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | 17 | github.com/inconshreveable/mousetrap v1.1.0 // indirect |
go.sum +4
| @@ -1,5 +1,9 @@ | |||
| 1 | github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= | 1 | github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= |
| 2 | github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= | 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 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | 7 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= |
| 4 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= | 8 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| 5 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= | 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 | return true | 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 | // AddEmail adds an address; verifiedBy is "" (unverified), "smtp", or "admin". | 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 | func (s *Store) AddEmail(userID int64, address, verifiedBy string, primary bool) error { | 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 | var vAt, vBy any | 153 | var vAt, vBy any |
| 147 | if verifiedBy != "" { | 154 | if verifiedBy != "" { |
| 148 | vAt = "now" | 155 | vAt = "now" |
| 149 | vBy = verifiedBy | 156 | vBy = verifiedBy |
| 150 | } | 157 | } |
| 151 | _, err := s.DB.Exec( | 158 | _, err = tx.Exec( |
| 152 | `INSERT INTO emails (user_id, address, verified_at, verified_by, is_primary) | 159 | `INSERT INTO emails (user_id, address, verified_at, verified_by, is_primary) |
| 153 | VALUES (?, ?, CASE WHEN ? IS NULL THEN NULL ELSE strftime('%Y-%m-%dT%H:%M:%fZ','now') END, ?, ?)`, | 160 | VALUES (?, ?, CASE WHEN ? IS NULL THEN NULL ELSE strftime('%Y-%m-%dT%H:%M:%fZ','now') END, ?, ?)`, |
| 154 | userID, address, vAt, vBy, boolInt(primary)) | 161 | userID, address, vAt, vBy, boolInt(primary)) |
| 155 | if isUniqueErr(err) { | 162 | if isUniqueErr(err) { |
| 156 | return fmt.Errorf("address %q is already in use", address) | 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 | func (s *Store) KeyEpoch() (int64, error) { | 176 | func (s *Store) KeyEpoch() (int64, error) { |