2026-03-14 21:57:52 -07:00
# 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.
2026-03-27 02:05:59 -07:00
Certificate generation uses the `certgen` package from `git.wntrmute.dev/mc/goutils/certlib/certgen` .
2026-03-14 21:57:52 -07:00
## Implementation Order
### Step 1: Add goutils dependency
**File: `go.mod`**
2026-03-27 02:05:59 -07:00
- Add `git.wntrmute.dev/mc/goutils` with local replace directive (same pattern as mcias)
2026-03-14 21:57:52 -07:00
- 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`**
2026-03-27 02:05:59 -07:00
- Import ` git.wntrmute.dev/mc/metacrypt/internal/engine/ca`
2026-03-14 21:57:52 -07:00
- 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) |