Add ACME (RFC 8555) server and Go client library

Implements full ACME protocol support in Metacrypt:

- internal/acme: core types, JWS verification (ES256/384/512 + RS256),
  nonce store, per-mount handler, all RFC 8555 protocol endpoints,
  HTTP-01 and DNS-01 challenge validation, EAB management
- internal/server/acme.go: management REST routes (EAB create, config,
  list accounts/orders) + ACME protocol route dispatch
- proto/metacrypt/v1/acme.proto: ACMEService (CreateEAB, SetConfig,
  ListAccounts, ListOrders) — protocol endpoints are HTTP-only per RFC
- clients/go: new Go module with MCIAS-auth bootstrap, ACME account
  registration, certificate issuance/renewal, HTTP-01 and DNS-01
  challenge providers
- .claude/launch.json: dev server configuration

EAB is required for all account creation; MCIAS-authenticated users
obtain a single-use KID + HMAC-SHA256 key via POST /v1/acme/{mount}/eab.
This commit is contained in:
2026-03-15 01:31:52 -07:00
parent aa9a378685
commit 167db48eb4
19 changed files with 2743 additions and 5 deletions

301
clients/go/client.go Normal file
View File

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