package e2e import ( "os" "os/exec" "path/filepath" "strings" "testing" "time" ) // signWith makes a key and points a working copy at it, which is all git needs to sign with ssh. func signWith(t *testing.T, dir string) { t.Helper() signWithKey(t, dir, newKey(t, "signer")) } // newKey makes a key pair and hands back the private half's path. func newKey(t *testing.T, comment string) string { t.Helper() key := filepath.Join(t.TempDir(), "id") if out, err := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-C", comment, "-f", key, "-q").CombinedOutput(); err != nil { t.Fatalf("ssh-keygen: %v\n%s", err, out) } return key } func signWithKey(t *testing.T, dir, key string) { t.Helper() run(t, dir, "git", "config", "gpg.format", "ssh") run(t, dir, "git", "config", "user.signingkey", key+".pub") run(t, dir, "git", "config", "commit.gpgsign", "true") } // An official repository distributes what it holds, so require_signed_commits refuses an unsigned commit. func TestARepositoryThatAsksForSignaturesRefusesAnUnsignedCommit(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") } if _, err := exec.LookPath("ssh-keygen"); err != nil { t.Skip("ssh-keygen is not installed") } in := newInstance(t) john := in.account("john") work := seed(t, in, john, "john", "johnbot") signWithKey(t, work, in.keyOf("john")) write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n"+ "[access]\nrequire_signed_commits = true\n") run(t, work, "git", "add", "-A") run(t, work, "git", "commit", "-qm", "this repository takes only signed commits") run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master") // The owner is not exempt, or the rule protects the repository from everyone except its owner. run(t, work, "git", "config", "commit.gpgsign", "false") commit(t, work, "package main\n\nfunc main() {}\n", "written without a signature") out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master") if err == nil { t.Fatalf("an unsigned commit landed on a repository that asks for signatures:\n%s", out) } for _, want := range []string{"only signed commits", "require_signed_commits", "commit.gpgsign"} { if !strings.Contains(out, want) { t.Errorf("the rejection does not mention %q:\n%s", want, out) } } // The same change, signed, is the push that must go through. run(t, work, "git", "config", "commit.gpgsign", "true") run(t, work, "git", "commit", "-q", "--amend", "--no-edit") if out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master"); err != nil { t.Errorf("a signed commit was refused: %v\n%s", err, out) } } // The key is off unless a repository asks for it, so nobody else's repository changes. func TestEveryOtherRepositoryStillTakesUnsignedCommits(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") } in := newInstance(t) john := in.account("john") work := seed(t, in, john, "john", "johnbot") commit(t, work, "package main\n\nfunc main() {}\n", "no signature, no config, no objection") if out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master"); err != nil { t.Errorf("a plain repository refused an unsigned commit: %v\n%s", err, out) } if os.Getenv("CI") != "" { t.Log("ran under CI") } } // A commit parked on a proposal ref is still unsigned when a branch reaches for it. func TestAnUnsignedCommitCannotEnterThroughAProposal(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") } if _, err := exec.LookPath("ssh-keygen"); err != nil { t.Skip("ssh-keygen is not installed") } in := newInstance(t) john := in.account("john") work := seed(t, in, john, "john", "johnbot") signWithKey(t, work, in.keyOf("john")) write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n"+ "[access]\nrequire_signed_commits = true\n") run(t, work, "git", "add", "-A") run(t, work, "git", "commit", "-qm", "this repository takes only signed commits") run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master") // The proposal namespace takes it, because a proposal is a request and not a landing. run(t, work, "git", "config", "commit.gpgsign", "false") commit(t, work, "package main\n\nfunc main() {}\n", "unsigned, offered as a proposal") if out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "HEAD:refs/proposals/new"); err != nil { t.Fatalf("the proposal was refused: %v\n%s", err, out) } // The commit is now in the repository, so a walk of what is new to the repository would miss it. out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master") if err == nil { t.Fatalf("an unsigned commit reached master through a proposal:\n%s", out) } if !strings.Contains(out, "only signed commits") { t.Errorf("the rejection does not say why:\n%s", out) } } // A signature is only worth the key behind it, so a key the server never saw does not count. func TestASignatureFromAKeyTheServerDoesNotHoldIsRefused(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") } if _, err := exec.LookPath("ssh-keygen"); err != nil { t.Skip("ssh-keygen is not installed") } in := newInstance(t) john := in.account("john") work := seed(t, in, john, "john", "johnbot") // The account's own key, which the server wrote into the signers file when the account was made. signWithKey(t, work, in.keyOf("john")) write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n"+ "[access]\nrequire_signed_commits = true\n") run(t, work, "git", "add", "-A") run(t, work, "git", "commit", "-qm", "this repository takes only signed commits") run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master") // A real signature, made by a key nobody published here. signWithKey(t, work, newKey(t, "a stranger's key")) commit(t, work, "package main\n\nfunc main() {}\n", "signed by a key the server never saw") out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master") if err == nil { t.Fatalf("a signature from an unknown key was taken as proof:\n%s", out) } if !strings.Contains(out, "only signed commits") { t.Errorf("the rejection does not say why:\n%s", out) } // The account's own key still works, so the rule is about the key and not about signing at all. signWithKey(t, work, in.keyOf("john")) run(t, work, "git", "commit", "-q", "--amend", "--no-edit") if out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master"); err != nil { t.Errorf("a signature from a published key was refused: %v\n%s", err, out) } } // A retired key must keep vouching for what it signed, or rotating a key rewrites the past. func TestARetiredKeyStillVouchesForWhatItAlreadySigned(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") } if _, err := exec.LookPath("ssh-keygen"); err != nil { t.Skip("ssh-keygen is not installed") } in := newInstance(t) john := in.account("john") work := seed(t, in, john, "john", "johnbot") signWithKey(t, work, in.keyOf("john")) write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n"+ "[access]\nrequire_signed_commits = true\n") run(t, work, "git", "add", "-A") run(t, work, "git", "commit", "-qm", "this repository takes only signed commits") run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master") // Written and signed while the old key is still john's, which is the ordinary case. commit(t, work, "package main\n\nfunc main() {}\n", "signed before the rotation") // Then john rotates, which is what happens between writing a commit and pushing it. keys, err := in.db.Keys(t.Context(), "john") if err != nil || len(keys) != 1 { t.Fatalf("john has %d keys, err %v", len(keys), err) } next := newKey(t, "john's new key") pub, err := os.ReadFile(next + ".pub") if err != nil { t.Fatal(err) } if _, err := in.db.AddKey(t.Context(), "john", string(pub)); err != nil { t.Fatal(err) } if err := in.db.DeleteKey(t.Context(), "john", keys[0].ID); err != nil { t.Fatal(err) } // The retired key opens no door any more. live, err := in.db.Keys(t.Context(), "john") if err != nil { t.Fatal(err) } for _, k := range live { if k.ID == keys[0].ID { t.Fatal("the retired key is still listed as one of john's keys") } } // The commit it signed while it was live still holds, which is the whole point of keeping it. if out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master"); err != nil { t.Errorf("a commit signed by a key that has since retired was refused: %v\n%s", err, out) } // Dated an hour on, because ssh records validity to the second and this test runs inside one. signWithKey(t, work, in.keyOf("john")) write(t, work, "config.go", "package main\n\nfunc main() { _ = 1 }\n") later := time.Now().Add(time.Hour).Format(time.RFC3339) cmd := exec.Command("git", "commit", "-qam", "signed after the rotation") cmd.Dir = work cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=tester", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=tester", "GIT_COMMITTER_EMAIL=t@x", "GIT_AUTHOR_DATE="+later, "GIT_COMMITTER_DATE="+later, "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=") if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git commit: %v\n%s", err, out) } if out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master"); err == nil { t.Errorf("a retired key signed something new and it was taken:\n%s", out) } } // A server that cannot check a signature must say so, not quietly accept whatever it is given. func TestASignerFileThatIsGoneRefusesRatherThanStopsChecking(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not installed") } if _, err := exec.LookPath("ssh-keygen"); err != nil { t.Skip("ssh-keygen is not installed") } in := newInstance(t) john := in.account("john") work := seed(t, in, john, "john", "johnbot") signWithKey(t, work, in.keyOf("john")) write(t, work, ".barerepo/config", "[repo]\nvisibility = \"public\"\n\n"+ "[access]\nrequire_signed_commits = true\n") run(t, work, "git", "add", "-A") run(t, work, "git", "commit", "-qm", "this repository takes only signed commits") run(t, work, "git", "push", "-q", in.url(john, "/john/johnbot"), "master") // The cache may be deleted at any time, and this file lives there. if err := os.Remove(filepath.Join(in.cfg.Paths.Cache, "allowed_signers")); err != nil { t.Fatal(err) } commit(t, work, "package main\n\nfunc main() {}\n", "properly signed, but nothing can check it") out, err := try(t, work, "git", "push", in.url(john, "/john/johnbot"), "master") if err == nil { t.Fatalf("the push was taken by a server that could not check it:\n%s", out) } if !strings.Contains(out, "cannot check a signature") { t.Errorf("the rejection does not say the server cannot check:\n%s", out) } if !strings.Contains(out, "barerepo doctor") { t.Errorf("the rejection does not say how to fix it:\n%s", out) } }