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>
322 lines
8.1 KiB
Go
322 lines
8.1 KiB
Go
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
|
|
}
|