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:
321
internal/acme/helpers_test.go
Normal file
321
internal/acme/helpers_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user