Files
LambdaMail/services/protocols/internal/application/usecase/rollover_tlsa.go
T

247 lines
8.4 KiB
Go

package usecase
import (
"context"
"crypto"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"time"
)
// RolloverStage names the state the rollover is in. It follows the state
// machine of PLAN.md section 5.1, which exists because the naive approach -
// publish the digest of the current certificate and update it at renewal -
// permanently breaks mail delivery to every DANE-validating receiver for as
// long as DNS has not caught up.
type RolloverStage string
const (
// StageStable means one association is published and the certificate
// matches it. Nothing to do.
StageStable RolloverStage = "STABLE"
// StagePublishNext means a key for the next certificate has been
// generated and its association has to go into DNS.
StagePublishNext RolloverStage = "PUBLISH_NEXT"
// StageAwaitPropagation means both associations are published and the
// rollover is waiting out the DNS caches before switching over.
StageAwaitPropagation RolloverStage = "AWAIT_PROPAGATION"
// StageRenew means the wait is over and the certificate can be re-issued
// against the pre-generated key.
StageRenew RolloverStage = "RENEW"
// StageCleanup means the new certificate is live and the old association
// can be retired, again only after the caches have expired.
StageCleanup RolloverStage = "CLEANUP"
)
// RolloverConfig tunes the timings.
type RolloverConfig struct {
// RenewBefore is how long before expiry a renewal starts. Let's Encrypt
// certificates last 90 days and are renewable from day 60.
RenewBefore time.Duration
// PrepareBefore is how long before the renewal the next key is generated
// and published. It has to exceed PropagationWait comfortably.
PrepareBefore time.Duration
// PropagationWait is how long both associations stay published before the
// certificate changes. RFC 7671 section 8.1 asks for at least twice the
// record TTL; a day is used because a resolver that ignores TTLs is not
// hypothetical.
PropagationWait time.Duration
}
// DefaultRolloverConfig follows PLAN.md section 5.1.
func DefaultRolloverConfig() RolloverConfig {
return RolloverConfig{
RenewBefore: 30 * 24 * time.Hour,
PrepareBefore: 30 * 24 * time.Hour,
PropagationWait: 24 * time.Hour,
}
}
// RolloverState is everything the decision needs.
type RolloverState struct {
// CurrentDigest is the TLSA value of the certificate being served.
CurrentDigest string
// NotAfter is the expiry of the certificate being served.
NotAfter time.Time
// PendingDigest is the association of the pre-generated next key, empty
// when no rollover is in flight.
PendingDigest string
// PendingPublishedAt is when that association went into DNS.
PendingPublishedAt *time.Time
// CertificateMatchesPending is true once the served certificate was
// issued against the pending key.
CertificateMatchesPending bool
}
// RolloverDecision is the action to take now.
type RolloverDecision struct {
Stage RolloverStage
// Publish is the full set of associations that must be in DNS after this
// step. It is always the complete desired state, never a delta, so
// applying it is idempotent.
Publish []string
Reason string
}
// RolloverTlsaUseCase decides how to move a DANE rollover forward. It is a
// pure decision function over the observed state: the caller performs the DNS
// writes and the issuance, which keeps the risky ordering rules in one
// testable place.
type RolloverTlsaUseCase struct {
config RolloverConfig
now func() time.Time
}
func NewRolloverTlsaUseCase(config RolloverConfig) *RolloverTlsaUseCase {
if config.RenewBefore == 0 {
config = DefaultRolloverConfig()
}
return &RolloverTlsaUseCase{config: config, now: time.Now}
}
// WithClock fixes the clock for tests.
func (uc *RolloverTlsaUseCase) WithClock(now func() time.Time) *RolloverTlsaUseCase {
uc.now = now
return uc
}
func (uc *RolloverTlsaUseCase) Decide(_ context.Context, state RolloverState) RolloverDecision {
now := uc.now()
// The new certificate is live. Both associations are still published;
// the old one may only go once the caches that hold it have expired.
if state.CertificateMatchesPending {
if state.PendingPublishedAt != nil && now.Sub(*state.PendingPublishedAt) < uc.config.PropagationWait {
return RolloverDecision{
Stage: StageCleanup,
Publish: dedupe(state.CurrentDigest, state.PendingDigest),
Reason: "new certificate is live; keeping the previous association until caches expire",
}
}
return RolloverDecision{
Stage: StageStable,
Publish: dedupe(state.PendingDigest),
Reason: "rollover complete; the previous association can be removed",
}
}
// A rollover already in flight: both are published, so the only question
// is whether the wait is over.
if state.PendingDigest != "" {
if state.PendingPublishedAt == nil {
return RolloverDecision{
Stage: StagePublishNext,
Publish: dedupe(state.CurrentDigest, state.PendingDigest),
Reason: "publishing the association for the pre-generated next key",
}
}
if now.Sub(*state.PendingPublishedAt) < uc.config.PropagationWait {
remaining := uc.config.PropagationWait - now.Sub(*state.PendingPublishedAt)
return RolloverDecision{
Stage: StageAwaitPropagation,
Publish: dedupe(state.CurrentDigest, state.PendingDigest),
Reason: fmt.Sprintf("waiting %s more for the next association to propagate", remaining.Round(time.Minute)),
}
}
return RolloverDecision{
Stage: StageRenew,
Publish: dedupe(state.CurrentDigest, state.PendingDigest),
Reason: "the next association has propagated; the certificate can be issued against the pre-generated key",
}
}
// No rollover in flight. Start one only when the renewal is close enough.
if !state.NotAfter.IsZero() && now.After(state.NotAfter.Add(-uc.config.PrepareBefore)) {
return RolloverDecision{
Stage: StagePublishNext,
Publish: dedupe(state.CurrentDigest),
Reason: "renewal is approaching; a next key has to be generated and published",
}
}
return RolloverDecision{
Stage: StageStable,
Publish: dedupe(state.CurrentDigest),
Reason: "certificate is current and no rollover is due",
}
}
// ShouldRenewNow reports whether the certificate itself is due for renewal,
// independently of DANE. Deployments without DANE use only this.
func (uc *RolloverTlsaUseCase) ShouldRenewNow(notAfter time.Time) bool {
if notAfter.IsZero() {
return true
}
return uc.now().After(notAfter.Add(-uc.config.RenewBefore))
}
// TlsaDigestForKey renders the "3 1 1" association of a private key: the
// SHA-256 of its SubjectPublicKeyInfo (RFC 6698 section 2.1.2). Deriving it
// from the key rather than from a certificate is what lets the association be
// published before the certificate exists.
func TlsaDigestForKey(privateKeyPEM []byte) (string, error) {
block, _ := pem.Decode(privateKeyPEM)
if block == nil {
return "", fmt.Errorf("rollover: private key is not valid PEM")
}
var publicKey any
switch parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes); {
case err == nil:
// crypto.Signer is the interface every usable private key satisfies;
// its Public method returns crypto.PublicKey, which is a defined type
// rather than a bare any, so it has to be named exactly.
signer, ok := parsed.(crypto.Signer)
if !ok {
return "", fmt.Errorf("rollover: key of type %T has no public half", parsed)
}
publicKey = signer.Public()
default:
ecKey, ecErr := x509.ParseECPrivateKey(block.Bytes)
if ecErr != nil {
return "", fmt.Errorf("rollover: parse private key: %w", err)
}
publicKey = &ecKey.PublicKey
}
spki, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
return "", fmt.Errorf("rollover: marshal public key: %w", err)
}
sum := sha256.Sum256(spki)
return hex.EncodeToString(sum[:]), nil
}
// TlsaDigestForCertificate renders the association of an already issued
// certificate, used to confirm what is actually being served.
func TlsaDigestForCertificate(certificatePEM []byte) (string, error) {
block, _ := pem.Decode(certificatePEM)
if block == nil {
return "", fmt.Errorf("rollover: certificate is not valid PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("rollover: parse certificate: %w", err)
}
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
return hex.EncodeToString(sum[:]), nil
}
func dedupe(values ...string) []string {
seen := map[string]bool{}
var out []string
for _, value := range values {
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}