Files
goacme/main.go
jbergner 1765d55ac6
All checks were successful
release-tag / release-image (push) Successful in 1m47s
WACS-Kompatibilität
2025-04-29 14:15:37 +02:00

952 lines
26 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDXLicenseIdentifier: Apache2.0
// ACME MiniCA, productionready example (Go 1.24+)
//
// Key changes compared to the teaching skeleton:
// * All previously “unused” helpers are now exercised in the ACME
// workflow (finalize, revoke, dns01) so staticcheck passes.
// * Contextaware MySQL interactions with prepared statements.
// * Structured logging via the standard librarys 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 leastprivilege defaults.
// * The code is a single file for review simplicity; split into
// packages (store, ca, api, etc.) for real deployments.
//
// ❗ This sample is still **NOT** a fullblown public CA. It omits many
// security, compliance and scalability aspects. Use as a starting
// point only.
package main
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"database/sql"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"math/big"
"net/http"
"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"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
"golang.org/x/crypto/ocsp"
)
// -----------------------------------------------------------------------------
// 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
}
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", "")
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")
)
// -----------------------------------------------------------------------------
// MySQL store (nonexhaustive)
// -----------------------------------------------------------------------------
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) {
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
}
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 does not implement crypto.Signer")
}
return &ca{cert: cert, key: signer, db: db}, nil
}
// 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 {
status = ocsp.Revoked
}
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{
// RFC6960 nonce (optional) omitted here
},
}
return ocsp.CreateResponse(c.cert, c.cert, template, c.key)
}
// 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
}
return pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: der}), nil
}
// 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 nonexistent 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
}
}
}()
}
}
// -----------------------------------------------------------------------------
// DNS provider util REST TXT create/delete
// -----------------------------------------------------------------------------
/*func publishTXT(fqdn, token string, present bool) {
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 doesnt care about the response body.
go func() {
resp, err := http.DefaultClient.Do(req)
if err == nil {
_ = resp.Body.Close()
}
}()
}*/
// -----------------------------------------------------------------------------
// Server struct & helpers
// -----------------------------------------------------------------------------
type server struct {
ca *ca
db *mysqlStore
mgr *autocert.Manager
log *slog.Logger
}
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}
}
// -----------------------------------------------------------------------------
// helper: write JSON + (optional) fresh nonce header
// -----------------------------------------------------------------------------
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)
}
// -----------------------------------------------------------------------------
// JWS verification helper
// -----------------------------------------------------------------------------
type jwsPayload struct {
Data []byte
JWK *jose.JSONWebKey
}
// -----------------------------------------------------------------------------
// JWS verification helper supports both JSON & compact JWS formats
// -----------------------------------------------------------------------------
func (s *server) verifyJWS(ctx context.Context, w http.ResponseWriter, r *http.Request) (*jwsPayload, bool) {
if !jwsVerify {
data, _ := io.ReadAll(r.Body)
return &jwsPayload{Data: data}, true
}
// read full body once
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body", http.StatusBadRequest)
return nil, false
}
// try JWS JSON first …
var sig jose.JSONWebSignature
if err := json.Unmarshal(raw, &sig); err != nil || len(sig.Signatures) == 0 {
// … fallback to compact serialization
cp, err2 := jose.ParseSigned(string(raw))
if err2 != nil {
http.Error(w, "bad JWS", http.StatusBadRequest)
return nil, false
}
sig = *cp
}
prot := sig.Signatures[0].Protected // already base-64 decoded
// Nonce replay-protection
if !s.db.takeNonce(ctx, prot.Nonce) {
http.Error(w, "bad nonce", http.StatusForbidden)
return nil, false
}
// Resolve verification key
var key *jose.JSONWebKey
switch {
case prot.JSONWebKey != nil:
key = prot.JSONWebKey
case prot.KeyID != "":
if k, err := s.db.accountKey(ctx, path.Base(prot.KeyID)); err == nil {
key = k
} else {
http.Error(w, "unknown kid", http.StatusUnauthorized)
return nil, false
}
default:
http.Error(w, "no verification key", http.StatusBadRequest)
return nil, false
}
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
}
// -----------------------------------------------------------------------------
// HTTP handlers (subset)
// -----------------------------------------------------------------------------
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
switch {
case (r.Method == http.MethodHead || r.Method == http.MethodGet) && 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.MethodGet && r.URL.Path == "/directory":
s.handleDirectory(w, r)
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 && r.URL.Path == "/acme/revoke-cert":
s.handleRevoke(ctx, w, r)
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/acme/order/"):
s.handleGetOrder(ctx, w, r)
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/acme/cert/"):
s.handleGetCert(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:
s.mgr.HTTPHandler(nil).ServeHTTP(w, r)
}
}
func (s *server) handleFinalize(ctx context.Context, w http.ResponseWriter, r *http.Request) {
pay, ok := s.verifyJWS(ctx, w, r)
if !ok {
return
}
orderID := path.Base(r.URL.Path)
finURL := fmt.Sprintf("https://%s/acme/finalize/%s", r.Host, orderID)
// --- decode wrapped CSR ---------------------------------------------------
var in struct {
CSR string `json:"csr"`
}
if err := json.Unmarshal(pay.Data, &in); err != nil {
http.Error(w, "bad csr wrapper", http.StatusBadRequest)
return
}
csrDER, err := base64.RawURLEncoding.DecodeString(in.CSR)
if err != nil {
http.Error(w, "bad csr b64", http.StatusBadRequest)
return
}
csr, err := x509.ParseCertificateRequest(csrDER)
if err != nil || csr.CheckSignature() != nil {
http.Error(w, "csr parse/sig", http.StatusBadRequest)
return
}
// --- sign leaf ------------------------------------------------------------
hash := sha256.Sum256([]byte(uuid.New().String()))
serial := new(big.Int).SetBytes(hash[:])
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", http.StatusInternalServerError)
return
}
// --- persist & reply ------------------------------------------------------
certID := uuid.New().String()
certURL := fmt.Sprintf("https://%s/acme/cert/%s", r.Host, certID)
if err := s.db.insertCert(ctx, certID, der, serial); err != nil {
http.Error(w, "db", http.StatusInternalServerError)
return
}
if _, err := s.db.db.ExecContext(ctx,
`UPDATE orders SET status='valid', cert_url=?, payload=? WHERE id=?`,
certURL, pay.Data, orderID); err != nil {
http.Error(w, "db", http.StatusInternalServerError)
return
}
orderURL := fmt.Sprintf("https://%s/acme/order/%s", r.Host, orderID)
nonce := uuid.New().String()
_ = s.db.putNonce(ctx, nonce)
resp := struct {
*acme.Order
URL string `json:"url"`
}{
Order: &acme.Order{
Status: acme.StatusValid,
FinalizeURL: finURL,
CertURL: certURL,
},
URL: orderURL,
}
w.Header().Set("Location", orderURL)
s.jsonResponse(w, http.StatusOK, resp, nonce)
}
// -----------------------------------------------------------------------------
// GET /acme/order/{id} order polling (Certbot/WACS)
// -----------------------------------------------------------------------------
func (s *server) handleGetOrder(ctx context.Context, w http.ResponseWriter, r *http.Request) {
id := path.Base(r.URL.Path)
var raw []byte
var status, finalize string
var certURL sql.NullString
if err := s.db.db.QueryRowContext(ctx,
`SELECT payload,status,finalize_url,cert_url FROM orders WHERE id=?`, id).
Scan(&raw, &status, &finalize, &certURL); err != nil {
http.Error(w, "order", http.StatusNotFound)
return
}
var orig acme.Order
_ = json.Unmarshal(raw, &orig)
resp := &acme.Order{
Status: status,
Identifiers: orig.Identifiers,
FinalizeURL: finalize,
}
if certURL.Valid {
resp.CertURL = certURL.String
}
s.jsonResponse(w, http.StatusOK, resp, "")
}
// -----------------------------------------------------------------------------
// GET /acme/cert/{id} retrieve PEM chain
// -----------------------------------------------------------------------------
func (s *server) handleGetCert(ctx context.Context, w http.ResponseWriter, r *http.Request) {
id := path.Base(r.URL.Path)
var der []byte
if err := s.db.db.QueryRowContext(ctx, `SELECT der FROM certs WHERE id=?`, id).
Scan(&der); err != nil {
http.Error(w, "cert", http.StatusNotFound)
return
}
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})
w.Header().Set("Content-Type", "application/pem-certificate-chain")
_, _ = w.Write(buf.Bytes())
}
// -----------------------------------------------------------------------------
// GET /.well-known/acme-directory capabilities
// -----------------------------------------------------------------------------
func (s *server) handleDirectory(w http.ResponseWriter, r *http.Request) {
base := "https://" + r.Host
s.jsonResponse(w, http.StatusOK, map[string]string{
"newNonce": base + "/acme/new-nonce",
"newAccount": base + "/acme/new-account",
"newOrder": base + "/acme/new-order",
"revokeCert": base + "/acme/revoke-cert",
}, "")
}
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, "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)
// 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
}
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,
}
nonce := uuid.New().String()
_ = s.db.putNonce(ctx, nonce)
w.Header().Set("Location", ordURL)
s.jsonResponse(w, 201, resp, nonce)
}
/*func (s *server) handleFinalize(ctx context.Context, w http.ResponseWriter, r *http.Request) {
pay, ok := s.verifyJWS(ctx, w, r)
if !ok {
return
}
orderID := path.Base(r.URL.Path)
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 := base64.RawURLEncoding.DecodeString(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
}
// --- FIX: sha256.Sum256 liefert ein [32]byte zuerst in Variable legen
hash := sha256.Sum256([]byte(uuid.New().String()))
serial := new(big.Int).SetBytes(hash[:])
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
}
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
}
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) 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)
}
// -----------------------------------------------------------------------------
// main
// -----------------------------------------------------------------------------
func main() {
// 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)
}