krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

main: 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}