krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: e2e/sig_test.go · raw
1package e2e
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/ProtonMail/go-crypto/openpgp"
15 "github.com/ProtonMail/go-crypto/openpgp/armor"
16 "github.com/ProtonMail/go-crypto/openpgp/packet"
17 "golang.org/x/crypto/ssh"
18
19 "gitbay.org/gitbay/internal/sig"
20)
21
22// --- fixture key helpers -------------------------------------------------
23
24func newPGPKey(t *testing.T, name, email string, cfg *packet.Config) *openpgp.Entity {
25 t.Helper()
26 e, err := openpgp.NewEntity(name, "", email, cfg)
27 if err != nil {
28 t.Fatal(err)
29 }
30 return e
31}
32
33func armorPub(t *testing.T, e *openpgp.Entity) string {
34 t.Helper()
35 var buf bytes.Buffer
36 w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
37 if err != nil {
38 t.Fatal(err)
39 }
40 if err := e.Serialize(w); err != nil {
41 t.Fatal(err)
42 }
43 w.Close()
44 return buf.String()
45}
46
47func pgpSign(t *testing.T, e *openpgp.Entity, payload []byte, cfg *packet.Config) string {
48 t.Helper()
49 var buf bytes.Buffer
50 if err := openpgp.ArmoredDetachSign(&buf, e, bytes.NewReader(payload), cfg); err != nil {
51 t.Fatal(err)
52 }
53 return buf.String()
54}
55
56// --- fixture commit construction ----------------------------------------
57
58type commitSpec struct {
59 authorEmail string
60 committerEmail string
61 subject string
62 sign func(payload []byte) string // "" = unsigned
63}
64
65// buildCommits writes a chain of hand-constructed commit objects into the
66// clone at dir and points refs/heads/main at the tip.
67func buildCommits(t *testing.T, dir string, env []string, specs []commitSpec) []string {
68 t.Helper()
69 tree := strings.TrimSpace(mustGit(t, dir, env, "mktree"))
70 return buildChain(t, dir, env, tree, "", specs)
71}
72
73// buildChain constructs signed commit objects on top of parent ("" for a
74// root commit) using the given tree, and points refs/heads/main at the tip.
75func buildChain(t *testing.T, dir string, env []string, tree, parent string, specs []commitSpec) []string {
76 t.Helper()
77 base := time.Now().Add(-time.Duration(len(specs)) * time.Minute).Unix()
78 var shas []string
79 for i, spec := range specs {
80 if spec.committerEmail == "" {
81 spec.committerEmail = spec.authorEmail
82 }
83 ts := base + int64(i)*60
84 var b strings.Builder
85 fmt.Fprintf(&b, "tree %s\n", tree)
86 if parent != "" {
87 fmt.Fprintf(&b, "parent %s\n", parent)
88 }
89 fmt.Fprintf(&b, "author T <%s> %d +0000\n", spec.authorEmail, ts)
90 fmt.Fprintf(&b, "committer T <%s> %d +0000\n", spec.committerEmail, ts)
91 payloadTail := fmt.Sprintf("\n%s\n", spec.subject)
92 payload := b.String() + payloadTail
93
94 full := payload
95 if spec.sign != nil {
96 sigText := spec.sign([]byte(payload))
97 var sigHeader strings.Builder
98 for j, line := range strings.Split(strings.TrimSuffix(sigText, "\n"), "\n") {
99 if j == 0 {
100 sigHeader.WriteString("gpgsig " + line + "\n")
101 } else {
102 sigHeader.WriteString(" " + line + "\n")
103 }
104 }
105 full = b.String() + sigHeader.String() + payloadTail
106 }
107
108 cmd := exec.Command("git", "hash-object", "-t", "commit", "-w", "--stdin")
109 cmd.Dir = dir
110 cmd.Env = env
111 cmd.Stdin = strings.NewReader(full)
112 out, err := cmd.Output()
113 if err != nil {
114 t.Fatalf("hash-object: %v", err)
115 }
116 parent = strings.TrimSpace(string(out))
117 shas = append(shas, parent)
118 }
119 mustGit(t, dir, env, "update-ref", "refs/heads/main", parent)
120 return shas
121}
122
123// --- the M4 milestone test ----------------------------------------------
124
125type logEntry struct {
126 SHA string `json:"sha"`
127 Subject string `json:"subject"`
128 AuthorEmail string `json:"author_email"`
129 CommitterEmail string `json:"committer_email"`
130 Signature struct {
131 State string `json:"state"`
132 Signer string `json:"signer"`
133 } `json:"signature"`
134}
135
136func (i *instance) repoLog(t *testing.T, key, repo string) map[string]logEntry {
137 t.Helper()
138 out, errOut, code := i.ssh(t, key, "", "repo", "log", repo, "--json")
139 if code != 0 {
140 t.Fatalf("repo log: exit %d, %s", code, errOut)
141 }
142 var env struct {
143 Data []logEntry `json:"data"`
144 }
145 if err := json.Unmarshal([]byte(out), &env); err != nil {
146 t.Fatalf("repo log JSON: %v\n%s", err, out)
147 }
148 byShaOrSubject := map[string]logEntry{}
149 for _, e := range env.Data {
150 byShaOrSubject[e.Subject] = e
151 }
152 return byShaOrSubject
153}
154
155func TestSignatureVerification(t *testing.T) {
156 inst := startInstance(t)
157
158 aliceKey := inst.newKey(t, "alice")
159 inst.admin(t, "admin", "user", "create", "alice",
160 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
161
162 // bob: registered SSH key, email NOT yet verified.
163 bobKey := inst.newKey(t, "bob")
164 inst.admin(t, "admin", "user", "create", "bob",
165 "--key", bobKey+".pub", "--email", "bob@example.test")
166
167 // PGP keys.
168 now := time.Now()
169 aliceEnt := newPGPKey(t, "Alice", "alice@example.test", nil)
170 malloryEnt := newPGPKey(t, "Mallory", "mallory@example.test", nil)
171
172 past := now.Add(-2 * time.Hour)
173 expiredCfg := &packet.Config{Time: func() time.Time { return past }, KeyLifetimeSecs: 3600}
174 expiredEnt := newPGPKey(t, "Alice Old", "alice@example.test", expiredCfg)
175
176 // The "revoked" key signs its commit first and is revoked before
177 // registration: go-crypto (correctly) refuses to sign with a revoked key.
178 revokedEnt := newPGPKey(t, "Alice Revoked", "alice@example.test", nil)
179
180 // Register alice's current and expired keys on her account.
181 for _, ent := range []*openpgp.Entity{aliceEnt, expiredEnt} {
182 _, errOut, code := inst.ssh(t, aliceKey, armorPub(t, ent), "pgp", "add")
183 if code != 0 {
184 t.Fatalf("pgp add: %s", errOut)
185 }
186 }
187
188 // Alice's SSH signer for SSHSIG commits; bob's too.
189 aliceSSHRaw, _ := os.ReadFile(aliceKey)
190 aliceSigner, err := ssh.ParsePrivateKey(aliceSSHRaw)
191 if err != nil {
192 t.Fatal(err)
193 }
194 bobSSHRaw, _ := os.ReadFile(bobKey)
195 bobSigner, err := ssh.ParsePrivateKey(bobSSHRaw)
196 if err != nil {
197 t.Fatal(err)
198 }
199
200 // Repo + working clone.
201 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/sig"); code != 0 {
202 t.Fatalf("repo create: %s", errOut)
203 }
204 work := t.TempDir()
205 env := inst.gitEnv(aliceKey)
206 mustGit(t, work, env, "clone", inst.sshURL("alice/sig"), "w")
207 dir := filepath.Join(work, "w")
208
209 sigCfg := &packet.Config{}
210 expiredSigCfg := &packet.Config{Time: func() time.Time { return past.Add(10 * time.Minute) }}
211 var verifiedPayloadSig string // captured to build the bad-signature commit
212
213 specs := []commitSpec{
214 {authorEmail: "alice@example.test", subject: "unsigned"},
215 {authorEmail: "alice@example.test", subject: "verified-pgp", sign: func(p []byte) string {
216 verifiedPayloadSig = pgpSign(t, aliceEnt, p, sigCfg)
217 return verifiedPayloadSig
218 }},
219 {authorEmail: "mallory@example.test", subject: "unknown-key", sign: func(p []byte) string {
220 return pgpSign(t, malloryEnt, p, sigCfg)
221 }},
222 {authorEmail: "eve@example.test", subject: "email-mismatch", sign: func(p []byte) string {
223 return pgpSign(t, aliceEnt, p, sigCfg)
224 }},
225 {authorEmail: "alice@example.test", subject: "expired-key", sign: func(p []byte) string {
226 return pgpSign(t, expiredEnt, p, expiredSigCfg)
227 }},
228 {authorEmail: "alice@example.test", subject: "revoked-key", sign: func(p []byte) string {
229 return pgpSign(t, revokedEnt, p, sigCfg)
230 }},
231 {authorEmail: "alice@example.test", subject: "bad-signature", sign: func(p []byte) string {
232 return verifiedPayloadSig // valid armor, wrong payload
233 }},
234 {authorEmail: "alice@example.test", committerEmail: "other@example.test", subject: "verified-sshsig", sign: func(p []byte) string {
235 s, err := sig.MarshalSSHSig(aliceSigner, p)
236 if err != nil {
237 t.Fatal(err)
238 }
239 return string(s)
240 }},
241 {authorEmail: "bob@example.test", subject: "sshsig-unverified-email", sign: func(p []byte) string {
242 s, err := sig.MarshalSSHSig(bobSigner, p)
243 if err != nil {
244 t.Fatal(err)
245 }
246 return string(s)
247 }},
248 }
249 buildCommits(t, dir, env, specs)
250 mustGit(t, dir, env, "push", "-q", "origin", "main")
251
252 // Now revoke the key and register it: revocation predates verification,
253 // which is what the revoked state is about.
254 if err := revokedEnt.RevokeKey(packet.KeyCompromised, "test", nil); err != nil {
255 t.Fatal(err)
256 }
257 if _, errOut, code := inst.ssh(t, aliceKey, armorPub(t, revokedEnt), "pgp", "add"); code != 0 {
258 t.Fatalf("pgp add revoked: %s", errOut)
259 }
260
261 // Golden state check: one commit per state.
262 want := map[string]struct {
263 state string
264 signer string
265 }{
266 "unsigned": {"unsigned", ""},
267 "verified-pgp": {"verified", "alice"},
268 "unknown-key": {"signed_unknown_key", ""},
269 "email-mismatch": {"signed_email_mismatch", "alice"},
270 "expired-key": {"signed_key_expired", "alice"},
271 "revoked-key": {"signed_key_revoked", "alice"},
272 "bad-signature": {"bad_signature", "alice"},
273 "verified-sshsig": {"verified", "alice"},
274 "sshsig-unverified-email": {"signed_email_mismatch", "bob"},
275 }
276 check := func(log map[string]logEntry, subjects ...string) {
277 t.Helper()
278 for _, subj := range subjects {
279 e, ok := log[subj]
280 if !ok {
281 t.Fatalf("commit %q missing from log", subj)
282 }
283 w := want[subj]
284 if e.Signature.State != w.state || e.Signature.Signer != w.signer {
285 t.Errorf("%s: state=%s signer=%q, want state=%s signer=%q",
286 subj, e.Signature.State, e.Signature.Signer, w.state, w.signer)
287 }
288 }
289 }
290 log := inst.repoLog(t, aliceKey, "alice/sig")
291 subjects := make([]string, 0, len(want))
292 for s := range want {
293 subjects = append(subjects, s)
294 }
295 check(log, subjects...)
296
297 // Committer email surfaces only when it differs from the author.
298 if log["verified-sshsig"].CommitterEmail != "other@example.test" {
299 t.Errorf("differing committer email not surfaced: %+v", log["verified-sshsig"])
300 }
301 if log["unsigned"].CommitterEmail != "" {
302 t.Errorf("identical committer email should be omitted: %+v", log["unsigned"])
303 }
304
305 // Epoch transition 1: registering mallory (key + verified email)
306 // upgrades the cached signed_unknown_key row to verified.
307 malloryKey := inst.newKey(t, "mallory")
308 inst.admin(t, "admin", "user", "create", "mallory",
309 "--key", malloryKey+".pub", "--email", "mallory@example.test", "--verified")
310 if _, errOut, code := inst.ssh(t, malloryKey, armorPub(t, malloryEnt), "pgp", "add"); code != 0 {
311 t.Fatalf("mallory pgp add: %s", errOut)
312 }
313 want["unknown-key"] = struct {
314 state string
315 signer string
316 }{"verified", "mallory"}
317 check(inst.repoLog(t, aliceKey, "alice/sig"), "unknown-key")
318
319 // Epoch transition 2: verifying bob's email upgrades his SSHSIG commit.
320 inst.admin(t, "admin", "email", "verify", "bob", "bob@example.test")
321 want["sshsig-unverified-email"] = struct {
322 state string
323 signer string
324 }{"verified", "bob"}
325 check(inst.repoLog(t, aliceKey, "alice/sig"), "sshsig-unverified-email")
326
327 // Epoch transition 3: removing alice's PGP key downgrades her verified
328 // commit; re-adding restores it.
329 fpr := fmt.Sprintf("%x", aliceEnt.PrimaryKey.Fingerprint)
330 if _, errOut, code := inst.ssh(t, aliceKey, "", "pgp", "remove", fpr); code != 0 {
331 t.Fatalf("pgp remove: %s", errOut)
332 }
333 if got := inst.repoLog(t, aliceKey, "alice/sig")["verified-pgp"].Signature.State; got != "signed_unknown_key" {
334 t.Errorf("after key removal: verified-pgp state = %s, want signed_unknown_key", got)
335 }
336 if _, errOut, code := inst.ssh(t, aliceKey, armorPub(t, aliceEnt), "pgp", "add"); code != 0 {
337 t.Fatalf("pgp re-add: %s", errOut)
338 }
339 check(inst.repoLog(t, aliceKey, "alice/sig"), "verified-pgp")
340
341 // Cross-check payload reconstruction against git itself, when gpg is
342 // available: git verify-commit must agree the signature is valid.
343 if gpgPath, err := exec.LookPath("gpg"); err == nil {
344 gnupgHome := t.TempDir()
345 gpgEnv := append(env, "GNUPGHOME="+gnupgHome)
346 imp := exec.Command(gpgPath, "--batch", "--import")
347 imp.Env = gpgEnv
348 imp.Stdin = strings.NewReader(armorPub(t, aliceEnt))
349 // gpg exits nonzero if it cannot reach its agent, even when the
350 // import itself succeeded; trust the summary line instead.
351 if out, err := imp.CombinedOutput(); err != nil && !strings.Contains(string(out), "imported: 1") {
352 t.Fatalf("gpg import: %v\n%s", err, out)
353 }
354 var sha string
355 for _, e := range inst.repoLog(t, aliceKey, "alice/sig") {
356 if e.Subject == "verified-pgp" {
357 sha = e.SHA
358 }
359 }
360 vc := exec.Command("git", "verify-commit", sha)
361 vc.Dir = dir
362 vc.Env = gpgEnv
363 if out, err := vc.CombinedOutput(); err != nil {
364 t.Errorf("git verify-commit disagrees with gitbay verification: %v\n%s", err, out)
365 }
366 } else {
367 t.Log("gpg not installed; skipping git verify-commit cross-check")
368 }
369}