//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, ", ")) }