#17: implement DANE TLSA, MTA-STS policy resolution, ARC sealing and mail authentication
This commit is contained in:
1 parent
df8b809683
commit
bd0af98076
22 files changed
+3296
No files matched your search
@@ -0,0 +1,246 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user