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>
195 lines
7.3 KiB
Markdown
195 lines
7.3 KiB
Markdown
# 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) |
|