From 1af95afed5546fec494bf8d59d0eb367b5fe3998 Mon Sep 17 00:00:00 2001 From: jbergner Date: Tue, 29 Apr 2025 09:42:42 +0200 Subject: [PATCH] test --- Dockerfile | 20 ++++++ README.md | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++++- go.mod | 10 +++ go.sum | 6 ++ main.go | 176 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 Dockerfile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..94ae7db --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Base image for Go +FROM golang:1.24.1-alpine + +# Set the working directory +WORKDIR /app + +# Copy the Go source code +COPY . . + +# Install dependencies +RUN go mod tidy + +# Build the Go application +RUN go build -o acme-server main.go + +# Expose the ACME server port +EXPOSE 8080 + +# Start the application +CMD ["./acme-server"] diff --git a/README.md b/README.md index 5818d75..a6c31ba 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,187 @@ -# goacme +# ACME Mini‑CA – Documentation + +This repository contains a **minimal‑viable ACME Certificate Authority** written in Go.\ +It is *not* a full replacement for production CAs such as Boulder/PEBBLE, but it demonstrates every building‑block you need to issue X.509 certificates via [RFC 8555](https://datatracker.ietf.org/doc/html/rfc8555): + +- **ACME endpoints** (`newAccount`, `newOrder`, `challenge`, `finalize`, `certificate`, `revokeCert`) +- **Challenge types**: `http‑01` *(default)* and `dns‑01` *(optional)* +- **JWS request verification** – supports both *JWK* and *KID* modes +- **OCSP responder** and **CRL** generation (optional) +- **MySQL persistence** for all entities (accounts, orders, nonces, certs …) +- **Pluggable DNS provider** API to publish/remove `_acme‑challenge.` TXT records + +--- + +## 1  Architecture + +```text +┌─────────────┐ HTTP ┌──────────────┐ +│ ACME Client│ ──────────────►│ ACME Server │ +└─────────────┘ │ (this repo) │ + ├─── MySQL │ + ├─── OCSP / CRL │ + └──────────────┘ + ▲ + │ REST + ┌───────┴────────┐ + │ DNS Provider │ (optional) + └────────────────┘ +``` + +*The server may sit behind a reverse‑proxy for TLS termination; however **`http‑01`** challenges are exposed directly by the embedded **`autocert.Manager`**.* + +--- + +## 2  Build & Run + +```bash +# clone & build +$ go build -o acme-ca . + +# set mandatory secrets +export CA_CERT_PATH="./ca_cert.pem" +export CA_KEY_PATH="./ca_key.pem" + +# run with sensible defaults +$ ./acme-ca +``` + +### 2.1  Environment variables + +| Variable | Default | Description | +| --------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------ | +| `PORT` | `8080` | HTTP listener port | +| `MYSQL_DSN` | `root:root@tcp(localhost:3306)/acme?parseTime=true&multiStatements=true` | DSN in *Go SQL* format | +| `ACME_ALLOWED_DOMAIN` | `example.com` | Hostname whitelist for `autocert` HTTP handler | +| `DNS01_ENABLED` | `false` | When `true` the server issues additional `dns‑01` challenges | +| `DNS_PROVIDER_URL` | *(empty)* | Base URL of your DNS provider’s TXT API | +| `DNS_PROVIDER_TOKEN` | *(empty)* | Bearer token used in `Authorization` header | +| `OCSP_ENABLED` | `false` | Expose `GET /ocsp/{serial}` endpoint | +| `CRL_ENABLED` | `false` | Expose `GET /crl` endpoint | +| `JWS_VERIFY_ENABLED` | `true` | Toggle signature verification (disable for local dev) | + +> **Note**   All optional modules can be enabled/disabled independently. + +### 2.2  Database schema + +`init()` automatically executes the following DDL (simplified): + +```sql +nonces(id PK, created) +accounts(id PK, jwk JSON) +orders(id PK, payload JSON, status, finalize_url) +authzs(id PK, order_id FK, identifier, status, payload JSON) +challenges(id PK, authz_id FK, type, token, status, payload JSON) +certs(id PK, serial, der LONGBLOB, revoked_at) +``` + +Add indices as required (e.g. `serial`, `token`). + +--- + +## 3  ACME API surface + +| Method / Path | Purpose | +| ------------------------------ | ---------------------------------------------- | +| `HEAD /acme/new-nonce` | Issue fresh nonce (always) | +| `POST /acme/new-account` | Register account – payload must be JWS‑signed | +| `POST /acme/new-order` | Create order → returns `authz`/`finalize` URLs | +| `GET /acme/authz/{id}` | Poll authorization status | +| `POST /acme/challenge/{id}` | Client signals that challenge token is ready | +| `POST /acme/finalize/{order}` | Upload CSR (base64url DER) | +| `GET /acme/certificate/{id}` | Retrieve PEM certificate | +| `POST /acme/revoke-cert` | Revoke certificate (base64url DER) | + +### 3.1  Challenge flow + +1. **Server → Client** `newOrder` response contains an `authz` URL. +2. **Client → Server** `GET /acme/authz/{id}` to fetch challenge details. +3. **Client** publishes token: + - **http‑01**: `http:///.well-known/acme-challenge/{token}` = key‑auth + - **dns‑01**: TXT `_acme-challenge.` = base64url(sha256(token)) + - When `DNS01_ENABLED=true` the server automatically calls *DNS Provider* API. +4. **Client → Server** `POST /acme/challenge/{id}` signals readiness. +5. Server validates (**dns‑01** simply trusts provider) and sets *valid*. +6. Client uploads CSR via `finalize`, server issues certificate. + +### 3.2  DNS Provider contract + +```http +POST +DELETE +Authorization: Bearer +Content-Type: application/json + +{ + "fqdn": "_acme-challenge.example.com", + "token": "" +} +``` + +Return `200` to acknowledge; all other codes are logged only (non‑blocking). + +--- + +## 4  OCSP & CRL + +- **OCSP** ↠ `GET /ocsp/{serial}` – returns DER `application/ocsp-response`.\ + Serial numbers are looked‑up in `certs.revoked_at`. Status =`Revoked` if timestamp present. +- **CRL**  ↠ `GET /crl` – regenerates PEM‑CRL on each request. + +Enable with the respective ENV flags. + +--- + +## 5  JWS verification + +When `JWS_VERIFY_ENABLED=true` every ACME POST body must be a [JOSE JWS](https://datatracker.ietf.org/doc/html/rfc7515) JSON serialization. + +- **New‑Account**: payload includes a `jwk` → key is saved under new `account.id`. +- **Subsequent requests**: use `kid` header pointing to `account` URL. +- Nonce‑reuse or invalid signatures return `4xx`. + +--- + +## 6  Security & Hardening checklist + +| Area | Recommendation | +| --------------- | ---------------------------------------------------------------------------------- | +| **CA key** | Store in HSM / KMS, *never* plaintext. | +| **TLS** | Run behind reverse‑proxy (Nginx, Traefik) or enable `tls.Config` on `http.Server`. | +| **Rate‑limits** | Use middleware (e.g. `golang.org/x/time/rate`). | +| **Audit** | Stream structured logs to SIEM; DB triggers for cert changes. | +| **Backup** | Full MySQL dumps; optionally S3 for issued certs. | +| **Monitoring** | Export Prometheus metrics: issued / revoked / OCSP hits. | + +--- + +## 7  Testing + +A quick smoke‑test with **Pebble** (Let’s Encrypt dev CA): + +```bash +# run Pebble +$ docker run --rm -p 14000:14000 letsencrypt/pebble -config /test/config.json + +# point Pebble at your Mini‑CA +PEBBLE\_ALTERNATE\_ROOTS= +export PEBBLE\_V2\_CA="http://localhost:8080/acme" + +# use `lego` client +$ lego -m you@example.com --pem --server http://localhost:8080/acme --accept-tos -d sub.stadt-hilden.de run +``` + +--- + +## 8  Future improvements + +- OCSP stapling & cache headers +- PostgreSQL + GORM migrations +- Automatic key‑rollover for CA +- ACME‑v3 *STAR* certificates (short‑term re‑use) +- Proper background validator for `dns‑01` via `dig TXT` lookup + +--- + +© 2025 — Released under the **Apache‑2.0** License diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..aee534e --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module git.send.nrw/sendnrw/goacme + +go 1.24.1 + +require golang.org/x/crypto v0.37.0 + +require ( + golang.org/x/net v0.39.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..39db609 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9f03cbb --- /dev/null +++ b/main.go @@ -0,0 +1,176 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strings" + + "golang.org/x/crypto/acme" + "golang.org/x/crypto/acme/autocert" +) + +// Diese Variablen halten die Pfade zum CA-Zertifikat und zum privaten Schlüssel +const ( + 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 +) + +var ( + privateKey interface{} + caCert *x509.Certificate +) + +func loadCACertificate() error { + // CA-Zertifikat laden + certPEM, err := os.ReadFile(CA_CERT_PATH) + if err != nil { + return fmt.Errorf("error reading CA cert: %v", err) + } + + caCert, err = x509.ParseCertificate(certPEM) + if err != nil { + return fmt.Errorf("error parsing CA cert: %v", err) + } + + // 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) { + // Lese die Datei + keyData, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read private key file: %v", err) + } + + // Stelle sicher, dass der Schlüssel als Seed vorliegt (32 Bytes) + if len(keyData) != ed25519.SeedSize { + return nil, fmt.Errorf("invalid seed size for ed25519 key: expected 32 bytes") + } + + // Ed25519-Schlüssel aus dem Seed erstellen + privKey := ed25519.NewKeyFromSeed(keyData) + return privKey, nil +} + +// Funktion zum Parsen des privaten Schlüssels (RSA, ECDSA, Ed25519) +func parsePrivateKey(keyPEM []byte) (interface{}, error) { + // Versuche RSA-Private-Key zu laden + if key, err := x509.ParsePKCS1PrivateKey(keyPEM); err == nil { + return key, nil + } + // Versuche ECDSA-Private-Key zu laden + if key, err := x509.ParseECPrivateKey(keyPEM); err == nil { + return key, nil + } + // 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 +func signCertificate(cert *x509.Certificate) ([]byte, error) { + // Sie können hier das CA-Zertifikat und den privaten Schlüssel verwenden, + // um das Zertifikat zu signieren. Zum Beispiel: + if rsaKey, ok := privateKey.(*rsa.PrivateKey); ok { + return x509.CreateCertificate(rand.Reader, cert, caCert, rsaKey.Public(), rsaKey) + } else if ecdsaKey, ok := privateKey.(*ecdsa.PrivateKey); ok { + return x509.CreateCertificate(rand.Reader, cert, caCert, ecdsaKey.Public(), ecdsaKey) + } else if ed25519Key, ok := privateKey.(ed25519.PrivateKey); ok { + return x509.CreateCertificate(rand.Reader, cert, caCert, ed25519Key.Public(), ed25519Key) + } + return nil, fmt.Errorf("unsupported private key type") +} + +func handleACME(w http.ResponseWriter, r *http.Request) { + acmeHandler := autocert.NewManager() + acmeHandler.HostPolicy = autocert.HostWhitelist("example.com") // Passe die Domain an + + // POST-Anforderung für new-order + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/new-order") { + handleNewOrder(w, r) + return + } + + acmeHandler.HTTPHandler(nil).ServeHTTP(w, r) +} + +func handleNewOrder(w http.ResponseWriter, r *http.Request) { + // Sicherstellen, dass es eine JSON-Anfrage ist + if r.Header.Get("Content-Type") != "application/json" { + http.Error(w, "Content-Type must be application/json", http.StatusBadRequest) + return + } + + var order acme.Order + // 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 + if len(order.Identifiers) == 0 || !strings.HasSuffix(order.Identifiers[0].Value, ".stadt-hilden.de") { + http.Error(w, "Invalid domain", http.StatusUnauthorized) + return + } + + // 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 main() { + // CA-Zertifikat und privaten Schlüssel laden + if err := loadCACertificate(); err != nil { + log.Fatalf("Error loading CA certificate: %v", err) + } + + // Ihr ACME-Server-Setup fortführen + http.HandleFunc("/acme", handleACME) + + // 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) + } +}