This bumps go.mod to the current tailscale/golang-x-crypto, picking up its rebase onto current upstream golang.org/x/crypto and its cherry-pick of the pending upstream change https://go-review.googlesource.com/c/crypto/+/788000, which adds ACME certificate profile support: a new WithOrderProfile order option and profile discovery via the directory metadata. That change has not yet been submitted upstream and is subject to final API changes before it lands there. It then re-vendors that fork's acme package into tempfork/acme as usual (per the TestSyncedToUpstream workflow), except for upstream's pebble_test.go, which is now excluded from the sync: its TestWithPebble downloads the Pebble module from outside our go.mod, then builds and runs its binaries during tests. Profile support is needed to request LetsEncrypt IP address certificates, which require the "shortlived" profile. Updates tailscale/corp#45167 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: I3f7c2a91e5d8b4a6c0e2f9d1b7a3c8e6f4d0a2b9
948 lines
27 KiB
Go
948 lines
27 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"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"strings"
|
|
"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
|
|
}
|
|
}
|
|
|
|
// newTestClientWithMockDirectory creates a client with a non-nil Directory
|
|
// that contains mock field values.
|
|
func newTestClientWithMockDirectory() *Client {
|
|
return &Client{
|
|
Key: testKeyEC,
|
|
dir: &Directory{
|
|
RenewalInfoURL: "https://example.com/acme/renewal-info/",
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
const (
|
|
leafPEM = `-----BEGIN CERTIFICATE-----
|
|
MIIEizCCAvOgAwIBAgIRAITApw7R8HSs7GU7cj8dEyUwDQYJKoZIhvcNAQELBQAw
|
|
gYUxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEtMCsGA1UECwwkY3Bh
|
|
bG1lckBwdW1wa2luLmxvY2FsIChDaHJpcyBQYWxtZXIpMTQwMgYDVQQDDCtta2Nl
|
|
cnQgY3BhbG1lckBwdW1wa2luLmxvY2FsIChDaHJpcyBQYWxtZXIpMB4XDTIzMDcx
|
|
MjE4MjIxNloXDTI1MTAxMjE4MjIxNlowWDEnMCUGA1UEChMebWtjZXJ0IGRldmVs
|
|
b3BtZW50IGNlcnRpZmljYXRlMS0wKwYDVQQLDCRjcGFsbWVyQHB1bXBraW4ubG9j
|
|
YWwgKENocmlzIFBhbG1lcikwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB
|
|
AQDNDO8P4MI9jaqVcPtF8C4GgHnTP5EK3U9fgyGApKGxTpicMQkA6z4GXwUP/Fvq
|
|
7RuCU9Wg7By5VetKIHF7FxkxWkUMrssr7mV8v6mRCh/a5GqDs14aj5ucjLQAJV74
|
|
tLAdrCiijQ1fkPWc82fob+LkfKWGCWw7Cxf6ZtEyC8jz/DnfQXUvOiZS729ndGF7
|
|
FobKRfIoirD+GI2NTYIp3LAUFSPR6HXTe7HAg8J81VoUKli8z504+FebfMmHePm/
|
|
zIfiI0njAj4czOlZD56/oLsV0WRUizFjafHHUFz1HVdfFw8Qf9IOOTydYOe8M5i0
|
|
lVbVO5G+HP+JDn3cr9MT41B9AgMBAAGjgaEwgZ4wDgYDVR0PAQH/BAQDAgWgMBMG
|
|
A1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFPpL4Q0O7Z7voTkjn2rrFCsf
|
|
s8TbMFYGA1UdEQRPME2CC2V4YW1wbGUuY29tgg0qLmV4YW1wbGUuY29tggxleGFt
|
|
cGxlLnRlc3SCCWxvY2FsaG9zdIcEfwAAAYcQAAAAAAAAAAAAAAAAAAAAATANBgkq
|
|
hkiG9w0BAQsFAAOCAYEAMlOb7lrHuSxwcnAu7mL1ysTGqKn1d2TyDJAN5W8YFY+4
|
|
XLpofNkK2UzZ0t9LQRnuFUcjmfqmfplh5lpC7pKmtL4G5Qcdc+BczQWcopbxd728
|
|
sht9BKRkH+Bo1I+1WayKKNXW+5bsMv4CH641zxaMBlzjEnPvwKkNaGLMH3x5lIeX
|
|
GGgkKNXwVtINmyV+lTNVtu2IlHprxJGCjRfEuX7mEv6uRnqz3Wif+vgyh3MBgM/1
|
|
dUOsTBNH4a6Jl/9VPSOfRdQOStqIlwTa/J1bhTvivsYt1+eWjLnsQJLgZQqwKvYH
|
|
BJ30gAk1oNnuSkx9dHbx4mO+4mB9oIYUALXUYakb8JHTOnuMSj9qelVj5vjVxl9q
|
|
KRitptU+kLYRA4HSgUXrhDIm4Q6D/w8/ascPqQ3HxPIDFLe+gTofEjqnnsnQB29L
|
|
gWpI8l5/MtXAOMdW69eEovnADc2pgaiif0T+v9nNKBc5xfDZHnrnqIqVzQEwL5Qv
|
|
niQI8IsWD5LcQ1Eg7kCq
|
|
-----END CERTIFICATE-----`
|
|
)
|
|
|
|
func TestGetRenewalURL(t *testing.T) {
|
|
leaf, _ := pem.Decode([]byte(leafPEM))
|
|
|
|
parsedLeaf, err := x509.ParseCertificate(leaf.Bytes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
client := newTestClientWithMockDirectory()
|
|
urlString := client.getRenewalURL(parsedLeaf)
|
|
|
|
parsedURL, err := url.Parse(urlString)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if scheme := parsedURL.Scheme; scheme == "" {
|
|
t.Fatalf("malformed URL scheme: %q from %q", scheme, urlString)
|
|
}
|
|
if host := parsedURL.Host; host == "" {
|
|
t.Fatalf("malformed URL host: %q from %q", host, urlString)
|
|
}
|
|
if parsedURL.RawQuery != "" {
|
|
t.Fatalf("malformed URL: should not have a query")
|
|
}
|
|
path := parsedURL.EscapedPath()
|
|
slash := strings.LastIndex(path, "/")
|
|
if slash == -1 {
|
|
t.Fatalf("malformed URL path: %q from %q", path, urlString)
|
|
}
|
|
certID := path[slash+1:]
|
|
if certID == "" {
|
|
t.Fatalf("missing certificate identifier in URL path: %q from %q", path, urlString)
|
|
}
|
|
certIDParts := strings.Split(certID, ".")
|
|
if len(certIDParts) != 2 {
|
|
t.Fatalf("certificate identifier should consist of 2 base64-encoded values separated by a dot: %q from %q", certID, urlString)
|
|
}
|
|
if _, err := base64.RawURLEncoding.DecodeString(certIDParts[0]); err != nil {
|
|
t.Fatalf("malformed AKI part in certificate identifier: %q from %q: %v", certIDParts[0], urlString, err)
|
|
}
|
|
if _, err := base64.RawURLEncoding.DecodeString(certIDParts[1]); err != nil {
|
|
t.Fatalf("malformed Serial part in certificate identifier: %q from %q: %v", certIDParts[1], urlString, err)
|
|
}
|
|
|
|
}
|
|
|
|
func TestUnmarshalRenewalInfo(t *testing.T) {
|
|
renewalInfoJSON := `{
|
|
"suggestedWindow": {
|
|
"start": "2021-01-03T00:00:00Z",
|
|
"end": "2021-01-07T00:00:00Z"
|
|
},
|
|
"explanationURL": "https://example.com/docs/example-mass-reissuance-event"
|
|
}`
|
|
expectedStart := time.Date(2021, time.January, 3, 0, 0, 0, 0, time.UTC)
|
|
expectedEnd := time.Date(2021, time.January, 7, 0, 0, 0, 0, time.UTC)
|
|
|
|
var info RenewalInfo
|
|
if err := json.Unmarshal([]byte(renewalInfoJSON), &info); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := url.Parse(info.ExplanationURL); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !info.SuggestedWindow.Start.Equal(expectedStart) {
|
|
t.Fatalf("%v != %v", expectedStart, info.SuggestedWindow.Start)
|
|
}
|
|
if !info.SuggestedWindow.End.Equal(expectedEnd) {
|
|
t.Fatalf("%v != %v", expectedEnd, info.SuggestedWindow.End)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|