package e2e import ( "net/http" "net/url" "os" "os/exec" "path/filepath" "regexp" "strings" "testing" ) // nonceIn pulls the nonce a page is showing, which is the value the next command signs. var nonceIn = regexp.MustCompile(`printf '%s' '([^']+)'`) // Chapters 10 and 31: an account is a name and a key, and signing in is one ssh-keygen line. func TestSignupAndSignInWithARealKey(t *testing.T) { if _, err := exec.LookPath("ssh-keygen"); err != nil { t.Skip("ssh-keygen is not installed") } in := newInstance(t) dir := t.TempDir() key := filepath.Join(dir, "id") run(t, "", "ssh-keygen", "-t", "ed25519", "-N", "", "-C", "john", "-f", key, "-q") pub, err := os.ReadFile(key + ".pub") if err != nil { t.Fatal(err) } // The form collects a name and a key, and answers with a nonce to sign. Chapter 10. body := form(t, in, "/signup", url.Values{"name": {"john"}, "pubkey": {string(pub)}}) nonce := nonceIn.FindStringSubmatch(body) if nonce == nil { t.Fatalf("signup did not answer with a nonce to sign:\n%s", body) } // A signature under the sign-in namespace must not claim an account. Chapter 10. wrong := sign(t, key, nonce[1], "barerepo-auth") body = form(t, in, "/signup", url.Values{"nonce": {nonce[1]}, "signature": {wrong}}) if !strings.Contains(body, "barerepo-signup") { t.Errorf("a barerepo-auth signature was accepted for signup, so one can be replayed:\n%s", body) } // A wrong signature spends the nonce, so the page says so and asks for the form again. if !strings.Contains(body, "the nonce is spent") { t.Errorf("the page does not say the nonce is gone, so the next try looks broken:\n%s", body) } body = form(t, in, "/signup", url.Values{"name": {"john"}, "pubkey": {string(pub)}}) nonce = nonceIn.FindStringSubmatch(body) if nonce == nil { t.Fatalf("signup did not answer with a second nonce:\n%s", body) } right := sign(t, key, nonce[1], "barerepo-signup") resp := formResponse(t, in, "/signup", url.Values{"nonce": {nonce[1]}, "signature": {right}}) if resp.StatusCode != http.StatusFound { t.Fatalf("finishing signup answered %d:\n%s", resp.StatusCode, resp.body) } if sessionCookie(resp) == "" { t.Error("signup did not sign the new account in") } // Now the other door, which is the one a returning reader takes. Chapter 31.3. body = form(t, in, "/auth/challenge", url.Values{"name": {"john"}}) nonce = nonceIn.FindStringSubmatch(body) if nonce == nil { t.Fatalf("the challenge did not answer with a nonce:\n%s", body) } // One line, reading stdin and writing stdout, so nothing is left on disk. Chapter 31.3. if !strings.Contains(body, "ssh-keygen -Y sign") || !strings.Contains(body, "-n barerepo-auth") { t.Errorf("the page does not show the command that signs the nonce:\n%s", body) } resp = formResponse(t, in, "/auth/verify", url.Values{"nonce": {nonce[1]}, "signature": {sign(t, key, nonce[1], "barerepo-auth")}}) if resp.StatusCode != http.StatusFound { t.Fatalf("verifying answered %d", resp.StatusCode) } cookie := sessionCookie(resp) if cookie == "" { t.Fatal("signing in handed back no session") } // The session is the whole point, so it has to open the one page that needs one. req, err := http.NewRequest(http.MethodGet, in.http.URL+"/keys", nil) if err != nil { t.Fatal(err) } req.AddCookie(&http.Cookie{Name: "barerepo_session", Value: cookie}) keys, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer keys.Body.Close() page := readAll(t, keys) if keys.StatusCode != http.StatusOK || !strings.Contains(page, "ssh keys") { t.Errorf("the session does not open the keys page: %d\n%s", keys.StatusCode, page) } } // sign is chapter 31.3's line: one command, stdin to stdout, nothing left behind. func sign(t *testing.T, key, nonce, namespace string) string { t.Helper() cmd := exec.Command("ssh-keygen", "-Y", "sign", "-f", key, "-n", namespace, "-") cmd.Stdin = strings.NewReader(nonce) out, err := cmd.Output() if err != nil { t.Fatalf("ssh-keygen -Y sign: %v", err) } return string(out) } func form(t *testing.T, in *instance, path string, values url.Values) string { t.Helper() resp := formResponse(t, in, path, values) return resp.body } type formResult struct { *http.Response body string } func formResponse(t *testing.T, in *instance, path string, values url.Values) formResult { t.Helper() req, err := http.NewRequest(http.MethodPost, in.http.URL+path, strings.NewReader(values.Encode())) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} resp, err := client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() return formResult{resp, readAll(t, resp)} } func sessionCookie(r formResult) string { for _, c := range r.Cookies() { if c.Name == "barerepo_session" { return c.Value } } return "" }