Implement CA/PKI engine with two-tier X.509 certificate issuance
Add the first concrete engine implementation: a CA (PKI) engine that generates
a self-signed root CA at mount time, issues scoped intermediate CAs ("issuers"),
and signs leaf certificates using configurable profiles (server, client, peer).
Engine framework updates:
- Add CallerInfo struct for auth context in engine requests
- Add config parameter to Engine.Initialize for mount-time configuration
- Export Mount.Engine field; add GetEngine/GetMount on Registry
CA engine (internal/engine/ca/):
- Two-tier PKI: root CA → issuers → leaf certificates
- 10 operations: get-root, get-chain, get-issuer, create/delete/list issuers,
issue, get-cert, list-certs, renew
- Certificate profiles with user-overridable TTL, key usages, and key algorithm
- Private keys never stored in barrier; zeroized from memory on seal
- Supports ECDSA, RSA, and Ed25519 key types via goutils/certlib/certgen
Server routes:
- Wire up engine mount/request handlers (replace Phase 1 stubs)
- Add public PKI routes (/v1/pki/{mount}/ca, /ca/chain, /issuer/{name})
for unauthenticated TLS trust bootstrapping
Also includes: ARCHITECTURE.md, deploy config updates, operational tooling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
194
PKI-ENGINE-PLAN.md
Normal file
194
PKI-ENGINE-PLAN.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# CA/PKI Engine Implementation Plan
|
||||
|
||||
## Context
|
||||
|
||||
Metacrypt needs its first concrete engine implementation: the CA (PKI) engine. This provides X.509 certificate issuance for Metacircular infrastructure. A single root CA issues scoped intermediate CAs ("issuers"), which in turn issue leaf certificates. An unauthenticated public API serves CA/issuer certificates to allow systems to bootstrap TLS trust.
|
||||
|
||||
Certificate generation uses the `certgen` package from `git.wntrmute.dev/kyle/goutils/certlib/certgen`.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Step 1: Add goutils dependency
|
||||
|
||||
**File: `go.mod`**
|
||||
- Add `git.wntrmute.dev/kyle/goutils` with local replace directive (same pattern as mcias)
|
||||
- Run `go mod tidy`
|
||||
|
||||
### Step 2: Update engine framework
|
||||
|
||||
**File: `internal/engine/engine.go`**
|
||||
|
||||
1. Add `CallerInfo` struct to carry auth context into engines:
|
||||
```go
|
||||
type CallerInfo struct {
|
||||
Username string
|
||||
Roles []string
|
||||
IsAdmin bool
|
||||
}
|
||||
```
|
||||
|
||||
2. Add `CallerInfo *CallerInfo` field to `Request` struct
|
||||
|
||||
3. Add `config map[string]interface{}` parameter to `Engine.Initialize` interface method
|
||||
|
||||
4. Update `Registry.Mount` to accept and pass through `config map[string]interface{}`
|
||||
|
||||
5. Add `GetEngine(name string) (Engine, error)` method on `Registry` (needed for public PKI routes)
|
||||
|
||||
6. Add `GetMount(name string) (*Mount, error)` method on `Registry` (to verify mount type)
|
||||
|
||||
7. Export `Mount.Engine` field (or add accessor) so server can type-assert to `*ca.CAEngine`
|
||||
|
||||
**File: `internal/engine/engine_test.go`**
|
||||
- Update `mockEngine.Initialize` signature for new `config` parameter
|
||||
- Update all `reg.Mount(...)` calls to pass `nil` config
|
||||
|
||||
### Step 3: Create CA engine types
|
||||
|
||||
**File: `internal/engine/ca/types.go`** (new)
|
||||
|
||||
- `CAConfig` - stored in barrier, holds org info, key algorithm, root expiry
|
||||
- `IssuerConfig` - per-issuer config (name, key algorithm, expiry, max_ttl, created_by/at)
|
||||
- `CertRecord` - issued cert metadata stored in barrier (serial, issuer, CN, SANs, cert PEM, issued_by/at; NO private key)
|
||||
|
||||
### Step 4: Create certificate profiles
|
||||
|
||||
**File: `internal/engine/ca/profiles.go`** (new)
|
||||
|
||||
Default `certgen.Profile` entries:
|
||||
- `server`: digital signature + key encipherment, server auth, 90 days
|
||||
- `client`: digital signature, client auth, 90 days
|
||||
- `peer`: digital signature + key encipherment, server auth + client auth, 90 days
|
||||
|
||||
### Step 5: Implement CA engine
|
||||
|
||||
**File: `internal/engine/ca/ca.go`** (new)
|
||||
|
||||
Core struct:
|
||||
```go
|
||||
type CAEngine struct {
|
||||
mu sync.RWMutex
|
||||
barrier barrier.Barrier
|
||||
mountPath string
|
||||
config *CAConfig
|
||||
rootCert *x509.Certificate
|
||||
rootKey crypto.PrivateKey
|
||||
issuers map[string]*issuerState
|
||||
}
|
||||
```
|
||||
|
||||
**Initialize**: Parse config, generate self-signed root CA via `certgen.GenerateSelfSigned`, store root cert+key and config in barrier.
|
||||
|
||||
**Unseal**: Load config, root cert+key, and all issuers from barrier into memory.
|
||||
|
||||
**Seal**: Zeroize all in-memory key material, nil out pointers.
|
||||
|
||||
**HandleRequest** dispatch:
|
||||
|
||||
| Operation | Auth Required | Description |
|
||||
|-----------|--------------|-------------|
|
||||
| `get-root` | none | Return root CA cert PEM |
|
||||
| `get-chain` | none | Return full chain PEM |
|
||||
| `get-issuer` | none | Return issuer cert PEM (path=name) |
|
||||
| `create-issuer` | admin | Generate intermediate CA signed by root |
|
||||
| `delete-issuer` | admin | Remove issuer and zeroize key |
|
||||
| `list-issuers` | any auth | List issuer names |
|
||||
| `issue` | user/admin | Issue leaf cert from named issuer |
|
||||
| `get-cert` | any auth | Get cert record by serial |
|
||||
| `list-certs` | any auth | List issued cert summaries |
|
||||
| `renew` | user/admin/system | Re-issue cert with same attributes, new validity |
|
||||
|
||||
**create-issuer** flow:
|
||||
1. Generate key pair, build CSR with CN="{name}", O=config.Organization
|
||||
2. Sign with root via `profile.SignRequest(rootCert, csr, rootKey)` (profile: IsCA=true, PathLen=0)
|
||||
3. Store issuer cert+key+config in barrier under `issuers/{name}/`
|
||||
|
||||
**issue** flow:
|
||||
1. Look up issuer by name from `req.Path`
|
||||
2. Start from named profile defaults, apply user overrides (ttl, key_usages, ext_key_usages, key_algorithm)
|
||||
3. Generate leaf key pair, build CSR, sign with issuer via `profile.SignRequest`
|
||||
4. Store `CertRecord` in barrier (cert PEM + metadata, NO private key)
|
||||
5. Return cert PEM, private key PEM, chain PEM, serial, metadata
|
||||
|
||||
**renew** flow:
|
||||
1. Load original `CertRecord`, parse cert to extract subject/SANs/usages
|
||||
2. Generate new key, sign new cert with same issuer and attributes
|
||||
3. Store new `CertRecord`, return new cert+key+chain
|
||||
|
||||
Exported methods for public routes:
|
||||
- `GetRootCertPEM() ([]byte, error)`
|
||||
- `GetIssuerCertPEM(name string) ([]byte, error)`
|
||||
- `GetChainPEM(issuerName string) ([]byte, error)`
|
||||
|
||||
**Barrier storage layout:**
|
||||
```
|
||||
engine/ca/{mount}/config.json
|
||||
engine/ca/{mount}/root/cert.pem
|
||||
engine/ca/{mount}/root/key.pem
|
||||
engine/ca/{mount}/issuers/{name}/cert.pem
|
||||
engine/ca/{mount}/issuers/{name}/key.pem
|
||||
engine/ca/{mount}/issuers/{name}/config.json
|
||||
engine/ca/{mount}/certs/{serial_hex}.json
|
||||
```
|
||||
|
||||
### Step 6: Update server routes
|
||||
|
||||
**File: `internal/server/routes.go`**
|
||||
|
||||
1. **Replace `handleEngineMount` stub**: Parse `{name, type, config}` from JSON body, call `s.engines.Mount(ctx, name, type, config)`
|
||||
|
||||
2. **Replace `handleEngineRequest` stub**: Parse request body, populate `req.CallerInfo` from `TokenInfoFromContext`, call `s.engines.HandleRequest`, return response JSON
|
||||
|
||||
3. **Add public PKI routes** (no auth middleware):
|
||||
```go
|
||||
mux.HandleFunc("GET /v1/pki/{mount}/ca", s.handlePKIRoot)
|
||||
mux.HandleFunc("GET /v1/pki/{mount}/ca/chain", s.handlePKIChain)
|
||||
mux.HandleFunc("GET /v1/pki/{mount}/issuer/{name}", s.handlePKIIssuer)
|
||||
```
|
||||
Each handler: get mount, verify type=ca, type-assert to `*ca.CAEngine`, call exported method, write PEM with `Content-Type: application/x-pem-file`. Return 503 if sealed.
|
||||
|
||||
### Step 7: Register CA factory
|
||||
|
||||
**File: `cmd/metacrypt/server.go`**
|
||||
- Import `git.wntrmute.dev/kyle/metacrypt/internal/engine/ca`
|
||||
- After creating `engineRegistry`, call `engineRegistry.RegisterFactory(engine.EngineTypeCA, ca.NewCAEngine)`
|
||||
|
||||
### Step 8: Tests
|
||||
|
||||
**File: `internal/engine/ca/ca_test.go`** (new)
|
||||
|
||||
In-memory barrier implementation for testing (stores data in a `map[string][]byte`).
|
||||
|
||||
Tests:
|
||||
- Initialize generates valid self-signed root CA
|
||||
- Unseal/Seal lifecycle preserves and zeroizes state
|
||||
- Create issuer produces intermediate signed by root
|
||||
- Issue certificate with profile defaults
|
||||
- Issue certificate with user overrides (ttl, key_usages)
|
||||
- Issued cert private key NOT stored in barrier
|
||||
- Renew produces new cert with same attributes
|
||||
- Get/List certs
|
||||
- Auth: create-issuer rejects non-admin CallerInfo
|
||||
- Auth: issue allows user role, rejects nil CallerInfo
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
go build ./... # Compiles cleanly
|
||||
go test ./... # All tests pass
|
||||
go vet ./... # No issues
|
||||
```
|
||||
|
||||
## Files Summary
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `go.mod` | modify (add goutils) |
|
||||
| `internal/engine/engine.go` | modify (CallerInfo, config param, GetEngine/GetMount) |
|
||||
| `internal/engine/engine_test.go` | modify (update mock + call sites) |
|
||||
| `internal/engine/ca/types.go` | create |
|
||||
| `internal/engine/ca/profiles.go` | create |
|
||||
| `internal/engine/ca/ca.go` | create |
|
||||
| `internal/engine/ca/ca_test.go` | create |
|
||||
| `internal/server/routes.go` | modify (wire up engine handlers, add PKI routes) |
|
||||
| `cmd/metacrypt/server.go` | modify (register CA factory) |
|
||||
Reference in New Issue
Block a user