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.
321 lines
9.3 KiB
Go
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
|
|
}
|