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:
1023
internal/engine/ca/ca.go
Normal file
1023
internal/engine/ca/ca.go
Normal file
File diff suppressed because it is too large
Load Diff
649
internal/engine/ca/ca_test.go
Normal file
649
internal/engine/ca/ca_test.go
Normal file
@@ -0,0 +1,649 @@
|
||||
package ca
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
||||
)
|
||||
|
||||
// memBarrier is an in-memory barrier for testing.
|
||||
type memBarrier struct {
|
||||
mu sync.RWMutex
|
||||
data map[string][]byte
|
||||
}
|
||||
|
||||
func newMemBarrier() *memBarrier {
|
||||
return &memBarrier{data: make(map[string][]byte)}
|
||||
}
|
||||
|
||||
func (m *memBarrier) Unseal(_ []byte) error { return nil }
|
||||
func (m *memBarrier) Seal() error { return nil }
|
||||
func (m *memBarrier) IsSealed() bool { return false }
|
||||
|
||||
func (m *memBarrier) Get(_ context.Context, path string) ([]byte, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
v, ok := m.data[path]
|
||||
if !ok {
|
||||
return nil, barrier.ErrNotFound
|
||||
}
|
||||
cp := make([]byte, len(v))
|
||||
copy(cp, v)
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
func (m *memBarrier) Put(_ context.Context, path string, value []byte) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cp := make([]byte, len(value))
|
||||
copy(cp, value)
|
||||
m.data[path] = cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memBarrier) Delete(_ context.Context, path string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.data, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memBarrier) List(_ context.Context, prefix string) ([]string, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var paths []string
|
||||
for k := range m.data {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
paths = append(paths, strings.TrimPrefix(k, prefix))
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func adminCaller() *engine.CallerInfo {
|
||||
return &engine.CallerInfo{Username: "admin", Roles: []string{"admin"}, IsAdmin: true}
|
||||
}
|
||||
|
||||
func userCaller() *engine.CallerInfo {
|
||||
return &engine.CallerInfo{Username: "user", Roles: []string{"user"}, IsAdmin: false}
|
||||
}
|
||||
|
||||
func setupEngine(t *testing.T) (*CAEngine, *memBarrier) {
|
||||
t.Helper()
|
||||
b := newMemBarrier()
|
||||
eng := NewCAEngine().(*CAEngine)
|
||||
ctx := context.Background()
|
||||
|
||||
config := map[string]interface{}{
|
||||
"organization": "TestOrg",
|
||||
"key_algorithm": "ecdsa",
|
||||
"key_size": float64(256),
|
||||
"root_expiry": "87600h",
|
||||
}
|
||||
|
||||
if err := eng.Initialize(ctx, b, "engine/ca/test/", config); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
return eng, b
|
||||
}
|
||||
|
||||
func TestInitializeGeneratesRootCA(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
|
||||
if eng.rootCert == nil {
|
||||
t.Fatal("root cert is nil")
|
||||
}
|
||||
if eng.rootKey == nil {
|
||||
t.Fatal("root key is nil")
|
||||
}
|
||||
if !eng.rootCert.IsCA {
|
||||
t.Error("root cert is not a CA")
|
||||
}
|
||||
if eng.rootCert.Subject.CommonName != "TestOrg Root CA" {
|
||||
t.Errorf("root CN: got %q, want %q", eng.rootCert.Subject.CommonName, "TestOrg Root CA")
|
||||
}
|
||||
if eng.rootCert.MaxPathLen != 1 {
|
||||
t.Errorf("root MaxPathLen: got %d, want 1", eng.rootCert.MaxPathLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsealSealLifecycle(t *testing.T) {
|
||||
eng, b := setupEngine(t)
|
||||
mountPath := "engine/ca/test/"
|
||||
|
||||
// Seal and verify state is cleared.
|
||||
if err := eng.Seal(); err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
if eng.rootCert != nil {
|
||||
t.Error("rootCert should be nil after seal")
|
||||
}
|
||||
if eng.rootKey != nil {
|
||||
t.Error("rootKey should be nil after seal")
|
||||
}
|
||||
|
||||
// Unseal and verify state is restored.
|
||||
ctx := context.Background()
|
||||
if err := eng.Unseal(ctx, b, mountPath); err != nil {
|
||||
t.Fatalf("Unseal: %v", err)
|
||||
}
|
||||
if eng.rootCert == nil {
|
||||
t.Error("rootCert should be non-nil after unseal")
|
||||
}
|
||||
if eng.rootKey == nil {
|
||||
t.Error("rootKey should be non-nil after unseal")
|
||||
}
|
||||
if !eng.rootCert.IsCA {
|
||||
t.Error("root cert should be CA after unseal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIssuer(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"name": "infra",
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := eng.HandleRequest(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("create-issuer: %v", err)
|
||||
}
|
||||
if resp.Data["name"] != "infra" {
|
||||
t.Errorf("issuer name: got %v, want %q", resp.Data["name"], "infra")
|
||||
}
|
||||
|
||||
// Verify the issuer cert is an intermediate CA signed by root.
|
||||
certPEM := resp.Data["cert_pem"].(string)
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
t.Fatal("failed to decode issuer cert PEM")
|
||||
}
|
||||
issuerCert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse issuer cert: %v", err)
|
||||
}
|
||||
if !issuerCert.IsCA {
|
||||
t.Error("issuer cert should be a CA")
|
||||
}
|
||||
// MaxPathLen 0 with MaxPathLenZero=false parses as -1 in Go's x509.
|
||||
// Either 0 or -1 is acceptable for a path-length-constrained intermediate.
|
||||
if issuerCert.MaxPathLen > 0 {
|
||||
t.Errorf("issuer MaxPathLen: got %d, want 0 or -1", issuerCert.MaxPathLen)
|
||||
}
|
||||
if issuerCert.Subject.CommonName != "infra" {
|
||||
t.Errorf("issuer CN: got %q, want %q", issuerCert.Subject.CommonName, "infra")
|
||||
}
|
||||
|
||||
// Verify issuer is in memory.
|
||||
if _, ok := eng.issuers["infra"]; !ok {
|
||||
t.Error("issuer not found in memory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIssuerRejectsNonAdmin(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"name": "infra",
|
||||
},
|
||||
}
|
||||
|
||||
_, err := eng.HandleRequest(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-admin create-issuer")
|
||||
}
|
||||
if err != ErrForbidden {
|
||||
t.Errorf("expected ErrForbidden, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIssuerRejectsNilCallerInfo(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
Data: map[string]interface{}{
|
||||
"name": "infra",
|
||||
},
|
||||
}
|
||||
|
||||
_, err := eng.HandleRequest(ctx, req)
|
||||
if err != ErrUnauthorized {
|
||||
t.Errorf("expected ErrUnauthorized, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCertificate(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issuer first.
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"name": "infra"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-issuer: %v", err)
|
||||
}
|
||||
|
||||
// Issue a certificate.
|
||||
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
Path: "infra",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "web.example.com",
|
||||
"profile": "server",
|
||||
"dns_names": []interface{}{"web.example.com", "www.example.com"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
|
||||
if resp.Data["cn"] != "web.example.com" {
|
||||
t.Errorf("cn: got %v", resp.Data["cn"])
|
||||
}
|
||||
if resp.Data["serial"] == nil || resp.Data["serial"] == "" {
|
||||
t.Error("serial should not be empty")
|
||||
}
|
||||
if resp.Data["cert_pem"] == nil {
|
||||
t.Error("cert_pem should not be nil")
|
||||
}
|
||||
if resp.Data["key_pem"] == nil {
|
||||
t.Error("key_pem should not be nil")
|
||||
}
|
||||
if resp.Data["chain_pem"] == nil {
|
||||
t.Error("chain_pem should not be nil")
|
||||
}
|
||||
|
||||
// Verify the leaf cert.
|
||||
certPEM := resp.Data["cert_pem"].(string)
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
leafCert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse leaf cert: %v", err)
|
||||
}
|
||||
if leafCert.IsCA {
|
||||
t.Error("leaf cert should not be a CA")
|
||||
}
|
||||
if leafCert.Subject.CommonName != "web.example.com" {
|
||||
t.Errorf("leaf CN: got %q", leafCert.Subject.CommonName)
|
||||
}
|
||||
if len(leafCert.DNSNames) != 2 {
|
||||
t.Errorf("leaf DNSNames: got %v", leafCert.DNSNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCertificateWithOverrides(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"name": "infra"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-issuer: %v", err)
|
||||
}
|
||||
|
||||
// Issue with custom TTL and key usages.
|
||||
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "peer.example.com",
|
||||
"profile": "peer",
|
||||
"ttl": "720h",
|
||||
"key_usages": []interface{}{"digital signature"},
|
||||
"ext_key_usages": []interface{}{"client auth"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue with overrides: %v", err)
|
||||
}
|
||||
|
||||
certPEM := resp.Data["cert_pem"].(string)
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
leafCert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse leaf: %v", err)
|
||||
}
|
||||
|
||||
// Verify client auth EKU.
|
||||
hasClientAuth := false
|
||||
for _, eku := range leafCert.ExtKeyUsage {
|
||||
if eku == x509.ExtKeyUsageClientAuth {
|
||||
hasClientAuth = true
|
||||
}
|
||||
}
|
||||
if !hasClientAuth {
|
||||
t.Error("expected client auth EKU")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueRejectsNilCallerInfo(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"name": "infra"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-issuer: %v", err)
|
||||
}
|
||||
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "test.example.com",
|
||||
},
|
||||
})
|
||||
if err != ErrUnauthorized {
|
||||
t.Errorf("expected ErrUnauthorized, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateKeyNotStoredInBarrier(t *testing.T) {
|
||||
eng, b := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"name": "infra"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-issuer: %v", err)
|
||||
}
|
||||
|
||||
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "test.example.com",
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
|
||||
serial := resp.Data["serial"].(string)
|
||||
|
||||
// Check that the cert record does not contain a private key.
|
||||
recordData, err := b.Get(ctx, "engine/ca/test/certs/"+serial+".json")
|
||||
if err != nil {
|
||||
t.Fatalf("get cert record: %v", err)
|
||||
}
|
||||
if strings.Contains(string(recordData), "PRIVATE KEY") {
|
||||
t.Error("cert record should not contain private key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewCertificate(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"name": "infra"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-issuer: %v", err)
|
||||
}
|
||||
|
||||
// Issue original cert.
|
||||
issueResp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "renew.example.com",
|
||||
"profile": "server",
|
||||
"dns_names": []interface{}{"renew.example.com"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
|
||||
origSerial := issueResp.Data["serial"].(string)
|
||||
|
||||
// Renew.
|
||||
renewResp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "renew",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"serial": origSerial,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("renew: %v", err)
|
||||
}
|
||||
|
||||
newSerial := renewResp.Data["serial"].(string)
|
||||
if newSerial == origSerial {
|
||||
t.Error("renewed cert should have different serial")
|
||||
}
|
||||
if renewResp.Data["cn"] != "renew.example.com" {
|
||||
t.Errorf("renewed CN: got %v", renewResp.Data["cn"])
|
||||
}
|
||||
if renewResp.Data["cert_pem"] == nil {
|
||||
t.Error("renewed cert_pem should not be nil")
|
||||
}
|
||||
if renewResp.Data["key_pem"] == nil {
|
||||
t.Error("renewed key_pem should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAndListCerts(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"name": "infra"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-issuer: %v", err)
|
||||
}
|
||||
|
||||
// Issue two certs.
|
||||
for _, cn := range []string{"a.example.com", "b.example.com"} {
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": cn,
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue %s: %v", cn, err)
|
||||
}
|
||||
}
|
||||
|
||||
// List certs.
|
||||
listResp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "list-certs",
|
||||
CallerInfo: userCaller(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list-certs: %v", err)
|
||||
}
|
||||
|
||||
certs, ok := listResp.Data["certs"].([]map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("certs type: %T", listResp.Data["certs"])
|
||||
}
|
||||
if len(certs) != 2 {
|
||||
t.Errorf("expected 2 certs, got %d", len(certs))
|
||||
}
|
||||
|
||||
// Get a specific cert.
|
||||
serial := certs[0]["serial"].(string)
|
||||
getResp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "get-cert",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"serial": serial,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get-cert: %v", err)
|
||||
}
|
||||
if getResp.Data["serial"] != serial {
|
||||
t.Errorf("get-cert serial: got %v, want %v", getResp.Data["serial"], serial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsealRestoresIssuers(t *testing.T) {
|
||||
eng, b := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
mountPath := "engine/ca/test/"
|
||||
|
||||
// Create issuer.
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"name": "infra"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-issuer: %v", err)
|
||||
}
|
||||
|
||||
// Seal.
|
||||
eng.Seal()
|
||||
|
||||
// Unseal.
|
||||
if err := eng.Unseal(ctx, b, mountPath); err != nil {
|
||||
t.Fatalf("Unseal: %v", err)
|
||||
}
|
||||
|
||||
// Verify issuer was restored.
|
||||
if _, ok := eng.issuers["infra"]; !ok {
|
||||
t.Error("issuer 'infra' not restored after unseal")
|
||||
}
|
||||
|
||||
// Verify we can issue from the restored issuer.
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "after-unseal.example.com",
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue after unseal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteIssuer(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"name": "infra"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-issuer: %v", err)
|
||||
}
|
||||
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "delete-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"name": "infra"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete-issuer: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := eng.issuers["infra"]; ok {
|
||||
t.Error("issuer should be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicMethods(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Test GetRootCertPEM.
|
||||
rootPEM, err := eng.GetRootCertPEM()
|
||||
if err != nil {
|
||||
t.Fatalf("GetRootCertPEM: %v", err)
|
||||
}
|
||||
block, _ := pem.Decode(rootPEM)
|
||||
if block == nil {
|
||||
t.Fatal("failed to decode root PEM")
|
||||
}
|
||||
|
||||
// Create issuer for chain/issuer tests.
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"name": "infra"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-issuer: %v", err)
|
||||
}
|
||||
|
||||
// Test GetIssuerCertPEM.
|
||||
issuerPEM, err := eng.GetIssuerCertPEM("infra")
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssuerCertPEM: %v", err)
|
||||
}
|
||||
block, _ = pem.Decode(issuerPEM)
|
||||
if block == nil {
|
||||
t.Fatal("failed to decode issuer PEM")
|
||||
}
|
||||
|
||||
// Test GetChainPEM.
|
||||
chainPEM, err := eng.GetChainPEM("infra")
|
||||
if err != nil {
|
||||
t.Fatalf("GetChainPEM: %v", err)
|
||||
}
|
||||
// Chain should contain two certificates.
|
||||
certCount := strings.Count(string(chainPEM), "BEGIN CERTIFICATE")
|
||||
if certCount != 2 {
|
||||
t.Errorf("chain should contain 2 certs, got %d", certCount)
|
||||
}
|
||||
|
||||
// Test nonexistent issuer.
|
||||
_, err = eng.GetIssuerCertPEM("nonexistent")
|
||||
if err != ErrIssuerNotFound {
|
||||
t.Errorf("expected ErrIssuerNotFound, got: %v", err)
|
||||
}
|
||||
}
|
||||
41
internal/engine/ca/profiles.go
Normal file
41
internal/engine/ca/profiles.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package ca
|
||||
|
||||
import "git.wntrmute.dev/kyle/goutils/certlib/certgen"
|
||||
|
||||
// Default certificate profiles.
|
||||
var defaultProfiles = map[string]certgen.Profile{
|
||||
"server": {
|
||||
KeyUse: []string{"digital signature", "key encipherment"},
|
||||
ExtKeyUsages: []string{"server auth"},
|
||||
Expiry: "2160h", // 90 days
|
||||
},
|
||||
"client": {
|
||||
KeyUse: []string{"digital signature"},
|
||||
ExtKeyUsages: []string{"client auth"},
|
||||
Expiry: "2160h", // 90 days
|
||||
},
|
||||
"peer": {
|
||||
KeyUse: []string{"digital signature", "key encipherment"},
|
||||
ExtKeyUsages: []string{"server auth", "client auth"},
|
||||
Expiry: "2160h", // 90 days
|
||||
},
|
||||
}
|
||||
|
||||
// GetProfile returns a copy of the named default profile.
|
||||
func GetProfile(name string) (certgen.Profile, bool) {
|
||||
p, ok := defaultProfiles[name]
|
||||
if !ok {
|
||||
return certgen.Profile{}, false
|
||||
}
|
||||
// Return a copy so callers can modify.
|
||||
cp := certgen.Profile{
|
||||
IsCA: p.IsCA,
|
||||
PathLen: p.PathLen,
|
||||
Expiry: p.Expiry,
|
||||
KeyUse: make([]string, len(p.KeyUse)),
|
||||
ExtKeyUsages: make([]string, len(p.ExtKeyUsages)),
|
||||
}
|
||||
copy(cp.KeyUse, p.KeyUse)
|
||||
copy(cp.ExtKeyUsages, p.ExtKeyUsages)
|
||||
return cp, true
|
||||
}
|
||||
37
internal/engine/ca/types.go
Normal file
37
internal/engine/ca/types.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package ca
|
||||
|
||||
import "time"
|
||||
|
||||
// CAConfig is the CA engine configuration stored in the barrier.
|
||||
type CAConfig struct {
|
||||
Organization string `json:"organization"`
|
||||
Country string `json:"country,omitempty"`
|
||||
KeyAlgorithm string `json:"key_algorithm"` // "ecdsa", "rsa", "ed25519"
|
||||
KeySize int `json:"key_size"` // e.g. 384 for ECDSA, 4096 for RSA
|
||||
RootExpiry string `json:"root_expiry"` // e.g. "87600h" (10 years)
|
||||
}
|
||||
|
||||
// IssuerConfig is per-issuer configuration stored in the barrier.
|
||||
type IssuerConfig struct {
|
||||
Name string `json:"name"`
|
||||
KeyAlgorithm string `json:"key_algorithm"`
|
||||
KeySize int `json:"key_size"`
|
||||
Expiry string `json:"expiry"` // issuer cert expiry, e.g. "43800h" (5 years)
|
||||
MaxTTL string `json:"max_ttl"` // max leaf cert TTL, e.g. "8760h" (1 year)
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CertRecord is metadata for an issued certificate, stored in the barrier.
|
||||
// The private key is NOT stored.
|
||||
type CertRecord struct {
|
||||
Serial string `json:"serial"`
|
||||
Issuer string `json:"issuer"`
|
||||
CN string `json:"cn"`
|
||||
SANs []string `json:"sans,omitempty"`
|
||||
Profile string `json:"profile"`
|
||||
CertPEM string `json:"cert_pem"`
|
||||
IssuedBy string `json:"issued_by"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
@@ -27,11 +27,19 @@ var (
|
||||
ErrUnknownType = errors.New("engine: unknown engine type")
|
||||
)
|
||||
|
||||
// CallerInfo carries authentication context into engines.
|
||||
type CallerInfo struct {
|
||||
Username string
|
||||
Roles []string
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// Request is a request to an engine.
|
||||
type Request struct {
|
||||
Operation string
|
||||
Path string
|
||||
Data map[string]interface{}
|
||||
Operation string
|
||||
Path string
|
||||
Data map[string]interface{}
|
||||
CallerInfo *CallerInfo
|
||||
}
|
||||
|
||||
// Response is a response from an engine.
|
||||
@@ -44,7 +52,7 @@ type Engine interface {
|
||||
// Type returns the engine type.
|
||||
Type() EngineType
|
||||
// Initialize sets up the engine for first use.
|
||||
Initialize(ctx context.Context, b barrier.Barrier, mountPath string) error
|
||||
Initialize(ctx context.Context, b barrier.Barrier, mountPath string, config map[string]interface{}) error
|
||||
// Unseal opens the engine using state from the barrier.
|
||||
Unseal(ctx context.Context, b barrier.Barrier, mountPath string) error
|
||||
// Seal closes the engine and zeroizes key material.
|
||||
@@ -58,10 +66,10 @@ type Factory func() Engine
|
||||
|
||||
// Mount represents a mounted engine instance.
|
||||
type Mount struct {
|
||||
Name string `json:"name"`
|
||||
Type EngineType `json:"type"`
|
||||
MountPath string `json:"mount_path"`
|
||||
engine Engine
|
||||
Name string `json:"name"`
|
||||
Type EngineType `json:"type"`
|
||||
MountPath string `json:"mount_path"`
|
||||
Engine Engine `json:"-"`
|
||||
}
|
||||
|
||||
// Registry manages mounted engine instances.
|
||||
@@ -89,7 +97,7 @@ func (r *Registry) RegisterFactory(t EngineType, f Factory) {
|
||||
}
|
||||
|
||||
// Mount creates and initializes a new engine mount.
|
||||
func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType) error {
|
||||
func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType, config map[string]interface{}) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
@@ -105,7 +113,7 @@ func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType
|
||||
eng := factory()
|
||||
mountPath := fmt.Sprintf("engine/%s/%s/", engineType, name)
|
||||
|
||||
if err := eng.Initialize(ctx, r.barrier, mountPath); err != nil {
|
||||
if err := eng.Initialize(ctx, r.barrier, mountPath, config); err != nil {
|
||||
return fmt.Errorf("engine: initialize %q: %w", name, err)
|
||||
}
|
||||
|
||||
@@ -113,11 +121,35 @@ func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType
|
||||
Name: name,
|
||||
Type: engineType,
|
||||
MountPath: mountPath,
|
||||
engine: eng,
|
||||
Engine: eng,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEngine returns the engine for the given mount name.
|
||||
func (r *Registry) GetEngine(name string) (Engine, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
mount, exists := r.mounts[name]
|
||||
if !exists {
|
||||
return nil, ErrMountNotFound
|
||||
}
|
||||
return mount.Engine, nil
|
||||
}
|
||||
|
||||
// GetMount returns the mount for the given name.
|
||||
func (r *Registry) GetMount(name string) (*Mount, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
mount, exists := r.mounts[name]
|
||||
if !exists {
|
||||
return nil, ErrMountNotFound
|
||||
}
|
||||
return mount, nil
|
||||
}
|
||||
|
||||
// Unmount removes and seals an engine mount.
|
||||
func (r *Registry) Unmount(name string) error {
|
||||
r.mu.Lock()
|
||||
@@ -128,7 +160,7 @@ func (r *Registry) Unmount(name string) error {
|
||||
return ErrMountNotFound
|
||||
}
|
||||
|
||||
if err := mount.engine.Seal(); err != nil {
|
||||
if err := mount.Engine.Seal(); err != nil {
|
||||
return fmt.Errorf("engine: seal %q: %w", name, err)
|
||||
}
|
||||
|
||||
@@ -162,7 +194,7 @@ func (r *Registry) HandleRequest(ctx context.Context, mountName string, req *Req
|
||||
return nil, ErrMountNotFound
|
||||
}
|
||||
|
||||
return mount.engine.HandleRequest(ctx, req)
|
||||
return mount.Engine.HandleRequest(ctx, req)
|
||||
}
|
||||
|
||||
// SealAll seals all mounted engines.
|
||||
@@ -171,7 +203,7 @@ func (r *Registry) SealAll() error {
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for name, mount := range r.mounts {
|
||||
if err := mount.engine.Seal(); err != nil {
|
||||
if err := mount.Engine.Seal(); err != nil {
|
||||
return fmt.Errorf("engine: seal %q: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,22 +14,28 @@ type mockEngine struct {
|
||||
unsealed bool
|
||||
}
|
||||
|
||||
func (m *mockEngine) Type() EngineType { return m.engineType }
|
||||
func (m *mockEngine) Initialize(_ context.Context, _ barrier.Barrier, _ string) error { m.initialized = true; return nil }
|
||||
func (m *mockEngine) Unseal(_ context.Context, _ barrier.Barrier, _ string) error { m.unsealed = true; return nil }
|
||||
func (m *mockEngine) Seal() error { m.unsealed = false; return nil }
|
||||
func (m *mockEngine) Type() EngineType { return m.engineType }
|
||||
func (m *mockEngine) Initialize(_ context.Context, _ barrier.Barrier, _ string, _ map[string]interface{}) error {
|
||||
m.initialized = true
|
||||
return nil
|
||||
}
|
||||
func (m *mockEngine) Unseal(_ context.Context, _ barrier.Barrier, _ string) error {
|
||||
m.unsealed = true
|
||||
return nil
|
||||
}
|
||||
func (m *mockEngine) Seal() error { m.unsealed = false; return nil }
|
||||
func (m *mockEngine) HandleRequest(_ context.Context, _ *Request) (*Response, error) {
|
||||
return &Response{Data: map[string]interface{}{"ok": true}}, nil
|
||||
}
|
||||
|
||||
type mockBarrier struct{}
|
||||
|
||||
func (m *mockBarrier) Unseal(_ []byte) error { return nil }
|
||||
func (m *mockBarrier) Seal() error { return nil }
|
||||
func (m *mockBarrier) IsSealed() bool { return false }
|
||||
func (m *mockBarrier) Get(_ context.Context, _ string) ([]byte, error) { return nil, barrier.ErrNotFound }
|
||||
func (m *mockBarrier) Put(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockBarrier) Delete(_ context.Context, _ string) error { return nil }
|
||||
func (m *mockBarrier) Unseal(_ []byte) error { return nil }
|
||||
func (m *mockBarrier) Seal() error { return nil }
|
||||
func (m *mockBarrier) IsSealed() bool { return false }
|
||||
func (m *mockBarrier) Get(_ context.Context, _ string) ([]byte, error) { return nil, barrier.ErrNotFound }
|
||||
func (m *mockBarrier) Put(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockBarrier) Delete(_ context.Context, _ string) error { return nil }
|
||||
func (m *mockBarrier) List(_ context.Context, _ string) ([]string, error) { return nil, nil }
|
||||
|
||||
func TestRegistryMountUnmount(t *testing.T) {
|
||||
@@ -39,7 +45,7 @@ func TestRegistryMountUnmount(t *testing.T) {
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
if err := reg.Mount(ctx, "default", EngineTypeTransit); err != nil {
|
||||
if err := reg.Mount(ctx, "default", EngineTypeTransit, nil); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
|
||||
@@ -52,7 +58,7 @@ func TestRegistryMountUnmount(t *testing.T) {
|
||||
}
|
||||
|
||||
// Duplicate mount should fail.
|
||||
if err := reg.Mount(ctx, "default", EngineTypeTransit); err != ErrMountExists {
|
||||
if err := reg.Mount(ctx, "default", EngineTypeTransit, nil); err != ErrMountExists {
|
||||
t.Fatalf("expected ErrMountExists, got: %v", err)
|
||||
}
|
||||
|
||||
@@ -75,7 +81,7 @@ func TestRegistryUnmountNotFound(t *testing.T) {
|
||||
|
||||
func TestRegistryUnknownType(t *testing.T) {
|
||||
reg := NewRegistry(&mockBarrier{})
|
||||
err := reg.Mount(context.Background(), "test", EngineTypeTransit)
|
||||
err := reg.Mount(context.Background(), "test", EngineTypeTransit, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown engine type")
|
||||
}
|
||||
@@ -88,7 +94,7 @@ func TestRegistryHandleRequest(t *testing.T) {
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
reg.Mount(ctx, "test", EngineTypeTransit)
|
||||
reg.Mount(ctx, "test", EngineTypeTransit, nil)
|
||||
|
||||
resp, err := reg.HandleRequest(ctx, "test", &Request{Operation: "encrypt"})
|
||||
if err != nil {
|
||||
@@ -111,8 +117,8 @@ func TestRegistrySealAll(t *testing.T) {
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
reg.Mount(ctx, "eng1", EngineTypeTransit)
|
||||
reg.Mount(ctx, "eng2", EngineTypeTransit)
|
||||
reg.Mount(ctx, "eng1", EngineTypeTransit, nil)
|
||||
reg.Mount(ctx, "eng2", EngineTypeTransit, nil)
|
||||
|
||||
if err := reg.SealAll(); err != nil {
|
||||
t.Fatalf("SealAll: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user