Add architecture docs, fix gRPC/REST API parity, project conventions
- Add ARCHITECTURE.md with full system specification - Add Project Structure and API Sync Rule to CLAUDE.md; ignore srv/ - Fix engine.proto MountRequest missing config field - Add pki.proto PKIService to match unauthenticated REST PKI routes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -87,35 +87,62 @@ func (e *CAEngine) Initialize(ctx context.Context, b barrier.Barrier, mountPath
|
||||
return fmt.Errorf("ca: store config: %w", err)
|
||||
}
|
||||
|
||||
// Generate self-signed root CA.
|
||||
creq := &certgen.CertificateRequest{
|
||||
KeySpec: certgen.KeySpec{
|
||||
Algorithm: cfg.KeyAlgorithm,
|
||||
Size: cfg.KeySize,
|
||||
},
|
||||
Subject: certgen.Subject{
|
||||
CommonName: cfg.Organization + " Root CA",
|
||||
Organization: cfg.Organization,
|
||||
Country: cfg.Country,
|
||||
},
|
||||
Profile: certgen.Profile{
|
||||
IsCA: true,
|
||||
PathLen: 1,
|
||||
KeyUse: []string{"cert sign", "crl sign"},
|
||||
Expiry: cfg.RootExpiry,
|
||||
},
|
||||
}
|
||||
var rootCert *x509.Certificate
|
||||
var rootKey crypto.PrivateKey
|
||||
var certPEM, keyPEM []byte
|
||||
|
||||
rootCert, rootKey, err := certgen.GenerateSelfSigned(creq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ca: generate root CA: %w", err)
|
||||
}
|
||||
// If root_cert_pem and root_key_pem are provided, import them
|
||||
// instead of generating a new root CA.
|
||||
rootCertStr, _ := config["root_cert_pem"].(string)
|
||||
rootKeyStr, _ := config["root_key_pem"].(string)
|
||||
|
||||
// Store root cert and key in barrier.
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootCert.Raw})
|
||||
keyPEM, err := marshalPrivateKey(rootKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ca: marshal root key: %w", err)
|
||||
if rootCertStr != "" && rootKeyStr != "" {
|
||||
certPEM = []byte(rootCertStr)
|
||||
keyPEM = []byte(rootKeyStr)
|
||||
|
||||
var err error
|
||||
rootCert, err = parseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ca: parse imported root cert: %w", err)
|
||||
}
|
||||
if !rootCert.IsCA {
|
||||
return fmt.Errorf("ca: imported certificate is not a CA")
|
||||
}
|
||||
rootKey, err = parsePrivateKeyPEM(keyPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ca: parse imported root key: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Generate self-signed root CA.
|
||||
creq := &certgen.CertificateRequest{
|
||||
KeySpec: certgen.KeySpec{
|
||||
Algorithm: cfg.KeyAlgorithm,
|
||||
Size: cfg.KeySize,
|
||||
},
|
||||
Subject: certgen.Subject{
|
||||
CommonName: cfg.Organization + " Root CA",
|
||||
Organization: cfg.Organization,
|
||||
Country: cfg.Country,
|
||||
},
|
||||
Profile: certgen.Profile{
|
||||
IsCA: true,
|
||||
PathLen: 1,
|
||||
KeyUse: []string{"cert sign", "crl sign"},
|
||||
Expiry: cfg.RootExpiry,
|
||||
},
|
||||
}
|
||||
|
||||
var err error
|
||||
rootCert, rootKey, err = certgen.GenerateSelfSigned(creq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ca: generate root CA: %w", err)
|
||||
}
|
||||
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootCert.Raw})
|
||||
keyPEM, err = marshalPrivateKey(rootKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ca: marshal root key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := b.Put(ctx, mountPath+"root/cert.pem", certPEM); err != nil {
|
||||
@@ -273,6 +300,8 @@ func (e *CAEngine) HandleRequest(ctx context.Context, req *engine.Request) (*eng
|
||||
return e.handleListCerts(ctx, req)
|
||||
case "renew":
|
||||
return e.handleRenew(ctx, req)
|
||||
case "import-root":
|
||||
return e.handleImportRoot(ctx, req)
|
||||
default:
|
||||
return nil, fmt.Errorf("ca: unknown operation: %s", req.Operation)
|
||||
}
|
||||
@@ -327,6 +356,67 @@ func (e *CAEngine) GetChainPEM(issuerName string) ([]byte, error) {
|
||||
|
||||
// --- Operation handlers ---
|
||||
|
||||
func (e *CAEngine) handleImportRoot(ctx context.Context, req *engine.Request) (*engine.Response, error) {
|
||||
if req.CallerInfo == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
if !req.CallerInfo.IsAdmin {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
certStr, _ := req.Data["cert_pem"].(string)
|
||||
keyStr, _ := req.Data["key_pem"].(string)
|
||||
if certStr == "" || keyStr == "" {
|
||||
return nil, fmt.Errorf("ca: cert_pem and key_pem are required")
|
||||
}
|
||||
|
||||
newCert, err := parseCertPEM([]byte(certStr))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ca: parse imported cert: %w", err)
|
||||
}
|
||||
if !newCert.IsCA {
|
||||
return nil, fmt.Errorf("ca: imported certificate is not a CA")
|
||||
}
|
||||
|
||||
newKey, err := parsePrivateKeyPEM([]byte(keyStr))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ca: parse imported key: %w", err)
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
// Only allow import if there is no root or the current root is expired.
|
||||
if e.rootCert != nil && time.Now().Before(e.rootCert.NotAfter) {
|
||||
return nil, fmt.Errorf("ca: current root is still valid (expires %s); cannot replace", e.rootCert.NotAfter.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// Zeroize old key if present.
|
||||
if e.rootKey != nil {
|
||||
zeroizeKey(e.rootKey)
|
||||
}
|
||||
|
||||
// Store in barrier.
|
||||
certPEM := []byte(certStr)
|
||||
keyPEM := []byte(keyStr)
|
||||
if err := e.barrier.Put(ctx, e.mountPath+"root/cert.pem", certPEM); err != nil {
|
||||
return nil, fmt.Errorf("ca: store root cert: %w", err)
|
||||
}
|
||||
if err := e.barrier.Put(ctx, e.mountPath+"root/key.pem", keyPEM); err != nil {
|
||||
return nil, fmt.Errorf("ca: store root key: %w", err)
|
||||
}
|
||||
|
||||
e.rootCert = newCert
|
||||
e.rootKey = newKey
|
||||
|
||||
return &engine.Response{
|
||||
Data: map[string]interface{}{
|
||||
"cn": newCert.Subject.CommonName,
|
||||
"expires_at": newCert.NotAfter,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *CAEngine) handleGetRoot(_ context.Context) (*engine.Response, error) {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
||||
@@ -113,6 +114,69 @@ func TestInitializeGeneratesRootCA(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeWithImportedRoot(t *testing.T) {
|
||||
// First, generate a root CA to use as the import source.
|
||||
srcEng, _ := setupEngine(t)
|
||||
rootPEM, err := srcEng.GetRootCertPEM()
|
||||
if err != nil {
|
||||
t.Fatalf("GetRootCertPEM: %v", err)
|
||||
}
|
||||
// Get the key PEM from barrier.
|
||||
srcKeyPEM, err := srcEng.barrier.Get(context.Background(), srcEng.mountPath+"root/key.pem")
|
||||
if err != nil {
|
||||
t.Fatalf("get root key: %v", err)
|
||||
}
|
||||
|
||||
// Now initialize a new engine with the imported root.
|
||||
b := newMemBarrier()
|
||||
eng := NewCAEngine().(*CAEngine)
|
||||
ctx := context.Background()
|
||||
|
||||
config := map[string]interface{}{
|
||||
"organization": "ImportOrg",
|
||||
"root_cert_pem": string(rootPEM),
|
||||
"root_key_pem": string(srcKeyPEM),
|
||||
}
|
||||
|
||||
if err := eng.Initialize(ctx, b, "engine/ca/imported/", config); err != nil {
|
||||
t.Fatalf("Initialize with import: %v", err)
|
||||
}
|
||||
|
||||
if eng.rootCert == nil {
|
||||
t.Fatal("root cert is nil after import")
|
||||
}
|
||||
if !eng.rootCert.IsCA {
|
||||
t.Error("imported root is not a CA")
|
||||
}
|
||||
// The CN should be from the original cert, not the new org.
|
||||
if eng.rootCert.Subject.CommonName != "TestOrg Root CA" {
|
||||
t.Errorf("imported root CN: got %q, want %q", eng.rootCert.Subject.CommonName, "TestOrg Root CA")
|
||||
}
|
||||
|
||||
// Verify we can create issuers and issue certs from the imported root.
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"name": "infra"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-issuer from imported root: %v", err)
|
||||
}
|
||||
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "issue",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"issuer": "infra",
|
||||
"common_name": "imported.example.com",
|
||||
"profile": "server",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("issue from imported root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsealSealLifecycle(t *testing.T) {
|
||||
eng, b := setupEngine(t)
|
||||
mountPath := "engine/ca/test/"
|
||||
@@ -596,6 +660,97 @@ func TestDeleteIssuer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRootRejectsValidRoot(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Generate a new root to try importing.
|
||||
other, _ := setupEngine(t)
|
||||
otherPEM, _ := other.GetRootCertPEM()
|
||||
otherKeyPEM, _ := other.barrier.Get(ctx, other.mountPath+"root/key.pem")
|
||||
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "import-root",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"cert_pem": string(otherPEM),
|
||||
"key_pem": string(otherKeyPEM),
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when importing over a valid root")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "still valid") {
|
||||
t.Errorf("expected 'still valid' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRootReplacesExpiredRoot(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Simulate an expired root by setting NotAfter to the past.
|
||||
eng.mu.Lock()
|
||||
eng.rootCert.NotAfter = time.Now().Add(-1 * time.Hour)
|
||||
eng.mu.Unlock()
|
||||
|
||||
// Generate a new root to import.
|
||||
other, _ := setupEngine(t)
|
||||
newPEM, _ := other.GetRootCertPEM()
|
||||
newKeyPEM, _ := other.barrier.Get(ctx, other.mountPath+"root/key.pem")
|
||||
|
||||
resp, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "import-root",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"cert_pem": string(newPEM),
|
||||
"key_pem": string(newKeyPEM),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("import-root: %v", err)
|
||||
}
|
||||
if resp.Data["cn"] == nil {
|
||||
t.Error("expected cn in response")
|
||||
}
|
||||
|
||||
// Verify the root was replaced.
|
||||
rootPEM, err := eng.GetRootCertPEM()
|
||||
if err != nil {
|
||||
t.Fatalf("GetRootCertPEM: %v", err)
|
||||
}
|
||||
if string(rootPEM) != string(newPEM) {
|
||||
t.Error("root cert was not replaced")
|
||||
}
|
||||
|
||||
// Verify we can still create issuers with the new root.
|
||||
_, err = eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "create-issuer",
|
||||
CallerInfo: adminCaller(),
|
||||
Data: map[string]interface{}{"name": "new-issuer"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create-issuer after import: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRootRequiresAdmin(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := eng.HandleRequest(ctx, &engine.Request{
|
||||
Operation: "import-root",
|
||||
CallerInfo: userCaller(),
|
||||
Data: map[string]interface{}{
|
||||
"cert_pem": "fake",
|
||||
"key_pem": "fake",
|
||||
},
|
||||
})
|
||||
if err != ErrForbidden {
|
||||
t.Errorf("expected ErrForbidden, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicMethods(t *testing.T) {
|
||||
eng, _ := setupEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
Reference in New Issue
Block a user