Files
metacrypt/internal/acme/jws.go
Kyle Isom 167db48eb4 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.
2026-03-15 08:09:12 -07:00

321 lines
9.3 KiB
Go

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
}