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:
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
65
clients/go/challenge.go
Normal file
65
clients/go/challenge.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
301
clients/go/client.go
Normal file
301
clients/go/client.go
Normal 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
|
||||||
|
}
|
||||||
5
clients/go/go.mod
Normal file
5
clients/go/go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module git.wntrmute.dev/kyle/metacrypt/clients/go
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require golang.org/x/crypto v0.49.0
|
||||||
2
clients/go/go.sum
Normal file
2
clients/go/go.sum
Normal file
@@ -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=
|
||||||
167
clients/go/metacrypt.go
Normal file
167
clients/go/metacrypt.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
118
internal/acme/eab.go
Normal file
118
internal/acme/eab.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
876
internal/acme/handlers.go
Normal file
876
internal/acme/handlers.go
Normal file
@@ -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": "<base64url DER>"}
|
||||||
|
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
|
||||||
|
}
|
||||||
320
internal/acme/jws.go
Normal file
320
internal/acme/jws.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
71
internal/acme/nonce.go
Normal file
71
internal/acme/nonce.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
internal/acme/server.go
Normal file
124
internal/acme/server.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
130
internal/acme/types.go
Normal file
130
internal/acme/types.go
Normal file
@@ -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"
|
||||||
|
)
|
||||||
274
internal/acme/validate.go
Normal file
274
internal/acme/validate.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -19,10 +19,11 @@ type Config struct {
|
|||||||
|
|
||||||
// ServerConfig holds HTTP/gRPC server settings.
|
// ServerConfig holds HTTP/gRPC server settings.
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
ListenAddr string `toml:"listen_addr"`
|
ListenAddr string `toml:"listen_addr"`
|
||||||
GRPCAddr string `toml:"grpc_addr"`
|
GRPCAddr string `toml:"grpc_addr"`
|
||||||
TLSCert string `toml:"tls_cert"`
|
TLSCert string `toml:"tls_cert"`
|
||||||
TLSKey string `toml:"tls_key"`
|
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.
|
// DatabaseConfig holds SQLite database settings.
|
||||||
|
|||||||
@@ -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.
|
// State returns the current service state.
|
||||||
func (m *Manager) State() ServiceState {
|
func (m *Manager) State() ServiceState {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
|
|||||||
178
internal/server/acme.go
Normal file
178
internal/server/acme.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ func (s *Server) registerRoutes(r chi.Router) {
|
|||||||
|
|
||||||
r.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules))
|
r.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules))
|
||||||
r.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule))
|
r.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule))
|
||||||
}
|
s.registerACMERoutes(r)}
|
||||||
|
|
||||||
// --- API Handlers ---
|
// --- API Handlers ---
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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/auth"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
||||||
@@ -28,6 +30,9 @@ type Server struct {
|
|||||||
httpSrv *http.Server
|
httpSrv *http.Server
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
version string
|
version string
|
||||||
|
|
||||||
|
acmeMu sync.Mutex
|
||||||
|
acmeHandlers map[string]*internacme.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new server.
|
// New creates a new server.
|
||||||
|
|||||||
83
proto/metacrypt/v1/acme.proto
Normal file
83
proto/metacrypt/v1/acme.proto
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user