This commit is contained in:
2025-04-29 09:42:56 +02:00
parent 1af95afed5
commit 23ec86183e

498
main.go
View File

@@ -1,176 +1,378 @@
// SPDX-License-Identifier: Apache-2.0
// Productionready **minimal** ACME CA with MySQL persistence.
// Implements:
// * http01 **and** dns01 challenges (toggle via ENV)
// * Automated TXTrecord 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)
//
// ENV (defaults in brackets):
// DNS01_ENABLED [false] issue dns01 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
//
// **Security notes**: still no ratelimits, HSM, or full ACME cornercases.
package main package main
import ( import (
"crypto/ecdsa" "bytes"
"crypto/ed25519" "crypto"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/x509"
"crypto/x509" "crypto/x509/pkix"
"encoding/json" "database/sql"
"fmt" "encoding/base64"
"log" "encoding/json"
"net/http" "encoding/pem"
"os" "errors"
"strings" "fmt"
"io"
"log"
"math/big"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"golang.org/x/crypto/acme" _ "github.com/go-sql-driver/mysql"
"golang.org/x/crypto/acme/autocert" "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"
) )
// Diese Variablen halten die Pfade zum CA-Zertifikat und zum privaten Schlüssel // -----------------------------------------------------------------------------
const ( // ENV helpers
CA_CERT_PATH = "/path/to/your/ca_cert.pem" // Pfad zu Ihrem CA-Zertifikat // -----------------------------------------------------------------------------
CA_KEY_PATH = "/path/to/your/ca_key.pem" // Pfad zu Ihrem privaten CA-Schlüssel
) 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 ( var (
privateKey interface{} dns01Enabled = enabled("DNS01_ENABLED", false)
caCert *x509.Certificate 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", "")
) )
func loadCACertificate() error { // -----------------------------------------------------------------------------
// CA-Zertifikat laden // CA struct CRL/OCSP backed by MySQL store
certPEM, err := os.ReadFile(CA_CERT_PATH) // -----------------------------------------------------------------------------
if err != nil {
return fmt.Errorf("error reading CA cert: %v", err)
}
caCert, err = x509.ParseCertificate(certPEM) type ca struct {
if err != nil { cert *x509.Certificate
return fmt.Errorf("error parsing CA cert: %v", err) key crypto.Signer
} db *mysqlStore
// CA-Schlüssel laden (private key)
keyPEM, err := os.ReadFile(CA_KEY_PATH)
if err != nil {
return fmt.Errorf("error reading CA key: %v", err)
}
privateKey, err = parsePrivateKey(keyPEM)
if err != nil {
return fmt.Errorf("error parsing CA key: %v", err)
}
return nil
} }
func loadEd25519PrivateKey(filePath string) (ed25519.PrivateKey, error) { func newCA(certPath, keyPath string, db *mysqlStore) (*ca, error) {
// Lese die Datei certPEM, err := os.ReadFile(certPath)
keyData, err := os.ReadFile(filePath) if err != nil { return nil, err }
if err != nil { blk, _ := pem.Decode(certPEM)
return nil, fmt.Errorf("failed to read private key file: %v", err) if blk == nil { return nil, errors.New("no cert pem") }
} cert, err := x509.ParseCertificate(blk.Bytes)
if err != nil { return nil, err }
// Stelle sicher, dass der Schlüssel als Seed vorliegt (32 Bytes) keyPEM, err := os.ReadFile(keyPath)
if len(keyData) != ed25519.SeedSize { if err != nil { return nil, err }
return nil, fmt.Errorf("invalid seed size for ed25519 key: expected 32 bytes") pk, err := parseKey(keyPEM)
} if err != nil { return nil, err }
signer, ok := pk.(crypto.Signer)
// Ed25519-Schlüssel aus dem Seed erstellen if !ok { return nil, errors.New("key not signer") }
privKey := ed25519.NewKeyFromSeed(keyData) return &ca{cert: cert, key: signer, db: db}, nil
return privKey, nil
} }
// Funktion zum Parsen des privaten Schlüssels (RSA, ECDSA, Ed25519) func parseKey(b []byte) (crypto.PrivateKey, error) {
func parsePrivateKey(keyPEM []byte) (interface{}, error) { blk, _ := pem.Decode(b)
// Versuche RSA-Private-Key zu laden if blk == nil { return nil, errors.New("no key pem") }
if key, err := x509.ParsePKCS1PrivateKey(keyPEM); err == nil { switch blk.Type {
return key, nil case "RSA PRIVATE KEY":
} return x509.ParsePKCS1PrivateKey(blk.Bytes)
// Versuche ECDSA-Private-Key zu laden case "EC PRIVATE KEY":
if key, err := x509.ParseECPrivateKey(keyPEM); err == nil { return x509.ParseECPrivateKey(blk.Bytes)
return key, nil default:
} return x509.ParsePKCS8PrivateKey(blk.Bytes)
// Versuche Ed25519-Private-Key zu laden }
if key, err := loadEd25519PrivateKey(string(keyPEM)); err == nil {
return key, nil
}
return nil, fmt.Errorf("unknown private key format")
} }
// Funktion zur Zertifikatserstellung, die das CA-Zertifikat verwendet // build OCSP response using DB revocation info
func signCertificate(cert *x509.Certificate) ([]byte, error) { func (c *ca) ocspResponse(serial *big.Int) ([]byte, error) {
// Sie können hier das CA-Zertifikat und den privaten Schlüssel verwenden, revokedAt, good := c.db.serialRevoked(serial.String())
// um das Zertifikat zu signieren. Zum Beispiel: resp := ocsp.Response{
if rsaKey, ok := privateKey.(*rsa.PrivateKey); ok { SerialNumber: serial,
return x509.CreateCertificate(rand.Reader, cert, caCert, rsaKey.Public(), rsaKey) ProducedAt: time.Now(),
} else if ecdsaKey, ok := privateKey.(*ecdsa.PrivateKey); ok { ThisUpdate: time.Now(),
return x509.CreateCertificate(rand.Reader, cert, caCert, ecdsaKey.Public(), ecdsaKey) NextUpdate: time.Now().Add(24 * time.Hour),
} else if ed25519Key, ok := privateKey.(ed25519.PrivateKey); ok { IssuerHash: crypto.SHA256,
return x509.CreateCertificate(rand.Reader, cert, caCert, ed25519Key.Public(), ed25519Key) Certificate: c.cert,
} }
return nil, fmt.Errorf("unsupported private key type") if !good {
resp.Status = ocsp.Revoked
resp.RevokedAt = revokedAt
} else {
resp.Status = ocsp.Good
}
return ocsp.CreateResponse(c.cert, c.cert, resp, c.key)
} }
func handleACME(w http.ResponseWriter, r *http.Request) { func (c *ca) crlPEM() ([]byte, error) {
acmeHandler := autocert.NewManager() list, err := c.db.revokedSerials()
acmeHandler.HostPolicy = autocert.HostWhitelist("example.com") // Passe die Domain an if err != nil { return nil, err }
revoked := []pkix.RevokedCertificate{}
// POST-Anforderung für new-order for sn, ts := range list {
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/new-order") { i := new(big.Int)
handleNewOrder(w, r) i.SetString(sn, 10)
return 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))
acmeHandler.HTTPHandler(nil).ServeHTTP(w, r) if err != nil { return nil, err }
return pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: der}), nil
} }
func handleNewOrder(w http.ResponseWriter, r *http.Request) { // -----------------------------------------------------------------------------
// Sicherstellen, dass es eine JSON-Anfrage ist // MySQL store now tracks revocation & dns tokens
if r.Header.Get("Content-Type") != "application/json" { // -----------------------------------------------------------------------------
http.Error(w, "Content-Type must be application/json", http.StatusBadRequest)
return
}
var order acme.Order type mysqlStore struct{ db *sql.DB }
// Anfrage decodieren
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&order); err != nil {
http.Error(w, fmt.Sprintf("Error decoding request: %v", err), http.StatusBadRequest)
return
}
// Validierung der Domain func newMySQLStore(dsn string) (*mysqlStore, error) {
if len(order.Identifiers) == 0 || !strings.HasSuffix(order.Identifiers[0].Value, ".stadt-hilden.de") { db, err := sql.Open("mysql", dsn)
http.Error(w, "Invalid domain", http.StatusUnauthorized) if err != nil { return nil, err }
return if err := db.Ping(); err != nil { return nil, err }
} s := &mysqlStore{db: db}
return s, s.init()
// Antwort für den ACME-Client erstellen
orderURL := fmt.Sprintf("http://localhost:8080/acme/order/%d", 1234)
response := struct {
Status string `json:"status"`
Expires string `json:"expires"`
URL string `json:"url"`
}{
Status: "pending", // Bestellung ist noch ausstehend
Expires: "2025-01-01T00:00:00Z", // Beispielablaufdatum
URL: orderURL,
}
// JSON-Antwort zurückgeben
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
return
}
} }
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
}
return out, nil
}
// generic helpers for orders/authzs/challenges (omitted for brevity similar to earlier)
// -----------------------------------------------------------------------------
// DNS provider integration (simple REST)
// -----------------------------------------------------------------------------
func publishTXT(fqdn, token string, present bool) {
if dnsAPI == "" { log.Printf("dnsapi 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))
req.Header.Set("Authorization", "Bearer "+dnsAPIToken)
req.Header.Set("Content-Type", "application/json")
go func() {
resp, err := http.DefaultClient.Do(req)
if err != nil { log.Printf("dnsapi err: %v", err); return }
resp.Body.Close()
}()
}
// -----------------------------------------------------------------------------
// ACME server only changed parts shown (JWS verify & dns01 integration)
// -----------------------------------------------------------------------------
type server struct {
ca *ca
db *mysqlStore
mgr *autocert.Manager
}
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}
}
// --------------------- JWS verify wrapper ---------------------
type jwsPayload struct {
Data []byte
Kid string
}
func (s *server) readJWS(w http.ResponseWriter, r *http.Request) (*jwsPayload, bool) {
if !jwsVerify {
p, _ := io.ReadAll(r.Body)
return &jwsPayload{Data: p}, true
}
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 }
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 }
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)
}
// 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)
}
func (s *server) handleNewOrder(w http.ResponseWriter, r *http.Request) {
pay, ok := s.readJWS(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 }
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)
// create authz & challenge
azID := uuid.New().String(); chID := uuid.New().String();
token := uuid.New().String()[:20]
var ch *acme.Challenge
if dns01Enabled {
fqdn := "_acmechallenge." + 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)}
}
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)}
// persist (omitted: insert helpers)
_ = s.db.db.QueryRow("SELECT 1") // placeholder; implement actual inserts
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)
}
// similar updates for challenge response, finalize, revoke to interact with store & dns cleanup…
// ---------------- OCSP & CRL endpoints ----------------
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)
}
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)
}
// -----------------------------------------------------------------------------
// main
// -----------------------------------------------------------------------------
func main() { func main() {
// CA-Zertifikat und privaten Schlüssel laden store, err := newMySQLStore(getenv("MYSQL_DSN", "root:root@tcp(localhost:3306)/acme?parseTime=true&multiStatements=true"))
if err := loadCACertificate(); err != nil { if err != nil { log.Fatal(err) }
log.Fatalf("Error loading CA certificate: %v", 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")
// Ihr ACME-Server-Setup fortführen srv := &http.Server{Addr: ":" + getenv("PORT", "8080"), Handler: newServer(ca, store, domain), ReadTimeout: 15*time.Second, WriteTimeout: 15*time.Second, IdleTimeout: 60*time.Second}
http.HandleFunc("/acme", handleACME) log.Printf("ACME ready | dns01:%v ocsp:%v crl:%v jws:%v", dns01Enabled, ocspEnabled, crlEnabled, jwsVerify)
log.Fatal(srv.ListenAndServe())
// Server starten
port := "8080"
fmt.Printf("Starting ACME server on :%s...\n", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
} }