1124 lines
31 KiB
Go
1124 lines
31 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 (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Decodes a JWS-encoded request and unmarshals the decoded JSON into a provided
|
|
// interface.
|
|
func decodeJWSRequest(t *testing.T, v interface{}, r *http.Request) {
|
|
// Decode request
|
|
var req struct{ Payload string }
|
|
if err := json.NewDecoder(r.Body).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
|
|
JWK map[string]string `json:"jwk"`
|
|
}
|
|
|
|
func decodeJWSHead(r *http.Request) (*jwsHead, error) {
|
|
var req struct{ Protected string }
|
|
if err := json.NewDecoder(r.Body).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 TestDiscover(t *testing.T) {
|
|
const (
|
|
keyChange = "https://example.com/acme/key-change"
|
|
newAccount = "https://example.com/acme/new-account"
|
|
newNonce = "https://example.com/acme/new-nonce"
|
|
newOrder = "https://example.com/acme/new-order"
|
|
revokeCert = "https://example.com/acme/revoke-cert"
|
|
terms = "https://example.com/acme/terms"
|
|
)
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{
|
|
"keyChange": %q,
|
|
"newAccount": %q,
|
|
"newNonce": %q,
|
|
"newOrder": %q,
|
|
"revokeCert": %q,
|
|
"meta": {
|
|
"termsOfService": %q
|
|
}
|
|
}`, keyChange, newAccount, newNonce, newOrder, revokeCert, terms)
|
|
}))
|
|
defer ts.Close()
|
|
c := Client{DirectoryURL: ts.URL}
|
|
dir, err := c.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if dir.KeyChangeURL != keyChange {
|
|
t.Errorf("dir.KeyChangeURL = %q; want %q", dir.KeyChangeURL, keyChange)
|
|
}
|
|
if dir.NewAccountURL != newAccount {
|
|
t.Errorf("dir.NewAccountURL = %q; want %q", dir.NewAccountURL, newAccount)
|
|
}
|
|
if dir.NewNonceURL != newNonce {
|
|
t.Errorf("dir.NewNonceURL = %q; want %q", dir.NewNonceURL, newNonce)
|
|
}
|
|
if dir.RevokeCertURL != revokeCert {
|
|
t.Errorf("dir.RevokeCertURL = %q; want %q", dir.RevokeCertURL, revokeCert)
|
|
}
|
|
if dir.Terms != terms {
|
|
t.Errorf("dir.Terms = %q; want %q", dir.Terms, terms)
|
|
}
|
|
}
|
|
|
|
func TestCreateAccount(t *testing.T) {
|
|
contacts := []string{"mailto:admin@example.com"}
|
|
|
|
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 {
|
|
Contact []string
|
|
TermsOfServiceAgreed bool
|
|
}
|
|
decodeJWSRequest(t, &j, r)
|
|
|
|
if !reflect.DeepEqual(j.Contact, contacts) {
|
|
t.Errorf("j.Contact = %v; want %v", j.Contact, contacts)
|
|
}
|
|
if !j.TermsOfServiceAgreed {
|
|
t.Error("j.TermsOfServiceAgreed = false; want true")
|
|
}
|
|
|
|
w.Header().Set("Location", "https://example.com/acme/account/1")
|
|
w.WriteHeader(http.StatusCreated)
|
|
b, _ := json.Marshal(contacts)
|
|
fmt.Fprintf(w, `{"status":"valid","orders":"https://example.com/acme/orders","contact":%s}`, b)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := Client{Key: testKeyEC, dir: &Directory{NewAccountURL: ts.URL, NewNonceURL: ts.URL}}
|
|
a := &Account{Contact: contacts, TermsAgreed: true}
|
|
var err error
|
|
if a, err = c.CreateAccount(context.Background(), a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a.URL != "https://example.com/acme/account/1" {
|
|
t.Errorf("a.URL = %q; want https://example.com/acme/account/1", a.URL)
|
|
}
|
|
if a.OrdersURL != "https://example.com/acme/orders" {
|
|
t.Errorf("a.OrdersURL = %q; want https://example.com/acme/orders", a.OrdersURL)
|
|
}
|
|
if a.Status != StatusValid {
|
|
t.Errorf("a.Status = %q; want valid", a.Status)
|
|
}
|
|
if !reflect.DeepEqual(a.Contact, contacts) {
|
|
t.Errorf("a.Contact = %v; want %v", a.Contact, contacts)
|
|
}
|
|
}
|
|
|
|
func TestUpdateAccount(t *testing.T) {
|
|
contacts := []string{"mailto:admin@example.com"}
|
|
|
|
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 {
|
|
Contact []string
|
|
}
|
|
decodeJWSRequest(t, &j, r)
|
|
|
|
if !reflect.DeepEqual(j.Contact, contacts) {
|
|
t.Errorf("j.Contact = %v; want %v", j.Contact, contacts)
|
|
}
|
|
b, _ := json.Marshal(contacts)
|
|
fmt.Fprintf(w, `{"status":"valid","orders":"https://example.com/acme/orders","contact":%s}`, b)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := Client{Key: testKeyEC, dir: &Directory{NewNonceURL: ts.URL}}
|
|
a := &Account{URL: ts.URL, Contact: contacts}
|
|
var err error
|
|
if a, err = c.UpdateAccount(context.Background(), a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a.OrdersURL != "https://example.com/acme/orders" {
|
|
t.Errorf("a.OrdersURL = %q; want https://example.com/acme/orders", a.OrdersURL)
|
|
}
|
|
if a.Status != StatusValid {
|
|
t.Errorf("a.Status = %q; want valid", a.Status)
|
|
}
|
|
if !reflect.DeepEqual(a.Contact, contacts) {
|
|
t.Errorf("a.Contact = %v; want %v", a.Contact, contacts)
|
|
}
|
|
if a.URL != ts.URL {
|
|
t.Errorf("a.URL = %q; want %q", a.URL, ts.URL)
|
|
}
|
|
}
|
|
|
|
func TestGetAccount(t *testing.T) {
|
|
contacts := []string{"mailto:admin@example.com"}
|
|
|
|
var ts *httptest.Server
|
|
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 req struct {
|
|
Existing bool `json:"onlyReturnExisting"`
|
|
}
|
|
decodeJWSRequest(t, &req, r)
|
|
if req.Existing {
|
|
w.Header().Set("Location", ts.URL)
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
b, _ := json.Marshal(contacts)
|
|
fmt.Fprintf(w, `{"status":"valid","orders":"https://example.com/acme/orders","contact":%s}`, b)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
c := Client{Key: testKeyEC, dir: &Directory{NewNonceURL: ts.URL, NewAccountURL: ts.URL}}
|
|
a, err := c.GetAccount(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a.OrdersURL != "https://example.com/acme/orders" {
|
|
t.Errorf("a.OrdersURL = %q; want https://example.com/acme/orders", a.OrdersURL)
|
|
}
|
|
if a.Status != StatusValid {
|
|
t.Errorf("a.Status = %q; want valid", a.Status)
|
|
}
|
|
if !reflect.DeepEqual(a.Contact, contacts) {
|
|
t.Errorf("a.Contact = %v; want %v", a.Contact, contacts)
|
|
}
|
|
if a.URL != ts.URL {
|
|
t.Errorf("a.URL = %q; want %q", a.URL, ts.URL)
|
|
}
|
|
}
|
|
|
|
func TestCreateOrder(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 {
|
|
Identifiers []struct {
|
|
Type string
|
|
Value string
|
|
}
|
|
}
|
|
decodeJWSRequest(t, &j, r)
|
|
|
|
// Test request
|
|
if len(j.Identifiers) != 1 {
|
|
t.Errorf("len(j.Identifiers) = %d; want 1", len(j.Identifiers))
|
|
}
|
|
if j.Identifiers[0].Type != "dns" {
|
|
t.Errorf("j.Identifier.Type = %q; want dns", j.Identifiers[0].Type)
|
|
}
|
|
if j.Identifiers[0].Value != "example.com" {
|
|
t.Errorf("j.Identifier.Value = %q; want example.com", j.Identifiers[0].Value)
|
|
}
|
|
|
|
w.Header().Set("Location", "https://example.com/acme/order/1")
|
|
w.WriteHeader(http.StatusCreated)
|
|
fmt.Fprintf(w, `{
|
|
"identifiers": [{"type":"dns","value":"example.com"}],
|
|
"status":"pending",
|
|
"authorizations":["https://example.com/acme/order/1/1"],
|
|
"finalize":"https://example.com/acme/order/1/finalize"
|
|
}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
cl := Client{Key: testKeyEC, accountURL: "https://example.com/acme/account", dir: &Directory{NewOrderURL: ts.URL, NewNonceURL: ts.URL}}
|
|
o, err := cl.CreateOrder(context.Background(), NewOrder("example.com"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if o.URL != "https://example.com/acme/order/1" {
|
|
t.Errorf("URL = %q; want https://example.com/acme/order/1", o.URL)
|
|
}
|
|
if o.Status != "pending" {
|
|
t.Errorf("Status = %q; want pending", o.Status)
|
|
}
|
|
if o.FinalizeURL != "https://example.com/acme/order/1/finalize" {
|
|
t.Errorf("FinalizeURL = %q; want https://example.com/acme/order/1/finalize", o.FinalizeURL)
|
|
}
|
|
|
|
if n := len(o.Identifiers); n != 1 {
|
|
t.Fatalf("len(o.Identifiers) = %d; want 1", n)
|
|
}
|
|
if o.Identifiers[0].Type != "dns" {
|
|
t.Errorf("Identifiers[0].Type = %q; want dns", o.Identifiers[0].Type)
|
|
}
|
|
if o.Identifiers[0].Value != "example.com" {
|
|
t.Errorf("Identifiers[0].Value = %q; want example.com", o.Identifiers[0].Value)
|
|
}
|
|
|
|
if n := len(o.Authorizations); n != 1 {
|
|
t.Fatalf("len(o.Authorizations) = %d; want 1", n)
|
|
}
|
|
if o.Authorizations[0] != "https://example.com/acme/order/1/1" {
|
|
t.Errorf("o.Authorizations[0] = %q; https://example.com/acme/order/1/1", o.Authorizations[0])
|
|
}
|
|
}
|
|
|
|
func TestGetAuthorization(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" {
|
|
t.Errorf("r.Method = %q; want GET", r.Method)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `{
|
|
"identifier": {"type":"dns","value":"example.com"},
|
|
"status":"pending",
|
|
"challenges":[
|
|
{
|
|
"type":"http-01",
|
|
"status":"pending",
|
|
"url":"https://example.com/acme/challenge/publickey/id1",
|
|
"token":"token1"
|
|
},
|
|
{
|
|
"type":"tls-sni-02",
|
|
"status":"pending",
|
|
"url":"https://example.com/acme/challenge/publickey/id2",
|
|
"token":"token2"
|
|
}
|
|
]
|
|
}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
cl := Client{Key: testKeyEC, dir: &Directory{NewNonceURL: ts.URL}}
|
|
auth, err := cl.GetAuthorization(context.Background(), ts.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if auth.Status != "pending" {
|
|
t.Errorf("Status = %q; want pending", auth.Status)
|
|
}
|
|
if auth.Identifier.Type != "dns" {
|
|
t.Errorf("Identifier.Type = %q; want dns", auth.Identifier.Type)
|
|
}
|
|
if auth.Identifier.Value != "example.com" {
|
|
t.Errorf("Identifier.Value = %q; want example.com", auth.Identifier.Value)
|
|
}
|
|
|
|
if n := len(auth.Challenges); n != 2 {
|
|
t.Fatalf("len(set.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.URL != "https://example.com/acme/challenge/publickey/id1" {
|
|
t.Errorf("c.URI = %q; want https://example.com/acme/challenge/publickey/id1", c.URL)
|
|
}
|
|
if c.Token != "token1" {
|
|
t.Errorf("c.Token = %q; want token1", c.Token)
|
|
}
|
|
|
|
c = auth.Challenges[1]
|
|
if c.Type != "tls-sni-02" {
|
|
t.Errorf("c.Type = %q; want tls-sni-02", c.Type)
|
|
}
|
|
if c.URL != "https://example.com/acme/challenge/publickey/id2" {
|
|
t.Errorf("c.URI = %q; want https://example.com/acme/challenge/publickey/id2", c.URL)
|
|
}
|
|
if c.Token != "token2" {
|
|
t.Errorf("c.Token = %q; want token2", c.Token)
|
|
}
|
|
}
|
|
|
|
func TestWaitAuthorization(t *testing.T) {
|
|
var count int
|
|
ts := httptest.NewServer(http.HandlerFunc(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"}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
type res struct {
|
|
authz *Authorization
|
|
err error
|
|
}
|
|
done := make(chan res)
|
|
defer close(done)
|
|
go func() {
|
|
var client Client
|
|
a, err := client.WaitAuthorization(context.Background(), ts.URL)
|
|
done <- res{a, err}
|
|
}()
|
|
|
|
select {
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("WaitAuthz took too long to return")
|
|
case res := <-done:
|
|
if res.err != nil {
|
|
t.Fatalf("res.err = %v", res.err)
|
|
}
|
|
if res.authz == nil {
|
|
t.Fatal("res.authz is nil")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWaitAuthorizationInvalid(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprintf(w, `{"status":"invalid"}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
res := make(chan error)
|
|
defer close(res)
|
|
go func() {
|
|
var client Client
|
|
_, err := client.WaitAuthorization(context.Background(), ts.URL)
|
|
res <- err
|
|
}()
|
|
|
|
select {
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("WaitAuthz took too long to return")
|
|
case err := <-res:
|
|
if err == nil {
|
|
t.Error("err is nil")
|
|
}
|
|
if _, ok := err.(AuthorizationError); !ok {
|
|
t.Errorf("err is %T; want *AuthorizationError", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWaitAuthorizationCancel(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Retry-After", "60")
|
|
fmt.Fprintf(w, `{"status":"pending"}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
res := make(chan error)
|
|
defer close(res)
|
|
go func() {
|
|
var client Client
|
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
|
defer cancel()
|
|
_, err := client.WaitAuthorization(ctx, ts.URL)
|
|
res <- err
|
|
}()
|
|
|
|
select {
|
|
case <-time.After(time.Second):
|
|
t.Fatal("WaitAuthz took too long to return")
|
|
case err := <-res:
|
|
if err == nil {
|
|
t.Error("err is nil")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeactivateAuthorization(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 {
|
|
Status string
|
|
}
|
|
decodeJWSRequest(t, &req, r)
|
|
if req.Status != "deactivated" {
|
|
t.Errorf("req.Status = %q; want deactivated", req.Status)
|
|
}
|
|
case "/2":
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
case "/account":
|
|
w.Header().Set("Location", "https://example.com/acme/account/0")
|
|
w.Write([]byte("{}"))
|
|
}
|
|
}))
|
|
defer ts.Close()
|
|
client := &Client{Key: testKey, dir: &Directory{NewNonceURL: ts.URL, NewAccountURL: ts.URL + "/account"}}
|
|
ctx := context.Background()
|
|
if err := client.DeactivateAuthorization(ctx, ts.URL+"/1"); err != nil {
|
|
t.Errorf("err = %v", err)
|
|
}
|
|
if client.DeactivateAuthorization(ctx, ts.URL+"/2") == nil {
|
|
t.Error("nil error")
|
|
}
|
|
}
|
|
|
|
func TestGetChallenge(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" {
|
|
t.Errorf("r.Method = %q; want GET", r.Method)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `{
|
|
"type":"http-01",
|
|
"status":"pending",
|
|
"url":"https://example.com/acme/challenge/publickey/id1",
|
|
"validated": "2014-12-01T12:05:00Z",
|
|
"errors": [{
|
|
"type": "urn:ietf:params:acme:error:malformed",
|
|
"detail": "rejected",
|
|
"subproblems": [
|
|
{
|
|
"type": "urn:ietf:params:acme:error:unknown",
|
|
"detail": "invalid",
|
|
"identifier": {
|
|
"type": "dns",
|
|
"value": "_example.com"
|
|
}
|
|
}
|
|
]
|
|
}],
|
|
"token":"token1"}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
cl := Client{Key: testKeyEC}
|
|
chall, err := cl.GetChallenge(context.Background(), ts.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if chall.Status != "pending" {
|
|
t.Errorf("Status = %q; want pending", chall.Status)
|
|
}
|
|
if chall.Type != "http-01" {
|
|
t.Errorf("c.Type = %q; want http-01", chall.Type)
|
|
}
|
|
if chall.URL != "https://example.com/acme/challenge/publickey/id1" {
|
|
t.Errorf("c.URI = %q; want https://example.com/acme/challenge/publickey/id1", chall.URL)
|
|
}
|
|
if chall.Token != "token1" {
|
|
t.Errorf("c.Token = %q; want token1", chall.Token)
|
|
}
|
|
vt, _ := time.Parse(time.RFC3339, "2014-12-01T12:05:00Z")
|
|
if !chall.Validated.Equal(vt) {
|
|
t.Errorf("c.Validated = %v; want %v", chall.Validated, vt)
|
|
}
|
|
if l := len(chall.Errors); l != 1 {
|
|
t.Fatalf("len(c.Errors) = %d; want 1", l)
|
|
}
|
|
e := chall.Errors[0]
|
|
if e.Type != "urn:ietf:params:acme:error:malformed" {
|
|
t.Fatalf("e.Type = %q; want urn:ietf:params:acme:error:malformed", e.Type)
|
|
}
|
|
if e.Detail != "rejected" {
|
|
t.Fatalf("e.Detail = %q; want rejected", e.Detail)
|
|
}
|
|
if l := len(e.Subproblems); l != 1 {
|
|
t.Fatalf("len(e.Subproblems) = %d; want 1", l)
|
|
}
|
|
p := e.Subproblems[0]
|
|
if p.Type != "urn:ietf:params:acme:error:unknown" {
|
|
t.Fatalf("p.Type = %q; want urn:ietf:params:acme:error:unknown", p.Type)
|
|
}
|
|
if p.Detail != "invalid" {
|
|
t.Fatalf("p.Detail = %q; want rejected", p.Detail)
|
|
}
|
|
if p.Identifier.Type != "dns" {
|
|
t.Fatalf("p.Identifier.Type = %q; want dns", p.Identifier.Type)
|
|
}
|
|
if p.Identifier.Value != "_example.com" {
|
|
t.Fatalf("p.Identifier.Type = %q; want _example.com", p.Identifier.Value)
|
|
}
|
|
}
|
|
|
|
func TestAcceptChallenge(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 {
|
|
Auth string `json:"keyAuthorization"`
|
|
}
|
|
decodeJWSRequest(t, &j, r)
|
|
|
|
keyAuth := "token1." + testKeyECThumbprint
|
|
if j.Auth != keyAuth {
|
|
t.Errorf(`keyAuthorization = %q; want %q`, j.Auth, keyAuth)
|
|
}
|
|
|
|
// Respond to request
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `{
|
|
"type":"http-01",
|
|
"status":"pending",
|
|
"url":"https://example.com/acme/challenge/publickey/id1",
|
|
"token":"token1",
|
|
"keyAuthorization":%q
|
|
}`, keyAuth)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
cl := Client{Key: testKeyEC, accountURL: "https://example.com/acme/account", dir: &Directory{NewNonceURL: ts.URL}}
|
|
c, err := cl.AcceptChallenge(context.Background(), &Challenge{
|
|
URL: ts.URL,
|
|
Token: "token1",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if c.Type != "http-01" {
|
|
t.Errorf("c.Type = %q; want http-01", c.Type)
|
|
}
|
|
if c.URL != "https://example.com/acme/challenge/publickey/id1" {
|
|
t.Errorf("c.URL = %q; want https://example.com/acme/challenge/publickey/id1", c.URL)
|
|
}
|
|
if c.Token != "token1" {
|
|
t.Errorf("c.Token = %q; want token1", c.Token)
|
|
}
|
|
}
|
|
|
|
func TestFinalizeOrder(t *testing.T) {
|
|
notBefore := time.Now()
|
|
notAfter := notBefore.AddDate(0, 2, 0)
|
|
timeNow = func() time.Time { return notBefore }
|
|
var sampleCert []byte
|
|
|
|
var ts *httptest.Server
|
|
var orderGets int
|
|
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.URL.Path == "/cert" && r.Method == "GET" {
|
|
pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: sampleCert})
|
|
return
|
|
}
|
|
if r.URL.Path == "/order" {
|
|
status := "processing"
|
|
if orderGets > 0 {
|
|
status = "valid"
|
|
}
|
|
fmt.Fprintf(w, `{
|
|
"identifiers": [{"type":"dns","value":"example.com"}],
|
|
"status":%q,
|
|
"authorizations":["https://example.com/acme/order/1/1"],
|
|
"finalize":"https://example.com/acme/order/1/finalize",
|
|
"certificate":%q
|
|
}`, status, ts.URL+"/cert")
|
|
orderGets++
|
|
return
|
|
}
|
|
if r.Method != "POST" {
|
|
t.Errorf("r.Method = %q; want POST", r.Method)
|
|
}
|
|
|
|
var j struct {
|
|
CSR string `json:"csr"`
|
|
}
|
|
decodeJWSRequest(t, &j, r)
|
|
|
|
template := x509.Certificate{
|
|
SerialNumber: big.NewInt(int64(1)),
|
|
Subject: pkix.Name{
|
|
Organization: []string{"goacme"},
|
|
},
|
|
NotBefore: notBefore,
|
|
NotAfter: notAfter,
|
|
|
|
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
BasicConstraintsValid: true,
|
|
}
|
|
|
|
var err error
|
|
sampleCert, err = x509.CreateCertificate(rand.Reader, &template, &template, &testKeyEC.PublicKey, testKeyEC)
|
|
if err != nil {
|
|
t.Fatalf("Error creating certificate: %v", err)
|
|
}
|
|
|
|
w.Header().Set("Location", "/order")
|
|
fmt.Fprintf(w, `{
|
|
"identifiers": [{"type":"dns","value":"example.com"}],
|
|
"status":"processing",
|
|
"authorizations":["https://example.com/acme/order/1/1"],
|
|
"finalize":"https://example.com/acme/order/1/finalize"
|
|
}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
csr := x509.CertificateRequest{
|
|
Version: 0,
|
|
Subject: pkix.Name{
|
|
CommonName: "example.com",
|
|
Organization: []string{"goacme"},
|
|
},
|
|
}
|
|
csrb, err := x509.CreateCertificateRequest(rand.Reader, &csr, testKeyEC)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
c := Client{Key: testKeyEC, accountURL: "https://example.com/acme/account", dir: &Directory{NewNonceURL: ts.URL}}
|
|
cert, err := c.FinalizeOrder(context.Background(), ts.URL, csrb)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cert == nil {
|
|
t.Errorf("cert is nil")
|
|
}
|
|
}
|
|
|
|
func TestWaitOrderInvalid(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
|
|
}
|
|
const order = `{"status":%q}`
|
|
if r.URL.Path == "/invalid" {
|
|
fmt.Fprintf(w, order, "invalid")
|
|
}
|
|
if r.URL.Path == "/pending" {
|
|
fmt.Fprintf(w, order, "pending")
|
|
}
|
|
}))
|
|
defer ts.Close()
|
|
|
|
var client Client
|
|
_, err := client.WaitOrder(context.Background(), ts.URL+"/pending")
|
|
if e, ok := err.(OrderPendingError); ok {
|
|
if e.Order == nil {
|
|
t.Error("order is nil")
|
|
}
|
|
if e.Order.Status != "pending" {
|
|
t.Errorf("status = %q; want pending", e.Order.Status)
|
|
}
|
|
} else if err != nil {
|
|
t.Error(err)
|
|
}
|
|
|
|
_, err = client.WaitOrder(context.Background(), ts.URL+"/invalid")
|
|
if e, ok := err.(OrderInvalidError); ok {
|
|
if e.Order == nil {
|
|
t.Error("order is nil")
|
|
}
|
|
if e.Order.Status != "invalid" {
|
|
t.Errorf("status = %q; want invalid", e.Order.Status)
|
|
}
|
|
} else if err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
|
|
func TestGetOrder(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprintf(w, `{
|
|
"identifiers": [{"type":"dns","value":"example.com"}],
|
|
"status":"valid",
|
|
"authorizations":["https://example.com/acme/order/1/1"],
|
|
"finalize":"https://example.com/acme/order/1/finalize",
|
|
"certificate":"https://example.com/acme/cert"
|
|
}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
var client Client
|
|
o, err := client.GetOrder(context.Background(), ts.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if o.URL != ts.URL {
|
|
t.Errorf("URL = %q; want %s", o.URL, ts.URL)
|
|
}
|
|
if o.Status != "valid" {
|
|
t.Errorf("Status = %q; want valid", o.Status)
|
|
}
|
|
if l := len(o.Authorizations); l != 1 {
|
|
t.Errorf("len(Authorizations) = %d; want 1", l)
|
|
}
|
|
if v := o.Authorizations[0]; v != "https://example.com/acme/order/1/1" {
|
|
t.Errorf("Authorizations[0] = %q; want https://example.com/acme/order/1/1", v)
|
|
}
|
|
if l := len(o.Identifiers); l != 1 {
|
|
t.Errorf("len(Identifiers) = %d; want 1", l)
|
|
}
|
|
if v := o.Identifiers[0].Type; v != "dns" {
|
|
t.Errorf("Identifiers[0].Type = %q; want dns", v)
|
|
}
|
|
if v := o.Identifiers[0].Value; v != "example.com" {
|
|
t.Errorf("Identifiers[0].Value = %q; want example.com", v)
|
|
}
|
|
if o.FinalizeURL != "https://example.com/acme/order/1/finalize" {
|
|
t.Errorf("FinalizeURL = %q; want https://example.com/acme/order/1/finalize", o.FinalizeURL)
|
|
}
|
|
if o.CertificateURL != "https://example.com/acme/cert" {
|
|
t.Errorf("FinalizeURL = %q; want https://example.com/acme/cert", o.CertificateURL)
|
|
}
|
|
}
|
|
|
|
func TestRevokeCert(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
|
|
}
|
|
|
|
var req struct {
|
|
Certificate string
|
|
Reason int
|
|
}
|
|
decodeJWSRequest(t, &req, r)
|
|
if req.Reason != 1 {
|
|
t.Errorf("req.Reason = %d; want 1", req.Reason)
|
|
}
|
|
// echo -n cert | base64 | tr -d '=' | tr '/+' '_-'
|
|
cert := "Y2VydA"
|
|
if req.Certificate != cert {
|
|
t.Errorf("req.Certificate = %q; want %q", req.Certificate, cert)
|
|
}
|
|
}))
|
|
defer ts.Close()
|
|
client := &Client{Key: testKeyEC, accountURL: "https://example.com/acme/account", dir: &Directory{RevokeCertURL: ts.URL, NewNonceURL: ts.URL}}
|
|
ctx := context.Background()
|
|
if err := client.RevokeCert(ctx, nil, []byte("cert"), CRLReasonKeyCompromise); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
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 := &Client{dir: &Directory{NewNonceURL: ts.URL}}
|
|
n, err := c.fetchNonce(context.Background())
|
|
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 := &Client{dir: &Directory{NewNonceURL: ts.URL}}
|
|
_, err := c.fetchNonce(context.Background())
|
|
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_postJWS(t *testing.T) {
|
|
var count int
|
|
seen := make(map[string]bool)
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
count++
|
|
w.Header().Set("Replay-Nonce", fmt.Sprintf("nonce%d", count))
|
|
if r.Method == "HEAD" {
|
|
// We expect the client do a HEAD request
|
|
// but only to fetch the first nonce.
|
|
return
|
|
}
|
|
// Make client.CreateOrder happy; we're not testing its result.
|
|
defer func() {
|
|
w.Header().Set("Location", "https://example.com/acme/order/1")
|
|
w.WriteHeader(http.StatusCreated)
|
|
w.Write([]byte(`{"status":"valid"}`))
|
|
}()
|
|
|
|
head, err := decodeJWSHead(r)
|
|
if err != nil {
|
|
t.Errorf("decodeJWSHead: %v", err)
|
|
return
|
|
}
|
|
if head.Nonce == "" {
|
|
t.Error("head.Nonce is empty")
|
|
return
|
|
}
|
|
if seen[head.Nonce] {
|
|
t.Errorf("nonce is already used: %q", head.Nonce)
|
|
}
|
|
seen[head.Nonce] = true
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client := Client{Key: testKey, accountURL: "https://example.com/acme/account", dir: &Directory{NewOrderURL: ts.URL, NewNonceURL: ts.URL}}
|
|
if _, err := client.CreateOrder(context.Background(), NewOrder("example.com")); err != nil {
|
|
t.Errorf("client.CreateOrder 1: %v", err)
|
|
}
|
|
// The second call should not generate another extra HEAD request.
|
|
if _, err := client.CreateOrder(context.Background(), NewOrder("example.com")); err != nil {
|
|
t.Errorf("client.CreateOrder 2: %v", err)
|
|
}
|
|
|
|
if count != 3 {
|
|
t.Errorf("total requests count: %d; want 3", count)
|
|
}
|
|
if n := len(client.nonces); n != 1 {
|
|
t.Errorf("len(client.nonces) = %d; want 1", n)
|
|
}
|
|
for k := range seen {
|
|
if _, exist := client.nonces[k]; exist {
|
|
t.Errorf("used nonce %q in client.nonces", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRetryPostJWS(t *testing.T) {
|
|
var count int
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
count++
|
|
w.Header().Set("Replay-Nonce", fmt.Sprintf("nonce%d", count))
|
|
if r.Method == "HEAD" {
|
|
// We expect the client to do 2 head requests to fetch
|
|
// nonces, one to start and another after getting badNonce
|
|
return
|
|
}
|
|
|
|
head, err := decodeJWSHead(r)
|
|
if err != nil {
|
|
t.Errorf("decodeJWSHead: %v", err)
|
|
} else if head.Nonce == "" {
|
|
t.Error("head.Nonce is empty")
|
|
} else if head.Nonce == "nonce1" {
|
|
// return a badNonce error to force the call to retry
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte(`{"type":"urn:ietf:params:acme:error:badNonce"}`))
|
|
return
|
|
}
|
|
// Make client.CreateOrder happy; we're not testing its result.
|
|
w.Header().Set("Location", "https://example.com/acme/order/1")
|
|
w.WriteHeader(http.StatusCreated)
|
|
w.Write([]byte(`{"status":"valid"}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client := Client{Key: testKey, accountURL: "https://example.com/acme/account", dir: &Directory{NewOrderURL: ts.URL, NewNonceURL: ts.URL}}
|
|
// This call will fail with badNonce, causing a retry
|
|
if _, err := client.CreateOrder(context.Background(), NewOrder("example.com")); err != nil {
|
|
t.Errorf("client.CreateOrder 1: %v", err)
|
|
}
|
|
if count != 4 {
|
|
t.Errorf("total requests count: %d; want 4", count)
|
|
}
|
|
}
|
|
|
|
func TestErrorResponse(t *testing.T) {
|
|
s := `{
|
|
"status": 400,
|
|
"type": "urn:acme:error:xxx",
|
|
"detail": "text"
|
|
}`
|
|
res := &http.Response{
|
|
StatusCode: 400,
|
|
Status: "400 Bad Request",
|
|
Body: ioutil.NopCloser(strings.NewReader(s)),
|
|
Header: http.Header{"X-Foo": {"bar"}},
|
|
}
|
|
err := responseError(res)
|
|
v, ok := err.(*Error)
|
|
if !ok {
|
|
t.Fatalf("err = %+v (%T); want *Error type", err, err)
|
|
}
|
|
if v.StatusCode != 400 {
|
|
t.Errorf("v.StatusCode = %v; want 400", v.StatusCode)
|
|
}
|
|
if v.Type != "urn:acme:error:xxx" {
|
|
t.Errorf("v.Type = %q; want urn:acme:error:xxx", v.Type)
|
|
}
|
|
if v.Detail != "text" {
|
|
t.Errorf("v.Detail = %q; want text", v.Detail)
|
|
}
|
|
if !reflect.DeepEqual(v.Header, res.Header) {
|
|
t.Errorf("v.Header = %+v; want %+v", v.Header, res.Header)
|
|
}
|
|
}
|
|
|
|
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 := &Client{Key: testKeyEC}
|
|
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"
|
|
|
|
client := &Client{Key: testKeyEC}
|
|
val, err := client.DNS01ChallengeRecord("xxx")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if val != value {
|
|
t.Errorf("val = %q; want %q", val, value)
|
|
}
|
|
}
|
|
|
|
func TestBackoff(t *testing.T) {
|
|
tt := []struct{ min, max time.Duration }{
|
|
{time.Second, 2 * time.Second},
|
|
{2 * time.Second, 3 * time.Second},
|
|
{4 * time.Second, 5 * time.Second},
|
|
{8 * time.Second, 9 * time.Second},
|
|
}
|
|
for i, test := range tt {
|
|
d := backoff(i, time.Minute)
|
|
if d < test.min || test.max < d {
|
|
t.Errorf("%d: d = %v; want between %v and %v", i, d, test.min, test.max)
|
|
}
|
|
}
|
|
|
|
min, max := time.Second, 2*time.Second
|
|
if d := backoff(-1, time.Minute); d < min || max < d {
|
|
t.Errorf("d = %v; want between %v and %v", d, min, max)
|
|
}
|
|
|
|
bound := 10 * time.Second
|
|
if d := backoff(100, bound); d != bound {
|
|
t.Errorf("d = %v; want %v", d, bound)
|
|
}
|
|
}
|