#15: implement ACME certificate store, Traefik/Coolify cert watcher & secrets vault
This commit is contained in:
1 parent
769e0b59f0
commit
6ef427dc09
11 files changed
+1667
No files matched your search
@@ -0,0 +1,220 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/infrastructure/vault"
|
||||
)
|
||||
|
||||
// AcmeRepository stores self-managed ACME state with every private key sealed
|
||||
// (PLAN.md section 8.3).
|
||||
type AcmeRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
vault *vault.SecretVault
|
||||
directoryURL string
|
||||
}
|
||||
|
||||
func NewAcmeRepository(pool *pgxpool.Pool, v *vault.SecretVault, directoryURL string) *AcmeRepository {
|
||||
return &AcmeRepository{pool: pool, vault: v, directoryURL: directoryURL}
|
||||
}
|
||||
|
||||
func (r *AcmeRepository) LoadCertificate(ctx context.Context, domain string) (*port.StoredCertificate, error) {
|
||||
var (
|
||||
cert port.StoredCertificate
|
||||
certPEM string
|
||||
enc, nonce []byte
|
||||
keyVersion int
|
||||
keyGeneration int
|
||||
notAfter time.Time
|
||||
)
|
||||
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT certificate_pem, private_key_enc, private_key_nonce, key_version, key_generation, not_after
|
||||
FROM acme_certificates WHERE domain = $1
|
||||
`, domain).Scan(&certPEM, &enc, &nonce, &keyVersion, &keyGeneration, ¬After)
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load certificate for %s: %w", domain, err)
|
||||
}
|
||||
|
||||
privateKey, err := r.vault.Open(enc, nonce, keyVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open certificate key for %s: %w", domain, err)
|
||||
}
|
||||
|
||||
cert.Domain = domain
|
||||
cert.CertificatePEM = []byte(certPEM)
|
||||
cert.PrivateKeyPEM = privateKey
|
||||
cert.KeyGeneration = keyGeneration
|
||||
cert.NotAfter = notAfter
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
func (r *AcmeRepository) SaveCertificate(ctx context.Context, cert port.StoredCertificate) error {
|
||||
enc, nonce, err := r.vault.Seal(cert.PrivateKeyPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal certificate key: %w", err)
|
||||
}
|
||||
|
||||
_, err = r.pool.Exec(ctx, `
|
||||
INSERT INTO acme_certificates (
|
||||
domain, certificate_pem, private_key_enc, private_key_nonce,
|
||||
key_version, key_generation, not_after
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (domain) DO UPDATE SET
|
||||
certificate_pem = EXCLUDED.certificate_pem,
|
||||
private_key_enc = EXCLUDED.private_key_enc,
|
||||
private_key_nonce = EXCLUDED.private_key_nonce,
|
||||
key_version = EXCLUDED.key_version,
|
||||
key_generation = EXCLUDED.key_generation,
|
||||
not_after = EXCLUDED.not_after,
|
||||
updated_at = NOW()
|
||||
`, cert.Domain, string(cert.CertificatePEM), enc, nonce, vault.CurrentKeyVersion, cert.KeyGeneration, cert.NotAfter)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("save certificate for %s: %w", cert.Domain, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AcmeRepository) LoadAccountKey(ctx context.Context) ([]byte, error) {
|
||||
var (
|
||||
enc, nonce []byte
|
||||
keyVersion int
|
||||
)
|
||||
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT private_key_enc, private_key_nonce, key_version
|
||||
FROM acme_accounts WHERE directory_url = $1
|
||||
`, r.directoryURL).Scan(&enc, &nonce, &keyVersion)
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load acme account key: %w", err)
|
||||
}
|
||||
|
||||
return r.vault.Open(enc, nonce, keyVersion)
|
||||
}
|
||||
|
||||
func (r *AcmeRepository) SaveAccountKey(ctx context.Context, privateKeyPEM []byte) error {
|
||||
enc, nonce, err := r.vault.Seal(privateKeyPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal acme account key: %w", err)
|
||||
}
|
||||
|
||||
_, err = r.pool.Exec(ctx, `
|
||||
INSERT INTO acme_accounts (directory_url, private_key_enc, private_key_nonce, key_version)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (directory_url) DO UPDATE SET
|
||||
private_key_enc = EXCLUDED.private_key_enc,
|
||||
private_key_nonce = EXCLUDED.private_key_nonce,
|
||||
key_version = EXCLUDED.key_version
|
||||
`, r.directoryURL, enc, nonce, vault.CurrentKeyVersion)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("save acme account key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AcmeRepository) LoadPendingKey(ctx context.Context, domain string) ([]byte, error) {
|
||||
var (
|
||||
enc, nonce []byte
|
||||
keyVersion int
|
||||
)
|
||||
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT private_key_enc, private_key_nonce, key_version
|
||||
FROM acme_pending_keys WHERE domain = $1
|
||||
`, domain).Scan(&enc, &nonce, &keyVersion)
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load pending key for %s: %w", domain, err)
|
||||
}
|
||||
|
||||
return r.vault.Open(enc, nonce, keyVersion)
|
||||
}
|
||||
|
||||
// SavePendingKey stores the key reserved for the next issuance together with
|
||||
// its TLSA digest, so the rollover can publish the association without having
|
||||
// to re-derive it.
|
||||
func (r *AcmeRepository) SavePendingKey(ctx context.Context, domain string, privateKeyPEM []byte) error {
|
||||
return r.SavePendingKeyWithDigest(ctx, domain, privateKeyPEM, "")
|
||||
}
|
||||
|
||||
func (r *AcmeRepository) SavePendingKeyWithDigest(ctx context.Context, domain string, privateKeyPEM []byte, tlsaDigest string) error {
|
||||
enc, nonce, err := r.vault.Seal(privateKeyPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal pending key: %w", err)
|
||||
}
|
||||
|
||||
_, err = r.pool.Exec(ctx, `
|
||||
INSERT INTO acme_pending_keys (domain, private_key_enc, private_key_nonce, key_version, tlsa_sha256)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (domain) DO UPDATE SET
|
||||
private_key_enc = EXCLUDED.private_key_enc,
|
||||
private_key_nonce = EXCLUDED.private_key_nonce,
|
||||
key_version = EXCLUDED.key_version,
|
||||
tlsa_sha256 = EXCLUDED.tlsa_sha256,
|
||||
published_at = NULL
|
||||
`, domain, enc, nonce, vault.CurrentKeyVersion, tlsaDigest)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("save pending key for %s: %w", domain, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PendingKeyDigest returns the TLSA digest of the reserved key and when its
|
||||
// association was published, or empty values when there is no pending key.
|
||||
func (r *AcmeRepository) PendingKeyDigest(ctx context.Context, domain string) (digest string, publishedAt *time.Time, err error) {
|
||||
err = r.pool.QueryRow(ctx, `
|
||||
SELECT tlsa_sha256, published_at FROM acme_pending_keys WHERE domain = $1
|
||||
`, domain).Scan(&digest, &publishedAt)
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("load pending key digest for %s: %w", domain, err)
|
||||
}
|
||||
return digest, publishedAt, nil
|
||||
}
|
||||
|
||||
// MarkPendingKeyPublished records that the association is now in DNS, which
|
||||
// starts the propagation wait.
|
||||
func (r *AcmeRepository) MarkPendingKeyPublished(ctx context.Context, domain string) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE acme_pending_keys SET published_at = NOW() WHERE domain = $1 AND published_at IS NULL
|
||||
`, domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark pending key published for %s: %w", domain, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AcmeRepository) ClearPendingKey(ctx context.Context, domain string) error {
|
||||
_, err := r.pool.Exec(ctx, `DELETE FROM acme_pending_keys WHERE domain = $1`, domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear pending key for %s: %w", domain, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure port interface compliance
|
||||
var _ port.CertificateStore = (*AcmeRepository)(nil)
|
||||
Reference in new issue
Block a user