e2e/sig_test.go
370 lines · 12600 bytes
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 t.Parallel()
157 inst := startInstance(t)
158
159 aliceKey := inst.newKey(t, "alice")
160 inst.admin(t, "admin", "user", "create", "alice",
161 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
162
163 // bob: registered SSH key, email NOT yet verified.
164 bobKey := inst.newKey(t, "bob")
165 inst.admin(t, "admin", "user", "create", "bob",
166 "--key", bobKey+".pub", "--email", "bob@example.test")
167
168 // PGP keys.
169 now := time.Now()
170 aliceEnt := newPGPKey(t, "Alice", "alice@example.test", nil)
171 malloryEnt := newPGPKey(t, "Mallory", "mallory@example.test", nil)
172
173 past := now.Add(-2 * time.Hour)
174 expiredCfg := &packet.Config{Time: func() time.Time { return past }, KeyLifetimeSecs: 3600}
175 expiredEnt := newPGPKey(t, "Alice Old", "alice@example.test", expiredCfg)
176
177 // The "revoked" key signs its commit first and is revoked before
178 // registration: go-crypto (correctly) refuses to sign with a revoked key.
179 revokedEnt := newPGPKey(t, "Alice Revoked", "alice@example.test", nil)
180
181 // Register alice's current and expired keys on her account.
182 for _, ent := range []*openpgp.Entity{aliceEnt, expiredEnt} {
183 _, errOut, code := inst.ssh(t, aliceKey, armorPub(t, ent), "pgp", "add")
184 if code != 0 {
185 t.Fatalf("pgp add: %s", errOut)
186 }
187 }
188
189 // Alice's SSH signer for SSHSIG commits; bob's too.
190 aliceSSHRaw, _ := os.ReadFile(aliceKey)
191 aliceSigner, err := ssh.ParsePrivateKey(aliceSSHRaw)
192 if err != nil {
193 t.Fatal(err)
194 }
195 bobSSHRaw, _ := os.ReadFile(bobKey)
196 bobSigner, err := ssh.ParsePrivateKey(bobSSHRaw)
197 if err != nil {
198 t.Fatal(err)
199 }
200
201 // Repo + working clone.
202 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/sig"); code != 0 {
203 t.Fatalf("repo create: %s", errOut)
204 }
205 work := t.TempDir()
206 env := inst.gitEnv(aliceKey)
207 mustGit(t, work, env, "clone", inst.sshURL("alice/sig"), "w")
208 dir := filepath.Join(work, "w")
209
210 sigCfg := &packet.Config{}
211 expiredSigCfg := &packet.Config{Time: func() time.Time { return past.Add(10 * time.Minute) }}
212 var verifiedPayloadSig string // captured to build the bad-signature commit
213
214 specs := []commitSpec{
215 {authorEmail: "alice@example.test", subject: "unsigned"},
216 {authorEmail: "alice@example.test", subject: "verified-pgp", sign: func(p []byte) string {
217 verifiedPayloadSig = pgpSign(t, aliceEnt, p, sigCfg)
218 return verifiedPayloadSig
219 }},
220 {authorEmail: "mallory@example.test", subject: "unknown-key", sign: func(p []byte) string {
221 return pgpSign(t, malloryEnt, p, sigCfg)
222 }},
223 {authorEmail: "eve@example.test", subject: "email-mismatch", sign: func(p []byte) string {
224 return pgpSign(t, aliceEnt, p, sigCfg)
225 }},
226 {authorEmail: "alice@example.test", subject: "expired-key", sign: func(p []byte) string {
227 return pgpSign(t, expiredEnt, p, expiredSigCfg)
228 }},
229 {authorEmail: "alice@example.test", subject: "revoked-key", sign: func(p []byte) string {
230 return pgpSign(t, revokedEnt, p, sigCfg)
231 }},
232 {authorEmail: "alice@example.test", subject: "bad-signature", sign: func(p []byte) string {
233 return verifiedPayloadSig // valid armor, wrong payload
234 }},
235 {authorEmail: "alice@example.test", committerEmail: "other@example.test", subject: "verified-sshsig", sign: func(p []byte) string {
236 s, err := sig.MarshalSSHSig(aliceSigner, p)
237 if err != nil {
238 t.Fatal(err)
239 }
240 return string(s)
241 }},
242 {authorEmail: "bob@example.test", subject: "sshsig-unverified-email", sign: func(p []byte) string {
243 s, err := sig.MarshalSSHSig(bobSigner, p)
244 if err != nil {
245 t.Fatal(err)
246 }
247 return string(s)
248 }},
249 }
250 buildCommits(t, dir, env, specs)
251 mustGit(t, dir, env, "push", "-q", "origin", "main")
252
253 // Now revoke the key and register it: revocation predates verification,
254 // which is what the revoked state is about.
255 if err := revokedEnt.RevokeKey(packet.KeyCompromised, "test", nil); err != nil {
256 t.Fatal(err)
257 }
258 if _, errOut, code := inst.ssh(t, aliceKey, armorPub(t, revokedEnt), "pgp", "add"); code != 0 {
259 t.Fatalf("pgp add revoked: %s", errOut)
260 }
261
262 // Golden state check: one commit per state.
263 want := map[string]struct {
264 state string
265 signer string
266 }{
267 "unsigned": {"unsigned", ""},
268 "verified-pgp": {"verified", "alice"},
269 "unknown-key": {"signed_unknown_key", ""},
270 "email-mismatch": {"signed_email_mismatch", "alice"},
271 "expired-key": {"signed_key_expired", "alice"},
272 "revoked-key": {"signed_key_revoked", "alice"},
273 "bad-signature": {"bad_signature", "alice"},
274 "verified-sshsig": {"verified", "alice"},
275 "sshsig-unverified-email": {"signed_email_mismatch", "bob"},
276 }
277 check := func(log map[string]logEntry, subjects ...string) {
278 t.Helper()
279 for _, subj := range subjects {
280 e, ok := log[subj]
281 if !ok {
282 t.Fatalf("commit %q missing from log", subj)
283 }
284 w := want[subj]
285 if e.Signature.State != w.state || e.Signature.Signer != w.signer {
286 t.Errorf("%s: state=%s signer=%q, want state=%s signer=%q",
287 subj, e.Signature.State, e.Signature.Signer, w.state, w.signer)
288 }
289 }
290 }
291 log := inst.repoLog(t, aliceKey, "alice/sig")
292 subjects := make([]string, 0, len(want))
293 for s := range want {
294 subjects = append(subjects, s)
295 }
296 check(log, subjects...)
297
298 // Committer email surfaces only when it differs from the author.
299 if log["verified-sshsig"].CommitterEmail != "other@example.test" {
300 t.Errorf("differing committer email not surfaced: %+v", log["verified-sshsig"])
301 }
302 if log["unsigned"].CommitterEmail != "" {
303 t.Errorf("identical committer email should be omitted: %+v", log["unsigned"])
304 }
305
306 // Epoch transition 1: registering mallory (key + verified email)
307 // upgrades the cached signed_unknown_key row to verified.
308 malloryKey := inst.newKey(t, "mallory")
309 inst.admin(t, "admin", "user", "create", "mallory",
310 "--key", malloryKey+".pub", "--email", "mallory@example.test", "--verified")
311 if _, errOut, code := inst.ssh(t, malloryKey, armorPub(t, malloryEnt), "pgp", "add"); code != 0 {
312 t.Fatalf("mallory pgp add: %s", errOut)
313 }
314 want["unknown-key"] = struct {
315 state string
316 signer string
317 }{"verified", "mallory"}
318 check(inst.repoLog(t, aliceKey, "alice/sig"), "unknown-key")
319
320 // Epoch transition 2: verifying bob's email upgrades his SSHSIG commit.
321 inst.admin(t, "admin", "email", "verify", "bob", "bob@example.test")
322 want["sshsig-unverified-email"] = struct {
323 state string
324 signer string
325 }{"verified", "bob"}
326 check(inst.repoLog(t, aliceKey, "alice/sig"), "sshsig-unverified-email")
327
328 // Epoch transition 3: removing alice's PGP key downgrades her verified
329 // commit; re-adding restores it.
330 fpr := fmt.Sprintf("%x", aliceEnt.PrimaryKey.Fingerprint)
331 if _, errOut, code := inst.ssh(t, aliceKey, "", "pgp", "remove", fpr); code != 0 {
332 t.Fatalf("pgp remove: %s", errOut)
333 }
334 if got := inst.repoLog(t, aliceKey, "alice/sig")["verified-pgp"].Signature.State; got != "signed_unknown_key" {
335 t.Errorf("after key removal: verified-pgp state = %s, want signed_unknown_key", got)
336 }
337 if _, errOut, code := inst.ssh(t, aliceKey, armorPub(t, aliceEnt), "pgp", "add"); code != 0 {
338 t.Fatalf("pgp re-add: %s", errOut)
339 }
340 check(inst.repoLog(t, aliceKey, "alice/sig"), "verified-pgp")
341
342 // Cross-check payload reconstruction against git itself, when gpg is
343 // available: git verify-commit must agree the signature is valid.
344 if gpgPath, err := exec.LookPath("gpg"); err == nil {
345 gnupgHome := t.TempDir()
346 gpgEnv := append(env, "GNUPGHOME="+gnupgHome)
347 imp := exec.Command(gpgPath, "--batch", "--import")
348 imp.Env = gpgEnv
349 imp.Stdin = strings.NewReader(armorPub(t, aliceEnt))
350 // gpg exits nonzero if it cannot reach its agent, even when the
351 // import itself succeeded; trust the summary line instead.
352 if out, err := imp.CombinedOutput(); err != nil && !strings.Contains(string(out), "imported: 1") {
353 t.Fatalf("gpg import: %v\n%s", err, out)
354 }
355 var sha string
356 for _, e := range inst.repoLog(t, aliceKey, "alice/sig") {
357 if e.Subject == "verified-pgp" {
358 sha = e.SHA
359 }
360 }
361 vc := exec.Command("git", "verify-commit", sha)
362 vc.Dir = dir
363 vc.Env = gpgEnv
364 if out, err := vc.CombinedOutput(); err != nil {
365 t.Errorf("git verify-commit disagrees with gitbay verification: %v\n%s", err, out)
366 }
367 } else {
368 t.Log("gpg not installed; skipping git verify-commit cross-check")
369 }
370}