mirror of https://github.com/tailscale/tailscale/
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1018 lines
29 KiB
Go
1018 lines
29 KiB
Go
// Copyright 2019 The Go Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package acme
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// While contents of this file is pertinent only to RFC8555,
|
|
// it is complementary to the tests in the other _test.go files
|
|
// many of which are valid for both pre- and RFC8555.
|
|
// This will make it easier to clean up the tests once non-RFC compliant
|
|
// code is removed.
|
|
|
|
func TestRFC_Discover(t *testing.T) {
|
|
const (
|
|
nonce = "https://example.com/acme/new-nonce"
|
|
reg = "https://example.com/acme/new-acct"
|
|
order = "https://example.com/acme/new-order"
|
|
authz = "https://example.com/acme/new-authz"
|
|
revoke = "https://example.com/acme/revoke-cert"
|
|
keychange = "https://example.com/acme/key-change"
|
|
metaTerms = "https://example.com/acme/terms/2017-5-30"
|
|
metaWebsite = "https://www.example.com/"
|
|
metaCAA = "example.com"
|
|
)
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{
|
|
"newNonce": %q,
|
|
"newAccount": %q,
|
|
"newOrder": %q,
|
|
"newAuthz": %q,
|
|
"revokeCert": %q,
|
|
"keyChange": %q,
|
|
"meta": {
|
|
"termsOfService": %q,
|
|
"website": %q,
|
|
"caaIdentities": [%q],
|
|
"externalAccountRequired": true
|
|
}
|
|
}`, nonce, reg, order, authz, revoke, keychange, metaTerms, metaWebsite, metaCAA)
|
|
}))
|
|
defer ts.Close()
|
|
c := &Client{DirectoryURL: ts.URL}
|
|
dir, err := c.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if dir.NonceURL != nonce {
|
|
t.Errorf("dir.NonceURL = %q; want %q", dir.NonceURL, nonce)
|
|
}
|
|
if dir.RegURL != reg {
|
|
t.Errorf("dir.RegURL = %q; want %q", dir.RegURL, reg)
|
|
}
|
|
if dir.OrderURL != order {
|
|
t.Errorf("dir.OrderURL = %q; want %q", dir.OrderURL, order)
|
|
}
|
|
if dir.AuthzURL != authz {
|
|
t.Errorf("dir.AuthzURL = %q; want %q", dir.AuthzURL, authz)
|
|
}
|
|
if dir.RevokeURL != revoke {
|
|
t.Errorf("dir.RevokeURL = %q; want %q", dir.RevokeURL, revoke)
|
|
}
|
|
if dir.KeyChangeURL != keychange {
|
|
t.Errorf("dir.KeyChangeURL = %q; want %q", dir.KeyChangeURL, keychange)
|
|
}
|
|
if dir.Terms != metaTerms {
|
|
t.Errorf("dir.Terms = %q; want %q", dir.Terms, metaTerms)
|
|
}
|
|
if dir.Website != metaWebsite {
|
|
t.Errorf("dir.Website = %q; want %q", dir.Website, metaWebsite)
|
|
}
|
|
if len(dir.CAA) == 0 || dir.CAA[0] != metaCAA {
|
|
t.Errorf("dir.CAA = %q; want [%q]", dir.CAA, metaCAA)
|
|
}
|
|
if !dir.ExternalAccountRequired {
|
|
t.Error("dir.Meta.ExternalAccountRequired is false")
|
|
}
|
|
}
|
|
|
|
func TestRFC_popNonce(t *testing.T) {
|
|
var count int
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// The Client uses only Directory.NonceURL when specified.
|
|
// Expect no other URL paths.
|
|
if r.URL.Path != "/new-nonce" {
|
|
t.Errorf("r.URL.Path = %q; want /new-nonce", r.URL.Path)
|
|
}
|
|
if count > 0 {
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
count++
|
|
w.Header().Set("Replay-Nonce", "second")
|
|
}))
|
|
cl := &Client{
|
|
DirectoryURL: ts.URL,
|
|
dir: &Directory{NonceURL: ts.URL + "/new-nonce"},
|
|
}
|
|
cl.addNonce(http.Header{"Replay-Nonce": {"first"}})
|
|
|
|
for i, nonce := range []string{"first", "second"} {
|
|
v, err := cl.popNonce(context.Background(), "")
|
|
if err != nil {
|
|
t.Errorf("%d: cl.popNonce: %v", i, err)
|
|
}
|
|
if v != nonce {
|
|
t.Errorf("%d: cl.popNonce = %q; want %q", i, v, nonce)
|
|
}
|
|
}
|
|
// No more nonces and server replies with an error past first nonce fetch.
|
|
// Expected to fail.
|
|
if _, err := cl.popNonce(context.Background(), ""); err == nil {
|
|
t.Error("last cl.popNonce returned nil error")
|
|
}
|
|
}
|
|
|
|
func TestRFC_postKID(t *testing.T) {
|
|
var ts *httptest.Server
|
|
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/new-nonce":
|
|
w.Header().Set("Replay-Nonce", "nonce")
|
|
case "/new-account":
|
|
w.Header().Set("Location", "/account-1")
|
|
w.Write([]byte(`{"status":"valid"}`))
|
|
case "/post":
|
|
b, _ := io.ReadAll(r.Body) // check err later in decodeJWSxxx
|
|
head, err := decodeJWSHead(bytes.NewReader(b))
|
|
if err != nil {
|
|
t.Errorf("decodeJWSHead: %v", err)
|
|
return
|
|
}
|
|
if head.KID != "/account-1" {
|
|
t.Errorf("head.KID = %q; want /account-1", head.KID)
|
|
}
|
|
if len(head.JWK) != 0 {
|
|
t.Errorf("head.JWK = %q; want zero map", head.JWK)
|
|
}
|
|
if v := ts.URL + "/post"; head.URL != v {
|
|
t.Errorf("head.URL = %q; want %q", head.URL, v)
|
|
}
|
|
|
|
var payload struct{ Msg string }
|
|
decodeJWSRequest(t, &payload, bytes.NewReader(b))
|
|
if payload.Msg != "ping" {
|
|
t.Errorf("payload.Msg = %q; want ping", payload.Msg)
|
|
}
|
|
w.Write([]byte("pong"))
|
|
default:
|
|
t.Errorf("unhandled %s %s", r.Method, r.URL)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}
|
|
}))
|
|
defer ts.Close()
|
|
|
|
ctx := context.Background()
|
|
cl := &Client{
|
|
Key: testKey,
|
|
DirectoryURL: ts.URL,
|
|
dir: &Directory{
|
|
NonceURL: ts.URL + "/new-nonce",
|
|
RegURL: ts.URL + "/new-account",
|
|
OrderURL: "/force-rfc-mode",
|
|
},
|
|
}
|
|
req := json.RawMessage(`{"msg":"ping"}`)
|
|
res, err := cl.post(ctx, nil /* use kid */, ts.URL+"/post", req, wantStatus(http.StatusOK))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
b, _ := io.ReadAll(res.Body) // don't care about err - just checking b
|
|
if string(b) != "pong" {
|
|
t.Errorf("res.Body = %q; want pong", b)
|
|
}
|
|
}
|
|
|
|
// acmeServer simulates a subset of RFC 8555 compliant CA.
|
|
//
|
|
// TODO: We also have x/crypto/acme/autocert/acmetest and startACMEServerStub in autocert_test.go.
|
|
// It feels like this acmeServer is a sweet spot between usefulness and added complexity.
|
|
// Also, acmetest and startACMEServerStub were both written for draft-02, no RFC support.
|
|
// The goal is to consolidate all into one ACME test server.
|
|
type acmeServer struct {
|
|
ts *httptest.Server
|
|
handler map[string]http.HandlerFunc // keyed by r.URL.Path
|
|
|
|
mu sync.Mutex
|
|
nnonce int
|
|
}
|
|
|
|
func newACMEServer() *acmeServer {
|
|
return &acmeServer{handler: make(map[string]http.HandlerFunc)}
|
|
}
|
|
|
|
func (s *acmeServer) handle(path string, f func(http.ResponseWriter, *http.Request)) {
|
|
s.handler[path] = http.HandlerFunc(f)
|
|
}
|
|
|
|
func (s *acmeServer) start() {
|
|
s.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
// Directory request.
|
|
if r.URL.Path == "/" {
|
|
fmt.Fprintf(w, `{
|
|
"newNonce": %q,
|
|
"newAccount": %q,
|
|
"newOrder": %q,
|
|
"newAuthz": %q,
|
|
"revokeCert": %q,
|
|
"keyChange": %q,
|
|
"meta": {"termsOfService": %q}
|
|
}`,
|
|
s.url("/acme/new-nonce"),
|
|
s.url("/acme/new-account"),
|
|
s.url("/acme/new-order"),
|
|
s.url("/acme/new-authz"),
|
|
s.url("/acme/revoke-cert"),
|
|
s.url("/acme/key-change"),
|
|
s.url("/terms"),
|
|
)
|
|
return
|
|
}
|
|
|
|
// All other responses contain a nonce value unconditionally.
|
|
w.Header().Set("Replay-Nonce", s.nonce())
|
|
if r.URL.Path == "/acme/new-nonce" {
|
|
return
|
|
}
|
|
|
|
h := s.handler[r.URL.Path]
|
|
if h == nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
fmt.Fprintf(w, "Unhandled %s", r.URL.Path)
|
|
return
|
|
}
|
|
h.ServeHTTP(w, r)
|
|
}))
|
|
}
|
|
|
|
func (s *acmeServer) close() {
|
|
s.ts.Close()
|
|
}
|
|
|
|
func (s *acmeServer) url(path string) string {
|
|
return s.ts.URL + path
|
|
}
|
|
|
|
func (s *acmeServer) nonce() string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.nnonce++
|
|
return fmt.Sprintf("nonce%d", s.nnonce)
|
|
}
|
|
|
|
func (s *acmeServer) error(w http.ResponseWriter, e *wireError) {
|
|
w.WriteHeader(e.Status)
|
|
json.NewEncoder(w).Encode(e)
|
|
}
|
|
|
|
func TestRFC_Register(t *testing.T) {
|
|
const email = "mailto:user@example.org"
|
|
|
|
s := newACMEServer()
|
|
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/accounts/1"))
|
|
w.WriteHeader(http.StatusCreated) // 201 means new account created
|
|
fmt.Fprintf(w, `{
|
|
"status": "valid",
|
|
"contact": [%q],
|
|
"orders": %q
|
|
}`, email, s.url("/accounts/1/orders"))
|
|
|
|
b, _ := io.ReadAll(r.Body) // check err later in decodeJWSxxx
|
|
head, err := decodeJWSHead(bytes.NewReader(b))
|
|
if err != nil {
|
|
t.Errorf("decodeJWSHead: %v", err)
|
|
return
|
|
}
|
|
if len(head.JWK) == 0 {
|
|
t.Error("head.JWK is empty")
|
|
}
|
|
|
|
var req struct{ Contact []string }
|
|
decodeJWSRequest(t, &req, bytes.NewReader(b))
|
|
if len(req.Contact) != 1 || req.Contact[0] != email {
|
|
t.Errorf("req.Contact = %q; want [%q]", req.Contact, email)
|
|
}
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
ctx := context.Background()
|
|
cl := &Client{
|
|
Key: testKeyEC,
|
|
DirectoryURL: s.url("/"),
|
|
}
|
|
|
|
var didPrompt bool
|
|
a := &Account{Contact: []string{email}}
|
|
acct, err := cl.Register(ctx, a, func(tos string) bool {
|
|
didPrompt = true
|
|
terms := s.url("/terms")
|
|
if tos != terms {
|
|
t.Errorf("tos = %q; want %q", tos, terms)
|
|
}
|
|
return true
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
okAccount := &Account{
|
|
URI: s.url("/accounts/1"),
|
|
Status: StatusValid,
|
|
Contact: []string{email},
|
|
OrdersURL: s.url("/accounts/1/orders"),
|
|
}
|
|
if !reflect.DeepEqual(acct, okAccount) {
|
|
t.Errorf("acct = %+v; want %+v", acct, okAccount)
|
|
}
|
|
if !didPrompt {
|
|
t.Error("tos prompt wasn't called")
|
|
}
|
|
if v := cl.accountKID(ctx); v != KeyID(okAccount.URI) {
|
|
t.Errorf("account kid = %q; want %q", v, okAccount.URI)
|
|
}
|
|
}
|
|
|
|
func TestRFC_RegisterExternalAccountBinding(t *testing.T) {
|
|
eab := &ExternalAccountBinding{
|
|
KID: "kid-1",
|
|
Key: []byte("secret"),
|
|
}
|
|
|
|
type protected struct {
|
|
Algorithm string `json:"alg"`
|
|
KID string `json:"kid"`
|
|
URL string `json:"url"`
|
|
}
|
|
const email = "mailto:user@example.org"
|
|
|
|
s := newACMEServer()
|
|
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/accounts/1"))
|
|
if r.Method != "POST" {
|
|
t.Errorf("r.Method = %q; want POST", r.Method)
|
|
}
|
|
|
|
var j struct {
|
|
Protected string
|
|
Contact []string
|
|
TermsOfServiceAgreed bool
|
|
ExternalaccountBinding struct {
|
|
Protected string
|
|
Payload string
|
|
Signature string
|
|
}
|
|
}
|
|
decodeJWSRequest(t, &j, r.Body)
|
|
protData, err := base64.RawURLEncoding.DecodeString(j.ExternalaccountBinding.Protected)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var prot protected
|
|
err = json.Unmarshal(protData, &prot)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if !reflect.DeepEqual(j.Contact, []string{email}) {
|
|
t.Errorf("j.Contact = %v; want %v", j.Contact, []string{email})
|
|
}
|
|
if !j.TermsOfServiceAgreed {
|
|
t.Error("j.TermsOfServiceAgreed = false; want true")
|
|
}
|
|
|
|
// Ensure same KID.
|
|
if prot.KID != eab.KID {
|
|
t.Errorf("j.ExternalAccountBinding.KID = %s; want %s", prot.KID, eab.KID)
|
|
}
|
|
// Ensure expected Algorithm.
|
|
if prot.Algorithm != "HS256" {
|
|
t.Errorf("j.ExternalAccountBinding.Alg = %s; want %s",
|
|
prot.Algorithm, "HS256")
|
|
}
|
|
|
|
// Ensure same URL as outer JWS.
|
|
url := fmt.Sprintf("http://%s/acme/new-account", r.Host)
|
|
if prot.URL != url {
|
|
t.Errorf("j.ExternalAccountBinding.URL = %s; want %s",
|
|
prot.URL, url)
|
|
}
|
|
|
|
// Ensure payload is base64URL encoded string of JWK in outer JWS
|
|
jwk, err := jwkEncode(testKeyEC.Public())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
decodedPayload, err := base64.RawURLEncoding.DecodeString(j.ExternalaccountBinding.Payload)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if jwk != string(decodedPayload) {
|
|
t.Errorf("j.ExternalAccountBinding.Payload = %s; want %s", decodedPayload, jwk)
|
|
}
|
|
|
|
// Check signature on inner external account binding JWS
|
|
hmac := hmac.New(sha256.New, []byte("secret"))
|
|
_, err = hmac.Write([]byte(j.ExternalaccountBinding.Protected + "." + j.ExternalaccountBinding.Payload))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mac := hmac.Sum(nil)
|
|
encodedMAC := base64.RawURLEncoding.EncodeToString(mac)
|
|
|
|
if !bytes.Equal([]byte(encodedMAC), []byte(j.ExternalaccountBinding.Signature)) {
|
|
t.Errorf("j.ExternalAccountBinding.Signature = %v; want %v",
|
|
[]byte(j.ExternalaccountBinding.Signature), encodedMAC)
|
|
}
|
|
|
|
w.Header().Set("Location", s.url("/accounts/1"))
|
|
w.WriteHeader(http.StatusCreated)
|
|
b, _ := json.Marshal([]string{email})
|
|
fmt.Fprintf(w, `{"status":"valid","orders":"%s","contact":%s}`, s.url("/accounts/1/orders"), b)
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
ctx := context.Background()
|
|
cl := &Client{
|
|
Key: testKeyEC,
|
|
DirectoryURL: s.url("/"),
|
|
}
|
|
|
|
var didPrompt bool
|
|
a := &Account{Contact: []string{email}, ExternalAccountBinding: eab}
|
|
acct, err := cl.Register(ctx, a, func(tos string) bool {
|
|
didPrompt = true
|
|
terms := s.url("/terms")
|
|
if tos != terms {
|
|
t.Errorf("tos = %q; want %q", tos, terms)
|
|
}
|
|
return true
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
okAccount := &Account{
|
|
URI: s.url("/accounts/1"),
|
|
Status: StatusValid,
|
|
Contact: []string{email},
|
|
OrdersURL: s.url("/accounts/1/orders"),
|
|
}
|
|
if !reflect.DeepEqual(acct, okAccount) {
|
|
t.Errorf("acct = %+v; want %+v", acct, okAccount)
|
|
}
|
|
if !didPrompt {
|
|
t.Error("tos prompt wasn't called")
|
|
}
|
|
if v := cl.accountKID(ctx); v != KeyID(okAccount.URI) {
|
|
t.Errorf("account kid = %q; want %q", v, okAccount.URI)
|
|
}
|
|
}
|
|
|
|
func TestRFC_RegisterExisting(t *testing.T) {
|
|
s := newACMEServer()
|
|
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/accounts/1"))
|
|
w.WriteHeader(http.StatusOK) // 200 means account already exists
|
|
w.Write([]byte(`{"status": "valid"}`))
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
|
|
_, err := cl.Register(context.Background(), &Account{}, AcceptTOS)
|
|
if err != ErrAccountAlreadyExists {
|
|
t.Errorf("err = %v; want %v", err, ErrAccountAlreadyExists)
|
|
}
|
|
kid := KeyID(s.url("/accounts/1"))
|
|
if v := cl.accountKID(context.Background()); v != kid {
|
|
t.Errorf("account kid = %q; want %q", v, kid)
|
|
}
|
|
}
|
|
|
|
func TestRFC_UpdateReg(t *testing.T) {
|
|
const email = "mailto:user@example.org"
|
|
|
|
s := newACMEServer()
|
|
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/accounts/1"))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "valid"}`))
|
|
})
|
|
var didUpdate bool
|
|
s.handle("/accounts/1", func(w http.ResponseWriter, r *http.Request) {
|
|
didUpdate = true
|
|
w.Header().Set("Location", s.url("/accounts/1"))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "valid"}`))
|
|
|
|
b, _ := io.ReadAll(r.Body) // check err later in decodeJWSxxx
|
|
head, err := decodeJWSHead(bytes.NewReader(b))
|
|
if err != nil {
|
|
t.Errorf("decodeJWSHead: %v", err)
|
|
return
|
|
}
|
|
if len(head.JWK) != 0 {
|
|
t.Error("head.JWK is non-zero")
|
|
}
|
|
kid := s.url("/accounts/1")
|
|
if head.KID != kid {
|
|
t.Errorf("head.KID = %q; want %q", head.KID, kid)
|
|
}
|
|
|
|
var req struct{ Contact []string }
|
|
decodeJWSRequest(t, &req, bytes.NewReader(b))
|
|
if len(req.Contact) != 1 || req.Contact[0] != email {
|
|
t.Errorf("req.Contact = %q; want [%q]", req.Contact, email)
|
|
}
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
|
|
_, err := cl.UpdateReg(context.Background(), &Account{Contact: []string{email}})
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
if !didUpdate {
|
|
t.Error("UpdateReg didn't update the account")
|
|
}
|
|
}
|
|
|
|
func TestRFC_GetReg(t *testing.T) {
|
|
s := newACMEServer()
|
|
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/accounts/1"))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "valid"}`))
|
|
|
|
head, err := decodeJWSHead(r.Body)
|
|
if err != nil {
|
|
t.Errorf("decodeJWSHead: %v", err)
|
|
return
|
|
}
|
|
if len(head.JWK) == 0 {
|
|
t.Error("head.JWK is empty")
|
|
}
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
|
|
acct, err := cl.GetReg(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
okAccount := &Account{
|
|
URI: s.url("/accounts/1"),
|
|
Status: StatusValid,
|
|
}
|
|
if !reflect.DeepEqual(acct, okAccount) {
|
|
t.Errorf("acct = %+v; want %+v", acct, okAccount)
|
|
}
|
|
}
|
|
|
|
func TestRFC_GetRegNoAccount(t *testing.T) {
|
|
s := newACMEServer()
|
|
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
|
|
s.error(w, &wireError{
|
|
Status: http.StatusBadRequest,
|
|
Type: "urn:ietf:params:acme:error:accountDoesNotExist",
|
|
})
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
|
|
if _, err := cl.GetReg(context.Background(), ""); err != ErrNoAccount {
|
|
t.Errorf("err = %v; want %v", err, ErrNoAccount)
|
|
}
|
|
}
|
|
|
|
func TestRFC_GetRegOtherError(t *testing.T) {
|
|
s := newACMEServer()
|
|
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
|
|
if _, err := cl.GetReg(context.Background(), ""); err == nil || err == ErrNoAccount {
|
|
t.Errorf("GetReg: %v; want any other non-nil err", err)
|
|
}
|
|
}
|
|
|
|
func TestRFC_AccountKeyRollover(t *testing.T) {
|
|
s := newACMEServer()
|
|
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/accounts/1"))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "valid"}`))
|
|
})
|
|
s.handle("/acme/key-change", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
|
|
if err := cl.AccountKeyRollover(context.Background(), testKeyEC384); err != nil {
|
|
t.Errorf("AccountKeyRollover: %v, wanted no error", err)
|
|
} else if cl.Key != testKeyEC384 {
|
|
t.Error("AccountKeyRollover did not rotate the client key")
|
|
}
|
|
}
|
|
|
|
func TestRFC_DeactivateReg(t *testing.T) {
|
|
const email = "mailto:user@example.org"
|
|
curStatus := StatusValid
|
|
|
|
type account struct {
|
|
Status string `json:"status"`
|
|
Contact []string `json:"contact"`
|
|
AcceptTOS bool `json:"termsOfServiceAgreed"`
|
|
Orders string `json:"orders"`
|
|
}
|
|
|
|
s := newACMEServer()
|
|
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/accounts/1"))
|
|
w.WriteHeader(http.StatusOK) // 200 means existing account
|
|
json.NewEncoder(w).Encode(account{
|
|
Status: curStatus,
|
|
Contact: []string{email},
|
|
AcceptTOS: true,
|
|
Orders: s.url("/accounts/1/orders"),
|
|
})
|
|
|
|
b, _ := io.ReadAll(r.Body) // check err later in decodeJWSxxx
|
|
head, err := decodeJWSHead(bytes.NewReader(b))
|
|
if err != nil {
|
|
t.Errorf("decodeJWSHead: %v", err)
|
|
return
|
|
}
|
|
if len(head.JWK) == 0 {
|
|
t.Error("head.JWK is empty")
|
|
}
|
|
|
|
var req struct {
|
|
Status string `json:"status"`
|
|
Contact []string `json:"contact"`
|
|
AcceptTOS bool `json:"termsOfServiceAgreed"`
|
|
OnlyExisting bool `json:"onlyReturnExisting"`
|
|
}
|
|
decodeJWSRequest(t, &req, bytes.NewReader(b))
|
|
if !req.OnlyExisting {
|
|
t.Errorf("req.OnlyReturnExisting = %t; want = %t", req.OnlyExisting, true)
|
|
}
|
|
})
|
|
s.handle("/accounts/1", func(w http.ResponseWriter, r *http.Request) {
|
|
if curStatus == StatusValid {
|
|
curStatus = StatusDeactivated
|
|
w.WriteHeader(http.StatusOK)
|
|
} else {
|
|
s.error(w, &wireError{
|
|
Status: http.StatusUnauthorized,
|
|
Type: "urn:ietf:params:acme:error:unauthorized",
|
|
})
|
|
}
|
|
var req account
|
|
b, _ := io.ReadAll(r.Body) // check err later in decodeJWSxxx
|
|
head, err := decodeJWSHead(bytes.NewReader(b))
|
|
if err != nil {
|
|
t.Errorf("decodeJWSHead: %v", err)
|
|
return
|
|
}
|
|
if len(head.JWK) != 0 {
|
|
t.Error("head.JWK is not empty")
|
|
}
|
|
if !strings.HasSuffix(head.KID, "/accounts/1") {
|
|
t.Errorf("head.KID = %q; want suffix /accounts/1", head.KID)
|
|
}
|
|
|
|
decodeJWSRequest(t, &req, bytes.NewReader(b))
|
|
if req.Status != StatusDeactivated {
|
|
t.Errorf("req.Status = %q; want = %q", req.Status, StatusDeactivated)
|
|
}
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
|
|
if err := cl.DeactivateReg(context.Background()); err != nil {
|
|
t.Errorf("DeactivateReg: %v, wanted no error", err)
|
|
}
|
|
if err := cl.DeactivateReg(context.Background()); err == nil {
|
|
t.Errorf("DeactivateReg: %v, wanted error for unauthorized", err)
|
|
}
|
|
}
|
|
|
|
func TestRF_DeactivateRegNoAccount(t *testing.T) {
|
|
s := newACMEServer()
|
|
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
|
|
s.error(w, &wireError{
|
|
Status: http.StatusBadRequest,
|
|
Type: "urn:ietf:params:acme:error:accountDoesNotExist",
|
|
})
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
|
|
if err := cl.DeactivateReg(context.Background()); !errors.Is(err, ErrNoAccount) {
|
|
t.Errorf("DeactivateReg: %v, wanted ErrNoAccount", err)
|
|
}
|
|
}
|
|
|
|
func TestRFC_AuthorizeOrder(t *testing.T) {
|
|
s := newACMEServer()
|
|
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/accounts/1"))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "valid"}`))
|
|
})
|
|
s.handle("/acme/new-order", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/orders/1"))
|
|
w.WriteHeader(http.StatusCreated)
|
|
fmt.Fprintf(w, `{
|
|
"status": "pending",
|
|
"expires": "2019-09-01T00:00:00Z",
|
|
"notBefore": "2019-08-31T00:00:00Z",
|
|
"notAfter": "2019-09-02T00:00:00Z",
|
|
"identifiers": [{"type":"dns", "value":"example.org"}],
|
|
"authorizations": [%q]
|
|
}`, s.url("/authz/1"))
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
|
|
o, err := cl.AuthorizeOrder(context.Background(), DomainIDs("example.org"),
|
|
WithOrderNotBefore(time.Date(2019, 8, 31, 0, 0, 0, 0, time.UTC)),
|
|
WithOrderNotAfter(time.Date(2019, 9, 2, 0, 0, 0, 0, time.UTC)),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
okOrder := &Order{
|
|
URI: s.url("/orders/1"),
|
|
Status: StatusPending,
|
|
Expires: time.Date(2019, 9, 1, 0, 0, 0, 0, time.UTC),
|
|
NotBefore: time.Date(2019, 8, 31, 0, 0, 0, 0, time.UTC),
|
|
NotAfter: time.Date(2019, 9, 2, 0, 0, 0, 0, time.UTC),
|
|
Identifiers: []AuthzID{AuthzID{Type: "dns", Value: "example.org"}},
|
|
AuthzURLs: []string{s.url("/authz/1")},
|
|
}
|
|
if !reflect.DeepEqual(o, okOrder) {
|
|
t.Errorf("AuthorizeOrder = %+v; want %+v", o, okOrder)
|
|
}
|
|
}
|
|
|
|
func TestRFC_GetOrder(t *testing.T) {
|
|
s := newACMEServer()
|
|
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/accounts/1"))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "valid"}`))
|
|
})
|
|
s.handle("/orders/1", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/orders/1"))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{
|
|
"status": "invalid",
|
|
"expires": "2019-09-01T00:00:00Z",
|
|
"notBefore": "2019-08-31T00:00:00Z",
|
|
"notAfter": "2019-09-02T00:00:00Z",
|
|
"identifiers": [{"type":"dns", "value":"example.org"}],
|
|
"authorizations": ["/authz/1"],
|
|
"finalize": "/orders/1/fin",
|
|
"certificate": "/orders/1/cert",
|
|
"error": {"type": "badRequest"}
|
|
}`))
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
|
|
o, err := cl.GetOrder(context.Background(), s.url("/orders/1"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
okOrder := &Order{
|
|
URI: s.url("/orders/1"),
|
|
Status: StatusInvalid,
|
|
Expires: time.Date(2019, 9, 1, 0, 0, 0, 0, time.UTC),
|
|
NotBefore: time.Date(2019, 8, 31, 0, 0, 0, 0, time.UTC),
|
|
NotAfter: time.Date(2019, 9, 2, 0, 0, 0, 0, time.UTC),
|
|
Identifiers: []AuthzID{AuthzID{Type: "dns", Value: "example.org"}},
|
|
AuthzURLs: []string{"/authz/1"},
|
|
FinalizeURL: "/orders/1/fin",
|
|
CertURL: "/orders/1/cert",
|
|
Error: &Error{ProblemType: "badRequest"},
|
|
}
|
|
if !reflect.DeepEqual(o, okOrder) {
|
|
t.Errorf("GetOrder = %+v\nwant %+v", o, okOrder)
|
|
}
|
|
}
|
|
|
|
func TestRFC_WaitOrder(t *testing.T) {
|
|
for _, st := range []string{StatusReady, StatusValid} {
|
|
t.Run(st, func(t *testing.T) {
|
|
testWaitOrderStatus(t, st)
|
|
})
|
|
}
|
|
}
|
|
|
|
func testWaitOrderStatus(t *testing.T, okStatus string) {
|
|
s := newACMEServer()
|
|
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/accounts/1"))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "valid"}`))
|
|
})
|
|
var count int
|
|
s.handle("/orders/1", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/orders/1"))
|
|
w.WriteHeader(http.StatusOK)
|
|
s := StatusPending
|
|
if count > 0 {
|
|
s = okStatus
|
|
}
|
|
fmt.Fprintf(w, `{"status": %q}`, s)
|
|
count++
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
|
|
order, err := cl.WaitOrder(context.Background(), s.url("/orders/1"))
|
|
if err != nil {
|
|
t.Fatalf("WaitOrder: %v", err)
|
|
}
|
|
if order.Status != okStatus {
|
|
t.Errorf("order.Status = %q; want %q", order.Status, okStatus)
|
|
}
|
|
}
|
|
|
|
func TestRFC_WaitOrderError(t *testing.T) {
|
|
s := newACMEServer()
|
|
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/accounts/1"))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "valid"}`))
|
|
})
|
|
var count int
|
|
s.handle("/orders/1", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/orders/1"))
|
|
w.WriteHeader(http.StatusOK)
|
|
s := StatusPending
|
|
if count > 0 {
|
|
s = StatusInvalid
|
|
}
|
|
fmt.Fprintf(w, `{"status": %q}`, s)
|
|
count++
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
|
|
_, err := cl.WaitOrder(context.Background(), s.url("/orders/1"))
|
|
if err == nil {
|
|
t.Fatal("WaitOrder returned nil error")
|
|
}
|
|
e, ok := err.(*OrderError)
|
|
if !ok {
|
|
t.Fatalf("err = %v (%T); want OrderError", err, err)
|
|
}
|
|
if e.OrderURL != s.url("/orders/1") {
|
|
t.Errorf("e.OrderURL = %q; want %q", e.OrderURL, s.url("/orders/1"))
|
|
}
|
|
if e.Status != StatusInvalid {
|
|
t.Errorf("e.Status = %q; want %q", e.Status, StatusInvalid)
|
|
}
|
|
}
|
|
|
|
func TestRFC_CreateOrderCert(t *testing.T) {
|
|
q := &x509.CertificateRequest{
|
|
Subject: pkix.Name{CommonName: "example.org"},
|
|
}
|
|
csr, err := x509.CreateCertificateRequest(rand.Reader, q, testKeyEC)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tmpl := &x509.Certificate{SerialNumber: big.NewInt(1)}
|
|
leaf, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &testKeyEC.PublicKey, testKeyEC)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s := newACMEServer()
|
|
s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/accounts/1"))
|
|
w.Write([]byte(`{"status": "valid"}`))
|
|
})
|
|
var count int
|
|
s.handle("/pleaseissue", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Location", s.url("/pleaseissue"))
|
|
st := StatusProcessing
|
|
if count > 0 {
|
|
st = StatusValid
|
|
}
|
|
fmt.Fprintf(w, `{"status":%q, "certificate":%q}`, st, s.url("/crt"))
|
|
count++
|
|
})
|
|
s.handle("/crt", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/pem-certificate-chain")
|
|
pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: leaf})
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
ctx := context.Background()
|
|
|
|
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
|
|
cert, curl, err := cl.CreateOrderCert(ctx, s.url("/pleaseissue"), csr, true)
|
|
if err != nil {
|
|
t.Fatalf("CreateOrderCert: %v", err)
|
|
}
|
|
if _, err := x509.ParseCertificate(cert[0]); err != nil {
|
|
t.Errorf("ParseCertificate: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(cert[0], leaf) {
|
|
t.Errorf("cert and leaf bytes don't match")
|
|
}
|
|
if u := s.url("/crt"); curl != u {
|
|
t.Errorf("curl = %q; want %q", curl, u)
|
|
}
|
|
}
|
|
|
|
func TestRFC_AlreadyRevokedCert(t *testing.T) {
|
|
s := newACMEServer()
|
|
s.handle("/acme/revoke-cert", func(w http.ResponseWriter, r *http.Request) {
|
|
s.error(w, &wireError{
|
|
Status: http.StatusBadRequest,
|
|
Type: "urn:ietf:params:acme:error:alreadyRevoked",
|
|
})
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
|
|
err := cl.RevokeCert(context.Background(), testKeyEC, []byte{0}, CRLReasonUnspecified)
|
|
if err != nil {
|
|
t.Fatalf("RevokeCert: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRFC_ListCertAlternates(t *testing.T) {
|
|
s := newACMEServer()
|
|
s.handle("/crt", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/pem-certificate-chain")
|
|
w.Header().Add("Link", `<https://example.com/crt/2>;rel="alternate"`)
|
|
w.Header().Add("Link", `<https://example.com/crt/3>; rel="alternate"`)
|
|
w.Header().Add("Link", `<https://example.com/acme>; rel="index"`)
|
|
})
|
|
s.handle("/crt2", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/pem-certificate-chain")
|
|
})
|
|
s.start()
|
|
defer s.close()
|
|
|
|
cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/")}
|
|
crts, err := cl.ListCertAlternates(context.Background(), s.url("/crt"))
|
|
if err != nil {
|
|
t.Fatalf("ListCertAlternates: %v", err)
|
|
}
|
|
want := []string{"https://example.com/crt/2", "https://example.com/crt/3"}
|
|
if !reflect.DeepEqual(crts, want) {
|
|
t.Errorf("ListCertAlternates(/crt): %v; want %v", crts, want)
|
|
}
|
|
crts, err = cl.ListCertAlternates(context.Background(), s.url("/crt2"))
|
|
if err != nil {
|
|
t.Fatalf("ListCertAlternates: %v", err)
|
|
}
|
|
if crts != nil {
|
|
t.Errorf("ListCertAlternates(/crt2): %v; want nil", crts)
|
|
}
|
|
}
|