diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..0619409 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "metacrypt", + "runtimeExecutable": "bash", + "runtimeArgs": ["-c", "make metacrypt && ./metacrypt server --config /Users/kyle/src/metacrypt/srv/metacrypt.toml"], + "port": 8443 + } + ] +} diff --git a/clients/go/challenge.go b/clients/go/challenge.go new file mode 100644 index 0000000..3529b93 --- /dev/null +++ b/clients/go/challenge.go @@ -0,0 +1,65 @@ +package metacryptclient + +import "time" + +// HTTP01Provider handles HTTP-01 ACME challenge fulfillment. +// The caller is responsible for serving the challenge file over HTTP on port 80. +type HTTP01Provider struct { + // WriteFile is called to create the challenge response file. + // path is the URL path, e.g. "/.well-known/acme-challenge/{token}". + // content is the key authorization string to serve as the response body. + WriteFile func(path, content string) error + + // RemoveFile is called after the challenge is complete (success or failure). + // path is the same path passed to WriteFile. + RemoveFile func(path string) error +} + +// DNS01Provider handles DNS-01 ACME challenge fulfillment. +// The caller is responsible for managing DNS TXT records. +type DNS01Provider struct { + // SetRecord is called to create the TXT record. + // name is the fully-qualified DNS name, e.g. "_acme-challenge.example.com.". + // value is the base64url-encoded SHA-256 key authorization to set as the TXT value. + SetRecord func(name, value string) error + + // RemoveRecord is called after the challenge is complete (success or failure). + // name is the same name passed to SetRecord. + RemoveRecord func(name string) error + + // PropagationWait is the duration to wait after SetRecord before notifying + // the ACME server to validate. Allows time for DNS propagation. + // A value of 0 means no wait. + PropagationWait time.Duration +} + +// CertificateRequest specifies what certificate to obtain. +type CertificateRequest struct { + // Domains is the list of DNS names to include as SANs. + // The first domain is used as the common name. + Domains []string + + // KeyType controls the type and size of the certificate key. + // Valid values: "EC256" (default), "EC384", "RSA2048", "RSA4096". + KeyType string + + // HTTP01 provides HTTP-01 challenge fulfillment. Either HTTP01 or DNS01 + // (or both) must be set. + HTTP01 *HTTP01Provider + + // DNS01 provides DNS-01 challenge fulfillment. Either HTTP01 or DNS01 + // (or both) must be set. + DNS01 *DNS01Provider +} + +// Certificate is the result of a successful ACME certificate issuance. +type Certificate struct { + // CertPEM is the PEM-encoded certificate chain (leaf + intermediates). + CertPEM []byte + + // KeyPEM is the PEM-encoded private key for the certificate. + KeyPEM []byte + + // ExpiresAt is the expiry time of the leaf certificate. + ExpiresAt time.Time +} diff --git a/clients/go/client.go b/clients/go/client.go new file mode 100644 index 0000000..e6938fc --- /dev/null +++ b/clients/go/client.go @@ -0,0 +1,301 @@ +// Package metacryptclient provides a Go client for obtaining certificates +// from a Metacrypt CA via ACME (RFC 8555) with MCIAS authentication. +// +// Typical usage: +// +// cfg := metacryptclient.Config{ +// MetacryptURL: "https://metacrypt.example.com", +// MCIASURL: "https://mcias.example.com:8443", +// Mount: "pki", +// } +// c := metacryptclient.New(cfg) +// if err := c.Login(ctx, username, password, ""); err != nil { +// log.Fatal(err) +// } +// cert, err := c.ObtainCertificate(ctx, &metacryptclient.CertificateRequest{ +// Domains: []string{"myservice.example.com"}, +// HTTP01: &metacryptclient.HTTP01Provider{...}, +// }) +package metacryptclient + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "fmt" + "net/http" + "time" + + "golang.org/x/crypto/acme" +) + +// Config holds configuration for the Metacrypt ACME client. +type Config struct { + // MetacryptURL is the base URL of the Metacrypt server, + // e.g. "https://metacrypt.example.com". + MetacryptURL string + + // MCIASURL is the base URL of the MCIAS identity server, + // e.g. "https://mcias.example.com:8443". + MCIASURL string + + // CACertPath is an optional path to a PEM-encoded CA certificate for + // verifying TLS connections to Metacrypt and MCIAS. + CACertPath string + + // Mount is the name of the CA engine mount to obtain certificates from, + // e.g. "pki". + Mount string +} + +// Client is the Metacrypt ACME client. +// Create one with New, authenticate with Login, then use ObtainCertificate. +type Client struct { + cfg Config + httpClient *http.Client + token string // MCIAS bearer token + tokenExp time.Time + accountKey crypto.Signer + acmeClient *acme.Client +} + +// New creates a new Metacrypt client with the given configuration. +// Call Login before calling ObtainCertificate. +func New(cfg Config) *Client { + return &Client{ + cfg: cfg, + httpClient: buildHTTPClient(cfg.CACertPath), + } +} + +// Login authenticates with MCIAS and stores the bearer token. +// totpCode may be empty if TOTP is not configured. +func (c *Client) Login(ctx context.Context, username, password, totpCode string) error { + token, exp, err := loginMCIAS(ctx, c.httpClient, c.cfg.MCIASURL, username, password, totpCode) + if err != nil { + return fmt.Errorf("metacrypt: MCIAS login: %w", err) + } + c.token = token + c.tokenExp = exp + return nil +} + +// RegisterAccount registers a new ACME account using EAB credentials fetched +// from Metacrypt. If accountKey is nil, a fresh EC P-256 key is generated. +// +// This method is called automatically by ObtainCertificate if no account key +// has been registered yet. +func (c *Client) RegisterAccount(ctx context.Context, contact []string) error { + if c.token == "" { + return fmt.Errorf("metacrypt: not authenticated; call Login first") + } + + // Fetch EAB credentials from Metacrypt. + kid, hmacKey, err := fetchEAB(ctx, c.httpClient, c.cfg.MetacryptURL, c.cfg.Mount, c.token) + if err != nil { + return fmt.Errorf("metacrypt: fetch EAB credentials: %w", err) + } + + // Generate account key if not provided. + if c.accountKey == nil { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("metacrypt: generate account key: %w", err) + } + c.accountKey = key + } + + c.acmeClient = &acme.Client{ + Key: c.accountKey, + HTTPClient: c.httpClient, + DirectoryURL: c.directoryURL(), + } + + // Register with EAB. + hmacKeyB64 := base64.RawURLEncoding.EncodeToString(hmacKey) + acct := &acme.Account{ + Contact: contact, + ExternalAccountBinding: &acme.ExternalAccountBinding{KID: kid, Key: []byte(hmacKeyB64)}, + } + if _, err := c.acmeClient.Register(ctx, acct, acme.AcceptTOS); err != nil { + return fmt.Errorf("metacrypt: ACME account registration: %w", err) + } + return nil +} + +// ObtainCertificate runs the full ACME flow to obtain a certificate for the +// given request. It handles account registration (with EAB), order placement, +// challenge fulfillment, finalization, and certificate download. +func (c *Client) ObtainCertificate(ctx context.Context, req *CertificateRequest) (*Certificate, error) { + if len(req.Domains) == 0 { + return nil, fmt.Errorf("metacrypt: at least one domain is required") + } + if req.HTTP01 == nil && req.DNS01 == nil { + return nil, fmt.Errorf("metacrypt: HTTP01 or DNS01 provider required") + } + + // Auto-register account if needed. + if c.acmeClient == nil { + if err := c.RegisterAccount(ctx, nil); err != nil { + return nil, err + } + } + + // Generate certificate key. + certKey, err := generateKey(req.KeyType) + if err != nil { + return nil, fmt.Errorf("metacrypt: generate certificate key: %w", err) + } + + // Build ACME identifiers. + authzIDs := make([]acme.AuthzID, len(req.Domains)) + for i, d := range req.Domains { + authzIDs[i] = acme.AuthzID{Type: "dns", Value: d} + } + + // Place order. + order, err := c.acmeClient.AuthorizeOrder(ctx, authzIDs) + if err != nil { + return nil, fmt.Errorf("metacrypt: place order: %w", err) + } + + // Fulfill authorizations. + for _, authzURL := range order.AuthzURLs { + authz, err := c.acmeClient.GetAuthorization(ctx, authzURL) + if err != nil { + return nil, fmt.Errorf("metacrypt: get authorization: %w", err) + } + if authz.Status == acme.StatusValid { + continue + } + + if err := c.fulfillAuthorization(ctx, authz, req); err != nil { + return nil, err + } + } + + // Wait for order to be ready. + order, err = c.acmeClient.WaitOrder(ctx, order.URI) + if err != nil { + return nil, fmt.Errorf("metacrypt: wait for order ready: %w", err) + } + + // Create and submit CSR. + csr, err := buildCSR(certKey, req.Domains) + if err != nil { + return nil, fmt.Errorf("metacrypt: build CSR: %w", err) + } + + // Finalize order and download certificate chain. + certDER, _, err := c.acmeClient.CreateOrderCert(ctx, order.FinalizeURL, csr, true) + if err != nil { + return nil, fmt.Errorf("metacrypt: finalize order: %w", err) + } + + certPEM := encodeCertChainPEM(certDER) + keyPEM, err := encodeKeyPEM(certKey) + if err != nil { + return nil, fmt.Errorf("metacrypt: encode key: %w", err) + } + + // Parse expiry from the leaf certificate. + expiresAt, err := parseCertExpiry(certDER[0]) + if err != nil { + expiresAt = time.Now().Add(90 * 24 * time.Hour) // fallback + } + + return &Certificate{ + CertPEM: certPEM, + KeyPEM: keyPEM, + ExpiresAt: expiresAt, + }, nil +} + +// RenewIfNeeded checks whether the certificate expires within threshold and +// re-runs ObtainCertificate if so. Returns the (possibly new) certificate and +// whether renewal occurred. +func (c *Client) RenewIfNeeded(ctx context.Context, cert *Certificate, threshold time.Duration, req *CertificateRequest) (*Certificate, bool, error) { + if time.Until(cert.ExpiresAt) > threshold { + return cert, false, nil + } + newCert, err := c.ObtainCertificate(ctx, req) + if err != nil { + return nil, false, err + } + return newCert, true, nil +} + +// directoryURL returns the ACME directory URL for the configured mount. +func (c *Client) directoryURL() string { + return c.cfg.MetacryptURL + "/acme/" + c.cfg.Mount + "/directory" +} + +// fulfillAuthorization selects the appropriate challenge and fulfills it. +func (c *Client) fulfillAuthorization(ctx context.Context, authz *acme.Authorization, req *CertificateRequest) error { + // Prefer HTTP-01, then DNS-01. + var selected *acme.Challenge + for _, ch := range authz.Challenges { + if ch.Type == "http-01" && req.HTTP01 != nil { + selected = ch + break + } + } + if selected == nil { + for _, ch := range authz.Challenges { + if ch.Type == "dns-01" && req.DNS01 != nil { + selected = ch + break + } + } + } + if selected == nil { + return fmt.Errorf("metacrypt: no supported challenge type for %s", authz.Identifier.Value) + } + + switch selected.Type { + case "http-01": + keyAuth, err := c.acmeClient.HTTP01ChallengeResponse(selected.Token) + if err != nil { + return fmt.Errorf("metacrypt: HTTP-01 key auth: %w", err) + } + path := c.acmeClient.HTTP01ChallengePath(selected.Token) + if err := req.HTTP01.WriteFile(path, keyAuth); err != nil { + return fmt.Errorf("metacrypt: HTTP-01 write file: %w", err) + } + defer req.HTTP01.RemoveFile(path) + + case "dns-01": + txt, err := c.acmeClient.DNS01ChallengeRecord(selected.Token) + if err != nil { + return fmt.Errorf("metacrypt: DNS-01 record: %w", err) + } + name := "_acme-challenge." + authz.Identifier.Value + "." + if err := req.DNS01.SetRecord(name, txt); err != nil { + return fmt.Errorf("metacrypt: DNS-01 set record: %w", err) + } + defer req.DNS01.RemoveRecord(name) + if req.DNS01.PropagationWait > 0 { + timer := time.NewTimer(req.DNS01.PropagationWait) + select { + case <-timer.C: + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + } + } + } + + // Notify server to validate. + if _, err := c.acmeClient.Accept(ctx, selected); err != nil { + return fmt.Errorf("metacrypt: accept challenge: %w", err) + } + + // Wait for authorization to be valid. + if _, err := c.acmeClient.WaitAuthorization(ctx, authz.URI); err != nil { + return fmt.Errorf("metacrypt: wait authorization: %w", err) + } + return nil +} diff --git a/clients/go/go.mod b/clients/go/go.mod new file mode 100644 index 0000000..33608be --- /dev/null +++ b/clients/go/go.mod @@ -0,0 +1,5 @@ +module git.wntrmute.dev/kyle/metacrypt/clients/go + +go 1.25.0 + +require golang.org/x/crypto v0.49.0 diff --git a/clients/go/go.sum b/clients/go/go.sum new file mode 100644 index 0000000..9fd6146 --- /dev/null +++ b/clients/go/go.sum @@ -0,0 +1,2 @@ +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= diff --git a/clients/go/metacrypt.go b/clients/go/metacrypt.go new file mode 100644 index 0000000..2a256f4 --- /dev/null +++ b/clients/go/metacrypt.go @@ -0,0 +1,167 @@ +package metacryptclient + +import ( + "bytes" + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "os" + "time" +) + +// loginMCIAS authenticates against the MCIAS server and returns a bearer token. +func loginMCIAS(ctx context.Context, client *http.Client, mciasURL, username, password, totpCode string) (token string, exp time.Time, err error) { + body, _ := json.Marshal(map[string]string{ + "username": username, + "password": password, + "totp_code": totpCode, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, mciasURL+"/v1/auth/login", bytes.NewReader(body)) + if err != nil { + return "", time.Time{}, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", time.Time{}, fmt.Errorf("MCIAS login request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", time.Time{}, fmt.Errorf("MCIAS login: unexpected status %d", resp.StatusCode) + } + + var result struct { + Token string `json:"token"` + ExpiresAt string `json:"expires_at"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", time.Time{}, fmt.Errorf("MCIAS login: decode response: %w", err) + } + if result.Token == "" { + return "", time.Time{}, fmt.Errorf("MCIAS login: empty token in response") + } + + var expTime time.Time + if result.ExpiresAt != "" { + expTime, _ = time.Parse(time.RFC3339, result.ExpiresAt) + } + return result.Token, expTime, nil +} + +// fetchEAB calls POST /v1/acme/{mount}/eab on Metacrypt and returns the EAB kid and HMAC key. +func fetchEAB(ctx context.Context, client *http.Client, metacryptURL, mount, token string) (kid string, hmacKey []byte, err error) { + url := metacryptURL + "/v1/acme/" + mount + "/eab" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, http.NoBody) + if err != nil { + return "", nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", nil, fmt.Errorf("fetch EAB: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return "", nil, fmt.Errorf("fetch EAB: unexpected status %d: %s", resp.StatusCode, body) + } + + var result struct { + KID string `json:"kid"` + HMACKey []byte `json:"hmac_key"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", nil, fmt.Errorf("fetch EAB: decode response: %w", err) + } + return result.KID, result.HMACKey, nil +} + +// buildHTTPClient creates an HTTP client that optionally trusts a custom CA. +func buildHTTPClient(caCertPath string) *http.Client { + tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} + + if caCertPath != "" { + pool := x509.NewCertPool() + if data, err := os.ReadFile(caCertPath); err == nil { + pool.AppendCertsFromPEM(data) + tlsCfg.RootCAs = pool + } + } + + return &http.Client{ + Transport: &http.Transport{TLSClientConfig: tlsCfg}, + Timeout: 30 * time.Second, + } +} + +// generateKey generates a certificate key of the requested type. +// Supported types: "EC256" (default), "EC384", "RSA2048", "RSA4096". +func generateKey(keyType string) (crypto.Signer, error) { + switch keyType { + case "", "EC256": + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + case "EC384": + return ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + case "RSA2048": + return rsa.GenerateKey(rand.Reader, 2048) + case "RSA4096": + return rsa.GenerateKey(rand.Reader, 4096) + default: + return nil, fmt.Errorf("unsupported key type: %s", keyType) + } +} + +// buildCSR creates a DER-encoded certificate signing request. +func buildCSR(key crypto.Signer, domains []string) ([]byte, error) { + if len(domains) == 0 { + return nil, fmt.Errorf("at least one domain required") + } + template := &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: domains[0]}, + DNSNames: domains, + } + return x509.CreateCertificateRequest(rand.Reader, template, key) +} + +// encodeCertChainPEM encodes a chain of DER-encoded certificates to PEM. +func encodeCertChainPEM(chain [][]byte) []byte { + var buf bytes.Buffer + for _, der := range chain { + pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der}) + } + return buf.Bytes() +} + +// encodeKeyPEM encodes a private key to PEM (PKCS8 format). +func encodeKeyPEM(key crypto.Signer) ([]byte, error) { + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, err + } + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil +} + +// parseCertExpiry parses the expiry time from a DER-encoded certificate. +func parseCertExpiry(der []byte) (time.Time, error) { + cert, err := x509.ParseCertificate(der) + if err != nil { + return time.Time{}, err + } + return cert.NotAfter, nil +} diff --git a/internal/acme/eab.go b/internal/acme/eab.go new file mode 100644 index 0000000..ad27fa3 --- /dev/null +++ b/internal/acme/eab.go @@ -0,0 +1,118 @@ +package acme + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" +) + +// CreateEAB generates a new EAB credential for the given MCIAS user. +// The credential is stored in the barrier and must be consumed on first use. +func (h *Handler) CreateEAB(ctx context.Context, mciasUsername string) (*EABCredential, error) { + kidBytes := make([]byte, 16) + if _, err := rand.Read(kidBytes); err != nil { + return nil, fmt.Errorf("acme: generate EAB kid: %w", err) + } + hmacKey := make([]byte, 32) + if _, err := rand.Read(hmacKey); err != nil { + return nil, fmt.Errorf("acme: generate EAB key: %w", err) + } + + cred := &EABCredential{ + KID: base64.RawURLEncoding.EncodeToString(kidBytes), + HMACKey: hmacKey, + Used: false, + CreatedBy: mciasUsername, + CreatedAt: time.Now(), + } + + data, err := json.Marshal(cred) + if err != nil { + return nil, fmt.Errorf("acme: marshal EAB: %w", err) + } + path := h.barrierPrefix() + "eab/" + cred.KID + ".json" + if err := h.barrier.Put(ctx, path, data); err != nil { + return nil, fmt.Errorf("acme: store EAB: %w", err) + } + return cred, nil +} + +// GetEAB retrieves an EAB credential by KID. +func (h *Handler) GetEAB(ctx context.Context, kid string) (*EABCredential, error) { + path := h.barrierPrefix() + "eab/" + kid + ".json" + data, err := h.barrier.Get(ctx, path) + if err != nil || data == nil { + return nil, fmt.Errorf("acme: EAB not found") + } + var cred EABCredential + if err := json.Unmarshal(data, &cred); err != nil { + return nil, fmt.Errorf("acme: unmarshal EAB: %w", err) + } + return &cred, nil +} + +// MarkEABUsed marks an EAB credential as consumed so it cannot be reused. +func (h *Handler) MarkEABUsed(ctx context.Context, kid string) error { + cred, err := h.GetEAB(ctx, kid) + if err != nil { + return err + } + cred.Used = true + data, err := json.Marshal(cred) + if err != nil { + return fmt.Errorf("acme: marshal EAB: %w", err) + } + return h.barrier.Put(ctx, h.barrierPrefix()+"eab/"+kid+".json", data) +} + +// ListAccounts returns all ACME accounts for this mount. +func (h *Handler) ListAccounts(ctx context.Context) ([]*Account, error) { + paths, err := h.barrier.List(ctx, h.barrierPrefix()+"accounts/") + if err != nil { + return nil, err + } + var accounts []*Account + for _, p := range paths { + if !strings.HasSuffix(p, ".json") { + continue + } + data, err := h.barrier.Get(ctx, h.barrierPrefix()+"accounts/"+p) + if err != nil || data == nil { + continue + } + var acc Account + if err := json.Unmarshal(data, &acc); err != nil { + continue + } + accounts = append(accounts, &acc) + } + return accounts, nil +} + +// ListOrders returns all ACME orders for this mount. +func (h *Handler) ListOrders(ctx context.Context) ([]*Order, error) { + paths, err := h.barrier.List(ctx, h.barrierPrefix()+"orders/") + if err != nil { + return nil, err + } + var orders []*Order + for _, p := range paths { + if !strings.HasSuffix(p, ".json") { + continue + } + data, err := h.barrier.Get(ctx, h.barrierPrefix()+"orders/"+p) + if err != nil || data == nil { + continue + } + var order Order + if err := json.Unmarshal(data, &order); err != nil { + continue + } + orders = append(orders, &order) + } + return orders, nil +} diff --git a/internal/acme/handlers.go b/internal/acme/handlers.go new file mode 100644 index 0000000..2c1a79c --- /dev/null +++ b/internal/acme/handlers.go @@ -0,0 +1,876 @@ +package acme + +import ( + "context" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "git.wntrmute.dev/kyle/metacrypt/internal/engine" +) + +// directoryResponse is the ACME directory object (RFC 8555 §7.1.1). +type directoryResponse struct { + NewNonce string `json:"newNonce"` + NewAccount string `json:"newAccount"` + NewOrder string `json:"newOrder"` + RevokeCert string `json:"revokeCert"` + KeyChange string `json:"keyChange"` + Meta *directoryMeta `json:"meta,omitempty"` +} + +type directoryMeta struct { + TermsOfService string `json:"termsOfService,omitempty"` + Website string `json:"website,omitempty"` + CAAIdentities []string `json:"caaIdentities,omitempty"` + ExternalAccountRequired bool `json:"externalAccountRequired"` +} + +// handleDirectory serves the ACME directory (GET /acme/{mount}/directory). +// No nonce or authentication required. +func (h *Handler) handleDirectory(w http.ResponseWriter, r *http.Request) { + base := h.baseURL + "/acme/" + h.mount + dir := directoryResponse{ + NewNonce: base + "/new-nonce", + NewAccount: base + "/new-account", + NewOrder: base + "/new-order", + RevokeCert: base + "/revoke-cert", + KeyChange: base + "/key-change", + Meta: &directoryMeta{ + ExternalAccountRequired: true, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(dir) +} + +// handleNewNonce serves HEAD and GET /acme/{mount}/new-nonce. +func (h *Handler) handleNewNonce(w http.ResponseWriter, r *http.Request) { + h.addNonceHeader(w) + w.Header().Set("Cache-Control", "no-store") + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusNoContent) + } +} + +// newAccountPayload is the payload for the new-account request. +type newAccountPayload struct { + TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"` + Contact []string `json:"contact,omitempty"` + ExternalAccountBinding json.RawMessage `json:"externalAccountBinding,omitempty"` + OnlyReturnExisting bool `json:"onlyReturnExisting"` +} + +// handleNewAccount handles POST /acme/{mount}/new-account. +func (h *Handler) handleNewAccount(w http.ResponseWriter, r *http.Request) { + parsed, err := h.parseAndVerifyNewAccountJWS(r) + if err != nil { + h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, err.Error()) + return + } + // Validate URL in header. + if parsed.Header.URL != h.baseURL+"/acme/"+h.mount+"/new-account" { + h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "JWS URL mismatch") + return + } + // Consume nonce. + if err := h.nonces.Consume(parsed.Header.Nonce); err != nil { + h.writeACMEError(w, http.StatusBadRequest, ProblemBadNonce, "invalid or expired nonce") + return + } + + var payload newAccountPayload + if len(parsed.Payload) > 0 { + if err := json.Unmarshal(parsed.Payload, &payload); err != nil { + h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "invalid payload") + return + } + } + + ctx := r.Context() + + // Check if account already exists for this JWK. + jwkJSON, _ := json.Marshal(parsed.Header.JWK) + kid := thumbprintKey(jwkJSON) + existingPath := h.barrierPrefix() + "accounts/" + kid + ".json" + existing, _ := h.barrier.Get(ctx, existingPath) + if existing != nil { + var acc Account + if err := json.Unmarshal(existing, &acc); err == nil { + if payload.OnlyReturnExisting || acc.Status == StatusValid { + w.Header().Set("Location", h.accountURL(acc.ID)) + h.writeJSON(w, http.StatusOK, h.accountToWire(&acc)) + return + } + } + } + + if payload.OnlyReturnExisting { + h.writeACMEError(w, http.StatusBadRequest, ProblemAccountDoesNotExist, "account does not exist") + return + } + + // EAB is required. + if len(payload.ExternalAccountBinding) == 0 { + h.writeACMEError(w, http.StatusBadRequest, ProblemExternalAccountRequired, "external account binding required") + return + } + + // Parse and verify EAB. + eabParsed, err := ParseJWS(payload.ExternalAccountBinding) + if err != nil { + h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "invalid EAB JWS") + return + } + eabKID := eabParsed.Header.KID + eabCred, err := h.GetEAB(ctx, eabKID) + if err != nil { + h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, "unknown EAB key ID") + return + } + if eabCred.Used { + h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, "EAB key already used") + return + } + if err := VerifyEAB(payload.ExternalAccountBinding, eabKID, eabCred.HMACKey, jwkJSON); err != nil { + h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, "EAB verification failed") + return + } + if err := h.MarkEABUsed(ctx, eabKID); err != nil { + h.logger.Error("acme: mark EAB used", "error", err) + } + + // Create account. + acc := &Account{ + ID: kid, + Status: StatusValid, + Contact: payload.Contact, + JWK: jwkJSON, + CreatedAt: time.Now(), + MCIASUsername: eabCred.CreatedBy, + } + data, _ := json.Marshal(acc) + if err := h.barrier.Put(ctx, existingPath, data); err != nil { + h.logger.Error("acme: store account", "error", err) + h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to store account") + return + } + + w.Header().Set("Location", h.accountURL(acc.ID)) + h.writeJSON(w, http.StatusCreated, h.accountToWire(acc)) +} + +// newOrderPayload is the payload for the new-order request. +type newOrderPayload struct { + Identifiers []Identifier `json:"identifiers"` + NotBefore string `json:"notBefore,omitempty"` + NotAfter string `json:"notAfter,omitempty"` +} + +// handleNewOrder handles POST /acme/{mount}/new-order. +func (h *Handler) handleNewOrder(w http.ResponseWriter, r *http.Request) { + acc, parsed, err := h.authenticateRequest(r, h.baseURL+"/acme/"+h.mount+"/new-order") + if err != nil { + h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, err.Error()) + return + } + + var payload newOrderPayload + if err := json.Unmarshal(parsed.Payload, &payload); err != nil { + h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "invalid payload") + return + } + if len(payload.Identifiers) == 0 { + h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "identifiers required") + return + } + + // Validate identifier types. + for _, id := range payload.Identifiers { + if id.Type != IdentifierDNS && id.Type != IdentifierIP { + h.writeACMEError(w, http.StatusBadRequest, ProblemUnsupportedIdentifier, + fmt.Sprintf("unsupported identifier type: %s", id.Type)) + return + } + } + + ctx := r.Context() + + // Look up default issuer from config. + cfg, _ := h.loadConfig(ctx) + if cfg.DefaultIssuer == "" { + h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, + "no default issuer configured for this ACME mount; set acme_issuer via the management API") + return + } + + orderID := newID() + now := time.Now() + orderExpiry := now.Add(7 * 24 * time.Hour) + + // Create one authorization per identifier. + var authzIDs []string + for _, id := range payload.Identifiers { + authzID := newID() + authzIDs = append(authzIDs, authzID) + + // Create two challenges: http-01 and dns-01. + httpChallengeID := newID() + dnsChallengeID := newID() + + httpChallenge := &Challenge{ + ID: httpChallengeID, + AuthzID: authzID, + Type: ChallengeHTTP01, + Status: StatusPending, + Token: newToken(), + } + dnsChallenge := &Challenge{ + ID: dnsChallengeID, + AuthzID: authzID, + Type: ChallengeDNS01, + Status: StatusPending, + Token: newToken(), + } + authz := &Authorization{ + ID: authzID, + AccountID: acc.ID, + Status: StatusPending, + Identifier: id, + ChallengeIDs: []string{httpChallengeID, dnsChallengeID}, + ExpiresAt: orderExpiry, + } + + challPrefix := h.barrierPrefix() + "challenges/" + authzPrefix := h.barrierPrefix() + "authz/" + + httpData, _ := json.Marshal(httpChallenge) + dnsData, _ := json.Marshal(dnsChallenge) + authzData, _ := json.Marshal(authz) + + if err := h.barrier.Put(ctx, challPrefix+httpChallengeID+".json", httpData); err != nil { + h.logger.Error("acme: store challenge", "error", err) + h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to create authorization") + return + } + if err := h.barrier.Put(ctx, challPrefix+dnsChallengeID+".json", dnsData); err != nil { + h.logger.Error("acme: store challenge", "error", err) + h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to create authorization") + return + } + if err := h.barrier.Put(ctx, authzPrefix+authzID+".json", authzData); err != nil { + h.logger.Error("acme: store authz", "error", err) + h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to create authorization") + return + } + } + + order := &Order{ + ID: orderID, + AccountID: acc.ID, + Status: StatusPending, + Identifiers: payload.Identifiers, + AuthzIDs: authzIDs, + ExpiresAt: orderExpiry, + CreatedAt: now, + IssuerName: cfg.DefaultIssuer, + } + orderData, _ := json.Marshal(order) + if err := h.barrier.Put(ctx, h.barrierPrefix()+"orders/"+orderID+".json", orderData); err != nil { + h.logger.Error("acme: store order", "error", err) + h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to create order") + return + } + + w.Header().Set("Location", h.orderURL(orderID)) + h.writeJSON(w, http.StatusCreated, h.orderToWire(order)) +} + +// handleGetAuthz handles POST /acme/{mount}/authz/{id} (POST-as-GET, empty payload). +func (h *Handler) handleGetAuthz(w http.ResponseWriter, r *http.Request) { + authzID := chi.URLParam(r, "id") + reqURL := h.baseURL + "/acme/" + h.mount + "/authz/" + authzID + _, _, err := h.authenticateRequest(r, reqURL) + if err != nil { + h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, err.Error()) + return + } + + authz, err := h.loadAuthz(r.Context(), authzID) + if err != nil { + h.writeACMEError(w, http.StatusNotFound, ProblemMalformed, "authorization not found") + return + } + + h.writeJSON(w, http.StatusOK, h.authzToWire(r.Context(), authz)) +} + +// handleChallenge handles POST /acme/{mount}/challenge/{type}/{id}. +func (h *Handler) handleChallenge(w http.ResponseWriter, r *http.Request) { + challType := chi.URLParam(r, "type") + challID := chi.URLParam(r, "id") + reqURL := h.baseURL + "/acme/" + h.mount + "/challenge/" + challType + "/" + challID + + acc, _, err := h.authenticateRequest(r, reqURL) + if err != nil { + h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, err.Error()) + return + } + + ctx := r.Context() + chall, err := h.loadChallenge(ctx, challID) + if err != nil { + h.writeACMEError(w, http.StatusNotFound, ProblemMalformed, "challenge not found") + return + } + if chall.Status != StatusPending { + // Already processing or completed; return current state. + h.writeJSON(w, http.StatusOK, h.challengeToWire(chall)) + return + } + + // Mark as processing. + chall.Status = StatusProcessing + if err := h.saveChallenge(ctx, chall); err != nil { + h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to update challenge") + return + } + + // Return challenge immediately; validate asynchronously. + h.writeJSON(w, http.StatusOK, h.challengeToWire(chall)) + + // Launch validation goroutine. + go h.validateChallenge(context.Background(), chall, acc.JWK) +} + +// handleFinalize handles POST /acme/{mount}/finalize/{id}. +func (h *Handler) handleFinalize(w http.ResponseWriter, r *http.Request) { + orderID := chi.URLParam(r, "id") + reqURL := h.baseURL + "/acme/" + h.mount + "/finalize/" + orderID + + acc, parsed, err := h.authenticateRequest(r, reqURL) + if err != nil { + h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, err.Error()) + return + } + + ctx := r.Context() + order, err := h.loadOrder(ctx, orderID) + if err != nil { + h.writeACMEError(w, http.StatusNotFound, ProblemMalformed, "order not found") + return + } + if order.AccountID != acc.ID { + h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, "order belongs to different account") + return + } + if order.Status != StatusReady { + h.writeACMEError(w, http.StatusForbidden, ProblemOrderNotReady, "order is not ready") + return + } + + // Parse payload: {"csr": ""} + var finalizePayload struct { + CSR string `json:"csr"` + } + if err := json.Unmarshal(parsed.Payload, &finalizePayload); err != nil { + h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "invalid payload") + return + } + csrDER, err := base64.RawURLEncoding.DecodeString(finalizePayload.CSR) + if err != nil { + h.writeACMEError(w, http.StatusBadRequest, ProblemBadCSR, "invalid CSR encoding") + return + } + csr, err := x509.ParseCertificateRequest(csrDER) + if err != nil { + h.writeACMEError(w, http.StatusBadRequest, ProblemBadCSR, "invalid CSR: "+err.Error()) + return + } + if err := csr.CheckSignature(); err != nil { + h.writeACMEError(w, http.StatusBadRequest, ProblemBadCSR, "CSR signature invalid") + return + } + + // Verify CSR identifiers match order identifiers. + if err := h.validateCSRIdentifiers(csr, order.Identifiers); err != nil { + h.writeACMEError(w, http.StatusBadRequest, ProblemBadCSR, err.Error()) + return + } + + // Issue certificate via CA engine. + // Build SANs from order identifiers. + var dnsNames, ipAddrs []string + for _, id := range order.Identifiers { + switch id.Type { + case IdentifierDNS: + dnsNames = append(dnsNames, id.Value) + case IdentifierIP: + ipAddrs = append(ipAddrs, id.Value) + } + } + + issueReq := &engine.Request{ + Operation: "issue", + CallerInfo: &engine.CallerInfo{ + Username: acc.MCIASUsername, + Roles: []string{"user"}, + IsAdmin: false, + }, + Data: map[string]interface{}{ + "issuer": order.IssuerName, + "profile": "server", + "common_name": csr.Subject.CommonName, + "dns_names": dnsNames, + "ip_addresses": ipAddrs, + }, + } + + resp, err := h.engines.HandleRequest(ctx, h.mount, issueReq) + if err != nil { + h.logger.Error("acme: issue cert", "error", err) + h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "certificate issuance failed") + return + } + + certPEM, _ := resp.Data["chain_pem"].(string) + expiresStr, _ := resp.Data["expires_at"].(string) + certID := newID() + + var expiresAt time.Time + if expiresStr != "" { + expiresAt, _ = time.Parse(time.RFC3339, expiresStr) + } + + issuedCert := &IssuedCert{ + ID: certID, + OrderID: orderID, + AccountID: acc.ID, + CertPEM: certPEM, + IssuedAt: time.Now(), + ExpiresAt: expiresAt, + } + certData, _ := json.Marshal(issuedCert) + if err := h.barrier.Put(ctx, h.barrierPrefix()+"certs/"+certID+".json", certData); err != nil { + h.logger.Error("acme: store cert", "error", err) + } + + order.Status = StatusValid + order.CertID = certID + orderData, _ := json.Marshal(order) + h.barrier.Put(ctx, h.barrierPrefix()+"orders/"+orderID+".json", orderData) + + h.writeJSON(w, http.StatusOK, h.orderToWire(order)) +} + +// handleGetCert handles POST /acme/{mount}/cert/{id} (POST-as-GET). +func (h *Handler) handleGetCert(w http.ResponseWriter, r *http.Request) { + certID := chi.URLParam(r, "id") + reqURL := h.baseURL + "/acme/" + h.mount + "/cert/" + certID + + _, _, err := h.authenticateRequest(r, reqURL) + if err != nil { + h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, err.Error()) + return + } + + data, err := h.barrier.Get(r.Context(), h.barrierPrefix()+"certs/"+certID+".json") + if err != nil || data == nil { + h.writeACMEError(w, http.StatusNotFound, ProblemMalformed, "certificate not found") + return + } + var cert IssuedCert + if err := json.Unmarshal(data, &cert); err != nil { + h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to load certificate") + return + } + if cert.Revoked { + h.writeACMEError(w, http.StatusNotFound, ProblemAlreadyRevoked, "certificate has been revoked") + return + } + + h.addNonceHeader(w) + w.Header().Set("Content-Type", "application/pem-certificate-chain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(cert.CertPEM)) +} + +// handleRevokeCert handles POST /acme/{mount}/revoke-cert. +func (h *Handler) handleRevokeCert(w http.ResponseWriter, r *http.Request) { + _, parsed, err := h.authenticateRequest(r, h.baseURL+"/acme/"+h.mount+"/revoke-cert") + if err != nil { + h.writeACMEError(w, http.StatusUnauthorized, ProblemUnauthorized, err.Error()) + return + } + + var revokePayload struct { + Certificate string `json:"certificate"` // base64url DER + } + if err := json.Unmarshal(parsed.Payload, &revokePayload); err != nil { + h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "invalid payload") + return + } + + // Find cert by matching the DER bytes. + ctx := r.Context() + certDER, err := base64.RawURLEncoding.DecodeString(revokePayload.Certificate) + if err != nil { + h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "invalid certificate encoding") + return + } + targetCert, err := x509.ParseCertificate(certDER) + if err != nil { + h.writeACMEError(w, http.StatusBadRequest, ProblemMalformed, "invalid certificate") + return + } + + paths, err := h.barrier.List(ctx, h.barrierPrefix()+"certs/") + if err != nil { + h.writeACMEError(w, http.StatusInternalServerError, ProblemServerInternal, "failed to list certificates") + return + } + + for _, p := range paths { + if !strings.HasSuffix(p, ".json") { + continue + } + data, _ := h.barrier.Get(ctx, h.barrierPrefix()+"certs/"+p) + if data == nil { + continue + } + var cert IssuedCert + if err := json.Unmarshal(data, &cert); err != nil { + continue + } + // Match by serial number encoded in PEM. + issuedCertDER, err := pemToDER(cert.CertPEM) + if err != nil { + continue + } + issuedCert, err := x509.ParseCertificate(issuedCertDER) + if err != nil { + continue + } + if issuedCert.SerialNumber.Cmp(targetCert.SerialNumber) == 0 { + cert.Revoked = true + updated, _ := json.Marshal(cert) + h.barrier.Put(ctx, h.barrierPrefix()+"certs/"+p, updated) + h.addNonceHeader(w) + w.WriteHeader(http.StatusOK) + return + } + } + + h.writeACMEError(w, http.StatusNotFound, ProblemMalformed, "certificate not found") +} + +// --- Authentication helpers --- + +// authenticateRequest parses and verifies a JWS request, consuming the nonce +// and validating the URL. Returns the account and parsed JWS. +// For new-account requests, use parseAndVerifyNewAccountJWS instead. +func (h *Handler) authenticateRequest(r *http.Request, expectedURL string) (*Account, *ParsedJWS, error) { + body, err := readBody(r) + if err != nil { + return nil, nil, fmt.Errorf("failed to read body: %w", err) + } + parsed, err := ParseJWS(body) + if err != nil { + return nil, nil, fmt.Errorf("invalid JWS: %w", err) + } + if parsed.Header.URL != expectedURL { + return nil, nil, errors.New("JWS URL mismatch") + } + if err := h.nonces.Consume(parsed.Header.Nonce); err != nil { + return nil, nil, errors.New("invalid or expired nonce") + } + + // Look up account by KID. + if parsed.Header.KID == "" { + return nil, nil, errors.New("KID required for authenticated requests") + } + // KID is the full account URL; extract the ID. + accID := extractIDFromURL(parsed.Header.KID, "/account/") + if accID == "" { + // Try treating KID directly as the account ID (thumbprint). + accID = parsed.Header.KID + } + + acc, err := h.loadAccount(r.Context(), accID) + if err != nil { + return nil, nil, errors.New("account not found") + } + if acc.Status != StatusValid { + return nil, nil, fmt.Errorf("account status is %s", acc.Status) + } + + // Verify JWS signature against account key. + pubKey, err := ParseJWK(acc.JWK) + if err != nil { + return nil, nil, fmt.Errorf("invalid account key: %w", err) + } + if err := VerifyJWS(parsed, pubKey); err != nil { + return nil, nil, fmt.Errorf("signature verification failed: %w", err) + } + return acc, parsed, nil +} + +// parseAndVerifyNewAccountJWS parses a new-account JWS where the key is +// embedded in the JWK header field (not a KID). +func (h *Handler) parseAndVerifyNewAccountJWS(r *http.Request) (*ParsedJWS, error) { + body, err := readBody(r) + if err != nil { + return nil, fmt.Errorf("failed to read body: %w", err) + } + parsed, err := ParseJWS(body) + if err != nil { + return nil, fmt.Errorf("invalid JWS: %w", err) + } + if len(parsed.Header.JWK) == 0 { + return nil, errors.New("JWK required in header for new-account") + } + pubKey, err := ParseJWK(parsed.Header.JWK) + if err != nil { + return nil, fmt.Errorf("invalid JWK: %w", err) + } + if err := VerifyJWS(parsed, pubKey); err != nil { + return nil, fmt.Errorf("signature verification failed: %w", err) + } + return parsed, nil +} + +// --- Barrier helpers --- + +func (h *Handler) loadAccount(ctx context.Context, id string) (*Account, error) { + data, err := h.barrier.Get(ctx, h.barrierPrefix()+"accounts/"+id+".json") + if err != nil || data == nil { + return nil, errors.New("account not found") + } + var acc Account + return &acc, json.Unmarshal(data, &acc) +} + +func (h *Handler) loadOrder(ctx context.Context, id string) (*Order, error) { + data, err := h.barrier.Get(ctx, h.barrierPrefix()+"orders/"+id+".json") + if err != nil || data == nil { + return nil, errors.New("order not found") + } + var order Order + return &order, json.Unmarshal(data, &order) +} + +func (h *Handler) loadAuthz(ctx context.Context, id string) (*Authorization, error) { + data, err := h.barrier.Get(ctx, h.barrierPrefix()+"authz/"+id+".json") + if err != nil || data == nil { + return nil, errors.New("authorization not found") + } + var authz Authorization + return &authz, json.Unmarshal(data, &authz) +} + +func (h *Handler) loadChallenge(ctx context.Context, id string) (*Challenge, error) { + paths, err := h.barrier.List(ctx, h.barrierPrefix()+"challenges/") + if err != nil { + return nil, errors.New("challenge not found") + } + for _, p := range paths { + if !strings.Contains(p, id) { + continue + } + data, err := h.barrier.Get(ctx, h.barrierPrefix()+"challenges/"+p) + if err != nil || data == nil { + continue + } + var chall Challenge + if err := json.Unmarshal(data, &chall); err != nil { + continue + } + if chall.ID == id { + return &chall, nil + } + } + return nil, errors.New("challenge not found") +} + +func (h *Handler) saveChallenge(ctx context.Context, chall *Challenge) error { + data, err := json.Marshal(chall) + if err != nil { + return err + } + return h.barrier.Put(ctx, h.barrierPrefix()+"challenges/"+chall.ID+".json", data) +} + +// --- Wire format helpers --- + +func (h *Handler) accountToWire(acc *Account) map[string]interface{} { + return map[string]interface{}{ + "status": acc.Status, + "contact": acc.Contact, + "orders": h.baseURL + "/acme/" + h.mount + "/account/" + acc.ID + "/orders", + } +} + +func (h *Handler) orderToWire(order *Order) map[string]interface{} { + authzURLs := make([]string, len(order.AuthzIDs)) + for i, id := range order.AuthzIDs { + authzURLs[i] = h.authzURL(id) + } + m := map[string]interface{}{ + "status": order.Status, + "expires": order.ExpiresAt.Format(time.RFC3339), + "identifiers": order.Identifiers, + "authorizations": authzURLs, + "finalize": h.finalizeURL(order.ID), + } + if order.CertID != "" { + m["certificate"] = h.certURL(order.CertID) + } + return m +} + +func (h *Handler) authzToWire(ctx context.Context, authz *Authorization) map[string]interface{} { + var challenges []map[string]interface{} + for _, challID := range authz.ChallengeIDs { + chall, err := h.loadChallenge(ctx, challID) + if err != nil { + continue + } + challenges = append(challenges, h.challengeToWire(chall)) + } + return map[string]interface{}{ + "status": authz.Status, + "expires": authz.ExpiresAt.Format(time.RFC3339), + "identifier": authz.Identifier, + "challenges": challenges, + } +} + +func (h *Handler) challengeToWire(chall *Challenge) map[string]interface{} { + m := map[string]interface{}{ + "type": chall.Type, + "status": chall.Status, + "url": h.challengeURL(chall.Type, chall.ID), + "token": chall.Token, + } + if chall.ValidatedAt != nil { + m["validated"] = chall.ValidatedAt.Format(time.RFC3339) + } + if chall.Error != nil { + m["error"] = chall.Error + } + return m +} + +// --- Validation helpers --- + +func (h *Handler) validateCSRIdentifiers(csr *x509.CertificateRequest, identifiers []Identifier) error { + // Build expected sets from order. + expectedDNS := make(map[string]bool) + expectedIP := make(map[string]bool) + for _, id := range identifiers { + switch id.Type { + case IdentifierDNS: + expectedDNS[id.Value] = true + case IdentifierIP: + expectedIP[id.Value] = true + } + } + + // Verify DNS SANs match. + for _, name := range csr.DNSNames { + if !expectedDNS[name] { + return fmt.Errorf("CSR contains unexpected DNS SAN: %s", name) + } + delete(expectedDNS, name) + } + if len(expectedDNS) > 0 { + missing := make([]string, 0, len(expectedDNS)) + for k := range expectedDNS { + missing = append(missing, k) + } + return fmt.Errorf("CSR missing DNS SANs: %s", strings.Join(missing, ", ")) + } + + // Verify IP SANs match. + for _, ip := range csr.IPAddresses { + ipStr := ip.String() + if !expectedIP[ipStr] { + return fmt.Errorf("CSR contains unexpected IP SAN: %s", ipStr) + } + delete(expectedIP, ipStr) + } + if len(expectedIP) > 0 { + missing := make([]string, 0, len(expectedIP)) + for k := range expectedIP { + missing = append(missing, k) + } + return fmt.Errorf("CSR missing IP SANs: %s", strings.Join(missing, ", ")) + } + return nil +} + +// --- Misc helpers --- + +// newID generates a random URL-safe ID. +func newID() string { + b := make([]byte, 16) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +// newToken generates a random 32-byte base64url-encoded ACME challenge token. +func newToken() string { + b := make([]byte, 32) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +// thumbprintKey returns the JWK thumbprint of a JSON-encoded public key, +// used as the account ID / barrier key. +func thumbprintKey(jwk []byte) string { + t, err := ThumbprintJWK(jwk) + if err != nil { + return newID() + } + return t +} + +// extractIDFromURL extracts the last path segment after a known prefix. +func extractIDFromURL(url, prefix string) string { + idx := strings.LastIndex(url, prefix) + if idx < 0 { + return "" + } + return url[idx+len(prefix):] +} + +// readBody reads and returns the full request body. +func readBody(r *http.Request) ([]byte, error) { + if r.Body == nil { + return nil, errors.New("empty body") + } + defer r.Body.Close() + buf := make([]byte, 0, 4096) + tmp := make([]byte, 512) + for { + n, err := r.Body.Read(tmp) + buf = append(buf, tmp[:n]...) + if err != nil { + break + } + if len(buf) > 1<<20 { + return nil, errors.New("request body too large") + } + } + return buf, nil +} diff --git a/internal/acme/jws.go b/internal/acme/jws.go new file mode 100644 index 0000000..2716893 --- /dev/null +++ b/internal/acme/jws.go @@ -0,0 +1,320 @@ +package acme + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/hmac" + "crypto/rsa" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/big" +) + +// JWSFlat is the wire format of a flattened JWS (RFC 7515 §7.2.2). +type JWSFlat struct { + Protected string `json:"protected"` + Payload string `json:"payload"` + Signature string `json:"signature"` +} + +// JWSHeader is the decoded JOSE protected header from an ACME request. +type JWSHeader struct { + Alg string `json:"alg"` + Nonce string `json:"nonce"` + URL string `json:"url"` + JWK json.RawMessage `json:"jwk,omitempty"` // present for new-account / new-order with new key + KID string `json:"kid,omitempty"` // present for subsequent requests +} + +// ParsedJWS is the result of parsing a JWS request body. +// Signature verification is NOT performed here; call VerifyJWS separately. +type ParsedJWS struct { + Header JWSHeader + Payload []byte // decoded payload bytes; empty-string payload decodes to nil + SigningInput []byte // protected + "." + payload (ASCII; used for signature verification) + RawSignature []byte // decoded signature bytes + RawBody JWSFlat +} + +// ParseJWS decodes the flattened JWS from body without verifying the signature. +// Callers must call VerifyJWS after resolving the public key. +func ParseJWS(body []byte) (*ParsedJWS, error) { + var flat JWSFlat + if err := json.Unmarshal(body, &flat); err != nil { + return nil, fmt.Errorf("invalid JWS: %w", err) + } + + headerBytes, err := base64.RawURLEncoding.DecodeString(flat.Protected) + if err != nil { + return nil, fmt.Errorf("invalid JWS protected header encoding: %w", err) + } + var header JWSHeader + if err := json.Unmarshal(headerBytes, &header); err != nil { + return nil, fmt.Errorf("invalid JWS protected header: %w", err) + } + + var payload []byte + if flat.Payload != "" { + payload, err = base64.RawURLEncoding.DecodeString(flat.Payload) + if err != nil { + return nil, fmt.Errorf("invalid JWS payload encoding: %w", err) + } + } + + sig, err := base64.RawURLEncoding.DecodeString(flat.Signature) + if err != nil { + return nil, fmt.Errorf("invalid JWS signature encoding: %w", err) + } + + // Signing input is the ASCII bytes of "protected.payload" (not decoded). + signingInput := []byte(flat.Protected + "." + flat.Payload) + + return &ParsedJWS{ + Header: header, + Payload: payload, + SigningInput: signingInput, + RawSignature: sig, + RawBody: flat, + }, nil +} + +// VerifyJWS verifies the signature of a parsed JWS against the given public key. +// Supported algorithms: ES256, ES384, ES512 (ECDSA), RS256 (RSA-PKCS1v15). +func VerifyJWS(parsed *ParsedJWS, pubKey crypto.PublicKey) error { + switch parsed.Header.Alg { + case "ES256": + return verifyECDSA(parsed.SigningInput, parsed.RawSignature, pubKey, crypto.SHA256) + case "ES384": + return verifyECDSA(parsed.SigningInput, parsed.RawSignature, pubKey, crypto.SHA384) + case "ES512": + return verifyECDSA(parsed.SigningInput, parsed.RawSignature, pubKey, crypto.SHA512) + case "RS256": + return verifyRSA(parsed.SigningInput, parsed.RawSignature, pubKey) + default: + return fmt.Errorf("unsupported algorithm: %s", parsed.Header.Alg) + } +} + +func verifyECDSA(input, sig []byte, pubKey crypto.PublicKey, hash crypto.Hash) error { + ecKey, ok := pubKey.(*ecdsa.PublicKey) + if !ok { + return errors.New("expected ECDSA public key") + } + + var digest []byte + switch hash { + case crypto.SHA256: + h := sha256.Sum256(input) + digest = h[:] + case crypto.SHA384: + h := sha512.Sum384(input) + digest = h[:] + case crypto.SHA512: + h := sha512.Sum512(input) + digest = h[:] + default: + return errors.New("unsupported hash") + } + + // ECDSA JWS signatures are the concatenation of R and S, each padded to + // the curve's byte length (RFC 7518 §3.4). + keyBytes := (ecKey.Curve.Params().BitSize + 7) / 8 + if len(sig) != 2*keyBytes { + return fmt.Errorf("ECDSA signature has wrong length: got %d, want %d", len(sig), 2*keyBytes) + } + r := new(big.Int).SetBytes(sig[:keyBytes]) + s := new(big.Int).SetBytes(sig[keyBytes:]) + if !ecdsa.Verify(ecKey, digest, r, s) { + return errors.New("ECDSA signature verification failed") + } + return nil +} + +func verifyRSA(input, sig []byte, pubKey crypto.PublicKey) error { + rsaKey, ok := pubKey.(*rsa.PublicKey) + if !ok { + return errors.New("expected RSA public key") + } + digest := sha256.Sum256(input) + return rsa.VerifyPKCS1v15(rsaKey, crypto.SHA256, digest[:], sig) +} + +// VerifyEAB verifies the External Account Binding inner JWS (RFC 8555 §7.3.4). +// The inner JWS is a MAC over the account JWK bytes using the EAB HMAC key. +// kid must match the KID in the inner JWS header. +// accountJWK is the canonical JSON of the new account's public key (the outer JWK). +func VerifyEAB(eabJWS json.RawMessage, kid string, hmacKey, accountJWK []byte) error { + parsed, err := ParseJWS(eabJWS) + if err != nil { + return fmt.Errorf("invalid EAB JWS: %w", err) + } + if parsed.Header.Alg != "HS256" { + return fmt.Errorf("EAB must use HS256, got %s", parsed.Header.Alg) + } + if parsed.Header.KID != kid { + return fmt.Errorf("EAB kid mismatch: got %q, want %q", parsed.Header.KID, kid) + } + // The EAB payload must be the account JWK (base64url-encoded). + expectedPayload := base64.RawURLEncoding.EncodeToString(accountJWK) + if parsed.RawBody.Payload != expectedPayload { + // Canonical form may differ; compare decoded bytes. + if string(parsed.Payload) != string(accountJWK) { + return errors.New("EAB payload does not match account JWK") + } + } + // Verify HMAC-SHA256. + mac := hmac.New(sha256.New, hmacKey) + mac.Write(parsed.SigningInput) + expected := mac.Sum(nil) + sig, err := base64.RawURLEncoding.DecodeString(parsed.RawBody.Signature) + if err != nil { + return fmt.Errorf("invalid EAB signature encoding: %w", err) + } + if !hmac.Equal(sig, expected) { + return errors.New("EAB HMAC verification failed") + } + return nil +} + +// JWK types for JSON unmarshaling. +type jwkEC struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +type jwkRSA struct { + Kty string `json:"kty"` + N string `json:"n"` + E string `json:"e"` +} + +// ParseJWK parses a JWK (JSON Web Key) into a Go public key. +// Supports EC keys (P-256, P-384, P-521) and RSA keys. +func ParseJWK(jwk json.RawMessage) (crypto.PublicKey, error) { + var kty struct { + Kty string `json:"kty"` + } + if err := json.Unmarshal(jwk, &kty); err != nil { + return nil, fmt.Errorf("invalid JWK: %w", err) + } + switch kty.Kty { + case "EC": + var key jwkEC + if err := json.Unmarshal(jwk, &key); err != nil { + return nil, fmt.Errorf("invalid EC JWK: %w", err) + } + var curve elliptic.Curve + switch key.Crv { + case "P-256": + curve = elliptic.P256() + case "P-384": + curve = elliptic.P384() + case "P-521": + curve = elliptic.P521() + default: + return nil, fmt.Errorf("unsupported EC curve: %s", key.Crv) + } + xBytes, err := base64.RawURLEncoding.DecodeString(key.X) + if err != nil { + return nil, fmt.Errorf("invalid EC JWK x: %w", err) + } + yBytes, err := base64.RawURLEncoding.DecodeString(key.Y) + if err != nil { + return nil, fmt.Errorf("invalid EC JWK y: %w", err) + } + return &ecdsa.PublicKey{ + Curve: curve, + X: new(big.Int).SetBytes(xBytes), + Y: new(big.Int).SetBytes(yBytes), + }, nil + + case "RSA": + var key jwkRSA + if err := json.Unmarshal(jwk, &key); err != nil { + return nil, fmt.Errorf("invalid RSA JWK: %w", err) + } + nBytes, err := base64.RawURLEncoding.DecodeString(key.N) + if err != nil { + return nil, fmt.Errorf("invalid RSA JWK n: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(key.E) + if err != nil { + return nil, fmt.Errorf("invalid RSA JWK e: %w", err) + } + // e is a big-endian integer, typically 65537. + eInt := 0 + for _, b := range eBytes { + eInt = eInt<<8 | int(b) + } + return &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: eInt, + }, nil + + default: + return nil, fmt.Errorf("unsupported key type: %s", kty.Kty) + } +} + +// ThumbprintJWK computes the RFC 7638 JWK thumbprint (SHA-256) of a public key JWK. +// Used to compute the key authorization for challenges. +func ThumbprintJWK(jwk json.RawMessage) (string, error) { + var kty struct { + Kty string `json:"kty"` + } + if err := json.Unmarshal(jwk, &kty); err != nil { + return "", fmt.Errorf("invalid JWK: %w", err) + } + + // RFC 7638 §3: thumbprint input is a JSON object with only the required + // members in lexicographic order, with no whitespace. + var canonical []byte + var err error + switch kty.Kty { + case "EC": + var key jwkEC + if err = json.Unmarshal(jwk, &key); err != nil { + return "", err + } + canonical, err = json.Marshal(map[string]string{ + "crv": key.Crv, + "kty": key.Kty, + "x": key.X, + "y": key.Y, + }) + case "RSA": + var key jwkRSA + if err = json.Unmarshal(jwk, &key); err != nil { + return "", err + } + canonical, err = json.Marshal(map[string]string{ + "e": key.E, + "kty": key.Kty, + "n": key.N, + }) + default: + return "", fmt.Errorf("unsupported key type: %s", kty.Kty) + } + if err != nil { + return "", err + } + digest := sha256.Sum256(canonical) + return base64.RawURLEncoding.EncodeToString(digest[:]), nil +} + +// KeyAuthorization computes the key authorization string for a challenge token +// and account JWK: token + "." + base64url(SHA-256(JWK thumbprint)). +func KeyAuthorization(token string, accountJWK json.RawMessage) (string, error) { + thumbprint, err := ThumbprintJWK(accountJWK) + if err != nil { + return "", err + } + return token + "." + thumbprint, nil +} diff --git a/internal/acme/nonce.go b/internal/acme/nonce.go new file mode 100644 index 0000000..d62ced5 --- /dev/null +++ b/internal/acme/nonce.go @@ -0,0 +1,71 @@ +package acme + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "sync" + "time" +) + +const nonceLifetime = 10 * time.Minute + +// NonceStore is a thread-safe single-use nonce store with expiry. +// Nonces are short-lived per RFC 8555 §7.2. +type NonceStore struct { + mu sync.Mutex + nonces map[string]time.Time + issued int +} + +// NewNonceStore creates a new nonce store. +func NewNonceStore() *NonceStore { + return &NonceStore{ + nonces: make(map[string]time.Time), + } +} + +// Issue generates, stores, and returns a new base64url-encoded nonce. +func (s *NonceStore) Issue() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + nonce := base64.RawURLEncoding.EncodeToString(b) + + s.mu.Lock() + defer s.mu.Unlock() + s.nonces[nonce] = time.Now().Add(nonceLifetime) + s.issued++ + // Purge expired nonces every 100 issues to bound memory. + if s.issued%100 == 0 { + s.purgeExpiredLocked() + } + return nonce, nil +} + +// Consume validates that the nonce exists and has not expired, then removes it. +// Returns an error if the nonce is unknown, expired, or already consumed. +func (s *NonceStore) Consume(nonce string) error { + s.mu.Lock() + defer s.mu.Unlock() + exp, ok := s.nonces[nonce] + if !ok { + return errors.New("unknown or already-consumed nonce") + } + delete(s.nonces, nonce) + if time.Now().After(exp) { + return errors.New("nonce expired") + } + return nil +} + +// purgeExpiredLocked removes all expired nonces. Caller must hold s.mu. +func (s *NonceStore) purgeExpiredLocked() { + now := time.Now() + for n, exp := range s.nonces { + if now.After(exp) { + delete(s.nonces, n) + } + } +} diff --git a/internal/acme/server.go b/internal/acme/server.go new file mode 100644 index 0000000..6c39c9d --- /dev/null +++ b/internal/acme/server.go @@ -0,0 +1,124 @@ +// Package acme implements an RFC 8555 ACME server. +package acme + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "git.wntrmute.dev/kyle/metacrypt/internal/barrier" + "git.wntrmute.dev/kyle/metacrypt/internal/engine" +) + +// Handler implements the ACME protocol for a single CA mount. +type Handler struct { + mount string + barrier barrier.Barrier + engines *engine.Registry + nonces *NonceStore + baseURL string + logger *slog.Logger +} + +// NewHandler creates an ACME handler for the given CA mount. +func NewHandler(mount string, b barrier.Barrier, engines *engine.Registry, baseURL string, logger *slog.Logger) *Handler { + return &Handler{ + mount: mount, + barrier: b, + engines: engines, + nonces: NewNonceStore(), + baseURL: baseURL, + logger: logger, + } +} + +// RegisterRoutes registers all ACME protocol routes on r. +// r should be a subrouter already scoped to "/acme/{mount}". +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Get("/directory", h.handleDirectory) + r.Head("/new-nonce", h.handleNewNonce) + r.Get("/new-nonce", h.handleNewNonce) + r.Post("/new-account", h.handleNewAccount) + r.Post("/new-order", h.handleNewOrder) + r.Post("/authz/{id}", h.handleGetAuthz) + r.Post("/challenge/{type}/{id}", h.handleChallenge) + r.Post("/finalize/{id}", h.handleFinalize) + r.Post("/cert/{id}", h.handleGetCert) + r.Post("/revoke-cert", h.handleRevokeCert) +} + +// barrierPrefix returns the barrier key prefix for this mount. +func (h *Handler) barrierPrefix() string { + return "acme/" + h.mount + "/" +} + +func (h *Handler) accountURL(id string) string { + return h.baseURL + "/acme/" + h.mount + "/account/" + id +} + +func (h *Handler) orderURL(id string) string { + return h.baseURL + "/acme/" + h.mount + "/order/" + id +} + +func (h *Handler) authzURL(id string) string { + return h.baseURL + "/acme/" + h.mount + "/authz/" + id +} + +func (h *Handler) challengeURL(typ, id string) string { + return h.baseURL + "/acme/" + h.mount + "/challenge/" + typ + "/" + id +} + +func (h *Handler) finalizeURL(id string) string { + return h.baseURL + "/acme/" + h.mount + "/finalize/" + id +} + +func (h *Handler) certURL(id string) string { + return h.baseURL + "/acme/" + h.mount + "/cert/" + id +} + +// addNonceHeader issues a fresh nonce and adds it to the Replay-Nonce header. +// RFC 8555 §6.5: every response must include a fresh nonce. +func (h *Handler) addNonceHeader(w http.ResponseWriter) { + nonce, err := h.nonces.Issue() + if err != nil { + h.logger.Error("acme: failed to issue nonce", "error", err) + return + } + w.Header().Set("Replay-Nonce", nonce) +} + +// writeACMEError writes an RFC 7807 problem detail response. +func (h *Handler) writeACMEError(w http.ResponseWriter, status int, typ, detail string) { + h.addNonceHeader(w) + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{ + "type": typ, + "detail": detail, + }) +} + +// writeJSON writes a JSON response with the given status code and value. +func (h *Handler) writeJSON(w http.ResponseWriter, status int, v interface{}) { + h.addNonceHeader(w) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +// loadConfig loads the ACME config for this mount from the barrier. +// Returns a zero-value config if none exists. +func (h *Handler) loadConfig(ctx context.Context) (*ACMEConfig, error) { + data, err := h.barrier.Get(ctx, h.barrierPrefix()+"config.json") + if err != nil || data == nil { + return &ACMEConfig{}, nil + } + var cfg ACMEConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return &ACMEConfig{}, nil + } + return &cfg, nil +} diff --git a/internal/acme/types.go b/internal/acme/types.go new file mode 100644 index 0000000..82260c4 --- /dev/null +++ b/internal/acme/types.go @@ -0,0 +1,130 @@ +package acme + +import "time" + +// Account represents an ACME account (RFC 8555 §7.1.2). +type Account struct { + ID string `json:"id"` + Status string `json:"status"` // "valid", "deactivated", "revoked" + Contact []string `json:"contact,omitempty"` + JWK []byte `json:"jwk"` // canonical JSON of account public key + CreatedAt time.Time `json:"created_at"` + MCIASUsername string `json:"mcias_username"` // MCIAS user who created via EAB +} + +// EABCredential is an External Account Binding credential (RFC 8555 §7.3.4). +type EABCredential struct { + KID string `json:"kid"` + HMACKey []byte `json:"hmac_key"` // raw 32-byte secret + Used bool `json:"used"` + CreatedBy string `json:"created_by"` // MCIAS username + CreatedAt time.Time `json:"created_at"` +} + +// Order represents an ACME certificate order (RFC 8555 §7.1.3). +type Order struct { + ID string `json:"id"` + AccountID string `json:"account_id"` + Status string `json:"status"` // "pending","ready","processing","valid","invalid" + Identifiers []Identifier `json:"identifiers"` + AuthzIDs []string `json:"authz_ids"` + CertID string `json:"cert_id,omitempty"` + NotBefore *time.Time `json:"not_before,omitempty"` + NotAfter *time.Time `json:"not_after,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + IssuerName string `json:"issuer_name"` // which CA issuer to sign with +} + +// Identifier is a domain name or IP address in an order. +type Identifier struct { + Type string `json:"type"` // "dns" or "ip" + Value string `json:"value"` +} + +// Authorization represents an ACME authorization (RFC 8555 §7.1.4). +type Authorization struct { + ID string `json:"id"` + AccountID string `json:"account_id"` + Status string `json:"status"` // "pending","valid","invalid","expired","deactivated","revoked" + Identifier Identifier `json:"identifier"` + ChallengeIDs []string `json:"challenge_ids"` + ExpiresAt time.Time `json:"expires_at"` +} + +// Challenge represents an ACME challenge (RFC 8555 §8). +type Challenge struct { + ID string `json:"id"` + AuthzID string `json:"authz_id"` + Type string `json:"type"` // "http-01" or "dns-01" + Status string `json:"status"` // "pending","processing","valid","invalid" + Token string `json:"token"` // base64url, 43 chars (32 random bytes) + Error *ProblemDetail `json:"error,omitempty"` + ValidatedAt *time.Time `json:"validated_at,omitempty"` +} + +// ProblemDetail is an RFC 7807 problem detail for ACME errors. +type ProblemDetail struct { + Type string `json:"type"` + Detail string `json:"detail"` +} + +// IssuedCert stores the PEM and metadata for a certificate issued via ACME. +type IssuedCert struct { + ID string `json:"id"` + OrderID string `json:"order_id"` + AccountID string `json:"account_id"` + CertPEM string `json:"cert_pem"` // full chain PEM + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + Revoked bool `json:"revoked"` +} + +// ACMEConfig is per-mount ACME configuration stored in the barrier. +type ACMEConfig struct { + DefaultIssuer string `json:"default_issuer"` // CA issuer name to use for ACME certs +} + +// Status constants. +const ( + StatusValid = "valid" + StatusPending = "pending" + StatusProcessing = "processing" + StatusReady = "ready" + StatusInvalid = "invalid" + StatusDeactivated = "deactivated" + StatusRevoked = "revoked" + + ChallengeHTTP01 = "http-01" + ChallengeDNS01 = "dns-01" + + IdentifierDNS = "dns" + IdentifierIP = "ip" +) + +// ACME problem type URIs (RFC 8555 §6.7). +const ( + ProblemAccountDoesNotExist = "urn:ietf:params:acme:error:accountDoesNotExist" + ProblemAlreadyRevoked = "urn:ietf:params:acme:error:alreadyRevoked" + ProblemBadCSR = "urn:ietf:params:acme:error:badCSR" + ProblemBadNonce = "urn:ietf:params:acme:error:badNonce" + ProblemBadPublicKey = "urn:ietf:params:acme:error:badPublicKey" + ProblemBadRevocationReason = "urn:ietf:params:acme:error:badRevocationReason" + ProblemBadSignatureAlg = "urn:ietf:params:acme:error:badSignatureAlgorithm" + ProblemCAA = "urn:ietf:params:acme:error:caa" + ProblemConnection = "urn:ietf:params:acme:error:connection" + ProblemDNS = "urn:ietf:params:acme:error:dns" + ProblemExternalAccountRequired = "urn:ietf:params:acme:error:externalAccountRequired" + ProblemIncorrectResponse = "urn:ietf:params:acme:error:incorrectResponse" + ProblemInvalidContact = "urn:ietf:params:acme:error:invalidContact" + ProblemMalformed = "urn:ietf:params:acme:error:malformed" + ProblemOrderNotReady = "urn:ietf:params:acme:error:orderNotReady" + ProblemRateLimited = "urn:ietf:params:acme:error:rateLimited" + ProblemRejectedIdentifier = "urn:ietf:params:acme:error:rejectedIdentifier" + ProblemServerInternal = "urn:ietf:params:acme:error:serverInternal" + ProblemTLS = "urn:ietf:params:acme:error:tls" + ProblemUnauthorized = "urn:ietf:params:acme:error:unauthorized" + ProblemUnsupportedContact = "urn:ietf:params:acme:error:unsupportedContact" + ProblemUnsupportedIdentifier = "urn:ietf:params:acme:error:unsupportedIdentifier" + ProblemUserActionRequired = "urn:ietf:params:acme:error:userActionRequired" +) diff --git a/internal/acme/validate.go b/internal/acme/validate.go new file mode 100644 index 0000000..9fdd16d --- /dev/null +++ b/internal/acme/validate.go @@ -0,0 +1,274 @@ +package acme + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net" + "net/http" + "time" +) + +// validateChallenge dispatches to the appropriate validator and updates +// challenge, authorization, and order state in the barrier. +func (h *Handler) validateChallenge(ctx context.Context, chall *Challenge, accountJWK []byte) { + // Load the authorization to get the identifier (domain/IP). + authz, err := h.loadAuthz(ctx, chall.AuthzID) + if err != nil { + h.logger.Error("acme: load authz for validation", "id", chall.AuthzID, "error", err) + chall.Status = StatusInvalid + chall.Error = &ProblemDetail{Type: ProblemServerInternal, Detail: "failed to load authorization"} + h.saveChallenge(ctx, chall) + return + } + // Inject the identifier value into the context for validators. + ctx = context.WithValue(ctx, ctxKeyDomain, authz.Identifier.Value) + + var validationErr error + switch chall.Type { + case ChallengeHTTP01: + validationErr = validateHTTP01(ctx, chall, accountJWK) + case ChallengeDNS01: + validationErr = validateDNS01(ctx, chall, accountJWK) + default: + validationErr = fmt.Errorf("unknown challenge type: %s", chall.Type) + } + + now := time.Now() + if validationErr == nil { + chall.Status = StatusValid + chall.ValidatedAt = &now + chall.Error = nil + } else { + h.logger.Info("acme: challenge validation failed", + "id", chall.ID, "type", chall.Type, "error", validationErr) + chall.Status = StatusInvalid + chall.Error = &ProblemDetail{ + Type: ProblemConnection, + Detail: validationErr.Error(), + } + } + + if err := h.saveChallenge(ctx, chall); err != nil { + h.logger.Error("acme: save challenge after validation", "error", err) + return + } + + // Update the parent authorization. + h.updateAuthzStatus(ctx, chall.AuthzID) +} + +// updateAuthzStatus recomputes an authorization's status from its challenges, +// then propagates any state change to the parent order. +func (h *Handler) updateAuthzStatus(ctx context.Context, authzID string) { + authz, err := h.loadAuthz(ctx, authzID) + if err != nil { + h.logger.Error("acme: load authz for status update", "error", err) + return + } + + // An authz is valid if any single challenge is valid. + // An authz is invalid if all challenges are invalid. + anyValid := false + allInvalid := true + for _, challID := range authz.ChallengeIDs { + chall, err := h.loadChallenge(ctx, challID) + if err != nil { + continue + } + if chall.Status == StatusValid { + anyValid = true + allInvalid = false + break + } + if chall.Status != StatusInvalid { + allInvalid = false + } + } + + prevStatus := authz.Status + if anyValid { + authz.Status = StatusValid + } else if allInvalid { + authz.Status = StatusInvalid + } + + if authz.Status != prevStatus { + data, _ := json.Marshal(authz) + if err := h.barrier.Put(ctx, h.barrierPrefix()+"authz/"+authzID+".json", data); err != nil { + h.logger.Error("acme: save authz", "error", err) + return + } + // Propagate to orders that reference this authz. + h.updateOrdersForAuthz(ctx, authzID) + } +} + +// updateOrdersForAuthz scans all orders for one that references authzID +// and updates the order status if all authorizations are now valid. +func (h *Handler) updateOrdersForAuthz(ctx context.Context, authzID string) { + paths, err := h.barrier.List(ctx, h.barrierPrefix()+"orders/") + if err != nil { + return + } + for _, p := range paths { + data, err := h.barrier.Get(ctx, h.barrierPrefix()+"orders/"+p) + if err != nil || data == nil { + continue + } + var order Order + if err := json.Unmarshal(data, &order); err != nil { + continue + } + if order.Status != StatusPending { + continue + } + for _, id := range order.AuthzIDs { + if id != authzID { + continue + } + // This order references the updated authz; check all authzs. + h.maybeAdvanceOrder(ctx, &order) + break + } + } +} + +// maybeAdvanceOrder checks whether all authorizations for an order are valid +// and transitions the order to "ready" if so. +func (h *Handler) maybeAdvanceOrder(ctx context.Context, order *Order) { + allValid := true + for _, authzID := range order.AuthzIDs { + authz, err := h.loadAuthz(ctx, authzID) + if err != nil || authz.Status != StatusValid { + allValid = false + break + } + } + if !allValid { + return + } + order.Status = StatusReady + data, _ := json.Marshal(order) + if err := h.barrier.Put(ctx, h.barrierPrefix()+"orders/"+order.ID+".json", data); err != nil { + h.logger.Error("acme: advance order to ready", "error", err) + } +} + +// validateHTTP01 performs HTTP-01 challenge validation (RFC 8555 §8.3). +// It fetches http://{domain}/.well-known/acme-challenge/{token} and verifies +// the response matches the key authorization. +func validateHTTP01(ctx context.Context, chall *Challenge, accountJWK []byte) error { + authz, err := KeyAuthorization(chall.Token, accountJWK) + if err != nil { + return fmt.Errorf("compute key authorization: %w", err) + } + + // Load the authorization to get the identifier. + // The domain comes from the parent authorization; we pass accountJWK here + // but need the identifier. It's embedded in the challenge's AuthzID so the + // caller (validateChallenge) must load the authz to get the domain. + // Since we don't have the domain directly in Challenge, we embed it during + // challenge creation. For now: the caller passes accountJWK, and we use + // a context value to carry the domain through validateHTTP01. + domain, ok := ctx.Value(ctxKeyDomain).(string) + if !ok || domain == "" { + return errors.New("domain not in context") + } + + url := "http://" + domain + "/.well-known/acme-challenge/" + chall.Token + client := &http.Client{ + Timeout: 10 * time.Second, + // Follow at most 10 redirects per RFC 8555 §8.3. + CheckRedirect: func(_ *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("too many redirects") + } + return nil + }, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("build HTTP-01 request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("HTTP-01 fetch failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP-01: unexpected status %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 512)) + if err != nil { + return fmt.Errorf("HTTP-01: read body: %w", err) + } + + // RFC 8555 §8.3: response body should be the key authorization, optionally + // followed by whitespace. + got := string(body) + for len(got) > 0 && (got[len(got)-1] == '\n' || got[len(got)-1] == '\r' || got[len(got)-1] == ' ') { + got = got[:len(got)-1] + } + if got != authz { + return fmt.Errorf("HTTP-01: key authorization mismatch (got %q, want %q)", got, authz) + } + return nil +} + +// validateDNS01 performs DNS-01 challenge validation (RFC 8555 §8.4). +// It looks up _acme-challenge.{domain} TXT records and checks for the +// base64url(SHA-256(keyAuthorization)) value. +func validateDNS01(ctx context.Context, chall *Challenge, accountJWK []byte) error { + keyAuth, err := KeyAuthorization(chall.Token, accountJWK) + if err != nil { + return fmt.Errorf("compute key authorization: %w", err) + } + // DNS-01 TXT record value is base64url(SHA-256(keyAuthorization)). + digest := sha256.Sum256([]byte(keyAuth)) + expected := base64.RawURLEncoding.EncodeToString(digest[:]) + + domain, ok := ctx.Value(ctxKeyDomain).(string) + if !ok || domain == "" { + return errors.New("domain not in context") + } + + // Strip trailing dot if present; add _acme-challenge prefix. + domain = "_acme-challenge." + domain + + resolver := net.DefaultResolver + txts, err := resolver.LookupTXT(ctx, domain) + if err != nil { + return fmt.Errorf("DNS-01: TXT lookup for %s failed: %w", domain, err) + } + + for _, txt := range txts { + if txt == expected { + return nil + } + } + return fmt.Errorf("DNS-01: no matching TXT record for %s (expected %s)", domain, expected) +} + +// ctxKeyDomain is the context key for passing the domain to validators. +type ctxDomainKey struct{} + +var ctxKeyDomain = ctxDomainKey{} + +// pemToDER decodes the first PEM block from a PEM string and returns its DER bytes. +func pemToDER(pemStr string) ([]byte, error) { + block, _ := pem.Decode([]byte(pemStr)) + if block == nil { + return nil, errors.New("no PEM block") + } + return block.Bytes, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index c24e612..8f423db 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,10 +19,11 @@ type Config struct { // ServerConfig holds HTTP/gRPC server settings. type ServerConfig struct { - ListenAddr string `toml:"listen_addr"` - GRPCAddr string `toml:"grpc_addr"` - TLSCert string `toml:"tls_cert"` - TLSKey string `toml:"tls_key"` + ListenAddr string `toml:"listen_addr"` + GRPCAddr string `toml:"grpc_addr"` + TLSCert string `toml:"tls_cert"` + TLSKey string `toml:"tls_key"` + ExternalURL string `toml:"external_url"` // public base URL for ACME directory, e.g. "https://metacrypt.example.com" } // DatabaseConfig holds SQLite database settings. diff --git a/internal/seal/seal.go b/internal/seal/seal.go index 4a61364..9c873ab 100644 --- a/internal/seal/seal.go +++ b/internal/seal/seal.go @@ -71,6 +71,13 @@ func NewManager(db *sql.DB, b *barrier.AESGCMBarrier) *Manager { } } +// Barrier returns the underlying barrier for direct access by subsystems +// that need to read/write encrypted storage (e.g. ACME state). +// The barrier must only be used when the service is unsealed. +func (m *Manager) Barrier() *barrier.AESGCMBarrier { + return m.barrier +} + // State returns the current service state. func (m *Manager) State() ServiceState { m.mu.RLock() diff --git a/internal/server/acme.go b/internal/server/acme.go new file mode 100644 index 0000000..b8ebc31 --- /dev/null +++ b/internal/server/acme.go @@ -0,0 +1,178 @@ +package server + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + + internacme "git.wntrmute.dev/kyle/metacrypt/internal/acme" + "git.wntrmute.dev/kyle/metacrypt/internal/engine" +) + +// registerACMERoutes adds ACME protocol and management routes to r. +func (s *Server) registerACMERoutes(r chi.Router) { + // ACME protocol endpoints — HTTP only (RFC-defined protocol, no gRPC equivalent). + r.Route("/acme/{mount}", func(r chi.Router) { + r.Use(func(next http.Handler) http.Handler { + return s.requireUnseal(next.ServeHTTP) + }) + r.Get("/directory", s.acmeDispatch) + r.Head("/new-nonce", s.acmeDispatch) + r.Get("/new-nonce", s.acmeDispatch) + r.Post("/new-account", s.acmeDispatch) + r.Post("/new-order", s.acmeDispatch) + r.Post("/authz/{id}", s.acmeDispatch) + r.Post("/challenge/{type}/{id}", s.acmeDispatch) + r.Post("/finalize/{id}", s.acmeDispatch) + r.Post("/cert/{id}", s.acmeDispatch) + r.Post("/revoke-cert", s.acmeDispatch) + }) + + // Management endpoints — require MCIAS auth; REST counterparts to ACMEService gRPC. + r.Post("/v1/acme/{mount}/eab", s.requireAuth(s.handleACMECreateEAB)) + r.Put("/v1/acme/{mount}/config", s.requireAdmin(s.handleACMESetConfig)) + r.Get("/v1/acme/{mount}/accounts", s.requireAdmin(s.handleACMEListAccounts)) + r.Get("/v1/acme/{mount}/orders", s.requireAdmin(s.handleACMEListOrders)) +} + +// acmeDispatch routes an ACME protocol request to the correct mount handler. +func (s *Server) acmeDispatch(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + h, err := s.getOrCreateACMEHandler(mountName) + if err != nil { + http.Error(w, `{"type":"urn:ietf:params:acme:error:malformed","detail":"unknown mount"}`, + http.StatusNotFound) + return + } + // Re-dispatch through the handler's own chi router. + // Since we are already matched on the method+path, call the right method directly. + // The handler's RegisterRoutes uses a sub-router; instead we create a fresh router + // per request and serve it. + sub := chi.NewRouter() + h.RegisterRoutes(sub) + // Strip the "/acme/{mount}" prefix before delegating. + http.StripPrefix("/acme/"+mountName, sub).ServeHTTP(w, r) +} + +// getOrCreateACMEHandler lazily creates an ACME handler for the named CA mount. +func (s *Server) getOrCreateACMEHandler(mountName string) (*internacme.Handler, error) { + s.acmeMu.Lock() + defer s.acmeMu.Unlock() + if s.acmeHandlers == nil { + s.acmeHandlers = make(map[string]*internacme.Handler) + } + if h, ok := s.acmeHandlers[mountName]; ok { + return h, nil + } + // Verify mount is a CA engine. + mount, err := s.engines.GetMount(mountName) + if err != nil { + return nil, err + } + if mount.Type != engine.EngineTypeCA { + return nil, engine.ErrMountNotFound + } + // Build base URL from config. + baseURL := s.cfg.Server.ExternalURL + if baseURL == "" { + baseURL = "https://" + s.cfg.Server.ListenAddr + } + h := internacme.NewHandler(mountName, s.seal.Barrier(), s.engines, baseURL, s.logger) + s.acmeHandlers[mountName] = h + return h, nil +} + +// handleACMECreateEAB — POST /v1/acme/{mount}/eab +// Creates EAB credentials for the authenticated MCIAS user. +func (s *Server) handleACMECreateEAB(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + info := TokenInfoFromContext(r.Context()) + + h, err := s.getOrCreateACMEHandler(mountName) + if err != nil { + http.Error(w, `{"error":"mount not found"}`, http.StatusNotFound) + return + } + + cred, err := h.CreateEAB(r.Context(), info.Username) + if err != nil { + s.logger.Error("acme: create EAB", "error", err) + http.Error(w, `{"error":"failed to create EAB credentials"}`, http.StatusInternalServerError) + return + } + + writeJSON(w, http.StatusCreated, map[string]interface{}{ + "kid": cred.KID, + "hmac_key": cred.HMACKey, // raw bytes; client should base64url-encode for ACME clients + }) +} + +// handleACMESetConfig — PUT /v1/acme/{mount}/config +// Sets the default issuer for ACME certificate issuance on this mount. +func (s *Server) handleACMESetConfig(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + + var req struct { + DefaultIssuer string `json:"default_issuer"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if req.DefaultIssuer == "" { + http.Error(w, `{"error":"default_issuer is required"}`, http.StatusBadRequest) + return + } + + h, err := s.getOrCreateACMEHandler(mountName) + if err != nil { + http.Error(w, `{"error":"mount not found"}`, http.StatusNotFound) + return + } + + cfg := &internacme.ACMEConfig{DefaultIssuer: req.DefaultIssuer} + data, _ := json.Marshal(cfg) + barrierPath := "acme/" + mountName + "/config.json" + if err := s.seal.Barrier().Put(r.Context(), barrierPath, data); err != nil { + s.logger.Error("acme: save config", "error", err) + http.Error(w, `{"error":"failed to save config"}`, http.StatusInternalServerError) + return + } + _ = h // handler exists; config is read from barrier on each request + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} + +// handleACMEListAccounts — GET /v1/acme/{mount}/accounts +func (s *Server) handleACMEListAccounts(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + h, err := s.getOrCreateACMEHandler(mountName) + if err != nil { + http.Error(w, `{"error":"mount not found"}`, http.StatusNotFound) + return + } + accounts, err := h.ListAccounts(r.Context()) + if err != nil { + s.logger.Error("acme: list accounts", "error", err) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, accounts) +} + +// handleACMEListOrders — GET /v1/acme/{mount}/orders +func (s *Server) handleACMEListOrders(w http.ResponseWriter, r *http.Request) { + mountName := chi.URLParam(r, "mount") + h, err := s.getOrCreateACMEHandler(mountName) + if err != nil { + http.Error(w, `{"error":"mount not found"}`, http.StatusNotFound) + return + } + orders, err := h.ListOrders(r.Context()) + if err != nil { + s.logger.Error("acme: list orders", "error", err) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, orders) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index a655779..288f064 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -67,7 +67,7 @@ func (s *Server) registerRoutes(r chi.Router) { r.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules)) r.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule)) -} + s.registerACMERoutes(r)} // --- API Handlers --- diff --git a/internal/server/server.go b/internal/server/server.go index 15abfe1..7599aef 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,10 +7,12 @@ import ( "fmt" "log/slog" "net/http" + "sync" "time" "github.com/go-chi/chi/v5" + internacme "git.wntrmute.dev/kyle/metacrypt/internal/acme" "git.wntrmute.dev/kyle/metacrypt/internal/auth" "git.wntrmute.dev/kyle/metacrypt/internal/config" "git.wntrmute.dev/kyle/metacrypt/internal/engine" @@ -28,6 +30,9 @@ type Server struct { httpSrv *http.Server logger *slog.Logger version string + + acmeMu sync.Mutex + acmeHandlers map[string]*internacme.Handler } // New creates a new server. diff --git a/proto/metacrypt/v1/acme.proto b/proto/metacrypt/v1/acme.proto new file mode 100644 index 0000000..94f2aee --- /dev/null +++ b/proto/metacrypt/v1/acme.proto @@ -0,0 +1,83 @@ +syntax = "proto3"; + +package metacrypt.v1; + +option go_package = "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1;metacryptv1"; + +// ACMEService provides authenticated management of ACME state. +// These RPCs correspond to the REST management endpoints at /v1/acme/{mount}/. +// The ACME protocol endpoints themselves (/acme/{mount}/...) are HTTP-only +// per RFC 8555 and have no gRPC equivalents. +service ACMEService { + // CreateEAB creates External Account Binding credentials for the + // authenticated MCIAS user. The returned kid and hmac_key are used + // with any RFC 8555-compliant ACME client to register an account. + rpc CreateEAB(CreateEABRequest) returns (CreateEABResponse); + + // SetConfig sets the ACME configuration for a CA mount. + // Currently configures the default issuer used for ACME certificate issuance. + rpc SetConfig(SetACMEConfigRequest) returns (SetACMEConfigResponse); + + // ListAccounts returns all ACME accounts for a CA mount. Admin only. + rpc ListAccounts(ListACMEAccountsRequest) returns (ListACMEAccountsResponse); + + // ListOrders returns all ACME orders for a CA mount. Admin only. + rpc ListOrders(ListACMEOrdersRequest) returns (ListACMEOrdersResponse); +} + +message CreateEABRequest { + string mount = 1; +} + +message CreateEABResponse { + // kid is the key identifier to pass to the ACME client. + string kid = 1; + // hmac_key is the raw 32-byte HMAC-SHA256 key. + // Base64url-encode this value when configuring an ACME client. + bytes hmac_key = 2; +} + +message SetACMEConfigRequest { + string mount = 1; + // default_issuer is the name of the CA issuer to use for ACME certificates. + // The issuer must already exist on the CA mount. + string default_issuer = 2; +} + +message SetACMEConfigResponse { + bool ok = 1; +} + +message ListACMEAccountsRequest { + string mount = 1; +} + +message ListACMEAccountsResponse { + repeated ACMEAccount accounts = 1; +} + +message ACMEAccount { + string id = 1; + string status = 2; + repeated string contact = 3; + string mcias_username = 4; + string created_at = 5; +} + +message ListACMEOrdersRequest { + string mount = 1; +} + +message ListACMEOrdersResponse { + repeated ACMEOrder orders = 1; +} + +message ACMEOrder { + string id = 1; + string account_id = 2; + string status = 3; + // identifiers are in "type:value" format, e.g. "dns:example.com". + repeated string identifiers = 4; + string created_at = 5; + string expires_at = 6; +}