e2e/sig_test.go

f8b976a97290a20d552056a999511f5d27d8e8ec
gitbay/e2e/sig_test.go history · blame · raw

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}