The TLSSNI01ChallengeCert and TLSSNI02ChallengeCert functions have been marked deprecated since 2022. The package documentation indicates pre-RFC 8555 functionality is retained for compilation success, but will return errors. This commit makes these two deprecated functions match that description. No meaningful support for these draft standard challenge types exists in the ACME ecosystem, and they are insecure for use in shared hosting environments. Change-Id: I1c17980a0630092c70eb971b3453a0f115834be0 Reviewed-on: https://go-review.googlesource.com/c/crypto/+/676835 Auto-Submit: Daniel McCarney <daniel@binaryparadox.net> LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Roland Shoemaker <roland@golang.org> Reviewed-by: Michael Knyszek <mknyszek@google.com>
832 lines
23 KiB
Go
832 lines
23 KiB
Go
// Copyright 2015 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/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// newTestClient creates a client with a non-nil Directory so that it skips
|
|
// the discovery which is otherwise done on the first call of almost every
|
|
// exported method.
|
|
func newTestClient() *Client {
|
|
return &Client{
|
|
Key: testKeyEC,
|
|
dir: &Directory{}, // skip discovery
|
|
}
|
|
}
|
|
|
|
// Decodes a JWS-encoded request and unmarshals the decoded JSON into a provided
|
|
// interface.
|
|
func decodeJWSRequest(t *testing.T, v interface{}, r io.Reader) {
|
|
// Decode request
|
|
var req struct{ Payload string }
|
|
if err := json.NewDecoder(r).Decode(&req); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(req.Payload)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = json.Unmarshal(payload, v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
type jwsHead struct {
|
|
Alg string
|
|
Nonce string
|
|
URL string `json:"url"`
|
|
KID string `json:"kid"`
|
|
JWK map[string]string `json:"jwk"`
|
|
}
|
|
|
|
func decodeJWSHead(r io.Reader) (*jwsHead, error) {
|
|
var req struct{ Protected string }
|
|
if err := json.NewDecoder(r).Decode(&req); err != nil {
|
|
return nil, err
|
|
}
|
|
b, err := base64.RawURLEncoding.DecodeString(req.Protected)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var head jwsHead
|
|
if err := json.Unmarshal(b, &head); err != nil {
|
|
return nil, err
|
|
}
|
|
return &head, nil
|
|
}
|
|
|
|
func TestRegisterWithoutKey(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "HEAD" {
|
|
w.Header().Set("Replay-Nonce", "test-nonce")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
fmt.Fprint(w, `{}`)
|
|
}))
|
|
defer ts.Close()
|
|
// First verify that using a complete client results in success.
|
|
c := Client{
|
|
Key: testKeyEC,
|
|
DirectoryURL: ts.URL,
|
|
dir: &Directory{RegURL: ts.URL},
|
|
}
|
|
if _, err := c.Register(context.Background(), &Account{}, AcceptTOS); err != nil {
|
|
t.Fatalf("c.Register() = %v; want success with a complete test client", err)
|
|
}
|
|
c.Key = nil
|
|
if _, err := c.Register(context.Background(), &Account{}, AcceptTOS); err == nil {
|
|
t.Error("c.Register() from client without key succeeded, wanted error")
|
|
}
|
|
}
|
|
|
|
func TestAuthorize(t *testing.T) {
|
|
tt := []struct{ typ, value string }{
|
|
{"dns", "example.com"},
|
|
{"ip", "1.2.3.4"},
|
|
}
|
|
for _, test := range tt {
|
|
t.Run(test.typ, func(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "HEAD" {
|
|
w.Header().Set("Replay-Nonce", "test-nonce")
|
|
return
|
|
}
|
|
if r.Method != "POST" {
|
|
t.Errorf("r.Method = %q; want POST", r.Method)
|
|
}
|
|
|
|
var j struct {
|
|
Resource string
|
|
Identifier struct {
|
|
Type string
|
|
Value string
|
|
}
|
|
}
|
|
decodeJWSRequest(t, &j, r.Body)
|
|
|
|
// Test request
|
|
if j.Resource != "new-authz" {
|
|
t.Errorf("j.Resource = %q; want new-authz", j.Resource)
|
|
}
|
|
if j.Identifier.Type != test.typ {
|
|
t.Errorf("j.Identifier.Type = %q; want %q", j.Identifier.Type, test.typ)
|
|
}
|
|
if j.Identifier.Value != test.value {
|
|
t.Errorf("j.Identifier.Value = %q; want %q", j.Identifier.Value, test.value)
|
|
}
|
|
|
|
w.Header().Set("Location", "https://ca.tld/acme/auth/1")
|
|
w.WriteHeader(http.StatusCreated)
|
|
fmt.Fprintf(w, `{
|
|
"identifier": {"type":%q,"value":%q},
|
|
"status":"pending",
|
|
"challenges":[
|
|
{
|
|
"type":"http-01",
|
|
"status":"pending",
|
|
"uri":"https://ca.tld/acme/challenge/publickey/id1",
|
|
"token":"token1"
|
|
},
|
|
{
|
|
"type":"tls-sni-01",
|
|
"status":"pending",
|
|
"uri":"https://ca.tld/acme/challenge/publickey/id2",
|
|
"token":"token2"
|
|
}
|
|
],
|
|
"combinations":[[0],[1]]
|
|
}`, test.typ, test.value)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
var (
|
|
auth *Authorization
|
|
err error
|
|
)
|
|
cl := Client{
|
|
Key: testKeyEC,
|
|
DirectoryURL: ts.URL,
|
|
dir: &Directory{AuthzURL: ts.URL},
|
|
}
|
|
switch test.typ {
|
|
case "dns":
|
|
auth, err = cl.Authorize(context.Background(), test.value)
|
|
case "ip":
|
|
auth, err = cl.AuthorizeIP(context.Background(), test.value)
|
|
default:
|
|
t.Fatalf("unknown identifier type: %q", test.typ)
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if auth.URI != "https://ca.tld/acme/auth/1" {
|
|
t.Errorf("URI = %q; want https://ca.tld/acme/auth/1", auth.URI)
|
|
}
|
|
if auth.Status != "pending" {
|
|
t.Errorf("Status = %q; want pending", auth.Status)
|
|
}
|
|
if auth.Identifier.Type != test.typ {
|
|
t.Errorf("Identifier.Type = %q; want %q", auth.Identifier.Type, test.typ)
|
|
}
|
|
if auth.Identifier.Value != test.value {
|
|
t.Errorf("Identifier.Value = %q; want %q", auth.Identifier.Value, test.value)
|
|
}
|
|
|
|
if n := len(auth.Challenges); n != 2 {
|
|
t.Fatalf("len(auth.Challenges) = %d; want 2", n)
|
|
}
|
|
|
|
c := auth.Challenges[0]
|
|
if c.Type != "http-01" {
|
|
t.Errorf("c.Type = %q; want http-01", c.Type)
|
|
}
|
|
if c.URI != "https://ca.tld/acme/challenge/publickey/id1" {
|
|
t.Errorf("c.URI = %q; want https://ca.tld/acme/challenge/publickey/id1", c.URI)
|
|
}
|
|
if c.Token != "token1" {
|
|
t.Errorf("c.Token = %q; want token1", c.Token)
|
|
}
|
|
|
|
c = auth.Challenges[1]
|
|
if c.Type != "tls-sni-01" {
|
|
t.Errorf("c.Type = %q; want tls-sni-01", c.Type)
|
|
}
|
|
if c.URI != "https://ca.tld/acme/challenge/publickey/id2" {
|
|
t.Errorf("c.URI = %q; want https://ca.tld/acme/challenge/publickey/id2", c.URI)
|
|
}
|
|
if c.Token != "token2" {
|
|
t.Errorf("c.Token = %q; want token2", c.Token)
|
|
}
|
|
|
|
combs := [][]int{{0}, {1}}
|
|
if !reflect.DeepEqual(auth.Combinations, combs) {
|
|
t.Errorf("auth.Combinations: %+v\nwant: %+v\n", auth.Combinations, combs)
|
|
}
|
|
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeValid(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "HEAD" {
|
|
w.Header().Set("Replay-Nonce", "nonce")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
w.Write([]byte(`{"status":"valid"}`))
|
|
}))
|
|
defer ts.Close()
|
|
client := Client{
|
|
Key: testKey,
|
|
DirectoryURL: ts.URL,
|
|
dir: &Directory{AuthzURL: ts.URL},
|
|
}
|
|
_, err := client.Authorize(context.Background(), "example.com")
|
|
if err != nil {
|
|
t.Errorf("err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeUnsupported(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"
|
|
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("Replay-Nonce", "nonce")
|
|
if r.Method == http.MethodHead {
|
|
return
|
|
}
|
|
switch r.URL.Path {
|
|
case "/": // Directory
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{
|
|
"newNonce": %q,
|
|
"newAccount": %q,
|
|
"newOrder": %q,
|
|
"revokeCert": %q,
|
|
"keyChange": %q,
|
|
"meta": {
|
|
"termsOfService": %q,
|
|
"website": %q,
|
|
"caaIdentities": [%q],
|
|
"externalAccountRequired": true
|
|
}
|
|
}`, nonce, reg, order, revoke, keychange, metaTerms, metaWebsite, metaCAA)
|
|
w.WriteHeader(http.StatusOK)
|
|
case "/acme/new-authz":
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}
|
|
}))
|
|
defer ts.Close()
|
|
client := &Client{Key: testKey, DirectoryURL: ts.URL}
|
|
dir, err := client.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if dir.AuthzURL != "" {
|
|
t.Fatalf("expected AuthzURL to be empty, got %q", dir.AuthzURL)
|
|
}
|
|
if _, err := client.Authorize(context.Background(), "example.com"); !errors.Is(err, errPreAuthorizationNotSupported) {
|
|
t.Errorf("expected err to indicate pre-authorization is unsupported, got %+v", err)
|
|
}
|
|
}
|
|
|
|
func TestWaitAuthorization(t *testing.T) {
|
|
t.Run("wait loop", func(t *testing.T) {
|
|
var count int
|
|
authz, err := runWaitAuthorization(context.Background(), t, func(w http.ResponseWriter, r *http.Request) {
|
|
count++
|
|
w.Header().Set("Retry-After", "0")
|
|
if count > 1 {
|
|
fmt.Fprintf(w, `{"status":"valid"}`)
|
|
return
|
|
}
|
|
fmt.Fprintf(w, `{"status":"pending"}`)
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("non-nil error: %v", err)
|
|
}
|
|
if authz == nil {
|
|
t.Fatal("authz is nil")
|
|
}
|
|
})
|
|
t.Run("invalid status", func(t *testing.T) {
|
|
_, err := runWaitAuthorization(context.Background(), t, func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprintf(w, `{"status":"invalid"}`)
|
|
})
|
|
if _, ok := err.(*AuthorizationError); !ok {
|
|
t.Errorf("err is %v (%T); want non-nil *AuthorizationError", err, err)
|
|
}
|
|
})
|
|
t.Run("invalid status with error returns the authorization error", func(t *testing.T) {
|
|
_, err := runWaitAuthorization(context.Background(), t, func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprintf(w, `{
|
|
"type": "dns-01",
|
|
"status": "invalid",
|
|
"error": {
|
|
"type": "urn:ietf:params:acme:error:caa",
|
|
"detail": "CAA record for <domain> prevents issuance",
|
|
"status": 403
|
|
},
|
|
"url": "https://acme-v02.api.letsencrypt.org/acme/chall-v3/xxx/xxx",
|
|
"token": "xxx",
|
|
"validationRecord": [
|
|
{
|
|
"hostname": "<domain>"
|
|
}
|
|
]
|
|
}`)
|
|
})
|
|
|
|
want := &AuthorizationError{
|
|
Errors: []error{
|
|
(&wireError{
|
|
Status: 403,
|
|
Type: "urn:ietf:params:acme:error:caa",
|
|
Detail: "CAA record for <domain> prevents issuance",
|
|
}).error(nil),
|
|
},
|
|
}
|
|
|
|
_, ok := err.(*AuthorizationError)
|
|
if !ok {
|
|
t.Errorf("err is %T; want non-nil *AuthorizationError", err)
|
|
}
|
|
|
|
if err.Error() != want.Error() {
|
|
t.Errorf("err is %v; want %v", err, want)
|
|
}
|
|
})
|
|
t.Run("non-retriable error", func(t *testing.T) {
|
|
const code = http.StatusBadRequest
|
|
_, err := runWaitAuthorization(context.Background(), t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(code)
|
|
})
|
|
res, ok := err.(*Error)
|
|
if !ok {
|
|
t.Fatalf("err is %v (%T); want a non-nil *Error", err, err)
|
|
}
|
|
if res.StatusCode != code {
|
|
t.Errorf("res.StatusCode = %d; want %d", res.StatusCode, code)
|
|
}
|
|
})
|
|
for _, code := range []int{http.StatusTooManyRequests, http.StatusInternalServerError} {
|
|
t.Run(fmt.Sprintf("retriable %d error", code), func(t *testing.T) {
|
|
var count int
|
|
authz, err := runWaitAuthorization(context.Background(), t, func(w http.ResponseWriter, r *http.Request) {
|
|
count++
|
|
w.Header().Set("Retry-After", "0")
|
|
if count > 1 {
|
|
fmt.Fprintf(w, `{"status":"valid"}`)
|
|
return
|
|
}
|
|
w.WriteHeader(code)
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("non-nil error: %v", err)
|
|
}
|
|
if authz == nil {
|
|
t.Fatal("authz is nil")
|
|
}
|
|
})
|
|
}
|
|
t.Run("context cancel", func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
_, err := runWaitAuthorization(ctx, t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Retry-After", "60")
|
|
fmt.Fprintf(w, `{"status":"pending"}`)
|
|
time.AfterFunc(1*time.Millisecond, cancel)
|
|
})
|
|
if err == nil {
|
|
t.Error("err is nil")
|
|
}
|
|
})
|
|
}
|
|
|
|
func runWaitAuthorization(ctx context.Context, t *testing.T, h http.HandlerFunc) (*Authorization, error) {
|
|
t.Helper()
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Replay-Nonce", fmt.Sprintf("bad-test-nonce-%v", time.Now().UnixNano()))
|
|
h(w, r)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client := &Client{
|
|
Key: testKey,
|
|
DirectoryURL: ts.URL,
|
|
dir: &Directory{},
|
|
KID: "some-key-id", // set to avoid lookup attempt
|
|
}
|
|
return client.WaitAuthorization(ctx, ts.URL)
|
|
}
|
|
|
|
func TestRevokeAuthorization(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "HEAD" {
|
|
w.Header().Set("Replay-Nonce", "nonce")
|
|
return
|
|
}
|
|
switch r.URL.Path {
|
|
case "/1":
|
|
var req struct {
|
|
Resource string
|
|
Status string
|
|
Delete bool
|
|
}
|
|
decodeJWSRequest(t, &req, r.Body)
|
|
if req.Resource != "authz" {
|
|
t.Errorf("req.Resource = %q; want authz", req.Resource)
|
|
}
|
|
if req.Status != "deactivated" {
|
|
t.Errorf("req.Status = %q; want deactivated", req.Status)
|
|
}
|
|
if !req.Delete {
|
|
t.Errorf("req.Delete is false")
|
|
}
|
|
case "/2":
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}
|
|
}))
|
|
defer ts.Close()
|
|
client := &Client{
|
|
Key: testKey,
|
|
DirectoryURL: ts.URL, // don't dial outside of localhost
|
|
dir: &Directory{}, // don't do discovery
|
|
}
|
|
ctx := context.Background()
|
|
if err := client.RevokeAuthorization(ctx, ts.URL+"/1"); err != nil {
|
|
t.Errorf("err = %v", err)
|
|
}
|
|
if client.RevokeAuthorization(ctx, ts.URL+"/2") == nil {
|
|
t.Error("nil error")
|
|
}
|
|
}
|
|
|
|
func TestFetchCertCancel(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
w.Header().Set("Retry-After", "0")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}))
|
|
defer ts.Close()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
var err error
|
|
go func() {
|
|
cl := newTestClient()
|
|
_, err = cl.FetchCert(ctx, ts.URL, false)
|
|
close(done)
|
|
}()
|
|
cancel()
|
|
<-done
|
|
if err != context.Canceled {
|
|
t.Errorf("err = %v; want %v", err, context.Canceled)
|
|
}
|
|
}
|
|
|
|
func TestFetchCertDepth(t *testing.T) {
|
|
var count byte
|
|
var ts *httptest.Server
|
|
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
count++
|
|
if count > maxChainLen+1 {
|
|
t.Errorf("count = %d; want at most %d", count, maxChainLen+1)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
w.Header().Set("Link", fmt.Sprintf("<%s>;rel=up", ts.URL))
|
|
w.Write([]byte{count})
|
|
}))
|
|
defer ts.Close()
|
|
cl := newTestClient()
|
|
_, err := cl.FetchCert(context.Background(), ts.URL, true)
|
|
if err == nil {
|
|
t.Errorf("err is nil")
|
|
}
|
|
}
|
|
|
|
func TestFetchCertBreadth(t *testing.T) {
|
|
var ts *httptest.Server
|
|
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
for i := 0; i < maxChainLen+1; i++ {
|
|
w.Header().Add("Link", fmt.Sprintf("<%s>;rel=up", ts.URL))
|
|
}
|
|
w.Write([]byte{1})
|
|
}))
|
|
defer ts.Close()
|
|
cl := newTestClient()
|
|
_, err := cl.FetchCert(context.Background(), ts.URL, true)
|
|
if err == nil {
|
|
t.Errorf("err is nil")
|
|
}
|
|
}
|
|
|
|
func TestFetchCertSize(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
b := bytes.Repeat([]byte{1}, maxCertSize+1)
|
|
w.Write(b)
|
|
}))
|
|
defer ts.Close()
|
|
cl := newTestClient()
|
|
_, err := cl.FetchCert(context.Background(), ts.URL, false)
|
|
if err == nil {
|
|
t.Errorf("err is nil")
|
|
}
|
|
}
|
|
|
|
func TestNonce_add(t *testing.T) {
|
|
var c Client
|
|
c.addNonce(http.Header{"Replay-Nonce": {"nonce"}})
|
|
c.addNonce(http.Header{"Replay-Nonce": {}})
|
|
c.addNonce(http.Header{"Replay-Nonce": {"nonce"}})
|
|
|
|
nonces := map[string]struct{}{"nonce": {}}
|
|
if !reflect.DeepEqual(c.nonces, nonces) {
|
|
t.Errorf("c.nonces = %q; want %q", c.nonces, nonces)
|
|
}
|
|
}
|
|
|
|
func TestNonce_addMax(t *testing.T) {
|
|
c := &Client{nonces: make(map[string]struct{})}
|
|
for i := 0; i < maxNonces; i++ {
|
|
c.nonces[fmt.Sprintf("%d", i)] = struct{}{}
|
|
}
|
|
c.addNonce(http.Header{"Replay-Nonce": {"nonce"}})
|
|
if n := len(c.nonces); n != maxNonces {
|
|
t.Errorf("len(c.nonces) = %d; want %d", n, maxNonces)
|
|
}
|
|
}
|
|
|
|
func TestNonce_fetch(t *testing.T) {
|
|
tests := []struct {
|
|
code int
|
|
nonce string
|
|
}{
|
|
{http.StatusOK, "nonce1"},
|
|
{http.StatusBadRequest, "nonce2"},
|
|
{http.StatusOK, ""},
|
|
}
|
|
var i int
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "HEAD" {
|
|
t.Errorf("%d: r.Method = %q; want HEAD", i, r.Method)
|
|
}
|
|
w.Header().Set("Replay-Nonce", tests[i].nonce)
|
|
w.WriteHeader(tests[i].code)
|
|
}))
|
|
defer ts.Close()
|
|
for ; i < len(tests); i++ {
|
|
test := tests[i]
|
|
c := newTestClient()
|
|
n, err := c.fetchNonce(context.Background(), ts.URL)
|
|
if n != test.nonce {
|
|
t.Errorf("%d: n=%q; want %q", i, n, test.nonce)
|
|
}
|
|
switch {
|
|
case err == nil && test.nonce == "":
|
|
t.Errorf("%d: n=%q, err=%v; want non-nil error", i, n, err)
|
|
case err != nil && test.nonce != "":
|
|
t.Errorf("%d: n=%q, err=%v; want %q", i, n, err, test.nonce)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNonce_fetchError(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
}))
|
|
defer ts.Close()
|
|
c := newTestClient()
|
|
_, err := c.fetchNonce(context.Background(), ts.URL)
|
|
e, ok := err.(*Error)
|
|
if !ok {
|
|
t.Fatalf("err is %T; want *Error", err)
|
|
}
|
|
if e.StatusCode != http.StatusTooManyRequests {
|
|
t.Errorf("e.StatusCode = %d; want %d", e.StatusCode, http.StatusTooManyRequests)
|
|
}
|
|
}
|
|
|
|
func TestNonce_popWhenEmpty(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "HEAD" {
|
|
t.Errorf("r.Method = %q; want HEAD", r.Method)
|
|
}
|
|
switch r.URL.Path {
|
|
case "/dir-with-nonce":
|
|
w.Header().Set("Replay-Nonce", "dirnonce")
|
|
case "/new-nonce":
|
|
w.Header().Set("Replay-Nonce", "newnonce")
|
|
case "/dir-no-nonce", "/empty":
|
|
// No nonce in the header.
|
|
default:
|
|
t.Errorf("Unknown URL: %s", r.URL)
|
|
}
|
|
}))
|
|
defer ts.Close()
|
|
ctx := context.Background()
|
|
|
|
tt := []struct {
|
|
dirURL, popURL, nonce string
|
|
wantOK bool
|
|
}{
|
|
{ts.URL + "/dir-with-nonce", ts.URL + "/new-nonce", "dirnonce", true},
|
|
{ts.URL + "/dir-no-nonce", ts.URL + "/new-nonce", "newnonce", true},
|
|
{ts.URL + "/dir-no-nonce", ts.URL + "/empty", "", false},
|
|
}
|
|
for _, test := range tt {
|
|
t.Run(fmt.Sprintf("nonce:%s wantOK:%v", test.nonce, test.wantOK), func(t *testing.T) {
|
|
c := Client{DirectoryURL: test.dirURL}
|
|
v, err := c.popNonce(ctx, test.popURL)
|
|
if !test.wantOK {
|
|
if err == nil {
|
|
t.Fatalf("c.popNonce(%q) returned nil error", test.popURL)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("c.popNonce(%q): %v", test.popURL, err)
|
|
}
|
|
if v != test.nonce {
|
|
t.Errorf("c.popNonce(%q) = %q; want %q", test.popURL, v, test.nonce)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLinkHeader(t *testing.T) {
|
|
h := http.Header{"Link": {
|
|
`<https://example.com/acme/new-authz>;rel="next"`,
|
|
`<https://example.com/acme/recover-reg>; rel=recover`,
|
|
`<https://example.com/acme/terms>; foo=bar; rel="terms-of-service"`,
|
|
`<dup>;rel="next"`,
|
|
}}
|
|
tests := []struct {
|
|
rel string
|
|
out []string
|
|
}{
|
|
{"next", []string{"https://example.com/acme/new-authz", "dup"}},
|
|
{"recover", []string{"https://example.com/acme/recover-reg"}},
|
|
{"terms-of-service", []string{"https://example.com/acme/terms"}},
|
|
{"empty", nil},
|
|
}
|
|
for i, test := range tests {
|
|
if v := linkHeader(h, test.rel); !reflect.DeepEqual(v, test.out) {
|
|
t.Errorf("%d: linkHeader(%q): %v; want %v", i, test.rel, v, test.out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTLSALPN01ChallengeCert(t *testing.T) {
|
|
const (
|
|
token = "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA"
|
|
keyAuth = "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA." + testKeyECThumbprint
|
|
// echo -n <token.testKeyECThumbprint> | shasum -a 256
|
|
h = "0420dbbd5eefe7b4d06eb9d1d9f5acb4c7cda27d320e4b30332f0b6cb441734ad7b0"
|
|
domain = "example.com"
|
|
)
|
|
|
|
extValue, err := hex.DecodeString(h)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tlscert, err := newTestClient().TLSALPN01ChallengeCert(token, domain)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if n := len(tlscert.Certificate); n != 1 {
|
|
t.Fatalf("len(tlscert.Certificate) = %d; want 1", n)
|
|
}
|
|
cert, err := x509.ParseCertificate(tlscert.Certificate[0])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
names := []string{domain}
|
|
if !reflect.DeepEqual(cert.DNSNames, names) {
|
|
t.Fatalf("cert.DNSNames = %v;\nwant %v", cert.DNSNames, names)
|
|
}
|
|
if cn := cert.Subject.CommonName; cn != domain {
|
|
t.Errorf("CommonName = %q; want %q", cn, domain)
|
|
}
|
|
acmeExts := []pkix.Extension{}
|
|
for _, ext := range cert.Extensions {
|
|
if idPeACMEIdentifier.Equal(ext.Id) {
|
|
acmeExts = append(acmeExts, ext)
|
|
}
|
|
}
|
|
if len(acmeExts) != 1 {
|
|
t.Errorf("acmeExts = %v; want exactly one", acmeExts)
|
|
}
|
|
if !acmeExts[0].Critical {
|
|
t.Errorf("acmeExt.Critical = %v; want true", acmeExts[0].Critical)
|
|
}
|
|
if bytes.Compare(acmeExts[0].Value, extValue) != 0 {
|
|
t.Errorf("acmeExt.Value = %v; want %v", acmeExts[0].Value, extValue)
|
|
}
|
|
|
|
}
|
|
|
|
func TestTLSChallengeCertOpt(t *testing.T) {
|
|
key, err := rsa.GenerateKey(rand.Reader, 1024)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
domain := "example.com"
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: big.NewInt(2),
|
|
Subject: pkix.Name{Organization: []string{"Test"}},
|
|
DNSNames: []string{"should-be-overwritten"},
|
|
}
|
|
opts := []CertOption{WithKey(key), WithTemplate(tmpl)}
|
|
|
|
client := newTestClient()
|
|
cert, err := client.TLSALPN01ChallengeCert("token", domain, opts...)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// verify generated cert private key
|
|
tlskey, ok := cert.PrivateKey.(*rsa.PrivateKey)
|
|
if !ok {
|
|
t.Fatalf("tlscert.PrivateKey is %T; want *rsa.PrivateKey", cert.PrivateKey)
|
|
}
|
|
if tlskey.D.Cmp(key.D) != 0 {
|
|
t.Errorf("tlskey.D = %v; want %v", tlskey.D, key.D)
|
|
}
|
|
// verify generated cert public key
|
|
x509Cert, err := x509.ParseCertificate(cert.Certificate[0])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tlspub, ok := x509Cert.PublicKey.(*rsa.PublicKey)
|
|
if !ok {
|
|
t.Fatalf("x509Cert.PublicKey is %T; want *rsa.PublicKey", x509Cert.PublicKey)
|
|
}
|
|
if tlspub.N.Cmp(key.N) != 0 {
|
|
t.Errorf("tlspub.N = %v; want %v", tlspub.N, key.N)
|
|
}
|
|
// verify template option
|
|
sn := big.NewInt(2)
|
|
if x509Cert.SerialNumber.Cmp(sn) != 0 {
|
|
t.Errorf("SerialNumber = %v; want %v", x509Cert.SerialNumber, sn)
|
|
}
|
|
org := []string{"Test"}
|
|
if !reflect.DeepEqual(x509Cert.Subject.Organization, org) {
|
|
t.Errorf("Subject.Organization = %+v; want %+v", x509Cert.Subject.Organization, org)
|
|
}
|
|
for _, v := range x509Cert.DNSNames {
|
|
if v != domain {
|
|
t.Errorf("invalid DNSNames element: %q", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHTTP01Challenge(t *testing.T) {
|
|
const (
|
|
token = "xxx"
|
|
// thumbprint is precomputed for testKeyEC in jws_test.go
|
|
value = token + "." + testKeyECThumbprint
|
|
urlpath = "/.well-known/acme-challenge/" + token
|
|
)
|
|
client := newTestClient()
|
|
val, err := client.HTTP01ChallengeResponse(token)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if val != value {
|
|
t.Errorf("val = %q; want %q", val, value)
|
|
}
|
|
if path := client.HTTP01ChallengePath(token); path != urlpath {
|
|
t.Errorf("path = %q; want %q", path, urlpath)
|
|
}
|
|
}
|
|
|
|
func TestDNS01ChallengeRecord(t *testing.T) {
|
|
// echo -n xxx.<testKeyECThumbprint> | \
|
|
// openssl dgst -binary -sha256 | \
|
|
// base64 | tr -d '=' | tr '/+' '_-'
|
|
const value = "8DERMexQ5VcdJ_prpPiA0mVdp7imgbCgjsG4SqqNMIo"
|
|
|
|
val, err := newTestClient().DNS01ChallengeRecord("xxx")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if val != value {
|
|
t.Errorf("val = %q; want %q", val, value)
|
|
}
|
|
}
|