Add comprehensive ACME test suite (60 tests, 2100 lines)

Test coverage for the entire ACME server implementation:

- helpers_test.go: memBarrier, key generation, JWS/EAB signing, test fixtures
- nonce_test.go: issue/consume lifecycle, reuse rejection, concurrency
- jws_test.go: JWS parsing/verification (ES256, ES384, RS256), JWK parsing,
  RFC 7638 thumbprints, EAB HMAC verification, key authorization
- eab_test.go: EAB credential CRUD, account/order listing
- validate_test.go: HTTP-01 challenge validation with httptest servers,
  authorization/order state machine transitions
- handlers_test.go: full ACME protocol flow via chi router — directory,
  nonce, account creation with EAB, order creation, authorization retrieval,
  challenge triggering, finalize (order-not-ready), cert retrieval/revocation,
  CSR identifier validation

One production change: extract dnsResolver variable in validate.go for
DNS-01 test injection (no behavior change).

All 60 tests pass with -race. Full project vet and test clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 21:01:23 -07:00
parent 7f9e7f433f
commit 7749c035ae
8 changed files with 2101 additions and 3 deletions

View File

@@ -0,0 +1,321 @@
package acme
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"math/big"
"strings"
"sync"
"testing"
"time"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
)
// memBarrier is an in-memory barrier for testing.
type memBarrier struct {
data map[string][]byte
mu sync.RWMutex
}
func newMemBarrier() *memBarrier {
return &memBarrier{data: make(map[string][]byte)}
}
func (m *memBarrier) Unseal(_ []byte) error { return nil }
func (m *memBarrier) Seal() error { return nil }
func (m *memBarrier) IsSealed() bool { return false }
func (m *memBarrier) Get(_ context.Context, path string) ([]byte, error) {
m.mu.RLock()
defer m.mu.RUnlock()
v, ok := m.data[path]
if !ok {
return nil, barrier.ErrNotFound
}
cp := make([]byte, len(v))
copy(cp, v)
return cp, nil
}
func (m *memBarrier) Put(_ context.Context, path string, value []byte) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := make([]byte, len(value))
copy(cp, value)
m.data[path] = cp
return nil
}
func (m *memBarrier) Delete(_ context.Context, path string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.data, path)
return nil
}
func (m *memBarrier) List(_ context.Context, prefix string) ([]string, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var paths []string
for k := range m.data {
if strings.HasPrefix(k, prefix) {
paths = append(paths, strings.TrimPrefix(k, prefix))
}
}
return paths, nil
}
// generateES256Key generates an ECDSA P-256 key pair and returns the private
// key along with a JWK JSON representation of the public key.
func generateES256Key(t *testing.T) (*ecdsa.PrivateKey, json.RawMessage) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate ES256 key: %v", err)
}
byteLen := (key.Curve.Params().BitSize + 7) / 8
xBytes := key.PublicKey.X.Bytes()
yBytes := key.PublicKey.Y.Bytes()
// Pad to curve byte length.
for len(xBytes) < byteLen {
xBytes = append([]byte{0}, xBytes...)
}
for len(yBytes) < byteLen {
yBytes = append([]byte{0}, yBytes...)
}
jwk, err := json.Marshal(map[string]string{
"kty": "EC",
"crv": "P-256",
"x": base64.RawURLEncoding.EncodeToString(xBytes),
"y": base64.RawURLEncoding.EncodeToString(yBytes),
})
if err != nil {
t.Fatalf("marshal ES256 JWK: %v", err)
}
return key, json.RawMessage(jwk)
}
// generateRSA2048Key generates an RSA 2048-bit key pair and returns the
// private key along with a JWK JSON representation of the public key.
func generateRSA2048Key(t *testing.T) (*rsa.PrivateKey, json.RawMessage) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate RSA 2048 key: %v", err)
}
nBytes := key.PublicKey.N.Bytes()
eBytes := big.NewInt(int64(key.PublicKey.E)).Bytes()
jwk, err := json.Marshal(map[string]string{
"kty": "RSA",
"n": base64.RawURLEncoding.EncodeToString(nBytes),
"e": base64.RawURLEncoding.EncodeToString(eBytes),
})
if err != nil {
t.Fatalf("marshal RSA JWK: %v", err)
}
return key, json.RawMessage(jwk)
}
// ecdsaSigASN1 is used to decode an ASN.1 ECDSA signature into R and S.
type ecdsaSigASN1 struct {
R *big.Int
S *big.Int
}
// signJWS creates a valid JWS in flattened serialization.
func signJWS(t *testing.T, key crypto.Signer, alg string, header JWSHeader, payload []byte) []byte {
t.Helper()
headerJSON, err := json.Marshal(header)
if err != nil {
t.Fatalf("marshal JWS header: %v", err)
}
protected := base64.RawURLEncoding.EncodeToString(headerJSON)
var encodedPayload string
if payload != nil {
encodedPayload = base64.RawURLEncoding.EncodeToString(payload)
}
signingInput := []byte(protected + "." + encodedPayload)
var sig []byte
switch alg {
case "ES256", "ES384":
var hashFunc crypto.Hash
if alg == "ES256" {
hashFunc = crypto.SHA256
} else {
hashFunc = crypto.SHA384
}
h := hashFunc.New()
h.Write(signingInput)
digest := h.Sum(nil)
derSig, err := ecdsa.SignASN1(rand.Reader, key.(*ecdsa.PrivateKey), digest)
if err != nil {
t.Fatalf("sign ECDSA: %v", err)
}
var parsed ecdsaSigASN1
if _, err := asn1.Unmarshal(derSig, &parsed); err != nil {
t.Fatalf("unmarshal ECDSA ASN.1 signature: %v", err)
}
ecKey := key.(*ecdsa.PrivateKey)
byteLen := (ecKey.Curve.Params().BitSize + 7) / 8
rBytes := parsed.R.Bytes()
sBytes := parsed.S.Bytes()
for len(rBytes) < byteLen {
rBytes = append([]byte{0}, rBytes...)
}
for len(sBytes) < byteLen {
sBytes = append([]byte{0}, sBytes...)
}
sig = append(rBytes, sBytes...)
case "RS256":
digest := sha256.Sum256(signingInput)
rsaSig, err := rsa.SignPKCS1v15(rand.Reader, key.(*rsa.PrivateKey), crypto.SHA256, digest[:])
if err != nil {
t.Fatalf("sign RSA: %v", err)
}
sig = rsaSig
default:
t.Fatalf("unsupported algorithm: %s", alg)
}
flat := JWSFlat{
Protected: protected,
Payload: encodedPayload,
Signature: base64.RawURLEncoding.EncodeToString(sig),
}
out, err := json.Marshal(flat)
if err != nil {
t.Fatalf("marshal JWSFlat: %v", err)
}
return out
}
// signEAB creates a valid EAB inner JWS (RFC 8555 section 7.3.4).
func signEAB(t *testing.T, kid string, hmacKey []byte, accountJWK json.RawMessage) json.RawMessage {
t.Helper()
header := map[string]string{
"alg": "HS256",
"kid": kid,
"url": "https://acme.test/acme/test-pki/new-account",
}
headerJSON, err := json.Marshal(header)
if err != nil {
t.Fatalf("marshal EAB header: %v", err)
}
protected := base64.RawURLEncoding.EncodeToString(headerJSON)
encodedPayload := base64.RawURLEncoding.EncodeToString(accountJWK)
signingInput := []byte(protected + "." + encodedPayload)
mac := hmac.New(sha256.New, hmacKey)
mac.Write(signingInput)
sig := mac.Sum(nil)
flat := JWSFlat{
Protected: protected,
Payload: encodedPayload,
Signature: base64.RawURLEncoding.EncodeToString(sig),
}
out, err := json.Marshal(flat)
if err != nil {
t.Fatalf("marshal EAB JWSFlat: %v", err)
}
return json.RawMessage(out)
}
// testHandler creates a Handler with in-memory barrier for testing.
func testHandler(t *testing.T) *Handler {
t.Helper()
b := newMemBarrier()
h := &Handler{
mount: "test-pki",
barrier: b,
engines: nil,
nonces: NewNonceStore(),
baseURL: "https://acme.test",
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
// Store a default ACME config with a test issuer.
cfg := &ACMEConfig{DefaultIssuer: "test-issuer"}
cfgData, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal ACME config: %v", err)
}
if err := b.Put(context.Background(), h.barrierPrefix()+"config.json", cfgData); err != nil {
t.Fatalf("store ACME config: %v", err)
}
return h
}
// createTestAccount creates an ACME account in the handler's barrier,
// bypassing the HTTP handler. Returns the account, private key, and JWK.
func createTestAccount(t *testing.T, h *Handler) (*Account, *ecdsa.PrivateKey, json.RawMessage) {
t.Helper()
ctx := context.Background()
key, jwk := generateES256Key(t)
// Create an EAB credential.
eab, err := h.CreateEAB(ctx, "test-user")
if err != nil {
t.Fatalf("create EAB: %v", err)
}
// Mark the EAB as used since we're storing the account directly.
if err := h.MarkEABUsed(ctx, eab.KID); err != nil {
t.Fatalf("mark EAB used: %v", err)
}
// Compute the account ID from the JWK thumbprint.
accountID := thumbprintKey(jwk)
acc := &Account{
ID: accountID,
Status: StatusValid,
Contact: []string{"mailto:test@example.com"},
JWK: jwk,
CreatedAt: time.Now(),
MCIASUsername: "test-user",
}
data, err := json.Marshal(acc)
if err != nil {
t.Fatalf("marshal account: %v", err)
}
path := h.barrierPrefix() + "accounts/" + accountID + ".json"
if err := h.barrier.Put(ctx, path, data); err != nil {
t.Fatalf("store account: %v", err)
}
return acc, key, jwk
}
// getNonce issues a nonce from the handler's nonce store and returns it.
func getNonce(t *testing.T, h *Handler) string {
t.Helper()
nonce, err := h.nonces.Issue()
if err != nil {
t.Fatalf("issue nonce: %v", err)
}
return nonce
}