mirror of
https://github.com/tailscale/tailscale.git
synced 2025-02-22 21:08:38 +00:00

We've been maintaining temporary dev forks of golang.org/x/crypto/{acme,ssh} in https://github.com/tailscale/golang-x-crypto instead of using this repo's tempfork directory as we do with other packages. The reason we were doing that was because x/crypto/ssh depended on x/crypto/ssh/internal/poly1305 and I hadn't noticed there are forwarding wrappers already available in x/crypto/poly1305. It also depended internal/bcrypt_pbkdf but we don't use that so it's easy to just delete that calling code in our tempfork/ssh. Now that our SSH changes have been upstreamed, we can soon unfork from SSH. That leaves ACME remaining. This change copies our tailscale/golang-x-crypto/acme code to tempfork/acme but adds a test that our vendored copied still matches our tailscale/golang-x-crypto repo, where we can continue to do development work and rebases with upstream. A comment on the new test describes the expected workflow. While we could continue to just import & use tailscale/golang-x-crypto/acme, it seems a bit nicer to not have that entire-fork-of-x-crypto visible at all in our transitive deps and the questions that invites. Showing just a fork of an ACME client is much less scary. It does add a step to the process of hacking on the ACME client code, but we do that approximately never anyway, and the extra step is very incremental compared to the existing tedious steps. Updates #8593 Updates #10238 Change-Id: I8af4378c04c1f82e63d31bf4d16dba9f510f9199 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
974 lines
28 KiB
Go
974 lines
28 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/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"sort"
|
|
"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 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, err := client.getRenewalURL(parsedLeaf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
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 TestTLSSNI01ChallengeCert(t *testing.T) {
|
|
const (
|
|
token = "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA"
|
|
// echo -n <token.testKeyECThumbprint> | shasum -a 256
|
|
san = "dbbd5eefe7b4d06eb9d1d9f5acb4c7cd.a27d320e4b30332f0b6cb441734ad7b0.acme.invalid"
|
|
)
|
|
|
|
tlscert, name, err := newTestClient().TLSSNI01ChallengeCert(token)
|
|
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)
|
|
}
|
|
if len(cert.DNSNames) != 1 || cert.DNSNames[0] != san {
|
|
t.Fatalf("cert.DNSNames = %v; want %q", cert.DNSNames, san)
|
|
}
|
|
if cert.DNSNames[0] != name {
|
|
t.Errorf("cert.DNSNames[0] != name: %q vs %q", cert.DNSNames[0], name)
|
|
}
|
|
if cn := cert.Subject.CommonName; cn != san {
|
|
t.Errorf("cert.Subject.CommonName = %q; want %q", cn, san)
|
|
}
|
|
}
|
|
|
|
func TestTLSSNI02ChallengeCert(t *testing.T) {
|
|
const (
|
|
token = "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA"
|
|
// echo -n evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA | shasum -a 256
|
|
sanA = "7ea0aaa69214e71e02cebb18bb867736.09b730209baabf60e43d4999979ff139.token.acme.invalid"
|
|
// echo -n <token.testKeyECThumbprint> | shasum -a 256
|
|
sanB = "dbbd5eefe7b4d06eb9d1d9f5acb4c7cd.a27d320e4b30332f0b6cb441734ad7b0.ka.acme.invalid"
|
|
)
|
|
|
|
tlscert, name, err := newTestClient().TLSSNI02ChallengeCert(token)
|
|
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{sanA, sanB}
|
|
if !reflect.DeepEqual(cert.DNSNames, names) {
|
|
t.Fatalf("cert.DNSNames = %v;\nwant %v", cert.DNSNames, names)
|
|
}
|
|
sort.Strings(cert.DNSNames)
|
|
i := sort.SearchStrings(cert.DNSNames, name)
|
|
if i >= len(cert.DNSNames) || cert.DNSNames[i] != name {
|
|
t.Errorf("%v doesn't have %q", cert.DNSNames, name)
|
|
}
|
|
if cn := cert.Subject.CommonName; cn != sanA {
|
|
t.Errorf("CommonName = %q; want %q", cn, sanA)
|
|
}
|
|
}
|
|
|
|
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, 512)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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()
|
|
cert1, _, err := client.TLSSNI01ChallengeCert("token", opts...)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cert2, _, err := client.TLSSNI02ChallengeCert("token", opts...)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for i, tlscert := range []tls.Certificate{cert1, cert2} {
|
|
// verify generated cert private key
|
|
tlskey, ok := tlscert.PrivateKey.(*rsa.PrivateKey)
|
|
if !ok {
|
|
t.Errorf("%d: tlscert.PrivateKey is %T; want *rsa.PrivateKey", i, tlscert.PrivateKey)
|
|
continue
|
|
}
|
|
if tlskey.D.Cmp(key.D) != 0 {
|
|
t.Errorf("%d: tlskey.D = %v; want %v", i, tlskey.D, key.D)
|
|
}
|
|
// verify generated cert public key
|
|
x509Cert, err := x509.ParseCertificate(tlscert.Certificate[0])
|
|
if err != nil {
|
|
t.Errorf("%d: %v", i, err)
|
|
continue
|
|
}
|
|
tlspub, ok := x509Cert.PublicKey.(*rsa.PublicKey)
|
|
if !ok {
|
|
t.Errorf("%d: x509Cert.PublicKey is %T; want *rsa.PublicKey", i, x509Cert.PublicKey)
|
|
continue
|
|
}
|
|
if tlspub.N.Cmp(key.N) != 0 {
|
|
t.Errorf("%d: tlspub.N = %v; want %v", i, tlspub.N, key.N)
|
|
}
|
|
// verify template option
|
|
sn := big.NewInt(2)
|
|
if x509Cert.SerialNumber.Cmp(sn) != 0 {
|
|
t.Errorf("%d: SerialNumber = %v; want %v", i, x509Cert.SerialNumber, sn)
|
|
}
|
|
org := []string{"Test"}
|
|
if !reflect.DeepEqual(x509Cert.Subject.Organization, org) {
|
|
t.Errorf("%d: Subject.Organization = %+v; want %+v", i, x509Cert.Subject.Organization, org)
|
|
}
|
|
for _, v := range x509Cert.DNSNames {
|
|
if !strings.HasSuffix(v, ".acme.invalid") {
|
|
t.Errorf("%d: invalid DNSNames element: %q", i, 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)
|
|
}
|
|
}
|