Files
metacrypt/PKI-ENGINE-PLAN.md
Kyle Isom bbe382dc10 Migrate module path from kyle/ to mc/ org
All import paths updated to git.wntrmute.dev/mc/. Bumps mcdsl to v1.2.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:05:59 -07:00

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/mc/goutils/certlib/certgen.

Implementation Order

Step 1: Add goutils dependency

File: go.mod

  • Add git.wntrmute.dev/mc/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:

    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:

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):

    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/mc/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

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)