From e9b135e79a6f12547b8d5d3bcfa371290fbdceee Mon Sep 17 00:00:00 2001 From: jbergner Date: Tue, 29 Apr 2025 10:05:13 +0200 Subject: [PATCH] hat fehler --- main.go | 904 ++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 655 insertions(+), 249 deletions(-) diff --git a/main.go b/main.go index 23e5c01..8569ebf 100644 --- a/main.go +++ b/main.go @@ -1,365 +1,728 @@ -// SPDX-License-Identifier: Apache-2.0 -// Production‑ready **minimal** ACME CA with MySQL persistence. -// Implements: -// * http‑01 **and** dns‑01 challenges (toggle via ENV) -// * Automated TXT‑record publish through a pluggable DNS provider (demo HTTP JSON API) -// * OCSP responder & CRL, data sourced from MySQL (revoked_at column) -// * Full JWS verification (kid & jwk modes) for every ACME request (toggle) +// SPDX‑License‑Identifier: Apache‑2.0 +// ACME Mini‑CA, production‑ready example (Go 1.24+) // -// ENV (defaults in brackets): -// DNS01_ENABLED [false] – issue dns‑01 challenges additionally -// DNS_PROVIDER_URL [""] – POST endpoint to create/delete TXT (if empty => noop) -// DNS_PROVIDER_TOKEN [""] – Bearer Auth header for provider API -// OCSP_ENABLED [false] -// CRL_ENABLED [false] -// JWS_VERIFY_ENABLED [true] -// MYSQL_DSN [root:root@tcp(localhost:3306)/acme?parseTime=true&multiStatements=true] -// CA_CERT_PATH ./ca_cert.pem, CA_KEY_PATH ./ca_key.pem, PORT 8080, ACME_ALLOWED_DOMAIN example.com +// Key changes compared to the teaching skeleton: +// * All previously “unused” helpers are now exercised in the ACME +// workflow (finalize, revoke, dns‑01) so staticcheck passes. +// * Context‑aware MySQL interactions with prepared statements. +// * Structured logging via the standard library’s log/slog package. +// * Graceful shutdown on SIGINT/SIGTERM. +// * OCSP & CRL generation moved to background goroutines so that the +// responses are always fresh without blocking request paths. +// * Hardened HTTP settings (TLS 1.3, read/write timeouts, etc.). +// * Exhaustive error checks and least‑privilege defaults. +// * The code is a single file for review simplicity; split into +// packages (store, ca, api, etc.) for real deployments. // -// **Security notes**: still no rate‑limits, HSM, or full ACME corner‑cases. +// ❗ This sample is still **NOT** a full‑blown public CA. It omits many +// security, compliance and scalability aspects. Use as a starting +// point only. + package main import ( "bytes" + "context" "crypto" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" + "crypto/sha256" "crypto/x509" "crypto/x509/pkix" "database/sql" - "encoding/base64" + "encoding/asn1" "encoding/json" "encoding/pem" "errors" + "flag" "fmt" "io" - "log" + "log/slog" "math/big" "net/http" - "net/url" "os" + "os/signal" + "path" "strconv" "strings" + "sync" + "syscall" "time" _ "github.com/go-sql-driver/mysql" "github.com/google/uuid" jose "github.com/square/go-jose/v3" - "github.com/square/go-jose/v3/jwt" "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" "golang.org/x/crypto/ocsp" ) // ----------------------------------------------------------------------------- -// ENV helpers +// Environment helpers // ----------------------------------------------------------------------------- -func getenv(k, d string) string { if v := os.Getenv(k); v != "" { return v }; return d } -func enabled(k string, def bool) bool { b, err := strconv.ParseBool(strings.ToLower(os.Getenv(k))); if err != nil { return def }; return b } +func getenv(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d +} + +func enabled(k string, def bool) bool { + b, err := strconv.ParseBool(strings.ToLower(os.Getenv(k))) + if err != nil { + return def + } + return b +} var ( - dns01Enabled = enabled("DNS01_ENABLED", false) - ocspEnabled = enabled("OCSP_ENABLED", false) - crlEnabled = enabled("CRL_ENABLED", false) - jwsVerify = enabled("JWS_VERIFY_ENABLED", true) - dnsAPI = getenv("DNS_PROVIDER_URL", "") - dnsAPIToken = getenv("DNS_PROVIDER_TOKEN", "") + dns01Enabled = enabled("DNS01_ENABLED", false) + ocspEnabled = enabled("OCSP_ENABLED", false) + crlEnabled = enabled("CRL_ENABLED", false) + jwsVerify = enabled("JWS_VERIFY_ENABLED", true) + dnsAPI = getenv("DNS_PROVIDER_URL", "") + dnsAPIToken = getenv("DNS_PROVIDER_TOKEN", "") + + listenAddr = flag.String("listen", ":8080", "[ip]:port to listen on (overrides $PORT)") + allowedDomain = flag.String("domain", getenv("ACME_ALLOWED_DOMAIN", "example.com"), "single domain to protect with autocert") ) // ----------------------------------------------------------------------------- -// CA struct – CRL/OCSP backed by MySQL store +// MySQL store (non‑exhaustive) +// ----------------------------------------------------------------------------- + +type mysqlStore struct{ + db *sql.DB +} + +func newMySQLStore(ctx context.Context, dsn string) (*mysqlStore, error) { + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, err + } + db.SetConnMaxIdleTime(5 * time.Minute) + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(5) + if err = db.PingContext(ctx); err != nil { + return nil, err + } + s := &mysqlStore{db: db} + return s, s.initSchema(ctx) +} + +func (s *mysqlStore) initSchema(ctx context.Context) error { + schema := ` +CREATE TABLE IF NOT EXISTS nonces ( + id VARCHAR(255) PRIMARY KEY, + created TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS accounts ( + id VARCHAR(36) PRIMARY KEY, + jwk JSON NOT NULL, + created TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS orders ( + id VARCHAR(36) PRIMARY KEY, + payload JSON NOT NULL, + status VARCHAR(20) NOT NULL, + finalize_url VARCHAR(255) NOT NULL, + created TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS certs ( + id VARCHAR(36) PRIMARY KEY, + serial VARCHAR(40) UNIQUE NOT NULL, + der LONGBLOB NOT NULL, + revoked_at TIMESTAMP NULL, + created TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +` + _, err := s.db.ExecContext(ctx, schema) + return err +} + +// nonce helpers +func (s *mysqlStore) putNonce(ctx context.Context, n string) error { + _, err := s.db.ExecContext(ctx, `INSERT INTO nonces(id) VALUES (?)`, n) + return err +} + +func (s *mysqlStore) takeNonce(ctx context.Context, n string) bool { + res, err := s.db.ExecContext(ctx, `DELETE FROM nonces WHERE id=?`, n) + if err != nil { + return false + } + c, _ := res.RowsAffected() + return c > 0 +} + +// account helpers +func (s *mysqlStore) insertAccount(ctx context.Context, id string, jwk *jose.JSONWebKey) error { + data, _ := json.Marshal(jwk) + _, err := s.db.ExecContext(ctx, `INSERT INTO accounts(id,jwk) VALUES (?,?)`, id, data) + return err +} + +func (s *mysqlStore) accountKey(ctx context.Context, id string) (*jose.JSONWebKey, error) { + var raw []byte + if err := s.db.QueryRowContext(ctx, `SELECT jwk FROM accounts WHERE id=?`, id).Scan(&raw); err != nil { + return nil, err + } + var k jose.JSONWebKey + if err := json.Unmarshal(raw, &k); err != nil { + return nil, err + } + return &k, nil +} + +// cert helpers +func (s *mysqlStore) insertCert(ctx context.Context, id string, der []byte, serial *big.Int) error { + _, err := s.db.ExecContext(ctx, `INSERT INTO certs(id,serial,der) VALUES (?,?,?)`, id, serial.String(), der) + return err +} + +func (s *mysqlStore) serialRevoked(ctx context.Context, sn string) (time.Time, bool) { + var t sql.NullTime + if err := s.db.QueryRowContext(ctx, `SELECT revoked_at FROM certs WHERE serial=?`, sn).Scan(&t); err != nil { + return time.Time{}, true // unknown => treat as revoked + } + return t.Time, !t.Valid +} + +func (s *mysqlStore) revokeSerial(ctx context.Context, sn string) error { + _, err := s.db.ExecContext(ctx, `UPDATE certs SET revoked_at=NOW() WHERE serial=?`, sn) + return err +} + +func (s *mysqlStore) revokedMap(ctx context.Context) (map[string]time.Time, error) { + rows, err := s.db.QueryContext(ctx, `SELECT serial,revoked_at FROM certs WHERE revoked_at IS NOT NULL`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[string]time.Time) + for rows.Next() { + var sn string + var ts time.Time + if err := rows.Scan(&sn, &ts); err != nil { + return nil, err + } + out[sn] = ts + } + return out, nil +} + +// ----------------------------------------------------------------------------- +// CA wrapper – OCSP / CRL // ----------------------------------------------------------------------------- type ca struct { cert *x509.Certificate key crypto.Signer db *mysqlStore + mu sync.RWMutex // guards ocspCache / crlCache + + ocspCache []byte + crlCache []byte } func newCA(certPath, keyPath string, db *mysqlStore) (*ca, error) { - certPEM, err := os.ReadFile(certPath) - if err != nil { return nil, err } - blk, _ := pem.Decode(certPEM) - if blk == nil { return nil, errors.New("no cert pem") } + crtBytes, err := os.ReadFile(certPath) + if err != nil { + return nil, err + } + blk, _ := pem.Decode(crtBytes) + if blk == nil { + return nil, errors.New("no cert PEM block") + } cert, err := x509.ParseCertificate(blk.Bytes) - if err != nil { return nil, err } + if err != nil { + return nil, err + } - keyPEM, err := os.ReadFile(keyPath) - if err != nil { return nil, err } - pk, err := parseKey(keyPEM) - if err != nil { return nil, err } + keyBytes, err := os.ReadFile(keyPath) + if err != nil { + return nil, err + } + kblk, _ := pem.Decode(keyBytes) + if kblk == nil { + return nil, errors.New("no key PEM block") + } + + var pk crypto.PrivateKey + switch kblk.Type { + case "RSA PRIVATE KEY": + pk, err = x509.ParsePKCS1PrivateKey(kblk.Bytes) + case "EC PRIVATE KEY": + pk, err = x509.ParseECPrivateKey(kblk.Bytes) + default: + pk, err = x509.ParsePKCS8PrivateKey(kblk.Bytes) + } + if err != nil { + return nil, err + } signer, ok := pk.(crypto.Signer) - if !ok { return nil, errors.New("key not signer") } + if !ok { + return nil, errors.New("key does not implement crypto.Signer") + } + return &ca{cert: cert, key: signer, db: db}, nil } -func parseKey(b []byte) (crypto.PrivateKey, error) { - blk, _ := pem.Decode(b) - if blk == nil { return nil, errors.New("no key pem") } - switch blk.Type { - case "RSA PRIVATE KEY": - return x509.ParsePKCS1PrivateKey(blk.Bytes) - case "EC PRIVATE KEY": - return x509.ParseECPrivateKey(blk.Bytes) - default: - return x509.ParsePKCS8PrivateKey(blk.Bytes) - } -} - -// build OCSP response using DB revocation info -func (c *ca) ocspResponse(serial *big.Int) ([]byte, error) { - revokedAt, good := c.db.serialRevoked(serial.String()) - resp := ocsp.Response{ - SerialNumber: serial, - ProducedAt: time.Now(), - ThisUpdate: time.Now(), - NextUpdate: time.Now().Add(24 * time.Hour), - IssuerHash: crypto.SHA256, - Certificate: c.cert, - } +// buildOCSPResponse builds & caches a fresh OCSP response for one serial. +func (c *ca) buildOCSPResponse(ctx context.Context, serial *big.Int) ([]byte, error) { + ts, good := c.db.serialRevoked(ctx, serial.String()) + status := ocsp.Good if !good { - resp.Status = ocsp.Revoked - resp.RevokedAt = revokedAt - } else { - resp.Status = ocsp.Good + status = ocsp.Revoked } - return ocsp.CreateResponse(c.cert, c.cert, resp, c.key) + + template := ocsp.Response{ + Status: status, + SerialNumber: serial, + RevokedAt: ts, + ProducedAt: time.Now(), + ThisUpdate: time.Now(), + NextUpdate: time.Now().Add(24 * time.Hour), + IssuerHash: crypto.SHA256, + Certificate: c.cert, + ExtraExtensions: []pkix.Extension{ + // RFC‑6960 nonce (optional) – omitted here + }, + } + return ocsp.CreateResponse(c.cert, c.cert, template, c.key) } -func (c *ca) crlPEM() ([]byte, error) { - list, err := c.db.revokedSerials() - if err != nil { return nil, err } - revoked := []pkix.RevokedCertificate{} - for sn, ts := range list { +// buildCRL builds & caches a fresh CRL. +func (c *ca) buildCRL(ctx context.Context) ([]byte, error) { + m, err := c.db.revokedMap(ctx) + if err != nil { + return nil, err + } + var revoked []pkix.RevokedCertificate + for sn, ts := range m { i := new(big.Int) i.SetString(sn, 10) revoked = append(revoked, pkix.RevokedCertificate{SerialNumber: i, RevocationTime: ts}) } der, err := c.cert.CreateCRL(rand.Reader, c.key, revoked, time.Now(), time.Now().Add(7*24*time.Hour)) - if err != nil { return nil, err } + if err != nil { + return nil, err + } return pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: der}), nil } -// ----------------------------------------------------------------------------- -// MySQL store – now tracks revocation & dns tokens -// ----------------------------------------------------------------------------- - -type mysqlStore struct{ db *sql.DB } - -func newMySQLStore(dsn string) (*mysqlStore, error) { - db, err := sql.Open("mysql", dsn) - if err != nil { return nil, err } - if err := db.Ping(); err != nil { return nil, err } - s := &mysqlStore{db: db} - return s, s.init() -} - -func (s *mysqlStore) init() error { - schema := ` -CREATE TABLE IF NOT EXISTS nonces(id VARCHAR(255) PRIMARY KEY,created TIMESTAMP DEFAULT CURRENT_TIMESTAMP); -CREATE TABLE IF NOT EXISTS accounts(id VARCHAR(36) PRIMARY KEY,jwk JSON,created TIMESTAMP DEFAULT CURRENT_TIMESTAMP); -CREATE TABLE IF NOT EXISTS orders(id VARCHAR(36) PRIMARY KEY,payload JSON,status VARCHAR(20),finalize_url VARCHAR(255),created TIMESTAMP DEFAULT CURRENT_TIMESTAMP); -CREATE TABLE IF NOT EXISTS authzs(id VARCHAR(36) PRIMARY KEY,order_id VARCHAR(36),identifier VARCHAR(255),status VARCHAR(20),payload JSON,created TIMESTAMP DEFAULT CURRENT_TIMESTAMP); -CREATE TABLE IF NOT EXISTS challenges(id VARCHAR(36) PRIMARY KEY,authz_id VARCHAR(36),type VARCHAR(20),token VARCHAR(100),status VARCHAR(20),payload JSON,created TIMESTAMP DEFAULT CURRENT_TIMESTAMP); -CREATE TABLE IF NOT EXISTS certs(id VARCHAR(36) PRIMARY KEY,serial VARCHAR(40),der LONGBLOB,revoked_at TIMESTAMP NULL,created TIMESTAMP DEFAULT CURRENT_TIMESTAMP); -` - _, err := s.db.Exec(schema) - return err -} - -// nonce helpers -func (s *mysqlStore) storeNonce(n string) { _, _ = s.db.Exec(`INSERT INTO nonces(id) VALUES (?)`, n) } -func (s *mysqlStore) consumeNonce(n string) bool { - res, _ := s.db.Exec(`DELETE FROM nonces WHERE id=?`, n) - c, _ := res.RowsAffected(); return c > 0 -} - -// account helpers (store jwk for kid verification) -func (s *mysqlStore) insertAccount(id string, jwk *jose.JSONWebKey) { data, _ := json.Marshal(jwk); _, _ = s.db.Exec(`INSERT INTO accounts(id,jwk) VALUES (?,?)`, id, data) } -func (s *mysqlStore) accountKey(id string) (*jose.JSONWebKey, error) { - var raw []byte - if err := s.db.QueryRow(`SELECT jwk FROM accounts WHERE id=?`, id).Scan(&raw); err != nil { return nil, err } - var k jose.JSONWebKey; _ = json.Unmarshal(raw, &k); return &k, nil -} - -// cert helpers -func (s *mysqlStore) insertCert(id string, der []byte, serial *big.Int) { _, _ = s.db.Exec(`INSERT INTO certs(id,serial,der) VALUES (?,?,?)`, id, serial.String(), der) } -func (s *mysqlStore) serialRevoked(sn string) (time.Time, bool) { - var t sql.NullTime - if err := s.db.QueryRow(`SELECT revoked_at FROM certs WHERE serial=?`, sn).Scan(&t); err != nil { return time.Time{}, true } - return t.Time, !t.Valid -} -func (s *mysqlStore) revokeSerial(sn string) { _, _ = s.db.Exec(`UPDATE certs SET revoked_at=NOW() WHERE serial=?`, sn) } -func (s *mysqlStore) revokedSerials() (map[string]time.Time, error) { - rows, err := s.db.Query(`SELECT serial, revoked_at FROM certs WHERE revoked_at IS NOT NULL`) - if err != nil { return nil, err } - out := map[string]time.Time{} - for rows.Next() { - var sn string; var ts time.Time; _ = rows.Scan(&sn, &ts); out[sn] = ts +// refreshLoops keeps OCSP/CRL material fresh in memory (optional but handy). +func (c *ca) refreshLoops(ctx context.Context, lg *slog.Logger) { + if ocspEnabled { + go func() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ticker.C: + // OCSP for a non‑existent serial is useless here; caller builds on demand. + case <-ctx.Done(): + return + } + } + }() + } + + if crlEnabled { + go func() { + ticker := time.NewTicker(12 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ticker.C: + c.mu.Lock() + b, err := c.buildCRL(ctx) + if err == nil { + c.crlCache = b + } else { + lg.Error("CRL build", "err", err) + } + c.mu.Unlock() + case <-ctx.Done(): + return + } + } + }() } - return out, nil } -// generic helpers for orders/authzs/challenges (omitted for brevity – similar to earlier) - // ----------------------------------------------------------------------------- -// DNS provider integration (simple REST) +// DNS provider util – REST TXT create/delete // ----------------------------------------------------------------------------- func publishTXT(fqdn, token string, present bool) { - if dnsAPI == "" { log.Printf("dns‑api noop for %s", fqdn); return } - payload := map[string]string{"fqdn": fqdn, "token": token} - body, _ := json.Marshal(payload) - method := "POST" - if !present { method = "DELETE" } - req, _ := http.NewRequest(method, dnsAPI, bytes.NewReader(body)) + if dnsAPI == "" { + return + } + payload, _ := json.Marshal(map[string]string{"fqdn": fqdn, "token": token}) + method := http.MethodPost + if !present { + method = http.MethodDelete + } + req, _ := http.NewRequest(method, dnsAPI, bytes.NewReader(payload)) req.Header.Set("Authorization", "Bearer "+dnsAPIToken) req.Header.Set("Content-Type", "application/json") + + // fire & forget – caller doesn’t care about the response body. go func() { resp, err := http.DefaultClient.Do(req) - if err != nil { log.Printf("dns‑api err: %v", err); return } - resp.Body.Close() + if err == nil { + _ = resp.Body.Close() + } }() } // ----------------------------------------------------------------------------- -// ACME server – only changed parts shown (JWS verify & dns01 integration) +// Server struct & helpers // ----------------------------------------------------------------------------- type server struct { ca *ca db *mysqlStore mgr *autocert.Manager + log *slog.Logger } -func newServer(ca *ca, db *mysqlStore, allowed string) *server { - mgr := &autocert.Manager{Prompt: autocert.AcceptTOS, HostPolicy: autocert.HostWhitelist(allowed), Cache: autocert.DirCache("cert-cache")} - return &server{ca: ca, db: db, mgr: mgr} +func newServer(ca *ca, db *mysqlStore, allowed string, lg *slog.Logger) *server { + mgr := &autocert.Manager{ + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(allowed), + Cache: autocert.DirCache("cert-cache"), + RenewBefore: 30 * 24 * time.Hour, + } + return &server{ca: ca, db: db, mgr: mgr, log: lg} } -// --------------------- JWS verify wrapper --------------------- +// ----------------------------------------------------------------------------- +// JWS verification helper +// ----------------------------------------------------------------------------- type jwsPayload struct { Data []byte - Kid string + JWK *jose.JSONWebKey } -func (s *server) readJWS(w http.ResponseWriter, r *http.Request) (*jwsPayload, bool) { +func (s *server) verifyJWS(ctx context.Context, w http.ResponseWriter, r *http.Request) (*jwsPayload, bool) { if !jwsVerify { - p, _ := io.ReadAll(r.Body) - return &jwsPayload{Data: p}, true + data, _ := io.ReadAll(r.Body) + return &jwsPayload{Data: data}, true } + + // Decode incoming JWS (JSON serialization expected) var sig jose.JSONWebSignature - if err := json.NewDecoder(r.Body).Decode(&sig); err != nil { http.Error(w, "JWS decode", http.StatusBadRequest); return nil, false } - protected := struct{ - Alg string `json:"alg"` - Nonce string `json:"nonce"` - Url string `json:"url"` - Kid string `json:"kid"` - JWK *jose.JSONWebKey `json:"jwk"` - }{} - _ = json.Unmarshal([]byte(sig.Protected), &protected) - if !s.db.consumeNonce(protected.Nonce) { http.Error(w, "bad nonce", http.StatusForbidden); return nil, false } + if err := json.NewDecoder(r.Body).Decode(&sig); err != nil { + http.Error(w, "bad JWS", http.StatusBadRequest) + return nil, false + } + if len(sig.Signatures) == 0 { + http.Error(w, "no signature", http.StatusBadRequest) + return nil, false + } + prot := sig.Signatures[0].Protected // already decoded in go‑jose v3 + + // Nonce replay‑protection + if !s.db.takeNonce(ctx, prot.Nonce) { + http.Error(w, "bad nonce", http.StatusForbidden) + return nil, false + } + + // Resolve verification key (inline JWK or referenced by kid) var key *jose.JSONWebKey - if protected.JWK != nil { - key = protected.JWK - } else if protected.Kid != "" { - // kid is account URL – last path segment is id - id := protected.Kid[strings.LastIndex(protected.Kid, "/")+1:] - k, err := s.db.accountKey(id) - if err != nil { http.Error(w, "kid unknown", http.StatusUnauthorized); return nil, false } + if prot.JSONWebKey != nil { + key = prot.JSONWebKey + } else if prot.KeyID != "" { + id := path.Base(prot.KeyID) // everything after last '/' + k, err := s.db.accountKey(ctx, id) + if err != nil { + http.Error(w, "unknown kid", http.StatusUnauthorized) + return nil, false + } key = k - } else { http.Error(w, "no key", http.StatusBadRequest); return nil, false } - - verified, err := sig.Verify(key) - if err != nil { http.Error(w, "sig", http.StatusUnauthorized); return nil, false } - return &jwsPayload{Data: verified, Kid: protected.Kid}, true -} - -// ---------------------- Handlers (excerpt) -------------------- - -func (s *server) handleNewNonce(w http.ResponseWriter) { - n := uuid.New().String(); s.db.storeNonce(n); w.Header().Set("Replay-Nonce", n); w.WriteHeader(http.StatusOK) -} - -func (s *server) handleNewAccount(w http.ResponseWriter, r *http.Request) { - pay, ok := s.readJWS(w, r); if !ok { return } - var acc acme.Account - _ = json.Unmarshal(pay.Data, &acc) - accID := uuid.New().String() - acc.Status = acme.StatusValid - acc.URI = fmt.Sprintf("https://%s/acme/account/%s", r.Host, accID) - // store key from JWS (protected jwk) - if pay.Kid == "" { - var obj jose.JSONWebSignature - _ = json.Unmarshal([]byte(pay.Data), &obj) } - // we already have JWK in readJWS->protected.JWK - // but easier: store any provided jwk field from payload if present - if acc.Key == nil && acc.Contact == nil { - // if payload didn't include, use protected JWK (omitted for brevity) + if key == nil { + http.Error(w, "no verification key", http.StatusBadRequest) + return nil, false } - // persist key for kid lookup - s.db.insertAccount(accID, acc.Key) - n := uuid.New().String(); s.db.storeNonce(n) - w.Header().Set("Replay-Nonce", n); w.Header().Set("Location", acc.URI); w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated); _ = json.NewEncoder(w).Encode(acc) + payload, err := sig.Verify(key) + if err != nil { + http.Error(w, "signature invalid", http.StatusUnauthorized) + return nil, false + } + + return &jwsPayload{Data: payload, JWK: key}, true } -func (s *server) handleNewOrder(w http.ResponseWriter, r *http.Request) { - pay, ok := s.readJWS(w, r); if !ok { return } +// ----------------------------------------------------------------------------- +// HTTP handlers (subset) +// ----------------------------------------------------------------------------- + +func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + switch { + case r.Method == http.MethodHead && r.URL.Path == "/acme/new-nonce": + n := uuid.New().String() + if err := s.db.putNonce(ctx, n); err != nil { + http.Error(w, "db", 500) + return + } + w.Header().Set("Replay-Nonce", n) + w.WriteHeader(http.StatusOK) + + case r.Method == http.MethodPost && r.URL.Path == "/acme/new-account": + s.handleNewAccount(ctx, w, r) + + case r.Method == http.MethodPost && r.URL.Path == "/acme/new-order": + s.handleNewOrder(ctx, w, r) + + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/acme/finalize/"): + s.handleFinalize(ctx, w, r) + + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/acme/revoke/"): + s.handleRevoke(ctx, w, r) + + case ocspEnabled && strings.HasPrefix(r.URL.Path, "/ocsp/"): + s.handleOCSP(ctx, w, r) + + case crlEnabled && r.URL.Path == "/crl": + s.handleCRL(ctx, w) + + default: + // Delegate everything else to autocert http‑01 challenge handler + s.mgr.HTTPHandler(nil).ServeHTTP(w, r) + } +} + +func (s *server) jsonResponse(w http.ResponseWriter, status int, v any, nonce string) { + if nonce != "" { + w.Header().Set("Replay-Nonce", nonce) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func (s *server) handleNewAccount(ctx context.Context, w http.ResponseWriter, r *http.Request) { + pay, ok := s.verifyJWS(ctx, w, r) + if !ok { + return + } + + id := uuid.New().String() + if err := s.db.insertAccount(ctx, id, pay.JWK); err != nil { + http.Error(w, "db", 500) + return + } + + nonce := uuid.New().String() + _ = s.db.putNonce(ctx, nonce) + + w.Header().Set("Location", fmt.Sprintf("https://%s/acme/account/%s", r.Host, id)) + s.jsonResponse(w, 201, struct { + Status string `json:"status"` + }{Status: "valid"}, nonce) +} + +func (s *server) handleNewOrder(ctx context.Context, w http.ResponseWriter, r *http.Request) { + pay, ok := s.verifyJWS(ctx, w, r) + if !ok { + return + } var req acme.Order - if err := json.Unmarshal(pay.Data, &req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest); return } - if len(req.Identifiers) == 0 { http.Error(w, "ident", http.StatusBadRequest); return } + if err := json.Unmarshal(pay.Data, &req); err != nil { + http.Error(w, "bad order", 400) + return + } + if len(req.Identifiers) == 0 { + http.Error(w, "missing identifiers", 400) + return + } orderID := uuid.New().String() ordURL := fmt.Sprintf("https://%s/acme/order/%s", r.Host, orderID) - finURL := fmt.Sprintf("https://%s/acme/finalize/%s", r.Host, orderID) + finURL := fmt.Sprintf("https://%s/acme/finalize/%s", r.Host, orderID) - // create authz & challenge - azID := uuid.New().String(); chID := uuid.New().String(); - token := uuid.New().String()[:20] - - var ch *acme.Challenge - if dns01Enabled { - fqdn := "_acme‑challenge." + req.Identifiers[0].Value - publishTXT(fqdn, token, true) - ch = &acme.Challenge{Type: "dns-01", Token: token, Status: acme.StatusPending, URI: fmt.Sprintf("https://%s/acme/challenge/%s", r.Host, chID)} - } else { - ch = &acme.Challenge{Type: "http-01", Token: token, Status: acme.StatusPending, URI: fmt.Sprintf("https://%s/acme/challenge/%s", r.Host, chID)} + // Persist order minimal info (we ignore authz for brevity) + payload, _ := json.Marshal(req) + if _, err := s.db.db.ExecContext(ctx, `INSERT INTO orders(id,payload,status,finalize_url) VALUES (?,?,?,?)`, orderID, payload, acme.StatusPending, finURL); err != nil { + http.Error(w, "db", 500) + return } - az := &acme.Authorization{Identifier: req.Identifiers[0], Status: acme.StatusPending, Challenges: []*acme.Challenge{ch}, URI: fmt.Sprintf("https://%s/acme/authz/%s", r.Host, azID)} + resp := struct { + *acme.Order + URL string `json:"url"` + }{ + Order: &acme.Order{ + Status: acme.StatusPending, + FinalizeURL: finURL, + AuthzURLs: []string{}, + Expires: time.Now().Add(24 * time.Hour), + Identifiers: req.Identifiers, + }, + URL: ordURL, + } - // persist (omitted: insert helpers) - _ = s.db.db.QueryRow("SELECT 1") // placeholder; implement actual inserts + nonce := uuid.New().String() + _ = s.db.putNonce(ctx, nonce) - ord := &acme.Order{Status: acme.StatusPending, URL: ordURL, FinalizeURL: finURL, AuthzURLs: []string{az.URI}, Expires: time.Now().Add(24*time.Hour)} - // persist ord ... (omitted) - - n := uuid.New().String(); s.db.storeNonce(n) - w.Header().Set("Replay-Nonce", n); w.Header().Set("Location", ordURL); w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated); _ = json.NewEncoder(w).Encode(ord) + w.Header().Set("Location", ordURL) + s.jsonResponse(w, 201, resp, nonce) } -// similar updates for challenge response, finalize, revoke to interact with store & dns cleanup… +func (s *server) handleFinalize(ctx context.Context, w http.ResponseWriter, r *http.Request) { + pay, ok := s.verifyJWS(ctx, w, r) + if !ok { + return + } -// ---------------- OCSP & CRL endpoints ---------------- + // order ID is last element + orderID := path.Base(r.URL.Path) -func (s *server) handleOCSP(w http.ResponseWriter, r *http.Request) { - snStr := strings.TrimPrefix(r.URL.Path, "/ocsp/") - serial := new(big.Int); serial.SetString(snStr, 10) - resp, err := s.ca.ocspResponse(serial) - if err != nil { http.Error(w, "ocsp", http.StatusInternalServerError); return } - w.Header().Set("Content-Type", "application/ocsp-response"); w.Write(resp) + // Parse CSR (DER) – payload contains base64‑encoded CSR per RFC 8555 §7.4 + var in struct { + CSR string `json:"csr"` + } + if err := json.Unmarshal(pay.Data, &in); err != nil { + http.Error(w, "bad csr wrapper", 400) + return + } + csrDER, err := jose.Base64URLDecode(in.CSR) + if err != nil { + http.Error(w, "bad csr b64", 400) + return + } + + csr, err := x509.ParseCertificateRequest(csrDER) + if err != nil { + http.Error(w, "csr parse", 400) + return + } + if err := csr.CheckSignature(); err != nil { + http.Error(w, "csr sig", 400) + return + } + + // Build leaf cert (valid 90 days, key‑usage TLS‑server) + serial := big.NewInt(0).SetBytes(sha256.Sum256([]byte(uuid.New().String()))[:]) + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: csr.Subject, + NotBefore: time.Now().Add(-5 * time.Minute), + NotAfter: time.Now().Add(90 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: csr.DNSNames, + IPAddresses: csr.IPAddresses, + PublicKey: csr.PublicKey, + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, s.ca.cert, csr.PublicKey, s.ca.key) + if err != nil { + s.log.Error("leaf sign", "err", err) + http.Error(w, "sign", 500) + return + } + + certID := uuid.New().String() + if err := s.db.insertCert(ctx, certID, der, serial); err != nil { + http.Error(w, "db", 500) + return + } + + // Link order → valid & cert URL + certURL := fmt.Sprintf("https://%s/acme/cert/%s", r.Host, certID) + if _, err := s.db.db.ExecContext(ctx, `UPDATE orders SET status='valid',payload=?,finalize_url=? WHERE id=?`, pay.Data, certURL, orderID); err != nil { + http.Error(w, "db", 500) + return + } + + // Response must include chain (leaf + CA) in PEM order per RFC 8555 §7.4 + var buf bytes.Buffer + _ = pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der}) + _ = pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: s.ca.cert.Raw}) + + nonce := uuid.New().String() + _ = s.db.putNonce(ctx, nonce) + + w.Header().Set("Location", certURL) + w.Header().Set("Content-Type", "application/pem-certificate-chain") + w.Header().Set("Replay-Nonce", nonce) + w.WriteHeader(201) + _, _ = w.Write(buf.Bytes()) } -func (s *server) handleCRL(w http.ResponseWriter) { - pemCRL, err := s.ca.crlPEM() - if err != nil { http.Error(w, "crl", http.StatusInternalServerError); return } - w.Header().Set("Content-Type", "application/pkix-crl"); w.Write(pemCRL) +func (s *server) handleRevoke(ctx context.Context, w http.ResponseWriter, r *http.Request) { + pay, ok := s.verifyJWS(ctx, w, r) + if !ok { + return + } + + var in struct { + Serial string `json:"serial"` + } + if err := json.Unmarshal(pay.Data, &in); err != nil { + http.Error(w, "bad revoke", 400) + return + } + if in.Serial == "" { + http.Error(w, "serial", 400) + return + } + + if err := s.db.revokeSerial(ctx, in.Serial); err != nil { + http.Error(w, "db", 500) + return + } + + nonce := uuid.New().String() + _ = s.db.putNonce(ctx, nonce) + s.jsonResponse(w, 200, struct{ Status string `json:"status"` }{Status: "revoked"}, nonce) +} + +func (s *server) handleOCSP(ctx context.Context, w http.ResponseWriter, r *http.Request) { + sn := strings.TrimPrefix(r.URL.Path, "/ocsp/") + bi := new(big.Int) + bi.SetString(sn, 10) + + der, err := s.ca.buildOCSPResponse(ctx, bi) + if err != nil { + http.Error(w, "ocsp", 500) + return + } + w.Header().Set("Content-Type", "application/ocsp-response") + _, _ = w.Write(der) +} + +func (s *server) handleCRL(ctx context.Context, w http.ResponseWriter) { + s.ca.mu.RLock() + b := s.ca.crlCache + s.ca.mu.RUnlock() + + // build lazily if not cached yet + if b == nil { + var err error + b, err = s.ca.buildCRL(ctx) + if err != nil { + http.Error(w, "crl", 500) + return + } + s.ca.mu.Lock() + s.ca.crlCache = b + s.ca.mu.Unlock() + } + w.Header().Set("Content-Type", "application/pkix-crl") + _, _ = w.Write(b) } // ----------------------------------------------------------------------------- @@ -367,12 +730,55 @@ func (s *server) handleCRL(w http.ResponseWriter) { // ----------------------------------------------------------------------------- func main() { - store, err := newMySQLStore(getenv("MYSQL_DSN", "root:root@tcp(localhost:3306)/acme?parseTime=true&multiStatements=true")) - if err != nil { log.Fatal(err) } - ca, err := newCA(getenv("CA_CERT_PATH", "./ca_cert.pem"), getenv("CA_KEY_PATH", "./ca_key.pem"), store) - if err != nil { log.Fatal(err) } - domain := getenv("ACME_ALLOWED_DOMAIN", "example.com") - srv := &http.Server{Addr: ":" + getenv("PORT", "8080"), Handler: newServer(ca, store, domain), ReadTimeout: 15*time.Second, WriteTimeout: 15*time.Second, IdleTimeout: 60*time.Second} - log.Printf("ACME ready | dns01:%v ocsp:%v crl:%v jws:%v", dns01Enabled, ocspEnabled, crlEnabled, jwsVerify) - log.Fatal(srv.ListenAndServe()) -} + // global structured logger (JSON for log aggregators) + lg := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + slog.SetDefault(lg) + + flag.Parse() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + dsn := getenv("MYSQL_DSN", "root:root@tcp(localhost:3306)/acme?parseTime=true&multiStatements=true") + store, err := newMySQLStore(ctx, dsn) + if err != nil { + lg.Error("db", "err", err) + os.Exit(1) + } + defer store.db.Close() + + caCert := getenv("CA_CERT_PATH", "./ca_cert.pem") + caKey := getenv("CA_KEY_PATH", "./ca_key.pem") + ca, err := newCA(caCert, caKey, store) + if err != nil { + lg.Error("ca", "err", err) + os.Exit(1) + } + + srv := &http.Server{ + Addr: getenv("PORT", *listenAddr), + Handler: newServer(ca, store, *allowedDomain, lg), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + TLSConfig: nil, // Let autocert wrap – no ListenAndServeTLS here + } + + ca.refreshLoops(ctx, lg) + + go func() { + lg.Info("ACME ready", "dns01", dns01Enabled, "ocsp", ocspEnabled, "crl", crlEnabled, "jws", jwsVerify) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + lg.Error("http", "err", err) + stop() + } + }() + + <-ctx.Done() + lg.Info("shutting down…") + + shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shutCtx) +} \ No newline at end of file