This commit is contained in:
2025-04-29 09:42:42 +02:00
parent cdba7f493e
commit 1af95afed5
5 changed files with 398 additions and 1 deletions

20
Dockerfile Normal file
View File

@@ -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"]

187
README.md
View File

@@ -1,2 +1,187 @@
# goacme
# ACME MiniCA Documentation
This repository contains a **minimalviable ACME Certificate Authority** written in Go.\
It is *not* a full replacement for production CAs such as Boulder/PEBBLE, but it demonstrates every buildingblock you need to issue X.509 certificates via [RFC8555](https://datatracker.ietf.org/doc/html/rfc8555):
- **ACME endpoints** (`newAccount`, `newOrder`, `challenge`, `finalize`, `certificate`, `revokeCert`)
- **Challenge types**: `http01` *(default)* and `dns01` *(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 `_acmechallenge.` 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 reverseproxy for TLS termination; however **`http01`** 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 `dns01` challenges |
| `DNS_PROVIDER_URL` | *(empty)* | Base URL of your DNS providers 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 JWSsigned |
| `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:
- **http01**: `http://<DOMAIN>/.well-known/acme-challenge/{token}` = keyauth
- **dns01**: TXT `_acme-challenge.<DOMAIN>` = 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 (**dns01** simply trusts provider) and sets *valid*.
6. Client uploads CSR via `finalize`, server issues certificate.
### 3.2  DNS Provider contract
```http
POST <DNS_PROVIDER_URL>
DELETE <DNS_PROVIDER_URL>
Authorization: Bearer <DNS_PROVIDER_TOKEN>
Content-Type: application/json
{
"fqdn": "_acme-challenge.example.com",
"token": "<txt contents>"
}
```
Return `200` to acknowledge; all other codes are logged only (nonblocking).
---
## 4  OCSP & CRL
- **OCSP** ↠ `GET /ocsp/{serial}` returns DER `application/ocsp-response`.\
Serial numbers are lookedup in `certs.revoked_at`. Status =`Revoked` if timestamp present.
- **CRL** `GET /crl` regenerates PEMCRL 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.
- **NewAccount**: payload includes a `jwk` key is saved under new `account.id`.
- **Subsequent requests**: use `kid` header pointing to `account` URL.
- Noncereuse or invalid signatures return `4xx`.
---
## 6  Security & Hardening checklist
| Area | Recommendation |
| --------------- | ---------------------------------------------------------------------------------- |
| **CA key** | Store in HSM / KMS, *never* plaintext. |
| **TLS** | Run behind reverseproxy (Nginx, Traefik) or enable `tls.Config` on `http.Server`. |
| **Ratelimits** | 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 smoketest with **Pebble** (Lets Encrypt dev CA):
```bash
# run Pebble
$ docker run --rm -p 14000:14000 letsencrypt/pebble -config /test/config.json
# point Pebble at your MiniCA
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 keyrollover for CA
- ACMEv3 *STAR* certificates (shortterm reuse)
- Proper background validator for `dns01` via `dig TXT` lookup
---
© 2025 — Released under the **Apache2.0** License

10
go.mod Normal file
View File

@@ -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
)

6
go.sum Normal file
View File

@@ -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=

176
main.go Normal file
View File

@@ -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)
}
}