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>
7.3 KiB
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/goutilswith local replace directive (same pattern as mcias) - Run
go mod tidy
Step 2: Update engine framework
File: internal/engine/engine.go
-
Add
CallerInfostruct to carry auth context into engines:type CallerInfo struct { Username string Roles []string IsAdmin bool } -
Add
CallerInfo *CallerInfofield toRequeststruct -
Add
config map[string]interface{}parameter toEngine.Initializeinterface method -
Update
Registry.Mountto accept and pass throughconfig map[string]interface{} -
Add
GetEngine(name string) (Engine, error)method onRegistry(needed for public PKI routes) -
Add
GetMount(name string) (*Mount, error)method onRegistry(to verify mount type) -
Export
Mount.Enginefield (or add accessor) so server can type-assert to*ca.CAEngine
File: internal/engine/engine_test.go
- Update
mockEngine.Initializesignature for newconfigparameter - Update all
reg.Mount(...)calls to passnilconfig
Step 3: Create CA engine types
File: internal/engine/ca/types.go (new)
CAConfig- stored in barrier, holds org info, key algorithm, root expiryIssuerConfig- 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 daysclient: digital signature, client auth, 90 dayspeer: digital signature + key encipherment, server auth + client auth, 90 days
Step 5: Implement CA engine
File: internal/engine/ca/ca.go (new)
Core struct:
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:
- Generate key pair, build CSR with CN="{name}", O=config.Organization
- Sign with root via
profile.SignRequest(rootCert, csr, rootKey)(profile: IsCA=true, PathLen=0) - Store issuer cert+key+config in barrier under
issuers/{name}/
issue flow:
- Look up issuer by name from
req.Path - Start from named profile defaults, apply user overrides (ttl, key_usages, ext_key_usages, key_algorithm)
- Generate leaf key pair, build CSR, sign with issuer via
profile.SignRequest - Store
CertRecordin barrier (cert PEM + metadata, NO private key) - Return cert PEM, private key PEM, chain PEM, serial, metadata
renew flow:
- Load original
CertRecord, parse cert to extract subject/SANs/usages - Generate new key, sign new cert with same issuer and attributes
- 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
-
Replace
handleEngineMountstub: Parse{name, type, config}from JSON body, calls.engines.Mount(ctx, name, type, config) -
Replace
handleEngineRequeststub: Parse request body, populatereq.CallerInfofromTokenInfoFromContext, calls.engines.HandleRequest, return response JSON -
Add public PKI routes (no auth middleware):
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 withContent-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, callengineRegistry.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
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) |