Tests run against a real Metacrypt instance, guarded by //go:build integration tag and METACRYPT_LIVE_TEST=1 env var. Covers: directory discovery, nonce issuance, full account creation with EAB (including reuse rejection), order creation with DNS identifiers, and authorization retrieval with HTTP-01/DNS-01 challenges. Handles server/client URL mismatch when ExternalURL is not configured (JWS URL fields use server's base URL, HTTP requests use the configured address). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
852 lines
24 KiB
Go
852 lines
24 KiB
Go
//go:build integration
|
|
|
|
package acme_test // external test package — tests the public surface
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/asn1"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func skipIfNotLive(t *testing.T) {
|
|
t.Helper()
|
|
if os.Getenv("METACRYPT_LIVE_TEST") != "1" {
|
|
t.Skip("set METACRYPT_LIVE_TEST=1 to run live integration tests")
|
|
}
|
|
}
|
|
|
|
func liveAddr() string { return envOr("METACRYPT_ADDR", "https://127.0.0.1:18443") }
|
|
func liveMount() string { return envOr("METACRYPT_ACME_MOUNT", "pki") }
|
|
func liveToken() string { return os.Getenv("METACRYPT_TOKEN") }
|
|
|
|
func envOr(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// liveHTTPClient returns an *http.Client configured with the CA cert from
|
|
// METACRYPT_CA_CERT, falling back to InsecureSkipVerify if unset.
|
|
func liveHTTPClient(t *testing.T) *http.Client {
|
|
t.Helper()
|
|
|
|
tlsCfg := &tls.Config{
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
|
|
caPath := os.Getenv("METACRYPT_CA_CERT")
|
|
if caPath != "" {
|
|
caPEM, err := os.ReadFile(caPath)
|
|
if err != nil {
|
|
t.Fatalf("read CA cert %s: %v", caPath, err)
|
|
}
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(caPEM) {
|
|
t.Fatalf("failed to parse CA cert from %s", caPath)
|
|
}
|
|
tlsCfg.RootCAs = pool
|
|
} else {
|
|
tlsCfg.InsecureSkipVerify = true //nolint:gosec
|
|
}
|
|
|
|
return &http.Client{
|
|
Transport: &http.Transport{TLSClientConfig: tlsCfg},
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// liveTestEnv — shared per-test state with lazy account creation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type liveTestEnv struct {
|
|
t *testing.T
|
|
client *http.Client
|
|
addr string
|
|
mount string
|
|
token string
|
|
|
|
// directory is fetched once to discover the server's base URL.
|
|
directory map[string]interface{}
|
|
dirFetched bool
|
|
acmeBaseURL string // our reachable base URL for HTTP requests
|
|
serverBaseURL string // the server's own base URL, for JWS URL fields
|
|
|
|
// lazily initialized
|
|
accountURL string // full URL returned in Location header
|
|
accountID string
|
|
key *ecdsa.PrivateKey
|
|
jwk json.RawMessage
|
|
}
|
|
|
|
func newLiveEnv(t *testing.T) *liveTestEnv {
|
|
t.Helper()
|
|
return &liveTestEnv{
|
|
t: t,
|
|
client: liveHTTPClient(t),
|
|
addr: liveAddr(),
|
|
mount: liveMount(),
|
|
token: liveToken(),
|
|
}
|
|
}
|
|
|
|
// acmeURL returns the full URL for an ACME path (e.g. "/directory").
|
|
// It uses the server's own base URL discovered from the directory.
|
|
func (e *liveTestEnv) acmeURL(path string) string {
|
|
e.t.Helper()
|
|
e.ensureDirectory()
|
|
return e.acmeBaseURL + path
|
|
}
|
|
|
|
// rawACMEURL returns a URL without consulting the directory — for the
|
|
// initial directory fetch itself.
|
|
func (e *liveTestEnv) rawACMEURL(path string) string {
|
|
return e.addr + "/acme/" + e.mount + path
|
|
}
|
|
|
|
// jwsURL returns the URL the server expects in JWS URL fields. This may
|
|
// differ from acmeURL when the server's ExternalURL is not configured.
|
|
func (e *liveTestEnv) jwsURL(path string) string {
|
|
e.t.Helper()
|
|
e.ensureDirectory()
|
|
return e.serverBaseURL + path
|
|
}
|
|
|
|
// mgmtURL returns the full URL for a management API path.
|
|
func (e *liveTestEnv) mgmtURL(path string) string {
|
|
return e.addr + "/v1/acme/" + e.mount + path
|
|
}
|
|
|
|
// ensureDirectory fetches and caches the ACME directory.
|
|
func (e *liveTestEnv) ensureDirectory() {
|
|
e.t.Helper()
|
|
if e.dirFetched {
|
|
return
|
|
}
|
|
|
|
resp, err := e.client.Get(e.rawACMEURL("/directory"))
|
|
if err != nil {
|
|
e.t.Fatalf("GET directory: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
e.t.Fatalf("GET directory: status %d: %s", resp.StatusCode, body)
|
|
}
|
|
|
|
var dir map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&dir); err != nil {
|
|
e.t.Fatalf("decode directory: %v", err)
|
|
}
|
|
e.directory = dir
|
|
e.dirFetched = true
|
|
|
|
// Our reachable base URL for HTTP requests.
|
|
e.acmeBaseURL = e.addr + "/acme/" + e.mount
|
|
|
|
// The server's own base URL, used for JWS URL fields which must match
|
|
// what the server expects. May differ from our reachable URL when the
|
|
// server's ExternalURL is misconfigured.
|
|
newNonce, ok := dir["newNonce"].(string)
|
|
if !ok || newNonce == "" {
|
|
e.t.Fatalf("directory missing newNonce")
|
|
}
|
|
e.serverBaseURL = strings.TrimSuffix(newNonce, "/new-nonce")
|
|
}
|
|
|
|
// getNonce fetches a fresh replay nonce from the server.
|
|
func (e *liveTestEnv) getNonce() string {
|
|
e.t.Helper()
|
|
e.ensureDirectory()
|
|
|
|
req, err := http.NewRequest(http.MethodHead, e.acmeURL("/new-nonce"), nil)
|
|
if err != nil {
|
|
e.t.Fatalf("build nonce request: %v", err)
|
|
}
|
|
resp, err := e.client.Do(req)
|
|
if err != nil {
|
|
e.t.Fatalf("HEAD new-nonce: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
nonce := resp.Header.Get("Replay-Nonce")
|
|
if nonce == "" {
|
|
e.t.Fatal("Replay-Nonce header is empty")
|
|
}
|
|
return nonce
|
|
}
|
|
|
|
// createEAB calls the management API to create an EAB credential.
|
|
// Returns kid and hmacKey (raw bytes).
|
|
func (e *liveTestEnv) createEAB() (kid string, hmacKey []byte) {
|
|
e.t.Helper()
|
|
if e.token == "" {
|
|
e.t.Fatal("METACRYPT_TOKEN is required for EAB creation")
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodPost, e.mgmtURL("/eab"), nil)
|
|
if err != nil {
|
|
e.t.Fatalf("build EAB request: %v", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+e.token)
|
|
|
|
resp, err := e.client.Do(req)
|
|
if err != nil {
|
|
e.t.Fatalf("POST eab: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
e.t.Fatalf("POST eab: status %d: %s", resp.StatusCode, body)
|
|
}
|
|
|
|
var eabResp struct {
|
|
KID string `json:"kid"`
|
|
HMACKey []byte `json:"hmac_key"` // Go json.Marshal of []byte produces base64
|
|
}
|
|
if err := json.Unmarshal(body, &eabResp); err != nil {
|
|
e.t.Fatalf("decode EAB response: %v (body: %s)", err, body)
|
|
}
|
|
if eabResp.KID == "" {
|
|
e.t.Fatalf("EAB response missing kid (body: %s)", body)
|
|
}
|
|
if len(eabResp.HMACKey) == 0 {
|
|
e.t.Fatalf("EAB response missing hmac_key (body: %s)", body)
|
|
}
|
|
return eabResp.KID, eabResp.HMACKey
|
|
}
|
|
|
|
// generateKey creates an ECDSA P-256 key pair and a JWK representation.
|
|
func (e *liveTestEnv) generateKey() (*ecdsa.PrivateKey, json.RawMessage) {
|
|
e.t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
e.t.Fatalf("generate ES256 key: %v", err)
|
|
}
|
|
byteLen := (key.Curve.Params().BitSize + 7) / 8
|
|
xBytes := key.PublicKey.X.Bytes()
|
|
yBytes := key.PublicKey.Y.Bytes()
|
|
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 {
|
|
e.t.Fatalf("marshal JWK: %v", err)
|
|
}
|
|
return key, json.RawMessage(jwk)
|
|
}
|
|
|
|
// ensureAccount creates an ACME account if not already created.
|
|
func (e *liveTestEnv) ensureAccount() {
|
|
e.t.Helper()
|
|
if e.accountURL != "" {
|
|
return
|
|
}
|
|
|
|
key, jwk := e.generateKey()
|
|
e.key = key
|
|
e.jwk = jwk
|
|
|
|
kid, hmacKey := e.createEAB()
|
|
|
|
nonce := e.getNonce()
|
|
|
|
// JWS URL field must match the server's own base URL.
|
|
jwsAccountURL := e.jwsURL("/new-account")
|
|
eabJWS := liveSignEAB(e.t, kid, hmacKey, jwk, jwsAccountURL)
|
|
|
|
// Build the new-account payload.
|
|
payload, err := json.Marshal(map[string]interface{}{
|
|
"termsOfServiceAgreed": true,
|
|
"contact": []string{"mailto:live-test@metacircular.net"},
|
|
"externalAccountBinding": json.RawMessage(eabJWS),
|
|
})
|
|
if err != nil {
|
|
e.t.Fatalf("marshal new-account payload: %v", err)
|
|
}
|
|
|
|
// Build the outer JWS with embedded JWK (new-account uses JWK, not KID).
|
|
header := map[string]interface{}{
|
|
"alg": "ES256",
|
|
"nonce": nonce,
|
|
"url": jwsAccountURL,
|
|
"jwk": json.RawMessage(jwk),
|
|
}
|
|
body := liveSignJWS(e.t, key, "ES256", header, payload)
|
|
|
|
// POST to our reachable URL (may differ from JWS URL).
|
|
resp, err := e.client.Post(e.acmeURL("/new-account"), "application/jose+json", strings.NewReader(string(body)))
|
|
if err != nil {
|
|
e.t.Fatalf("POST new-account: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
|
e.t.Fatalf("POST new-account: status %d: %s", resp.StatusCode, respBody)
|
|
}
|
|
|
|
location := resp.Header.Get("Location")
|
|
if location == "" {
|
|
e.t.Fatal("new-account response missing Location header")
|
|
}
|
|
e.accountURL = location
|
|
|
|
// Extract account ID from the Location URL (last path segment after /account/).
|
|
if idx := strings.LastIndex(location, "/account/"); idx >= 0 {
|
|
e.accountID = location[idx+len("/account/"):]
|
|
} else {
|
|
// Use the full URL as the ID / KID.
|
|
e.accountID = location
|
|
}
|
|
}
|
|
|
|
// postAsGet sends a POST-as-GET request (empty payload, KID auth).
|
|
// httpURL is where to send the request; jwsURL is the URL in the JWS header.
|
|
func (e *liveTestEnv) postAsGet(httpURL, jwsURL string) (*http.Response, []byte) {
|
|
e.t.Helper()
|
|
e.ensureAccount()
|
|
|
|
nonce := e.getNonce()
|
|
header := map[string]interface{}{
|
|
"alg": "ES256",
|
|
"nonce": nonce,
|
|
"url": jwsURL,
|
|
"kid": e.accountURL,
|
|
}
|
|
body := liveSignJWS(e.t, e.key, "ES256", header, nil) // nil payload = POST-as-GET
|
|
|
|
resp, err := e.client.Post(httpURL, "application/jose+json", strings.NewReader(string(body)))
|
|
if err != nil {
|
|
e.t.Fatalf("POST-as-GET %s: %v", httpURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return resp, respBody
|
|
}
|
|
|
|
// postSigned sends a signed POST with a payload (KID auth).
|
|
// httpURL is where to send the request; jwsURL is the URL in the JWS header.
|
|
func (e *liveTestEnv) postSigned(httpURL, jwsURL string, payload []byte) (*http.Response, []byte) {
|
|
e.t.Helper()
|
|
e.ensureAccount()
|
|
|
|
nonce := e.getNonce()
|
|
header := map[string]interface{}{
|
|
"alg": "ES256",
|
|
"nonce": nonce,
|
|
"url": jwsURL,
|
|
"kid": e.accountURL,
|
|
}
|
|
body := liveSignJWS(e.t, e.key, "ES256", header, payload)
|
|
|
|
resp, err := e.client.Post(httpURL, "application/jose+json", strings.NewReader(string(body)))
|
|
if err != nil {
|
|
e.t.Fatalf("POST %s: %v", httpURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return resp, respBody
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JWS signing helpers (local implementations for external test package)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ecdsaSigASN1 is used to decode an ASN.1 ECDSA signature into R and S.
|
|
type ecdsaSigASN1 struct {
|
|
R *big.Int
|
|
S *big.Int
|
|
}
|
|
|
|
// liveSignJWS creates a flattened JWS. header is marshaled as the protected
|
|
// header. If payload is nil, the payload field is empty (POST-as-GET).
|
|
func liveSignJWS(t *testing.T, key *ecdsa.PrivateKey, alg string, header map[string]interface{}, 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":
|
|
digest := sha256.Sum256(signingInput)
|
|
derSig, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
|
|
if err != nil {
|
|
t.Fatalf("sign ES256: %v", err)
|
|
}
|
|
var parsed ecdsaSigASN1
|
|
if _, err := asn1.Unmarshal(derSig, &parsed); err != nil {
|
|
t.Fatalf("unmarshal ECDSA ASN.1 sig: %v", err)
|
|
}
|
|
byteLen := (key.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...)
|
|
default:
|
|
t.Fatalf("liveSignJWS: unsupported algorithm %s", alg)
|
|
}
|
|
|
|
flat := struct {
|
|
Protected string `json:"protected"`
|
|
Payload string `json:"payload"`
|
|
Signature string `json:"signature"`
|
|
}{
|
|
Protected: protected,
|
|
Payload: encodedPayload,
|
|
Signature: base64.RawURLEncoding.EncodeToString(sig),
|
|
}
|
|
out, err := json.Marshal(flat)
|
|
if err != nil {
|
|
t.Fatalf("marshal JWS flat: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// liveSignEAB creates a valid EAB inner JWS (RFC 8555 section 7.3.4).
|
|
// The EAB binds the account JWK to the EAB kid using HMAC-SHA256.
|
|
func liveSignEAB(t *testing.T, kid string, hmacKey []byte, accountJWK json.RawMessage, url string) []byte {
|
|
t.Helper()
|
|
|
|
header := map[string]string{
|
|
"alg": "HS256",
|
|
"kid": kid,
|
|
"url": url,
|
|
}
|
|
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 := struct {
|
|
Protected string `json:"protected"`
|
|
Payload string `json:"payload"`
|
|
Signature string `json:"signature"`
|
|
}{
|
|
Protected: protected,
|
|
Payload: encodedPayload,
|
|
Signature: base64.RawURLEncoding.EncodeToString(sig),
|
|
}
|
|
out, err := json.Marshal(flat)
|
|
if err != nil {
|
|
t.Fatalf("marshal EAB flat: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestLiveDirectory(t *testing.T) {
|
|
skipIfNotLive(t)
|
|
env := newLiveEnv(t)
|
|
|
|
resp, err := env.client.Get(env.rawACMEURL("/directory"))
|
|
if err != nil {
|
|
t.Fatalf("GET directory: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body)
|
|
}
|
|
|
|
var dir map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&dir); err != nil {
|
|
t.Fatalf("decode directory: %v", err)
|
|
}
|
|
|
|
for _, field := range []string{"newNonce", "newAccount", "newOrder"} {
|
|
v, ok := dir[field]
|
|
if !ok {
|
|
t.Errorf("directory missing field %q", field)
|
|
continue
|
|
}
|
|
s, ok := v.(string)
|
|
if !ok || s == "" {
|
|
t.Errorf("directory field %q is empty or not a string: %v", field, v)
|
|
}
|
|
}
|
|
|
|
meta, ok := dir["meta"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatal("directory missing meta object")
|
|
}
|
|
ear, ok := meta["externalAccountRequired"].(bool)
|
|
if !ok || !ear {
|
|
t.Errorf("expected meta.externalAccountRequired=true, got %v", meta["externalAccountRequired"])
|
|
}
|
|
}
|
|
|
|
func TestLiveNonce(t *testing.T) {
|
|
skipIfNotLive(t)
|
|
env := newLiveEnv(t)
|
|
env.ensureDirectory()
|
|
|
|
req, err := http.NewRequest(http.MethodHead, env.acmeURL("/new-nonce"), nil)
|
|
if err != nil {
|
|
t.Fatalf("build request: %v", err)
|
|
}
|
|
resp, err := env.client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("HEAD new-nonce: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
nonce := resp.Header.Get("Replay-Nonce")
|
|
if nonce == "" {
|
|
t.Fatal("Replay-Nonce header is empty")
|
|
}
|
|
|
|
// Verify a second request returns a different nonce.
|
|
req2, _ := http.NewRequest(http.MethodHead, env.acmeURL("/new-nonce"), nil)
|
|
resp2, err := env.client.Do(req2)
|
|
if err != nil {
|
|
t.Fatalf("second HEAD new-nonce: %v", err)
|
|
}
|
|
defer resp2.Body.Close()
|
|
nonce2 := resp2.Header.Get("Replay-Nonce")
|
|
if nonce2 == "" {
|
|
t.Fatal("second Replay-Nonce header is empty")
|
|
}
|
|
if nonce == nonce2 {
|
|
t.Error("two nonces should not be identical")
|
|
}
|
|
}
|
|
|
|
func TestLiveCreateEABAndAccount(t *testing.T) {
|
|
skipIfNotLive(t)
|
|
env := newLiveEnv(t)
|
|
|
|
// a) Create EAB via management API.
|
|
kid, hmacKey := env.createEAB()
|
|
if kid == "" {
|
|
t.Fatal("EAB kid is empty")
|
|
}
|
|
if len(hmacKey) == 0 {
|
|
t.Fatal("EAB hmac_key is empty")
|
|
}
|
|
|
|
// b) Get a nonce.
|
|
nonce := env.getNonce()
|
|
if nonce == "" {
|
|
t.Fatal("nonce is empty")
|
|
}
|
|
|
|
// c) Generate an ES256 key pair.
|
|
key, jwk := env.generateKey()
|
|
|
|
// d) Construct EAB inner JWS.
|
|
jwsAccountURL := env.jwsURL("/new-account")
|
|
eabJWS := liveSignEAB(t, kid, hmacKey, jwk, jwsAccountURL)
|
|
|
|
// e) Construct the new-account JWS with EAB.
|
|
payload, err := json.Marshal(map[string]interface{}{
|
|
"termsOfServiceAgreed": true,
|
|
"contact": []string{"mailto:live-test@metacircular.net"},
|
|
"externalAccountBinding": json.RawMessage(eabJWS),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal payload: %v", err)
|
|
}
|
|
|
|
header := map[string]interface{}{
|
|
"alg": "ES256",
|
|
"nonce": nonce,
|
|
"url": jwsAccountURL,
|
|
"jwk": json.RawMessage(jwk),
|
|
}
|
|
body := liveSignJWS(t, key, "ES256", header, payload)
|
|
|
|
// f) POST to new-account (use our reachable URL, not the JWS URL).
|
|
resp, err := env.client.Post(env.acmeURL("/new-account"), "application/jose+json", strings.NewReader(string(body)))
|
|
if err != nil {
|
|
t.Fatalf("POST new-account: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
|
|
// g) Verify response.
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, respBody)
|
|
}
|
|
|
|
location := resp.Header.Get("Location")
|
|
if location == "" {
|
|
t.Fatal("Location header missing from new-account response")
|
|
}
|
|
t.Logf("account created: %s", location)
|
|
|
|
var acctResp map[string]interface{}
|
|
if err := json.Unmarshal(respBody, &acctResp); err != nil {
|
|
t.Fatalf("decode account response: %v", err)
|
|
}
|
|
status, _ := acctResp["status"].(string)
|
|
if status != "valid" {
|
|
t.Errorf("expected account status 'valid', got %q", status)
|
|
}
|
|
|
|
// Verify the EAB is now consumed — re-using it should fail.
|
|
nonce2 := env.getNonce()
|
|
eabJWS2 := liveSignEAB(t, kid, hmacKey, jwk, jwsAccountURL)
|
|
key2, jwk2 := env.generateKey()
|
|
payload2, _ := json.Marshal(map[string]interface{}{
|
|
"termsOfServiceAgreed": true,
|
|
"contact": []string{"mailto:live-test-2@metacircular.net"},
|
|
"externalAccountBinding": json.RawMessage(eabJWS2),
|
|
})
|
|
header2 := map[string]interface{}{
|
|
"alg": "ES256",
|
|
"nonce": nonce2,
|
|
"url": jwsAccountURL,
|
|
"jwk": json.RawMessage(jwk2),
|
|
}
|
|
body2 := liveSignJWS(t, key2, "ES256", header2, payload2)
|
|
resp2, err := env.client.Post(env.acmeURL("/new-account"), "application/jose+json", strings.NewReader(string(body2)))
|
|
if err != nil {
|
|
t.Fatalf("POST new-account (reuse EAB): %v", err)
|
|
}
|
|
defer resp2.Body.Close()
|
|
if resp2.StatusCode == http.StatusCreated {
|
|
t.Error("expected EAB reuse to be rejected, but got 201")
|
|
}
|
|
}
|
|
|
|
func TestLiveNewOrder(t *testing.T) {
|
|
skipIfNotLive(t)
|
|
env := newLiveEnv(t)
|
|
env.ensureAccount()
|
|
|
|
orderPayload, err := json.Marshal(map[string]interface{}{
|
|
"identifiers": []map[string]string{
|
|
{"type": "dns", "value": "live-test.svc.mcp.metacircular.net"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal order payload: %v", err)
|
|
}
|
|
|
|
resp, respBody := env.postSigned(env.acmeURL("/new-order"), env.jwsURL("/new-order"), orderPayload)
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, respBody)
|
|
}
|
|
|
|
var order map[string]interface{}
|
|
if err := json.Unmarshal(respBody, &order); err != nil {
|
|
t.Fatalf("decode order: %v", err)
|
|
}
|
|
|
|
status, _ := order["status"].(string)
|
|
if status != "pending" {
|
|
t.Errorf("expected order status 'pending', got %q", status)
|
|
}
|
|
|
|
identifiers, ok := order["identifiers"].([]interface{})
|
|
if !ok || len(identifiers) == 0 {
|
|
t.Fatal("order missing identifiers")
|
|
}
|
|
firstID, _ := identifiers[0].(map[string]interface{})
|
|
if idType, _ := firstID["type"].(string); idType != "dns" {
|
|
t.Errorf("expected identifier type 'dns', got %q", idType)
|
|
}
|
|
if idVal, _ := firstID["value"].(string); idVal != "live-test.svc.mcp.metacircular.net" {
|
|
t.Errorf("expected identifier value 'live-test.svc.mcp.metacircular.net', got %q", idVal)
|
|
}
|
|
|
|
authzURLs, ok := order["authorizations"].([]interface{})
|
|
if !ok || len(authzURLs) == 0 {
|
|
t.Fatal("order missing authorizations")
|
|
}
|
|
t.Logf("order created with %d authorization(s)", len(authzURLs))
|
|
|
|
location := resp.Header.Get("Location")
|
|
if location == "" {
|
|
t.Error("order response missing Location header")
|
|
}
|
|
}
|
|
|
|
func TestLiveGetAuthz(t *testing.T) {
|
|
skipIfNotLive(t)
|
|
env := newLiveEnv(t)
|
|
env.ensureAccount()
|
|
|
|
// Create an order to get an authorization URL.
|
|
orderPayload, _ := json.Marshal(map[string]interface{}{
|
|
"identifiers": []map[string]string{
|
|
{"type": "dns", "value": "live-authz-test.svc.mcp.metacircular.net"},
|
|
},
|
|
})
|
|
|
|
resp, respBody := env.postSigned(env.acmeURL("/new-order"), env.jwsURL("/new-order"), orderPayload)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create order: status %d: %s", resp.StatusCode, respBody)
|
|
}
|
|
|
|
var order map[string]interface{}
|
|
if err := json.Unmarshal(respBody, &order); err != nil {
|
|
t.Fatalf("decode order: %v", err)
|
|
}
|
|
|
|
authzURLs, ok := order["authorizations"].([]interface{})
|
|
if !ok || len(authzURLs) == 0 {
|
|
t.Fatal("order has no authorizations")
|
|
}
|
|
authzURL, _ := authzURLs[0].(string)
|
|
if authzURL == "" {
|
|
t.Fatal("authorization URL is empty")
|
|
}
|
|
|
|
// POST-as-GET to the authorization URL.
|
|
// authzURL uses the server's base URL; translate for HTTP request.
|
|
reachableAuthzURL := strings.Replace(authzURL, env.serverBaseURL, env.acmeBaseURL, 1)
|
|
authzResp, authzBody := env.postAsGet(reachableAuthzURL, authzURL)
|
|
if authzResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET authz: status %d: %s", authzResp.StatusCode, authzBody)
|
|
}
|
|
|
|
var authz map[string]interface{}
|
|
if err := json.Unmarshal(authzBody, &authz); err != nil {
|
|
t.Fatalf("decode authz: %v", err)
|
|
}
|
|
|
|
authzStatus, _ := authz["status"].(string)
|
|
if authzStatus != "pending" {
|
|
t.Errorf("expected authz status 'pending', got %q", authzStatus)
|
|
}
|
|
|
|
identifier, _ := authz["identifier"].(map[string]interface{})
|
|
if idType, _ := identifier["type"].(string); idType != "dns" {
|
|
t.Errorf("expected identifier type 'dns', got %q", idType)
|
|
}
|
|
if idVal, _ := identifier["value"].(string); idVal != "live-authz-test.svc.mcp.metacircular.net" {
|
|
t.Errorf("expected identifier value 'live-authz-test.svc.mcp.metacircular.net', got %q", idVal)
|
|
}
|
|
|
|
challenges, ok := authz["challenges"].([]interface{})
|
|
if !ok || len(challenges) == 0 {
|
|
t.Fatal("authz has no challenges")
|
|
}
|
|
|
|
// Verify we have http-01 and dns-01 challenge types.
|
|
challengeTypes := make(map[string]bool)
|
|
for _, c := range challenges {
|
|
ch, _ := c.(map[string]interface{})
|
|
cType, _ := ch["type"].(string)
|
|
challengeTypes[cType] = true
|
|
|
|
// Each challenge should have a token and status.
|
|
token, _ := ch["token"].(string)
|
|
if token == "" {
|
|
t.Errorf("challenge %s missing token", cType)
|
|
}
|
|
cStatus, _ := ch["status"].(string)
|
|
if cStatus == "" {
|
|
t.Errorf("challenge %s missing status", cType)
|
|
}
|
|
cURL, _ := ch["url"].(string)
|
|
if cURL == "" {
|
|
t.Errorf("challenge %s missing url", cType)
|
|
}
|
|
}
|
|
|
|
if !challengeTypes["http-01"] {
|
|
t.Error("expected http-01 challenge type, not found")
|
|
}
|
|
if !challengeTypes["dns-01"] {
|
|
t.Error("expected dns-01 challenge type, not found")
|
|
}
|
|
t.Logf("authorization has %d challenge(s): %v", len(challenges), challengeTypesList(challengeTypes))
|
|
}
|
|
|
|
func TestLiveFullFlow(t *testing.T) {
|
|
skipIfNotLive(t)
|
|
if os.Getenv("METACRYPT_LIVE_DNS_TEST") != "1" {
|
|
t.Skip("set METACRYPT_LIVE_DNS_TEST=1 to run full ACME flow " +
|
|
"(requires programmatic DNS updates to CoreDNS zones)")
|
|
}
|
|
|
|
// TODO: Implement full ACME flow once CoreDNS zone update automation
|
|
// is in place. The flow would be:
|
|
// 1. Create account
|
|
// 2. Create order
|
|
// 3. Get authorization + challenge
|
|
// 4. Provision DNS-01 TXT record via CoreDNS API
|
|
// 5. Respond to challenge
|
|
// 6. Poll authorization until valid
|
|
// 7. Finalize order with CSR
|
|
// 8. Download certificate
|
|
// 9. Verify certificate chain
|
|
t.Fatal("not implemented")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Utility
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// challengeTypesList extracts the keys from a map for logging.
|
|
func challengeTypesList(m map[string]bool) string {
|
|
var types []string
|
|
for k := range m {
|
|
types = append(types, k)
|
|
}
|
|
return fmt.Sprintf("[%s]", strings.Join(types, ", "))
|
|
}
|