// 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 }