417 lines
12 KiB
Go
417 lines
12 KiB
Go
// Copyright 2018 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 acmetest provides types for testing acme and autocert packages.
|
|
//
|
|
// TODO: Consider moving this to x/crypto/acme/internal/acmetest for acme tests as well.
|
|
package acmetest
|
|
|
|
import (
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// CAServer is a simple test server which implements ACME spec bits needed for testing.
|
|
type CAServer struct {
|
|
URL string // server URL after it has been started
|
|
Roots *x509.CertPool // CA root certificates; initialized in NewCAServer
|
|
|
|
rootKey crypto.Signer
|
|
rootCert []byte // DER encoding
|
|
rootTemplate *x509.Certificate
|
|
|
|
server *httptest.Server
|
|
challengeTypes []string // supported challenge types
|
|
domainsWhitelist []string // only these domains are valid for issuing, unless empty
|
|
|
|
mu sync.Mutex
|
|
certCount int // number of issued certs
|
|
domainAddr map[string]string // domain name to addr:port resolution
|
|
authorizations map[string]*authorization // keyed by domain name
|
|
errors []error // encountered client errors
|
|
}
|
|
|
|
// NewCAServer creates a new ACME test server and starts serving requests.
|
|
// The returned CAServer issues certs signed with the CA roots
|
|
// available in the Roots field.
|
|
//
|
|
// The challengeTypes argument defines the supported ACME challenge types
|
|
// sent to a client in a response for a domain authorization.
|
|
// If domainsWhitelist is non-empty, the certs will be issued only for the specified
|
|
// list of domains. Otherwise, any domain name is allowed.
|
|
func NewCAServer(challengeTypes []string, domainsWhitelist []string) *CAServer {
|
|
var whitelist []string
|
|
for _, name := range domainsWhitelist {
|
|
whitelist = append(whitelist, name)
|
|
}
|
|
sort.Strings(whitelist)
|
|
ca := &CAServer{
|
|
challengeTypes: challengeTypes,
|
|
domainsWhitelist: whitelist,
|
|
domainAddr: make(map[string]string),
|
|
authorizations: make(map[string]*authorization),
|
|
}
|
|
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("ecdsa.GenerateKey: %v", err))
|
|
}
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{
|
|
Organization: []string{"Test Acme Co"},
|
|
CommonName: "Root CA",
|
|
},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
|
KeyUsage: x509.KeyUsageCertSign,
|
|
BasicConstraintsValid: true,
|
|
IsCA: true,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("x509.CreateCertificate: %v", err))
|
|
}
|
|
cert, err := x509.ParseCertificate(der)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("x509.ParseCertificate: %v", err))
|
|
}
|
|
ca.Roots = x509.NewCertPool()
|
|
ca.Roots.AddCert(cert)
|
|
ca.rootKey = key
|
|
ca.rootCert = der
|
|
ca.rootTemplate = tmpl
|
|
|
|
ca.server = httptest.NewServer(http.HandlerFunc(ca.handle))
|
|
ca.URL = ca.server.URL
|
|
return ca
|
|
}
|
|
|
|
// Close shuts down the server and blocks until all outstanding
|
|
// requests on this server have completed.
|
|
func (ca *CAServer) Close() {
|
|
ca.server.Close()
|
|
}
|
|
|
|
// Errors returns all client errors.
|
|
func (ca *CAServer) Errors() []error {
|
|
ca.mu.Lock()
|
|
defer ca.mu.Unlock()
|
|
return ca.errors
|
|
}
|
|
|
|
// Resolve adds a domain to address resolution for the ca to dial to
|
|
// when validating challenges for the domain authorization.
|
|
func (ca *CAServer) Resolve(domain, addr string) {
|
|
ca.mu.Lock()
|
|
defer ca.mu.Unlock()
|
|
ca.domainAddr[domain] = addr
|
|
}
|
|
|
|
type discovery struct {
|
|
NewReg string `json:"new-reg"`
|
|
NewAuthz string `json:"new-authz"`
|
|
NewCert string `json:"new-cert"`
|
|
}
|
|
|
|
type challenge struct {
|
|
URI string `json:"uri"`
|
|
Type string `json:"type"`
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
type authorization struct {
|
|
Status string `json:"status"`
|
|
Challenges []challenge `json:"challenges"`
|
|
|
|
id int
|
|
domain string
|
|
}
|
|
|
|
func (ca *CAServer) handle(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Replay-Nonce", "nonce")
|
|
if r.Method == "HEAD" {
|
|
// a nonce request
|
|
return
|
|
}
|
|
|
|
// TODO: Verify nonce header for all POST requests.
|
|
|
|
switch {
|
|
default:
|
|
err := fmt.Errorf("unrecognized r.URL.Path: %s", r.URL.Path)
|
|
ca.addError(err)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
// Discovery request.
|
|
case r.URL.Path == "/":
|
|
resp := &discovery{
|
|
NewReg: ca.serverURL("/new-reg"),
|
|
NewAuthz: ca.serverURL("/new-authz"),
|
|
NewCert: ca.serverURL("/new-cert"),
|
|
}
|
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
|
panic(fmt.Sprintf("discovery response: %v", err))
|
|
}
|
|
|
|
// Client key registration request.
|
|
case r.URL.Path == "/new-reg":
|
|
// TODO: Check the user account key against a ca.accountKeys?
|
|
w.Write([]byte("{}"))
|
|
|
|
// Domain authorization request.
|
|
case r.URL.Path == "/new-authz":
|
|
var req struct {
|
|
Identifier struct{ Value string }
|
|
}
|
|
if err := decodePayload(&req, r.Body); err != nil {
|
|
ca.addError(err)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
ca.mu.Lock()
|
|
defer ca.mu.Unlock()
|
|
authz, ok := ca.authorizations[req.Identifier.Value]
|
|
if !ok {
|
|
authz = &authorization{
|
|
domain: req.Identifier.Value,
|
|
Status: "pending",
|
|
}
|
|
for _, typ := range ca.challengeTypes {
|
|
authz.Challenges = append(authz.Challenges, challenge{
|
|
Type: typ,
|
|
URI: ca.serverURL("/challenge/%s/%s", typ, authz.domain),
|
|
Token: challengeToken(authz.domain, typ),
|
|
})
|
|
}
|
|
ca.authorizations[authz.domain] = authz
|
|
}
|
|
w.Header().Set("Location", ca.serverURL("/authz/%s", authz.domain))
|
|
w.WriteHeader(http.StatusCreated)
|
|
if err := json.NewEncoder(w).Encode(authz); err != nil {
|
|
panic(fmt.Sprintf("new authz response: %v", err))
|
|
}
|
|
|
|
// Accept tls-alpn-01 challenge type requests.
|
|
// TODO: Add http-01 and dns-01 handlers.
|
|
case strings.HasPrefix(r.URL.Path, "/challenge/tls-alpn-01/"):
|
|
domain := strings.TrimPrefix(r.URL.Path, "/challenge/tls-alpn-01/")
|
|
ca.mu.Lock()
|
|
defer ca.mu.Unlock()
|
|
if _, ok := ca.authorizations[domain]; !ok {
|
|
err := fmt.Errorf("challenge accept: no authz for %q", domain)
|
|
ca.addError(err)
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
go func(domain string) {
|
|
err := ca.verifyALPNChallenge(domain)
|
|
ca.mu.Lock()
|
|
defer ca.mu.Unlock()
|
|
authz := ca.authorizations[domain]
|
|
if err != nil {
|
|
authz.Status = "invalid"
|
|
return
|
|
}
|
|
authz.Status = "valid"
|
|
|
|
}(domain)
|
|
w.Write([]byte("{}"))
|
|
|
|
// Get authorization status requests.
|
|
case strings.HasPrefix(r.URL.Path, "/authz/"):
|
|
domain := strings.TrimPrefix(r.URL.Path, "/authz/")
|
|
ca.mu.Lock()
|
|
defer ca.mu.Unlock()
|
|
authz, ok := ca.authorizations[domain]
|
|
if !ok {
|
|
http.Error(w, fmt.Sprintf("no authz for %q", domain), http.StatusNotFound)
|
|
return
|
|
}
|
|
if err := json.NewEncoder(w).Encode(authz); err != nil {
|
|
panic(fmt.Sprintf("get authz for %q response: %v", domain, err))
|
|
}
|
|
|
|
// Cert issuance request.
|
|
case r.URL.Path == "/new-cert":
|
|
var req struct {
|
|
CSR string `json:"csr"`
|
|
}
|
|
decodePayload(&req, r.Body)
|
|
b, _ := base64.RawURLEncoding.DecodeString(req.CSR)
|
|
csr, err := x509.ParseCertificateRequest(b)
|
|
if err != nil {
|
|
ca.addError(err)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
names := unique(append(csr.DNSNames, csr.Subject.CommonName))
|
|
if err := ca.matchWhitelist(names); err != nil {
|
|
ca.addError(err)
|
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if err := ca.authorized(names); err != nil {
|
|
ca.addError(err)
|
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
|
return
|
|
}
|
|
der, err := ca.leafCert(csr)
|
|
if err != nil {
|
|
err = fmt.Errorf("new-cert response: ca.leafCert: %v", err)
|
|
ca.addError(err)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
}
|
|
w.Header().Set("Link", fmt.Sprintf("<%s>; rel=up", ca.serverURL("/ca-cert")))
|
|
w.WriteHeader(http.StatusCreated)
|
|
w.Write(der)
|
|
|
|
// CA chain cert request.
|
|
case r.URL.Path == "/ca-cert":
|
|
w.Write(ca.rootCert)
|
|
}
|
|
}
|
|
|
|
func (ca *CAServer) addError(err error) {
|
|
ca.mu.Lock()
|
|
defer ca.mu.Unlock()
|
|
ca.errors = append(ca.errors, err)
|
|
}
|
|
|
|
func (ca *CAServer) serverURL(format string, arg ...interface{}) string {
|
|
return ca.server.URL + fmt.Sprintf(format, arg...)
|
|
}
|
|
|
|
func (ca *CAServer) matchWhitelist(dnsNames []string) error {
|
|
if len(ca.domainsWhitelist) == 0 {
|
|
return nil
|
|
}
|
|
var nomatch []string
|
|
for _, name := range dnsNames {
|
|
i := sort.SearchStrings(ca.domainsWhitelist, name)
|
|
if i == len(ca.domainsWhitelist) || ca.domainsWhitelist[i] != name {
|
|
nomatch = append(nomatch, name)
|
|
}
|
|
}
|
|
if len(nomatch) > 0 {
|
|
return fmt.Errorf("matchWhitelist: some domains don't match: %q", nomatch)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ca *CAServer) authorized(dnsNames []string) error {
|
|
ca.mu.Lock()
|
|
defer ca.mu.Unlock()
|
|
var noauthz []string
|
|
for _, name := range dnsNames {
|
|
authz, ok := ca.authorizations[name]
|
|
if !ok || authz.Status != "valid" {
|
|
noauthz = append(noauthz, name)
|
|
}
|
|
}
|
|
if len(noauthz) > 0 {
|
|
return fmt.Errorf("CAServer: no authz for %q", noauthz)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ca *CAServer) leafCert(csr *x509.CertificateRequest) (der []byte, err error) {
|
|
ca.mu.Lock()
|
|
defer ca.mu.Unlock()
|
|
ca.certCount++ // next leaf cert serial number
|
|
leaf := &x509.Certificate{
|
|
SerialNumber: big.NewInt(int64(ca.certCount)),
|
|
Subject: pkix.Name{Organization: []string{"Test Acme Co"}},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
DNSNames: csr.DNSNames,
|
|
BasicConstraintsValid: true,
|
|
}
|
|
if len(csr.DNSNames) == 0 {
|
|
leaf.DNSNames = []string{csr.Subject.CommonName}
|
|
}
|
|
return x509.CreateCertificate(rand.Reader, leaf, ca.rootTemplate, csr.PublicKey, ca.rootKey)
|
|
}
|
|
|
|
func (ca *CAServer) addr(domain string) (string, error) {
|
|
ca.mu.Lock()
|
|
defer ca.mu.Unlock()
|
|
addr, ok := ca.domainAddr[domain]
|
|
if !ok {
|
|
return "", fmt.Errorf("CAServer: no addr resolution for %q", domain)
|
|
}
|
|
return addr, nil
|
|
}
|
|
|
|
func (ca *CAServer) verifyALPNChallenge(domain string) error {
|
|
const acmeALPNProto = "acme-tls/1"
|
|
|
|
addr, err := ca.addr(domain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
conn, err := tls.Dial("tcp", addr, &tls.Config{
|
|
ServerName: domain,
|
|
InsecureSkipVerify: true,
|
|
NextProtos: []string{acmeALPNProto},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if v := conn.ConnectionState().NegotiatedProtocol; v != acmeALPNProto {
|
|
return fmt.Errorf("CAServer: verifyALPNChallenge: negotiated proto is %q; want %q", v, acmeALPNProto)
|
|
}
|
|
if n := len(conn.ConnectionState().PeerCertificates); n != 1 {
|
|
return fmt.Errorf("len(PeerCertificates) = %d; want 1", n)
|
|
}
|
|
// TODO: verify conn.ConnectionState().PeerCertificates[0]
|
|
return nil
|
|
}
|
|
|
|
func decodePayload(v interface{}, r io.Reader) error {
|
|
var req struct{ Payload string }
|
|
if err := json.NewDecoder(r).Decode(&req); err != nil {
|
|
return err
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(req.Payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(payload, v)
|
|
}
|
|
|
|
func challengeToken(domain, challType string) string {
|
|
return fmt.Sprintf("token-%s-%s", domain, challType)
|
|
}
|
|
|
|
func unique(a []string) []string {
|
|
seen := make(map[string]bool)
|
|
var res []string
|
|
for _, s := range a {
|
|
if s != "" && !seen[s] {
|
|
seen[s] = true
|
|
res = append(res, s)
|
|
}
|
|
}
|
|
return res
|
|
}
|