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.
168 lines
4.8 KiB
Go
168 lines
4.8 KiB
Go
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
|
|
}
|