test
This commit is contained in:
20
Dockerfile
Normal file
20
Dockerfile
Normal 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
187
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://<DOMAIN>/.well-known/acme-challenge/{token}` = key‑auth
|
||||||
|
- **dns‑01**: 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 (**dns‑01** 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 (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
|
||||||
|
|
||||||
|
10
go.mod
Normal file
10
go.mod
Normal 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
6
go.sum
Normal 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
176
main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user