From 8f77050a844d327d344d41b557a871ee4c740006 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sat, 14 Mar 2026 21:57:52 -0700 Subject: [PATCH] Implement CA/PKI engine with two-tier X.509 certificate issuance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 3 + ARCHITECTURE.md | 699 ++++++++++++++++ CLAUDE.md | 29 + Dockerfile | 13 +- PKI-ENGINE-PLAN.md | 194 +++++ RUNBOOK.md | 57 +- cmd/metacrypt/init.go | 2 +- cmd/metacrypt/root.go | 4 +- cmd/metacrypt/server.go | 4 +- cmd/metacrypt/snapshot.go | 2 +- deploy/docker/docker-compose.yml | 8 +- deploy/examples/metacrypt-docker.toml | 10 +- deploy/examples/metacrypt.toml | 10 +- deploy/scripts/backup.sh | 4 +- deploy/scripts/install.sh | 27 +- deploy/systemd/metacrypt-backup.service | 4 +- deploy/systemd/metacrypt.service | 6 +- go.mod | 3 + internal/engine/ca/ca.go | 1023 +++++++++++++++++++++++ internal/engine/ca/ca_test.go | 649 ++++++++++++++ internal/engine/ca/profiles.go | 41 + internal/engine/ca/types.go | 37 + internal/engine/engine.go | 60 +- internal/engine/engine_test.go | 38 +- internal/server/routes.go | 163 +++- metacrypt.toml.example | 19 - 26 files changed, 2980 insertions(+), 129 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 PKI-ENGINE-PLAN.md create mode 100644 internal/engine/ca/ca.go create mode 100644 internal/engine/ca/ca_test.go create mode 100644 internal/engine/ca/profiles.go create mode 100644 internal/engine/ca/types.go delete mode 100644 metacrypt.toml.example diff --git a/.gitignore b/.gitignore index 4f5511e..bd2ae6e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ certs/ # OS .DS_Store Thumbs.db + +# test develop configs +/srv/** \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..8363e04 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,699 @@ +# Metacrypt Architecture & Specification + +Metacrypt is a cryptographic service for the Metacircular platform. It provides +cryptographic resources via a modular "engines" architecture, backed by an +encrypted storage barrier inspired by HashiCorp Vault. Authentication is +delegated to the Metacircular Identity and Access Service (MCIAS). + +## Table of Contents + +1. [System Overview](#system-overview) +2. [Key Hierarchy & Cryptographic Design](#key-hierarchy--cryptographic-design) +3. [Seal/Unseal Lifecycle](#sealunseal-lifecycle) +4. [Encrypted Storage Barrier](#encrypted-storage-barrier) +5. [Authentication & Authorization](#authentication--authorization) +6. [Engine Architecture](#engine-architecture) +7. [API Surface](#api-surface) +8. [Web Interface](#web-interface) +9. [Database Schema](#database-schema) +10. [Configuration](#configuration) +11. [Deployment](#deployment) +12. [Security Model](#security-model) +13. [Future Work](#future-work) + +--- + +## System Overview + +``` +┌─────────────────────────────────────────┐ +│ Clients (CLI / Web UI) │ +└──────────────────┬──────────────────────┘ + │ HTTPS (TLS 1.2+) +┌──────────────────▼──────────────────────┐ +│ HTTP API & Web Routes (server/) │ +│ ├── Seal/Unseal endpoints │ +│ ├── Auth endpoints (MCIAS delegation) │ +│ ├── Engine request routing │ +│ └── Policy management │ +├─────────────────────────────────────────┤ +│ Middleware │ +│ ├── Structured logging │ +│ ├── Token authentication │ +│ ├── Policy authorization │ +│ └── Unseal-state gating │ +├─────────────────────────────────────────┤ +│ Service Layer │ +│ ├── Seal Manager (seal/) │ +│ ├── Authenticator (auth/) │ +│ ├── Policy Engine (policy/) │ +│ └── Engine Registry (engine/) │ +├─────────────────────────────────────────┤ +│ Encrypted Storage Barrier (barrier/) │ +│ └── AES-256-GCM per-entry encryption │ +├─────────────────────────────────────────┤ +│ Database Layer (db/) │ +│ └── SQLite (WAL mode, foreign keys) │ +└─────────────────────────────────────────┘ +``` + +### Package Layout + +``` +cmd/metacrypt/ CLI entry point (server, init, status, snapshot) +internal/ + config/ TOML configuration loading & validation + crypto/ Low-level cryptographic primitives + db/ SQLite setup & schema migrations + seal/ Seal/unseal state machine + barrier/ Encrypted key-value storage abstraction + auth/ MCIAS token authentication & caching + policy/ Priority-based ACL engine + engine/ Pluggable engine registry & interface + ca/ CA (PKI) engine — X.509 certificate issuance + server/ HTTP server, routes, middleware +web/ + templates/ Go HTML templates (layout, init, unseal, login, dashboard) + static/ CSS, HTMX +deploy/ Docker Compose, example configs +``` + +--- + +## Key Hierarchy & Cryptographic Design + +### Primitives + +| Purpose | Algorithm | Parameters | +|--------------------|--------------------|---------------------------------| +| Key derivation | Argon2id | 3 iterations, 128 MiB, 4 threads (configurable) | +| Symmetric encryption | AES-256-GCM | 256-bit keys, 12-byte random nonce | +| Key size | 256 bits | All symmetric keys | +| Salt size | 256 bits | Argon2id salt | +| CSPRNG | `crypto/rand` | Keys, salts, nonces | +| Constant-time comparison | `crypto/subtle` | Password & token comparison | +| Zeroization | Explicit overwrite | MEK, KWK, passwords in memory | + +### Key Hierarchy + +``` +User Password (not stored) + │ + ▼ +┌─────────────────────────────────────┐ +│ Argon2id(password, salt, params) │ salt + params stored in seal_config +└──────────────────┬──────────────────┘ + ▼ + Key Wrap Key (KWK) 256-bit, ephemeral (derived on unseal) + │ + ▼ + ┌────────────────┐ + │ AES-256-GCM │ encrypted_mek stored in seal_config + │ Decrypt │ + └───────┬────────┘ + ▼ + Master Encryption Key (MEK) 256-bit, held in memory only when unsealed + │ + ▼ + ┌──────────────────────┐ + │ Barrier Entries │ Each entry encrypted individually + │ ├── Policy rules │ with MEK via AES-256-GCM + │ ├── Engine configs │ + │ └── Engine DEKs │ Per-engine data encryption keys + └──────────────────────┘ +``` + +### Ciphertext Format + +All encrypted values use a versioned binary format: + +``` +[version: 1 byte][nonce: 12 bytes][ciphertext + GCM tag] +``` + +The version byte (currently `0x01`) enables future algorithm migration, +including post-quantum hybrid schemes. + +--- + +## Seal/Unseal Lifecycle + +Metacrypt operates as a state machine with four states: + +``` + ┌────────────────┐ + │ Uninitialized │ No seal_config row in DB + └───────┬────────┘ + │ Initialize(password) + │ • generate salt + │ • derive KWK = Argon2id(password, salt) + │ • generate random MEK + │ • store Encrypt(KWK, MEK) + salt + params + ▼ + Seal() ┌──────────────────┐ + ◄──────────────│ Unsealed │ MEK in memory; barrier open + │ └──────────────────┘ + │ ▲ + │ │ Unseal(password) + │ │ • load salt + params + encrypted_mek + │ │ • derive KWK = Argon2id(password, salt) + │ │ • MEK = Decrypt(KWK, encrypted_mek) + │ │ • barrier.Unseal(MEK) + │ ┌──────────────────┐ + └─────────────►│ Sealed │ MEK zeroized; barrier locked + └──────────────────┘ +``` + +### Rate Limiting + +Unseal attempts are rate-limited to mitigate online brute-force: +- **5 attempts** within a 1-minute sliding window +- **60-second lockout** after exceeding the limit +- Counter resets after 1 minute of inactivity + +### Sealing + +Calling `Seal()` immediately: +1. Zeroizes the MEK from memory +2. Seals the storage barrier (all reads/writes return `ErrSealed`) +3. Seals all mounted engines +4. Flushes the authentication token cache + +--- + +## Encrypted Storage Barrier + +The barrier provides an encrypted key-value store over the `barrier_entries` +table. Every value is independently encrypted with the MEK using AES-256-GCM. + +### Interface + +```go +type Barrier interface { + Unseal(mek []byte) error + Seal() error + IsSealed() bool + Get(ctx context.Context, path string) ([]byte, error) + Put(ctx context.Context, path string, value []byte) error + Delete(ctx context.Context, path string) error + List(ctx context.Context, prefix string) ([]string, error) +} +``` + +### Path Namespace Conventions + +| Prefix | Owner | Contents | +|--------------------------------|----------------|---------------------------| +| `policy/rules/{id}` | Policy engine | JSON-encoded ACL rules | +| `engine/{type}/{mount}/` | Engine | Config, keys, engine data | +| `engine/ca/{mount}/root/` | CA engine | Root CA cert + key | +| `engine/ca/{mount}/issuers/` | CA engine | Issuer certs, keys, config | +| `engine/ca/{mount}/certs/` | CA engine | Issued cert records (no private keys) | + +### Properties + +- **Encryption at rest**: All values encrypted with MEK before database write +- **Fresh nonce per write**: Every `Put` generates a new random nonce +- **Atomic upsert**: Uses `INSERT ... ON CONFLICT UPDATE` for Put +- **Glob listing**: `List(prefix)` returns relative paths matching the prefix +- **Thread-safe**: All operations guarded by `sync.RWMutex` +- **Fail-closed**: Returns `ErrSealed` for any operation when the barrier is sealed + +--- + +## Authentication & Authorization + +### Authentication (MCIAS Delegation) + +Metacrypt does not manage user accounts. All authentication is delegated to +MCIAS: + +1. Client sends `POST /v1/auth/login` with `{username, password, totp_code}` +2. Metacrypt forwards credentials to the MCIAS client library +3. On success, MCIAS returns a bearer token and expiration +4. Token is returned to the client (also set as `metacrypt_token` cookie for web UI) +5. Subsequent requests include `Authorization: Bearer ` or the cookie + +**Token validation** calls MCIAS `ValidateToken()`, with results cached for +30 seconds (keyed by SHA-256 hash of the token) to reduce MCIAS load. + +**Admin detection**: Users with the `admin` role in MCIAS are granted admin +privileges in Metacrypt. + +### Authorization (Policy Engine) + +The policy engine evaluates access control rules stored in the barrier. + +**Rule structure**: + +```go +type Rule struct { + ID string // unique identifier + Priority int // lower number = higher priority + Effect Effect // "allow" or "deny" + Usernames []string // match specific users (optional) + Roles []string // match roles (optional) + Resources []string // glob patterns, e.g. "engine/transit/*" (optional) + Actions []string // e.g. "read", "write", "admin" (optional) +} +``` + +**Evaluation algorithm**: + +1. If the requester has the `admin` role, **allow** immediately (bypass) +2. Collect all rules where username, role, resource, and action match +3. Sort matching rules by priority (ascending; lower number = higher priority) +4. Return the effect of the highest-priority matching rule +5. **Default deny** if no rules match + +Matching is case-insensitive for usernames and roles. Resources use glob +patterns. Empty fields in a rule match everything. + +--- + +## Engine Architecture + +Engines are pluggable cryptographic service providers. The CA (PKI) engine is +implemented; remaining engine types are planned. + +### Engine Types + +| Type | Status | Purpose | +|-----------|---------------|----------------------------------------------| +| `ca` | Implemented | X.509 Certificate Authority (PKI) | +| `sshca` | Planned | SSH Certificate Authority | +| `transit` | Planned | Encrypt/decrypt data in transit (envelope encryption) | +| `user` | Planned | User-to-user end-to-end encryption | + +### Engine Interface + +```go +type CallerInfo struct { + Username string + Roles []string + IsAdmin bool +} + +type Request struct { + Operation string + Path string + Data map[string]interface{} + CallerInfo *CallerInfo +} + +type Engine interface { + Type() EngineType + Initialize(ctx context.Context, b barrier.Barrier, mountPath string, config map[string]interface{}) error + Unseal(ctx context.Context, b barrier.Barrier, mountPath string) error + Seal() error + HandleRequest(ctx context.Context, req *Request) (*Response, error) +} +``` + +`CallerInfo` carries authentication context into engine operations, allowing +engines to enforce their own auth requirements (e.g. admin-only operations). + +`Initialize` accepts a `config` map for engine-specific configuration passed +at mount time. + +### Mount Registry + +Engines are instantiated through a factory pattern and tracked in a central +registry: + +```go +registry.RegisterFactory(engine.EngineTypeCA, ca.NewCAEngine) +registry.Mount(ctx, "pki", engine.EngineTypeCA, map[string]interface{}{ + "organization": "Metacircular", + "key_algorithm": "ecdsa", + "key_size": 384, +}) +// Creates engine at barrier path: engine/ca/pki/ +``` + +Each mount gets its own namespace in the barrier for isolated data storage +(config, keys, operational data). Mounting an engine calls `Initialize()`, +which performs first-time setup (e.g. generating a root CA). On subsequent +unseals, `Unseal()` loads existing state from the barrier. + +The registry provides `GetEngine(name)` and `GetMount(name)` methods for +direct engine access, used by the public PKI routes to serve certificates +without authentication. + +### Request Routing + +``` +POST /v1/engine/request {mount: "pki", operation: "issue", path: "infra", data: {...}} + → Registry.HandleRequest("pki", req) + → engine.HandleRequest(ctx, req) + → Response{Data: {...}} +``` + +### CA (PKI) Engine + +The CA engine (`internal/engine/ca/`) provides X.509 certificate issuance for +Metacircular infrastructure. It implements a two-tier PKI: a single root CA +issues scoped intermediate CAs ("issuers"), which in turn issue leaf +certificates. + +Certificate generation uses the `certgen` package from +`git.wntrmute.dev/kyle/goutils/certlib/certgen`. + +#### Lifecycle + +- **Initialize**: Generates a self-signed root CA, stores root cert+key and + config in the barrier. +- **Unseal**: Loads config, root cert+key, and all issuers from the barrier + into memory. +- **Seal**: Zeroizes all in-memory private key material (root key, all issuer + keys), nils out pointers. + +#### Operations + +| Operation | Auth Required | Description | +|-----------------|---------------|------------------------------------------| +| `get-root` | None | Return root CA cert PEM | +| `get-chain` | None | Return full chain PEM (issuer + root) | +| `get-issuer` | None | Return issuer cert PEM | +| `create-issuer` | Admin | Generate intermediate CA signed by root | +| `delete-issuer` | Admin | Remove issuer and zeroize its 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 | Re-issue cert with same attributes | + +#### Certificate Profiles + +Three default profiles control leaf certificate key usage and validity: + +| Profile | Key Usage | Ext Key Usage | Default TTL | +|----------|----------------------------------------|----------------------------|-------------| +| `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 | + +Users can override TTL, key usages, ext key usages, and key algorithm at +issuance time. + +#### Issuance Flow + +1. Look up issuer by name +2. Start from named profile defaults, apply user overrides +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 + +#### Barrier Storage Layout + +``` +engine/ca/{mount}/config.json CA config (org, key algo, root expiry) +engine/ca/{mount}/root/cert.pem Root CA certificate +engine/ca/{mount}/root/key.pem Root CA private key +engine/ca/{mount}/issuers/{name}/cert.pem Issuer certificate +engine/ca/{mount}/issuers/{name}/key.pem Issuer private key +engine/ca/{mount}/issuers/{name}/config.json Issuer config +engine/ca/{mount}/certs/{serial_hex}.json Issued cert record (no private key) +``` + +#### CA Configuration + +Passed as `config` at mount time: + +| Field | Default | Description | +|-----------------|------------------|----------------------------------| +| `organization` | `"Metacircular"` | Root/issuer certificate O field | +| `country` | `""` | Root/issuer certificate C field | +| `key_algorithm` | `"ecdsa"` | Key type: ecdsa, rsa, ed25519 | +| `key_size` | `384` | Key size (e.g. 256/384 for ECDSA, 2048/4096 for RSA) | +| `root_expiry` | `"87600h"` | Root CA validity (10 years) | + +--- + +## API Surface + +### Seal/Unseal (Unauthenticated) + +| Method | Path | Description | Precondition | +|--------|---------------|--------------------------------|------------------| +| GET | `/v1/status` | Service state + version info | None | +| POST | `/v1/init` | First-time seal initialization | `uninitialized` | +| POST | `/v1/unseal` | Unseal with password | `sealed` | + +### Seal Control (Admin Only) + +| Method | Path | Description | +|--------|-------------|-------------------------| +| POST | `/v1/seal` | Seal service & engines | + +### Authentication + +| Method | Path | Description | Auth Required | +|--------|--------------------|--------------------------|---------------| +| POST | `/v1/auth/login` | MCIAS login → token | No | +| POST | `/v1/auth/logout` | Invalidate token | Yes | +| GET | `/v1/auth/tokeninfo` | Current user info | Yes | + +### Engines (Authenticated) + +| Method | Path | Description | Auth | +|--------|-----------------------|---------------------------|-----------| +| GET | `/v1/engine/mounts` | List mounted engines | User | +| POST | `/v1/engine/mount` | Create new engine mount | Admin | +| POST | `/v1/engine/unmount` | Remove engine mount | Admin | +| POST | `/v1/engine/request` | Route request to engine | User | + +The mount endpoint accepts `{name, type, config}` where `config` is an +engine-type-specific configuration object. The request endpoint accepts +`{mount, operation, path, data}` and populates `CallerInfo` from the +authenticated user's token. + +### Public PKI (Unauthenticated, Unsealed Required) + +| Method | Path | Description | +|--------|-------------------------------------|-------------------------------| +| GET | `/v1/pki/{mount}/ca` | Root CA certificate (PEM) | +| GET | `/v1/pki/{mount}/ca/chain?issuer=` | Full chain: issuer + root (PEM) | +| GET | `/v1/pki/{mount}/issuer/{name}` | Issuer certificate (PEM) | + +These routes serve certificates with `Content-Type: application/x-pem-file`, +allowing systems to bootstrap TLS trust without authentication. The mount +must be of type `ca`; returns 404 otherwise. + +### Policy (Admin Only) + +| Method | Path | Description | +|--------|-----------------------|---------------------| +| GET | `/v1/policy/rules` | List all rules | +| POST | `/v1/policy/rules` | Create a rule | +| GET | `/v1/policy/rule?id=` | Get rule by ID | +| DELETE | `/v1/policy/rule?id=` | Delete rule by ID | + +### Error Responses + +All API errors return JSON: + +```json +{"error": "description of what went wrong"} +``` + +HTTP status codes: +- `401` — missing or invalid token +- `403` — insufficient privileges +- `412` — service not initialized +- `503` — service is sealed + +--- + +## Web Interface + +Metacrypt includes an HTMX-powered web UI for basic operations: + +| Route | Purpose | +|---------------|--------------------------------------------------| +| `/` | Redirects based on service state | +| `/init` | Password setup form (first-time only) | +| `/unseal` | Password entry to unseal | +| `/login` | MCIAS login form (username, password, TOTP) | +| `/dashboard` | Engine mounts, service state, admin controls | + +The dashboard shows mounted engines, the service state, and (for admins) a seal +button. Templates use Go's `html/template` with a shared layout. HTMX provides +form submission without full page reloads. + +--- + +## Database Schema + +SQLite with WAL mode, foreign keys enabled, 5-second busy timeout. + +### Tables + +**`seal_config`** — Single row storing the encrypted master key material. + +| Column | Type | Description | +|-----------------|----------|--------------------------------------| +| id | INTEGER | Always 1 (enforced primary key) | +| encrypted_mek | BLOB | MEK encrypted with KWK | +| kdf_salt | BLOB | 32-byte Argon2id salt | +| argon2_time | INTEGER | Argon2id time cost | +| argon2_memory | INTEGER | Argon2id memory cost (KiB) | +| argon2_threads | INTEGER | Argon2id parallelism | +| initialized_at | DATETIME | Timestamp of initialization | + +**`barrier_entries`** — Encrypted key-value store. + +| Column | Type | Description | +|-------------|----------|-----------------------------------------| +| path | TEXT | Primary key; hierarchical path | +| value | BLOB | AES-256-GCM encrypted value | +| created_at | DATETIME | Row creation time | +| updated_at | DATETIME | Last modification time | + +**`schema_migrations`** — Tracks applied schema versions. + +| Column | Type | Description | +|-------------|----------|--------------------------| +| version | INTEGER | Migration version number | +| applied_at | DATETIME | When applied | + +Migrations are idempotent and run sequentially at startup. + +--- + +## Configuration + +TOML configuration with environment variable overrides (`METACRYPT_*`). + +```toml +[server] +listen_addr = ":8443" # required +tls_cert = "/path/cert.pem" # required +tls_key = "/path/key.pem" # required + +[database] +path = "/path/metacrypt.db" # required + +[mcias] +server_url = "https://mcias.metacircular.net:8443" # required +ca_cert = "/path/ca.pem" # optional, for custom CA + +[seal] +argon2_time = 3 # default: 3 +argon2_memory = 131072 # default: 128 MiB (in KiB) +argon2_threads = 4 # default: 4 + +[log] +level = "info" # default: "info" +``` + +Required fields are validated at startup; the server refuses to start if any +are missing. + +--- + +## Deployment + +### Docker + +Multi-stage build: + +1. **Builder stage**: Go compilation with symbols stripped (`-s -w`) +2. **Runtime stage**: Alpine 3.21, non-root `metacrypt` user + +``` +VOLUME /data # config, certs, database +EXPOSE 8443 +ENTRYPOINT ["metacrypt", "server", "--config", "/data/metacrypt.toml"] +``` + +### TLS Configuration + +- Minimum TLS version: 1.2 +- Cipher suites: `TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384`, + `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384` +- Timeouts: read 30s, write 30s, idle 120s + +### CLI Commands + +| Command | Purpose | +|------------|-------------------------------------------| +| `server` | Start the HTTPS server | +| `init` | Interactive first-time seal setup | +| `status` | Query a running server's state | +| `snapshot` | Create a consistent database backup (VACUUM INTO) | + +### Graceful Shutdown + +The server handles `SIGINT` and `SIGTERM` signals, sealing all engines and +closing connections before exit. + +--- + +## Security Model + +### Threat Mitigations + +| Threat | Mitigation | +|-----------------------------|-------------------------------------------------------------| +| Database theft | All barrier values encrypted with MEK (AES-256-GCM) | +| Brute-force unseal | Argon2id (128 MiB memory-hard), rate limiting (5/min + lockout) | +| MEK exposure | Held in memory only when unsealed; zeroized on seal | +| Token theft | 30-second cache TTL; MCIAS-backed validation | +| Privilege escalation | Default-deny policy; admin bypass only for MCIAS admin role | +| Nonce reuse | Fresh random nonce per encryption operation | +| Timing attacks | Constant-time comparison for passwords and tokens | +| Unauthorized access at rest | Database file permissions 0600; non-root container user | +| TLS downgrade | Minimum TLS 1.2; only AEAD cipher suites | +| CA key compromise | CA/issuer keys encrypted in barrier; zeroized on seal; two-tier PKI limits blast radius | +| Leaf key leakage via storage | Issued cert private keys never persisted; only returned to requester | + +### Security Invariants + +1. The MEK never leaves process memory and is never logged or serialized + in plaintext. +2. The seal password is never stored; only its Argon2id-derived output is used + transiently. +3. All barrier writes produce fresh ciphertexts (random nonce per encryption). +4. The service is fail-closed: a sealed barrier rejects all operations. +5. Admin privileges are determined solely by MCIAS role membership; + Metacrypt has no local user database. +6. Issued certificate private keys are returned to the caller but **never + stored** in the barrier. Only cert metadata is persisted. +7. CA and issuer private keys are encrypted at rest in the barrier and + zeroized from memory on seal (explicit overwrite of ECDSA `D`, RSA `D` + and primes, Ed25519 key bytes). + +--- + +## Future Work + +### Remaining Engine Implementations + +- **SSH CA Engine** — Sign SSH host and user certificates +- **Transit Engine** — Encrypt/decrypt payloads on behalf of applications + (envelope encryption); key rotation +- **User Engine** — Key exchange and encryption between Metacircular users + +### CA Engine Enhancements + +- **CRL management** — Certificate revocation lists +- **OCSP responder** — Online certificate status checking +- **Certificate templates** — Admin-defined custom profiles beyond server/client/peer + +### Planned Capabilities + +- **gRPC API** — In addition to the JSON REST API (config field already reserved) +- **Post-quantum readiness** — Hybrid key exchange (ML-KEM + ECDH); the + versioned ciphertext format and engine interface are designed for algorithm + agility +- **Key rotation** — MEK and per-engine DEK rotation without re-sealing +- **Audit logging** — Tamper-evident log of all cryptographic operations +- **Engine persistence** — Auto-remounting engines from barrier state on unseal + +### Public Key Algorithms + +The CA engine supports ECDSA (P-256, P-384, P-521), RSA, and Ed25519 for +certificate key pairs, configurable per-CA and overridable per-issuer or +per-issuance. The system is designed for algorithm agility to support future +post-quantum algorithms. diff --git a/CLAUDE.md b/CLAUDE.md index b647e97..ad6884d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,3 +20,32 @@ go vet ./... # Static analysis - **Storage**: SQLite database with an encrypted storage barrier (similar to HashiCorp Vault) - **Seal/Unseal**: Single password unseals the service; a master encryption key serves as a key-encryption key (KEK) to decrypt per-engine data encryption keys - **Auth**: MCIAS integration; MCIAS admin users get admin privileges on this service + +## Project Structure + +``` +. +├── cmd/metacrypt/ # CLI entry point (server, init, status, snapshot) +├── deploy/ +│ ├── docker/ # Docker Compose configuration +│ ├── examples/ # Example config files +│ ├── scripts/ # Deployment scripts +│ └── systemd/ # systemd unit files +├── internal/ +│ ├── auth/ # MCIAS token authentication & caching +│ ├── barrier/ # Encrypted key-value storage abstraction +│ ├── config/ # TOML configuration loading & validation +│ ├── crypto/ # Low-level cryptographic primitives +│ ├── db/ # SQLite setup & schema migrations +│ ├── engine/ # Pluggable engine registry & interface +│ ├── policy/ # Priority-based ACL engine +│ ├── seal/ # Seal/unseal state machine +│ └── server/ # HTTP server, routes, middleware +├── proto/metacrypt/ # Protobuf/gRPC definitions +├── web/ +│ ├── static/ # CSS, HTMX +│ └── templates/ # Go HTML templates +├── Dockerfile +├── Makefile +└── metacrypt.toml.example +``` diff --git a/Dockerfile b/Dockerfile index c58908e..dd24ab4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,22 +13,23 @@ FROM alpine:3.21 RUN apk add --no-cache ca-certificates tzdata \ && addgroup -S metacrypt \ - && adduser -S -G metacrypt -h /metacrypt -s /sbin/nologin metacrypt + && adduser -S -G metacrypt -h /srv/metacrypt -s /sbin/nologin metacrypt \ + && mkdir -p /srv/metacrypt && chown metacrypt:metacrypt /srv/metacrypt COPY --from=builder /metacrypt /usr/local/bin/metacrypt -COPY web/ /metacrypt/web/ +COPY web/ /srv/metacrypt/web/ -# /data is the single volume mount point. +# /srv/metacrypt is the single volume mount point. # It must contain: # metacrypt.toml — configuration file # certs/ — TLS certificate and key # metacrypt.db — created automatically on first run -VOLUME /data -WORKDIR /data +VOLUME /srv/metacrypt +WORKDIR /srv/metacrypt EXPOSE 8443 USER metacrypt ENTRYPOINT ["metacrypt"] -CMD ["server", "--config", "/data/metacrypt.toml"] +CMD ["server", "--config", "/srv/metacrypt/metacrypt.toml"] diff --git a/PKI-ENGINE-PLAN.md b/PKI-ENGINE-PLAN.md new file mode 100644 index 0000000..0dcabfa --- /dev/null +++ b/PKI-ENGINE-PLAN.md @@ -0,0 +1,194 @@ +# 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/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: + ```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`** +- Import `git.wntrmute.dev/kyle/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 + +```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) | diff --git a/RUNBOOK.md b/RUNBOOK.md index bcd9989..f39348b 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -51,9 +51,10 @@ This creates: | Path | Purpose | |---|---| | `/usr/local/bin/metacrypt` | Binary | -| `/etc/metacrypt/metacrypt.toml` | Configuration | -| `/etc/metacrypt/certs/` | TLS certificates | -| `/var/lib/metacrypt/` | Database and backups | +| `/srv/metacrypt/metacrypt.toml` | Configuration | +| `/srv/metacrypt/certs/` | TLS certificates | +| `/srv/metacrypt/backups/` | Database backups | +| `/srv/metacrypt/metacrypt.db` | Database (created on first run) | ### Docker Install @@ -65,7 +66,7 @@ make docker docker compose -f deploy/docker/docker-compose.yml up -d ``` -The Docker container mounts a single volume at `/data` which must contain: +The Docker container mounts a single volume at `/srv/metacrypt` which must contain: | File | Required | Description | |---|---|---| @@ -81,8 +82,8 @@ To prepare a Docker volume: docker volume create metacrypt-data # Copy files into the volume -docker run --rm -v metacrypt-data:/data -v $(pwd)/deploy/examples:/src alpine \ - sh -c "cp /src/metacrypt-docker.toml /data/metacrypt.toml && mkdir -p /data/certs" +docker run --rm -v metacrypt-data:/srv/metacrypt -v $(pwd)/deploy/examples:/src alpine \ + sh -c "cp /src/metacrypt-docker.toml /srv/metacrypt/metacrypt.toml && mkdir -p /srv/metacrypt/certs" # Then copy your TLS certs into the volume the same way ``` @@ -96,7 +97,7 @@ Configuration is loaded from TOML. The config file location is determined by (in 1. `--config` flag 2. `METACRYPT_CONFIG` environment variable (via viper) 3. `metacrypt.toml` in the current directory -4. `/etc/metacrypt/metacrypt.toml` +4. `/srv/metacrypt/metacrypt.toml` All config values can be overridden via environment variables with the `METACRYPT_` prefix (e.g., `METACRYPT_SERVER_LISTEN_ADDR`). @@ -131,7 +132,7 @@ For production, use certificates from your internal CA or a public CA. ### Option A: CLI (recommended for servers) ```bash -metacrypt init --config /etc/metacrypt/metacrypt.toml +metacrypt init --config /srv/metacrypt/metacrypt.toml ``` This prompts for a seal password, generates the master encryption key, and stores the encrypted MEK in the database. The service is left in the unsealed state. @@ -161,7 +162,7 @@ sudo systemctl start metacrypt docker compose -f deploy/docker/docker-compose.yml up -d # Manual -metacrypt server --config /etc/metacrypt/metacrypt.toml +metacrypt server --config /srv/metacrypt/metacrypt.toml ``` The service starts **sealed**. It must be unsealed before it can serve requests. @@ -230,7 +231,7 @@ Users with the MCIAS `admin` role automatically get admin privileges in Metacryp ```bash # CLI -metacrypt snapshot --config /etc/metacrypt/metacrypt.toml --output /var/lib/metacrypt/backups/metacrypt-$(date +%Y%m%d).db +metacrypt snapshot --config /srv/metacrypt/metacrypt.toml --output /srv/metacrypt/backups/metacrypt-$(date +%Y%m%d).db # Using the backup script (with 30-day retention) deploy/scripts/backup.sh 30 @@ -249,8 +250,8 @@ This runs a backup daily at 02:00 with up to 5 minutes of jitter. ### Restoring from Backup 1. Stop the service: `systemctl stop metacrypt` -2. Replace the database: `cp /var/lib/metacrypt/backups/metacrypt-20260314.db /var/lib/metacrypt/metacrypt.db` -3. Fix permissions: `chown metacrypt:metacrypt /var/lib/metacrypt/metacrypt.db && chmod 0600 /var/lib/metacrypt/metacrypt.db` +2. Replace the database: `cp /srv/metacrypt/backups/metacrypt-20260314.db /srv/metacrypt/metacrypt.db` +3. Fix permissions: `chown metacrypt:metacrypt /srv/metacrypt/metacrypt.db && chmod 0600 /srv/metacrypt/metacrypt.db` 4. Start the service: `systemctl start metacrypt` 5. Unseal with the original seal password @@ -290,8 +291,8 @@ journalctl -u metacrypt --priority=err --since="1 hour ago" |---|---|---| | Service state | `GET /v1/status` | `state != "unsealed"` for more than a few minutes after restart | | TLS certificate expiry | External cert checker | < 30 days to expiry | -| Database file size | `stat /var/lib/metacrypt/metacrypt.db` | Unexpectedly large growth | -| Backup age | `find /var/lib/metacrypt/backups -name '*.db' -mtime +2` | No backup in 48 hours | +| Database file size | `stat /srv/metacrypt/metacrypt.db` | Unexpectedly large growth | +| Backup age | `find /srv/metacrypt/backups -name '*.db' -mtime +2` | No backup in 48 hours | | MCIAS connectivity | Login attempt | Auth failures not caused by bad credentials | --- @@ -303,7 +304,7 @@ journalctl -u metacrypt --priority=err --since="1 hour ago" | Symptom | Cause | Fix | |---|---|---| | `config: server.tls_cert is required` | Missing or invalid config | Check config file path and contents | -| `db: create file: permission denied` | Wrong permissions on data dir | `chown -R metacrypt:metacrypt /var/lib/metacrypt` | +| `db: create file: permission denied` | Wrong permissions on data dir | `chown -R metacrypt:metacrypt /srv/metacrypt` | | `server: tls: failed to find any PEM data` | Bad cert/key files | Verify PEM format: `openssl x509 -in server.crt -text -noout` | ### Unseal fails @@ -326,14 +327,14 @@ journalctl -u metacrypt --priority=err --since="1 hour ago" ```bash # Check database integrity -sqlite3 /var/lib/metacrypt/metacrypt.db "PRAGMA integrity_check;" +sqlite3 /srv/metacrypt/metacrypt.db "PRAGMA integrity_check;" # Check WAL mode -sqlite3 /var/lib/metacrypt/metacrypt.db "PRAGMA journal_mode;" +sqlite3 /srv/metacrypt/metacrypt.db "PRAGMA journal_mode;" # Should return: wal # Check file permissions -ls -la /var/lib/metacrypt/metacrypt.db +ls -la /srv/metacrypt/metacrypt.db # Should be: -rw------- metacrypt metacrypt ``` @@ -360,18 +361,18 @@ Sealing the service (`POST /v1/seal`) explicitly zeroizes all key material from | Path | Mode | Owner | |---|---|---| -| `/etc/metacrypt/metacrypt.toml` | 0640 | metacrypt:metacrypt | -| `/etc/metacrypt/certs/server.key` | 0600 | metacrypt:metacrypt | -| `/var/lib/metacrypt/metacrypt.db` | 0600 | metacrypt:metacrypt | -| `/var/lib/metacrypt/backups/` | 0700 | metacrypt:metacrypt | +| `/srv/metacrypt/metacrypt.toml` | 0640 | metacrypt:metacrypt | +| `/srv/metacrypt/certs/server.key` | 0600 | metacrypt:metacrypt | +| `/srv/metacrypt/metacrypt.db` | 0600 | metacrypt:metacrypt | +| `/srv/metacrypt/backups/` | 0700 | metacrypt:metacrypt | ### systemd Hardening -The provided service unit applies: `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`, `PrivateDevices`, `MemoryDenyWriteExecute`, and namespace restrictions. Only `/var/lib/metacrypt` is writable. +The provided service unit applies: `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`, `PrivateDevices`, `MemoryDenyWriteExecute`, and namespace restrictions. Only `/srv/metacrypt` is writable. ### Docker Security -The container runs as a non-root `metacrypt` user. The `/data` volume should be owned by the container's metacrypt UID (determined at build time). Do not run the container with `--privileged`. +The container runs as a non-root `metacrypt` user. The `/srv/metacrypt` volume should be owned by the container's metacrypt UID (determined at build time). Do not run the container with `--privileged`. --- @@ -388,7 +389,7 @@ The container runs as a non-root `metacrypt` user. The `/data` volume should be There is no online password rotation in Phase 1. To change the seal password: -1. Create a backup: `metacrypt snapshot --output pre-rotation.db` +1. Create a backup: `metacrypt snapshot --output /srv/metacrypt/backups/pre-rotation.db` 2. Stop the service 3. Re-initialize with a new database and password 4. Migrate data from the old barrier (requires custom tooling or a future `metacrypt rekey` command) @@ -398,8 +399,8 @@ There is no online password rotation in Phase 1. To change the seal password: If the server is lost but you have a database backup and the seal password: 1. Install Metacrypt on a new server (see Installation) -2. Copy the backup database to `/var/lib/metacrypt/metacrypt.db` -3. Fix ownership: `chown metacrypt:metacrypt /var/lib/metacrypt/metacrypt.db` +2. Copy the backup database to `/srv/metacrypt/metacrypt.db` +3. Fix ownership: `chown metacrypt:metacrypt /srv/metacrypt/metacrypt.db` 4. Start the service and unseal with the original password The database backup contains the encrypted MEK and all barrier data. No additional secrets beyond the seal password are needed for recovery. @@ -407,7 +408,7 @@ The database backup contains the encrypted MEK and all barrier data. No addition ### Upgrading Metacrypt 1. Build or download the new binary -2. Create a backup: `metacrypt snapshot --output pre-upgrade.db` +2. Create a backup: `metacrypt snapshot --output /srv/metacrypt/backups/pre-upgrade.db` 3. Replace the binary: `install -m 0755 metacrypt /usr/local/bin/metacrypt` 4. Restart: `systemctl restart metacrypt` 5. Unseal and verify diff --git a/cmd/metacrypt/init.go b/cmd/metacrypt/init.go index af6eb77..1988eee 100644 --- a/cmd/metacrypt/init.go +++ b/cmd/metacrypt/init.go @@ -29,7 +29,7 @@ func init() { func runInit(cmd *cobra.Command, args []string) error { configPath := cfgFile if configPath == "" { - configPath = "metacrypt.toml" + configPath = "/srv/metacrypt/metacrypt.toml" } cfg, err := config.Load(configPath) diff --git a/cmd/metacrypt/root.go b/cmd/metacrypt/root.go index 5782341..7cf2f2c 100644 --- a/cmd/metacrypt/root.go +++ b/cmd/metacrypt/root.go @@ -15,7 +15,7 @@ var rootCmd = &cobra.Command{ func init() { cobra.OnInitialize(initConfig) - rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default metacrypt.toml)") + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default /srv/metacrypt/metacrypt.toml)") } func initConfig() { @@ -25,7 +25,7 @@ func initConfig() { viper.SetConfigName("metacrypt") viper.SetConfigType("toml") viper.AddConfigPath(".") - viper.AddConfigPath("/etc/metacrypt") + viper.AddConfigPath("/srv/metacrypt") } viper.AutomaticEnv() viper.SetEnvPrefix("METACRYPT") diff --git a/cmd/metacrypt/server.go b/cmd/metacrypt/server.go index f2539d7..369a577 100644 --- a/cmd/metacrypt/server.go +++ b/cmd/metacrypt/server.go @@ -15,6 +15,7 @@ import ( "git.wntrmute.dev/kyle/metacrypt/internal/config" "git.wntrmute.dev/kyle/metacrypt/internal/db" "git.wntrmute.dev/kyle/metacrypt/internal/engine" + "git.wntrmute.dev/kyle/metacrypt/internal/engine/ca" "git.wntrmute.dev/kyle/metacrypt/internal/policy" "git.wntrmute.dev/kyle/metacrypt/internal/seal" "git.wntrmute.dev/kyle/metacrypt/internal/server" @@ -36,7 +37,7 @@ func runServer(cmd *cobra.Command, args []string) error { configPath := cfgFile if configPath == "" { - configPath = "metacrypt.toml" + configPath = "/srv/metacrypt/metacrypt.toml" } cfg, err := config.Load(configPath) @@ -71,6 +72,7 @@ func runServer(cmd *cobra.Command, args []string) error { authenticator := auth.NewAuthenticator(mcClient) policyEngine := policy.NewEngine(b) engineRegistry := engine.NewRegistry(b) + engineRegistry.RegisterFactory(engine.EngineTypeCA, ca.NewCAEngine) srv := server.New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger) diff --git a/cmd/metacrypt/snapshot.go b/cmd/metacrypt/snapshot.go index e2130cd..82da51f 100644 --- a/cmd/metacrypt/snapshot.go +++ b/cmd/metacrypt/snapshot.go @@ -28,7 +28,7 @@ func init() { func runSnapshot(cmd *cobra.Command, args []string) error { configPath := cfgFile if configPath == "" { - configPath = "metacrypt.toml" + configPath = "/srv/metacrypt/metacrypt.toml" } cfg, err := config.Load(configPath) diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index e3a098e..be2a5b4 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -8,13 +8,13 @@ services: ports: - "8443:8443" volumes: - - metacrypt-data:/data - # To populate /data before first run, use an init container or + - metacrypt-data:/srv/metacrypt + # To populate /srv/metacrypt before first run, use an init container or # bind-mount a host directory instead of a named volume: # volumes: - # - ./data:/data + # - ./data:/srv/metacrypt healthcheck: - test: ["CMD", "metacrypt", "status", "--addr", "https://localhost:8443", "--ca-cert", "/data/certs/ca.crt"] + test: ["CMD", "metacrypt", "status", "--addr", "https://localhost:8443", "--ca-cert", "/srv/metacrypt/certs/ca.crt"] interval: 30s timeout: 5s retries: 3 diff --git a/deploy/examples/metacrypt-docker.toml b/deploy/examples/metacrypt-docker.toml index 06e2014..68c2eaf 100644 --- a/deploy/examples/metacrypt-docker.toml +++ b/deploy/examples/metacrypt-docker.toml @@ -1,17 +1,17 @@ # Metacrypt configuration for Docker deployment. -# Place this file at /data/metacrypt.toml inside the container volume. +# Place this file at /srv/metacrypt/metacrypt.toml inside the container volume. [server] listen_addr = ":8443" -tls_cert = "/data/certs/server.crt" -tls_key = "/data/certs/server.key" +tls_cert = "/srv/metacrypt/certs/server.crt" +tls_key = "/srv/metacrypt/certs/server.key" [database] -path = "/data/metacrypt.db" +path = "/srv/metacrypt/metacrypt.db" [mcias] server_url = "https://mcias.metacircular.net:8443" -# ca_cert = "/data/certs/mcias-ca.crt" +# ca_cert = "/srv/metacrypt/certs/mcias-ca.crt" [seal] # argon2_time = 3 diff --git a/deploy/examples/metacrypt.toml b/deploy/examples/metacrypt.toml index 2e07e51..86ae8b4 100644 --- a/deploy/examples/metacrypt.toml +++ b/deploy/examples/metacrypt.toml @@ -1,18 +1,18 @@ # Metacrypt production configuration -# Copy to /etc/metacrypt/metacrypt.toml and adjust for your environment. +# Copy to /srv/metacrypt/metacrypt.toml and adjust for your environment. [server] # Address to listen on. Use "0.0.0.0:8443" to listen on all interfaces. listen_addr = ":8443" # TLS certificate and key. Metacrypt always terminates TLS. -tls_cert = "/etc/metacrypt/certs/server.crt" -tls_key = "/etc/metacrypt/certs/server.key" +tls_cert = "/srv/metacrypt/certs/server.crt" +tls_key = "/srv/metacrypt/certs/server.key" [database] # SQLite database path. Created automatically on first run. # The directory must be writable by the metacrypt user. -path = "/var/lib/metacrypt/metacrypt.db" +path = "/srv/metacrypt/metacrypt.db" [mcias] # MCIAS server URL for authentication. @@ -20,7 +20,7 @@ server_url = "https://mcias.metacircular.net:8443" # CA certificate for verifying the MCIAS server's TLS certificate. # Omit if MCIAS uses a publicly trusted certificate. -# ca_cert = "/etc/metacrypt/certs/mcias-ca.crt" +# ca_cert = "/srv/metacrypt/certs/mcias-ca.crt" [seal] # Argon2id parameters for key derivation. diff --git a/deploy/scripts/backup.sh b/deploy/scripts/backup.sh index 6cb114b..fb610b0 100755 --- a/deploy/scripts/backup.sh +++ b/deploy/scripts/backup.sh @@ -7,8 +7,8 @@ # set -euo pipefail -CONFIG="${METACRYPT_CONFIG:-/etc/metacrypt/metacrypt.toml}" -BACKUP_DIR="${METACRYPT_BACKUP_DIR:-/var/lib/metacrypt/backups}" +CONFIG="${METACRYPT_CONFIG:-/srv/metacrypt/metacrypt.toml}" +BACKUP_DIR="${METACRYPT_BACKUP_DIR:-/srv/metacrypt/backups}" RETENTION_DAYS="${1:-30}" TIMESTAMP="$(date +%Y%m%d-%H%M%S)" BACKUP_FILE="${BACKUP_DIR}/metacrypt-${TIMESTAMP}.db" diff --git a/deploy/scripts/install.sh b/deploy/scripts/install.sh index 950afed..3162ae8 100755 --- a/deploy/scripts/install.sh +++ b/deploy/scripts/install.sh @@ -8,9 +8,9 @@ set -euo pipefail BINARY="${1:?Usage: $0 /path/to/metacrypt}" INSTALL_DIR="/usr/local/bin" -CONFIG_DIR="/etc/metacrypt" -DATA_DIR="/var/lib/metacrypt" -BACKUP_DIR="${DATA_DIR}/backups" +SRV_DIR="/srv/metacrypt" +BACKUP_DIR="${SRV_DIR}/backups" +CERTS_DIR="${SRV_DIR}/certs" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" DEPLOY_DIR="$(dirname "$SCRIPT_DIR")" @@ -19,25 +19,24 @@ if ! getent group metacrypt >/dev/null 2>&1; then groupadd --system metacrypt fi if ! getent passwd metacrypt >/dev/null 2>&1; then - useradd --system --gid metacrypt --home-dir "$DATA_DIR" --shell /usr/sbin/nologin metacrypt + useradd --system --gid metacrypt --home-dir "$SRV_DIR" --shell /usr/sbin/nologin metacrypt fi echo "==> Installing binary" install -m 0755 "$BINARY" "$INSTALL_DIR/metacrypt" echo "==> Creating directories" -install -d -m 0750 -o metacrypt -g metacrypt "$CONFIG_DIR" -install -d -m 0750 -o metacrypt -g metacrypt "$CONFIG_DIR/certs" -install -d -m 0700 -o metacrypt -g metacrypt "$DATA_DIR" +install -d -m 0750 -o metacrypt -g metacrypt "$SRV_DIR" +install -d -m 0750 -o metacrypt -g metacrypt "$CERTS_DIR" install -d -m 0700 -o metacrypt -g metacrypt "$BACKUP_DIR" echo "==> Installing configuration" -if [ ! -f "$CONFIG_DIR/metacrypt.toml" ]; then - install -m 0640 -o metacrypt -g metacrypt "$DEPLOY_DIR/examples/metacrypt.toml" "$CONFIG_DIR/metacrypt.toml" - echo " Installed default config to $CONFIG_DIR/metacrypt.toml" +if [ ! -f "$SRV_DIR/metacrypt.toml" ]; then + install -m 0640 -o metacrypt -g metacrypt "$DEPLOY_DIR/examples/metacrypt.toml" "$SRV_DIR/metacrypt.toml" + echo " Installed default config to $SRV_DIR/metacrypt.toml" echo " >>> Edit this file before starting the service <<<" else - echo " Config already exists at $CONFIG_DIR/metacrypt.toml — skipping" + echo " Config already exists at $SRV_DIR/metacrypt.toml — skipping" fi echo "==> Installing systemd units" @@ -49,8 +48,8 @@ systemctl daemon-reload echo "==> Done" echo "" echo "Next steps:" -echo " 1. Place TLS cert and key in $CONFIG_DIR/certs/" -echo " 2. Edit $CONFIG_DIR/metacrypt.toml" -echo " 3. Initialize: metacrypt init --config $CONFIG_DIR/metacrypt.toml" +echo " 1. Place TLS cert and key in $CERTS_DIR/" +echo " 2. Edit $SRV_DIR/metacrypt.toml" +echo " 3. Initialize: metacrypt init --config $SRV_DIR/metacrypt.toml" echo " 4. Start: systemctl enable --now metacrypt" echo " 5. Backups: systemctl enable --now metacrypt-backup.timer" diff --git a/deploy/systemd/metacrypt-backup.service b/deploy/systemd/metacrypt-backup.service index e830922..648fc60 100644 --- a/deploy/systemd/metacrypt-backup.service +++ b/deploy/systemd/metacrypt-backup.service @@ -6,10 +6,10 @@ After=metacrypt.service Type=oneshot User=metacrypt Group=metacrypt -ExecStart=/usr/local/bin/metacrypt snapshot --config /etc/metacrypt/metacrypt.toml --output /var/lib/metacrypt/backups/metacrypt-%i.db +ExecStart=/usr/local/bin/metacrypt snapshot --config /srv/metacrypt/metacrypt.toml --output /srv/metacrypt/backups/metacrypt-%i.db NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true -ReadWritePaths=/var/lib/metacrypt +ReadWritePaths=/srv/metacrypt diff --git a/deploy/systemd/metacrypt.service b/deploy/systemd/metacrypt.service index aeb242d..4a2b92c 100644 --- a/deploy/systemd/metacrypt.service +++ b/deploy/systemd/metacrypt.service @@ -9,7 +9,7 @@ Type=simple User=metacrypt Group=metacrypt -ExecStart=/usr/local/bin/metacrypt server --config /etc/metacrypt/metacrypt.toml +ExecStart=/usr/local/bin/metacrypt server --config /srv/metacrypt/metacrypt.toml ExecReload=/bin/kill -HUP $MAINPID Restart=on-failure @@ -30,8 +30,8 @@ LockPersonality=true MemoryDenyWriteExecute=true RestrictRealtime=true -# Allow write access to the database directory and log -ReadWritePaths=/var/lib/metacrypt +# Allow write access to the data directory +ReadWritePaths=/srv/metacrypt # Limit file descriptor count LimitNOFILE=65535 diff --git a/go.mod b/go.mod index 6fbf891..b248bc9 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,10 @@ go 1.25.0 replace git.wntrmute.dev/kyle/mcias/clients/go => /Users/kyle/src/mcias/clients/go +replace git.wntrmute.dev/kyle/goutils => /Users/kyle/src/goutils + require ( + git.wntrmute.dev/kyle/goutils v0.0.0-00010101000000-000000000000 git.wntrmute.dev/kyle/mcias/clients/go v0.0.0-00010101000000-000000000000 github.com/pelletier/go-toml/v2 v2.2.4 github.com/spf13/cobra v1.10.2 diff --git a/internal/engine/ca/ca.go b/internal/engine/ca/ca.go new file mode 100644 index 0000000..7863f64 --- /dev/null +++ b/internal/engine/ca/ca.go @@ -0,0 +1,1023 @@ +// Package ca implements the CA (PKI) engine for X.509 certificate issuance. +package ca + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net" + "strings" + "sync" + "time" + + "git.wntrmute.dev/kyle/goutils/certlib/certgen" + + "git.wntrmute.dev/kyle/metacrypt/internal/barrier" + "git.wntrmute.dev/kyle/metacrypt/internal/engine" +) + +var ( + ErrSealed = errors.New("ca: engine is sealed") + ErrIssuerNotFound = errors.New("ca: issuer not found") + ErrIssuerExists = errors.New("ca: issuer already exists") + ErrCertNotFound = errors.New("ca: certificate not found") + ErrUnknownProfile = errors.New("ca: unknown profile") + ErrForbidden = errors.New("ca: forbidden") + ErrUnauthorized = errors.New("ca: authentication required") +) + +// issuerState holds in-memory state for a loaded issuer. +type issuerState struct { + cert *x509.Certificate + key crypto.PrivateKey + config *IssuerConfig +} + +// CAEngine implements the CA (PKI) engine. +type CAEngine struct { + mu sync.RWMutex + barrier barrier.Barrier + mountPath string + config *CAConfig + rootCert *x509.Certificate + rootKey crypto.PrivateKey + issuers map[string]*issuerState +} + +// NewCAEngine creates a new CA engine instance. +func NewCAEngine() engine.Engine { + return &CAEngine{ + issuers: make(map[string]*issuerState), + } +} + +func (e *CAEngine) Type() engine.EngineType { + return engine.EngineTypeCA +} + +// Initialize sets up the CA engine for first use: generates a self-signed root CA. +func (e *CAEngine) Initialize(ctx context.Context, b barrier.Barrier, mountPath string, config map[string]interface{}) error { + e.mu.Lock() + defer e.mu.Unlock() + + e.barrier = b + e.mountPath = mountPath + + cfg := defaultCAConfig() + if config != nil { + if err := mapToCAConfig(config, cfg); err != nil { + return fmt.Errorf("ca: parse config: %w", err) + } + } + e.config = cfg + + // Store config. + configData, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("ca: marshal config: %w", err) + } + if err := b.Put(ctx, mountPath+"config.json", configData); err != nil { + 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, + }, + } + + rootCert, rootKey, err := certgen.GenerateSelfSigned(creq) + if err != nil { + return fmt.Errorf("ca: generate root CA: %w", err) + } + + // 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 err := b.Put(ctx, mountPath+"root/cert.pem", certPEM); err != nil { + return fmt.Errorf("ca: store root cert: %w", err) + } + if err := b.Put(ctx, mountPath+"root/key.pem", keyPEM); err != nil { + return fmt.Errorf("ca: store root key: %w", err) + } + + e.rootCert = rootCert + e.rootKey = rootKey + + return nil +} + +// Unseal loads the CA state from the barrier into memory. +func (e *CAEngine) Unseal(ctx context.Context, b barrier.Barrier, mountPath string) error { + e.mu.Lock() + defer e.mu.Unlock() + + e.barrier = b + e.mountPath = mountPath + + // Load config. + configData, err := b.Get(ctx, mountPath+"config.json") + if err != nil { + return fmt.Errorf("ca: load config: %w", err) + } + var cfg CAConfig + if err := json.Unmarshal(configData, &cfg); err != nil { + return fmt.Errorf("ca: parse config: %w", err) + } + e.config = &cfg + + // Load root cert and key. + certPEM, err := b.Get(ctx, mountPath+"root/cert.pem") + if err != nil { + return fmt.Errorf("ca: load root cert: %w", err) + } + keyPEM, err := b.Get(ctx, mountPath+"root/key.pem") + if err != nil { + return fmt.Errorf("ca: load root key: %w", err) + } + + rootCert, err := parseCertPEM(certPEM) + if err != nil { + return fmt.Errorf("ca: parse root cert: %w", err) + } + rootKey, err := parsePrivateKeyPEM(keyPEM) + if err != nil { + return fmt.Errorf("ca: parse root key: %w", err) + } + + e.rootCert = rootCert + e.rootKey = rootKey + e.issuers = make(map[string]*issuerState) + + // Load all issuers. + issuerPaths, err := b.List(ctx, mountPath+"issuers/") + if err != nil { + return fmt.Errorf("ca: list issuers: %w", err) + } + + // Collect unique issuer names from paths like "name/cert.pem", "name/key.pem", "name/config.json". + issuerNames := make(map[string]bool) + for _, p := range issuerPaths { + parts := strings.SplitN(p, "/", 2) + if len(parts) > 0 && parts[0] != "" { + issuerNames[parts[0]] = true + } + } + + for name := range issuerNames { + is, err := e.loadIssuer(ctx, b, mountPath, name) + if err != nil { + return fmt.Errorf("ca: load issuer %q: %w", name, err) + } + e.issuers[name] = is + } + + return nil +} + +func (e *CAEngine) loadIssuer(ctx context.Context, b barrier.Barrier, mountPath, name string) (*issuerState, error) { + prefix := mountPath + "issuers/" + name + "/" + + certPEM, err := b.Get(ctx, prefix+"cert.pem") + if err != nil { + return nil, fmt.Errorf("load cert: %w", err) + } + keyPEM, err := b.Get(ctx, prefix+"key.pem") + if err != nil { + return nil, fmt.Errorf("load key: %w", err) + } + configData, err := b.Get(ctx, prefix+"config.json") + if err != nil { + return nil, fmt.Errorf("load config: %w", err) + } + + cert, err := parseCertPEM(certPEM) + if err != nil { + return nil, fmt.Errorf("parse cert: %w", err) + } + key, err := parsePrivateKeyPEM(keyPEM) + if err != nil { + return nil, fmt.Errorf("parse key: %w", err) + } + var cfg IssuerConfig + if err := json.Unmarshal(configData, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + return &issuerState{cert: cert, key: key, config: &cfg}, nil +} + +// Seal zeroizes all in-memory key material. +func (e *CAEngine) Seal() error { + e.mu.Lock() + defer e.mu.Unlock() + + zeroizeKey(e.rootKey) + e.rootKey = nil + e.rootCert = nil + e.config = nil + + for name, is := range e.issuers { + zeroizeKey(is.key) + delete(e.issuers, name) + } + e.issuers = nil + + return nil +} + +// HandleRequest dispatches CA operations. +func (e *CAEngine) HandleRequest(ctx context.Context, req *engine.Request) (*engine.Response, error) { + switch req.Operation { + case "get-root": + return e.handleGetRoot(ctx) + case "get-chain": + return e.handleGetChain(ctx, req) + case "get-issuer": + return e.handleGetIssuer(ctx, req) + case "create-issuer": + return e.handleCreateIssuer(ctx, req) + case "delete-issuer": + return e.handleDeleteIssuer(ctx, req) + case "list-issuers": + return e.handleListIssuers(ctx, req) + case "issue": + return e.handleIssue(ctx, req) + case "get-cert": + return e.handleGetCert(ctx, req) + case "list-certs": + return e.handleListCerts(ctx, req) + case "renew": + return e.handleRenew(ctx, req) + default: + return nil, fmt.Errorf("ca: unknown operation: %s", req.Operation) + } +} + +// --- Public methods for unauthenticated PKI routes --- + +// GetRootCertPEM returns the root CA certificate in PEM format. +func (e *CAEngine) GetRootCertPEM() ([]byte, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + if e.rootCert == nil { + return nil, ErrSealed + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: e.rootCert.Raw}), nil +} + +// GetIssuerCertPEM returns the named issuer's certificate in PEM format. +func (e *CAEngine) GetIssuerCertPEM(name string) ([]byte, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + if e.rootCert == nil { + return nil, ErrSealed + } + is, ok := e.issuers[name] + if !ok { + return nil, ErrIssuerNotFound + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: is.cert.Raw}), nil +} + +// GetChainPEM returns the full certificate chain (issuer + root) in PEM format. +func (e *CAEngine) GetChainPEM(issuerName string) ([]byte, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + if e.rootCert == nil { + return nil, ErrSealed + } + is, ok := e.issuers[issuerName] + if !ok { + return nil, ErrIssuerNotFound + } + + var chain []byte + chain = append(chain, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: is.cert.Raw})...) + chain = append(chain, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: e.rootCert.Raw})...) + return chain, nil +} + +// --- Operation handlers --- + +func (e *CAEngine) handleGetRoot(_ context.Context) (*engine.Response, error) { + e.mu.RLock() + defer e.mu.RUnlock() + + if e.rootCert == nil { + return nil, ErrSealed + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: e.rootCert.Raw}) + return &engine.Response{ + Data: map[string]interface{}{ + "cert_pem": string(certPEM), + }, + }, nil +} + +func (e *CAEngine) handleGetChain(_ context.Context, req *engine.Request) (*engine.Response, error) { + issuerName, _ := req.Data["issuer"].(string) + if issuerName == "" { + issuerName = req.Path + } + + chain, err := e.GetChainPEM(issuerName) + if err != nil { + return nil, err + } + return &engine.Response{ + Data: map[string]interface{}{ + "chain_pem": string(chain), + }, + }, nil +} + +func (e *CAEngine) handleGetIssuer(_ context.Context, req *engine.Request) (*engine.Response, error) { + name := req.Path + + certPEM, err := e.GetIssuerCertPEM(name) + if err != nil { + return nil, err + } + return &engine.Response{ + Data: map[string]interface{}{ + "cert_pem": string(certPEM), + }, + }, nil +} + +func (e *CAEngine) handleCreateIssuer(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsAdmin { + return nil, ErrForbidden + } + + name, _ := req.Data["name"].(string) + if name == "" { + return nil, fmt.Errorf("ca: issuer name is required") + } + + e.mu.Lock() + defer e.mu.Unlock() + + if e.rootCert == nil { + return nil, ErrSealed + } + + if _, exists := e.issuers[name]; exists { + return nil, ErrIssuerExists + } + + // Determine key spec: use issuer-specific overrides or fall back to CA config. + keyAlg := e.config.KeyAlgorithm + keySize := e.config.KeySize + if v, ok := req.Data["key_algorithm"].(string); ok && v != "" { + keyAlg = v + } + if v, ok := req.Data["key_size"].(float64); ok { + keySize = int(v) + } + + expiry := "43800h" // 5 years default + if v, ok := req.Data["expiry"].(string); ok && v != "" { + expiry = v + } + + maxTTL := "2160h" // 90 days default + if v, ok := req.Data["max_ttl"].(string); ok && v != "" { + maxTTL = v + } + + // Generate issuer key pair and CSR. + ks := certgen.KeySpec{Algorithm: keyAlg, Size: keySize} + _, priv, err := ks.Generate() + if err != nil { + return nil, fmt.Errorf("ca: generate issuer key: %w", err) + } + + creq := certgen.CertificateRequest{ + KeySpec: ks, + Subject: certgen.Subject{ + CommonName: name, + Organization: e.config.Organization, + Country: e.config.Country, + }, + } + + csr, err := creq.Request(priv) + if err != nil { + return nil, fmt.Errorf("ca: create issuer CSR: %w", err) + } + + // Sign with root CA using an intermediate CA profile. + profile := certgen.Profile{ + IsCA: true, + PathLen: 0, + KeyUse: []string{"cert sign", "crl sign"}, + Expiry: expiry, + } + + issuerCert, err := profile.SignRequest(e.rootCert, csr, e.rootKey) + if err != nil { + return nil, fmt.Errorf("ca: sign issuer cert: %w", err) + } + + // Store in barrier. + prefix := e.mountPath + "issuers/" + name + "/" + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: issuerCert.Raw}) + keyPEM, err := marshalPrivateKey(priv) + if err != nil { + return nil, fmt.Errorf("ca: marshal issuer key: %w", err) + } + + issuerCfg := &IssuerConfig{ + Name: name, + KeyAlgorithm: keyAlg, + KeySize: keySize, + Expiry: expiry, + MaxTTL: maxTTL, + CreatedBy: req.CallerInfo.Username, + CreatedAt: time.Now(), + } + cfgData, err := json.Marshal(issuerCfg) + if err != nil { + return nil, fmt.Errorf("ca: marshal issuer config: %w", err) + } + + if err := e.barrier.Put(ctx, prefix+"cert.pem", certPEM); err != nil { + return nil, fmt.Errorf("ca: store issuer cert: %w", err) + } + if err := e.barrier.Put(ctx, prefix+"key.pem", keyPEM); err != nil { + return nil, fmt.Errorf("ca: store issuer key: %w", err) + } + if err := e.barrier.Put(ctx, prefix+"config.json", cfgData); err != nil { + return nil, fmt.Errorf("ca: store issuer config: %w", err) + } + + e.issuers[name] = &issuerState{cert: issuerCert, key: priv, config: issuerCfg} + + return &engine.Response{ + Data: map[string]interface{}{ + "name": name, + "cert_pem": string(certPEM), + }, + }, nil +} + +func (e *CAEngine) handleDeleteIssuer(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + if !req.CallerInfo.IsAdmin { + return nil, ErrForbidden + } + + name, _ := req.Data["name"].(string) + if name == "" { + name = req.Path + } + + e.mu.Lock() + defer e.mu.Unlock() + + if e.rootCert == nil { + return nil, ErrSealed + } + + is, exists := e.issuers[name] + if !exists { + return nil, ErrIssuerNotFound + } + + // Zeroize key material. + zeroizeKey(is.key) + + // Delete from barrier. + prefix := e.mountPath + "issuers/" + name + "/" + for _, suffix := range []string{"cert.pem", "key.pem", "config.json"} { + if err := e.barrier.Delete(ctx, prefix+suffix); err != nil { + return nil, fmt.Errorf("ca: delete issuer %s: %w", suffix, err) + } + } + + delete(e.issuers, name) + + return &engine.Response{ + Data: map[string]interface{}{"ok": true}, + }, nil +} + +func (e *CAEngine) handleListIssuers(_ context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + + e.mu.RLock() + defer e.mu.RUnlock() + + if e.rootCert == nil { + return nil, ErrSealed + } + + names := make([]string, 0, len(e.issuers)) + for name := range e.issuers { + names = append(names, name) + } + + return &engine.Response{ + Data: map[string]interface{}{ + "issuers": names, + }, + }, nil +} + +func (e *CAEngine) handleIssue(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + + issuerName, _ := req.Data["issuer"].(string) + if issuerName == "" { + issuerName = req.Path + } + if issuerName == "" { + return nil, fmt.Errorf("ca: issuer name is required") + } + + profileName, _ := req.Data["profile"].(string) + if profileName == "" { + profileName = "server" + } + + cn, _ := req.Data["common_name"].(string) + if cn == "" { + return nil, fmt.Errorf("ca: common_name is required") + } + + e.mu.Lock() + defer e.mu.Unlock() + + if e.rootCert == nil { + return nil, ErrSealed + } + + is, ok := e.issuers[issuerName] + if !ok { + return nil, ErrIssuerNotFound + } + + profile, ok := GetProfile(profileName) + if !ok { + return nil, fmt.Errorf("%w: %s", ErrUnknownProfile, profileName) + } + + // Apply user overrides. + if v, ok := req.Data["ttl"].(string); ok && v != "" { + profile.Expiry = v + } + if v, ok := req.Data["key_usages"].([]interface{}); ok { + profile.KeyUse = toStringSlice(v) + } + if v, ok := req.Data["ext_key_usages"].([]interface{}); ok { + profile.ExtKeyUsages = toStringSlice(v) + } + + // Determine leaf key spec. + keyAlg := is.config.KeyAlgorithm + keySize := is.config.KeySize + if v, ok := req.Data["key_algorithm"].(string); ok && v != "" { + keyAlg = v + } + if v, ok := req.Data["key_size"].(float64); ok { + keySize = int(v) + } + + // Parse SANs. + var dnsNames []string + var ipAddrs []string + if v, ok := req.Data["dns_names"].([]interface{}); ok { + dnsNames = toStringSlice(v) + } + if v, ok := req.Data["ip_addresses"].([]interface{}); ok { + ipAddrs = toStringSlice(v) + } + + // Generate leaf key pair and CSR. + ks := certgen.KeySpec{Algorithm: keyAlg, Size: keySize} + _, leafKey, err := ks.Generate() + if err != nil { + return nil, fmt.Errorf("ca: generate leaf key: %w", err) + } + + creq := certgen.CertificateRequest{ + KeySpec: ks, + Subject: certgen.Subject{ + CommonName: cn, + Organization: e.config.Organization, + DNSNames: dnsNames, + IPAddresses: ipAddrs, + }, + } + + csr, err := creq.Request(leafKey) + if err != nil { + return nil, fmt.Errorf("ca: create leaf CSR: %w", err) + } + + leafCert, err := profile.SignRequest(is.cert, csr, is.key) + if err != nil { + return nil, fmt.Errorf("ca: sign leaf cert: %w", err) + } + + // Build PEM outputs. + leafCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafCert.Raw}) + leafKeyPEM, err := marshalPrivateKey(leafKey) + if err != nil { + return nil, fmt.Errorf("ca: marshal leaf key: %w", err) + } + + var chainPEM []byte + chainPEM = append(chainPEM, leafCertPEM...) + chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: is.cert.Raw})...) + chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: e.rootCert.Raw})...) + + serial := fmt.Sprintf("%x", leafCert.SerialNumber) + + // Collect all SANs for the record. + allSANs := append(leafCert.DNSNames, ipStrings(leafCert.IPAddresses)...) + + // Store cert record (NO private key). + record := &CertRecord{ + Serial: serial, + Issuer: issuerName, + CN: cn, + SANs: allSANs, + Profile: profileName, + CertPEM: string(leafCertPEM), + IssuedBy: req.CallerInfo.Username, + IssuedAt: time.Now(), + ExpiresAt: leafCert.NotAfter, + } + recordData, err := json.Marshal(record) + if err != nil { + return nil, fmt.Errorf("ca: marshal cert record: %w", err) + } + if err := e.barrier.Put(ctx, e.mountPath+"certs/"+serial+".json", recordData); err != nil { + return nil, fmt.Errorf("ca: store cert record: %w", err) + } + + return &engine.Response{ + Data: map[string]interface{}{ + "serial": serial, + "cert_pem": string(leafCertPEM), + "key_pem": string(leafKeyPEM), + "chain_pem": string(chainPEM), + "cn": cn, + "sans": allSANs, + "issued_by": req.CallerInfo.Username, + "expires_at": leafCert.NotAfter, + }, + }, nil +} + +func (e *CAEngine) handleGetCert(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + + serial, _ := req.Data["serial"].(string) + if serial == "" { + serial = req.Path + } + if serial == "" { + return nil, fmt.Errorf("ca: serial is required") + } + + e.mu.RLock() + defer e.mu.RUnlock() + + recordData, err := e.barrier.Get(ctx, e.mountPath+"certs/"+serial+".json") + if err != nil { + if errors.Is(err, barrier.ErrNotFound) { + return nil, ErrCertNotFound + } + return nil, fmt.Errorf("ca: load cert record: %w", err) + } + + var record CertRecord + if err := json.Unmarshal(recordData, &record); err != nil { + return nil, fmt.Errorf("ca: parse cert record: %w", err) + } + + return &engine.Response{ + Data: map[string]interface{}{ + "serial": record.Serial, + "issuer": record.Issuer, + "cn": record.CN, + "sans": record.SANs, + "profile": record.Profile, + "cert_pem": record.CertPEM, + "issued_by": record.IssuedBy, + "issued_at": record.IssuedAt, + "expires_at": record.ExpiresAt, + }, + }, nil +} + +func (e *CAEngine) handleListCerts(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + + e.mu.RLock() + defer e.mu.RUnlock() + + paths, err := e.barrier.List(ctx, e.mountPath+"certs/") + if err != nil { + return nil, fmt.Errorf("ca: list certs: %w", err) + } + + var certs []map[string]interface{} + for _, p := range paths { + if !strings.HasSuffix(p, ".json") { + continue + } + recordData, err := e.barrier.Get(ctx, e.mountPath+"certs/"+p) + if err != nil { + continue + } + var record CertRecord + if err := json.Unmarshal(recordData, &record); err != nil { + continue + } + certs = append(certs, map[string]interface{}{ + "serial": record.Serial, + "issuer": record.Issuer, + "cn": record.CN, + "profile": record.Profile, + "issued_by": record.IssuedBy, + "issued_at": record.IssuedAt, + "expires_at": record.ExpiresAt, + }) + } + + return &engine.Response{ + Data: map[string]interface{}{ + "certs": certs, + }, + }, nil +} + +func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engine.Response, error) { + if req.CallerInfo == nil { + return nil, ErrUnauthorized + } + + serial, _ := req.Data["serial"].(string) + if serial == "" { + serial = req.Path + } + if serial == "" { + return nil, fmt.Errorf("ca: serial is required") + } + + e.mu.Lock() + defer e.mu.Unlock() + + if e.rootCert == nil { + return nil, ErrSealed + } + + // Load original cert record. + recordData, err := e.barrier.Get(ctx, e.mountPath+"certs/"+serial+".json") + if err != nil { + if errors.Is(err, barrier.ErrNotFound) { + return nil, ErrCertNotFound + } + return nil, fmt.Errorf("ca: load cert record: %w", err) + } + + var record CertRecord + if err := json.Unmarshal(recordData, &record); err != nil { + return nil, fmt.Errorf("ca: parse cert record: %w", err) + } + + // Look up issuer. + is, ok := e.issuers[record.Issuer] + if !ok { + return nil, fmt.Errorf("ca: original issuer %q no longer exists", record.Issuer) + } + + // Parse original cert to extract attributes. + origCert, err := parseCertPEM([]byte(record.CertPEM)) + if err != nil { + return nil, fmt.Errorf("ca: parse original cert: %w", err) + } + + // Build profile from original cert's usages. + profile, _ := GetProfile(record.Profile) + // Use original TTL duration. + origDuration := origCert.NotAfter.Sub(origCert.NotBefore) + profile.Expiry = fmt.Sprintf("%dh", int(origDuration.Hours())) + + // Generate new key. + ks := certgen.KeySpec{Algorithm: is.config.KeyAlgorithm, Size: is.config.KeySize} + _, newKey, err := ks.Generate() + if err != nil { + return nil, fmt.Errorf("ca: generate renewal key: %w", err) + } + + creq := certgen.CertificateRequest{ + KeySpec: ks, + Subject: certgen.Subject{ + CommonName: origCert.Subject.CommonName, + Organization: firstOrEmpty(origCert.Subject.Organization), + DNSNames: origCert.DNSNames, + IPAddresses: ipStrings(origCert.IPAddresses), + }, + } + + csr, err := creq.Request(newKey) + if err != nil { + return nil, fmt.Errorf("ca: create renewal CSR: %w", err) + } + + newCert, err := profile.SignRequest(is.cert, csr, is.key) + if err != nil { + return nil, fmt.Errorf("ca: sign renewal cert: %w", err) + } + + // Build PEMs. + newCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: newCert.Raw}) + newKeyPEM, err := marshalPrivateKey(newKey) + if err != nil { + return nil, fmt.Errorf("ca: marshal renewal key: %w", err) + } + + var chainPEM []byte + chainPEM = append(chainPEM, newCertPEM...) + chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: is.cert.Raw})...) + chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: e.rootCert.Raw})...) + + newSerial := fmt.Sprintf("%x", newCert.SerialNumber) + + allSANs := append(newCert.DNSNames, ipStrings(newCert.IPAddresses)...) + + newRecord := &CertRecord{ + Serial: newSerial, + Issuer: record.Issuer, + CN: record.CN, + SANs: allSANs, + Profile: record.Profile, + CertPEM: string(newCertPEM), + IssuedBy: req.CallerInfo.Username, + IssuedAt: time.Now(), + ExpiresAt: newCert.NotAfter, + } + newRecordData, err := json.Marshal(newRecord) + if err != nil { + return nil, fmt.Errorf("ca: marshal renewal record: %w", err) + } + if err := e.barrier.Put(ctx, e.mountPath+"certs/"+newSerial+".json", newRecordData); err != nil { + return nil, fmt.Errorf("ca: store renewal record: %w", err) + } + + return &engine.Response{ + Data: map[string]interface{}{ + "serial": newSerial, + "cert_pem": string(newCertPEM), + "key_pem": string(newKeyPEM), + "chain_pem": string(chainPEM), + "cn": record.CN, + "expires_at": newCert.NotAfter, + }, + }, nil +} + +// --- Helpers --- + +func defaultCAConfig() *CAConfig { + return &CAConfig{ + Organization: "Metacircular", + KeyAlgorithm: "ecdsa", + KeySize: 384, + RootExpiry: "87600h", // 10 years + } +} + +func mapToCAConfig(m map[string]interface{}, cfg *CAConfig) error { + if v, ok := m["organization"].(string); ok { + cfg.Organization = v + } + if v, ok := m["country"].(string); ok { + cfg.Country = v + } + if v, ok := m["key_algorithm"].(string); ok { + cfg.KeyAlgorithm = v + } + if v, ok := m["key_size"].(float64); ok { + cfg.KeySize = int(v) + } + if v, ok := m["root_expiry"].(string); ok { + cfg.RootExpiry = v + } + return nil +} + +func marshalPrivateKey(key crypto.PrivateKey) ([]byte, error) { + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, err + } + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil +} + +func parseCertPEM(data []byte) (*x509.Certificate, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("no PEM block found") + } + return x509.ParseCertificate(block.Bytes) +} + +func parsePrivateKeyPEM(data []byte) (crypto.PrivateKey, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("no PEM block found") + } + return x509.ParsePKCS8PrivateKey(block.Bytes) +} + +func zeroizeKey(key crypto.PrivateKey) { + if key == nil { + return + } + switch k := key.(type) { + case *ecdsa.PrivateKey: + k.D.SetInt64(0) + case *rsa.PrivateKey: + k.D.SetInt64(0) + for _, p := range k.Primes { + p.SetInt64(0) + } + case ed25519.PrivateKey: + for i := range k { + k[i] = 0 + } + } +} + +func toStringSlice(v []interface{}) []string { + s := make([]string, 0, len(v)) + for _, item := range v { + if str, ok := item.(string); ok { + s = append(s, str) + } + } + return s +} + +func ipStrings(ips []net.IP) []string { + s := make([]string, 0, len(ips)) + for _, ip := range ips { + s = append(s, ip.String()) + } + return s +} + +func firstOrEmpty(s []string) string { + if len(s) > 0 { + return s[0] + } + return "" +} diff --git a/internal/engine/ca/ca_test.go b/internal/engine/ca/ca_test.go new file mode 100644 index 0000000..fc4c7ca --- /dev/null +++ b/internal/engine/ca/ca_test.go @@ -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) + } +} diff --git a/internal/engine/ca/profiles.go b/internal/engine/ca/profiles.go new file mode 100644 index 0000000..cca3958 --- /dev/null +++ b/internal/engine/ca/profiles.go @@ -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 +} diff --git a/internal/engine/ca/types.go b/internal/engine/ca/types.go new file mode 100644 index 0000000..b7f546b --- /dev/null +++ b/internal/engine/ca/types.go @@ -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"` +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 3ea40f5..ab889a6 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -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) } } diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 344af3b..8361065 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -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) diff --git a/internal/server/routes.go b/internal/server/routes.go index 6029b45..84477c7 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -3,14 +3,18 @@ package server import ( "context" "encoding/json" + "errors" "html/template" "io" "net/http" "path/filepath" + "strings" mcias "git.wntrmute.dev/kyle/mcias/clients/go" "git.wntrmute.dev/kyle/metacrypt/internal/crypto" + "git.wntrmute.dev/kyle/metacrypt/internal/engine" + "git.wntrmute.dev/kyle/metacrypt/internal/engine/ca" "git.wntrmute.dev/kyle/metacrypt/internal/policy" "git.wntrmute.dev/kyle/metacrypt/internal/seal" ) @@ -41,6 +45,11 @@ func (s *Server) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("/v1/engine/unmount", s.requireAdmin(s.handleEngineUnmount)) mux.HandleFunc("/v1/engine/request", s.requireAuth(s.handleEngineRequest)) + // Public PKI routes (no auth required, but must be unsealed). + mux.HandleFunc("GET /v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot)) + mux.HandleFunc("GET /v1/pki/{mount}/ca/chain", s.requireUnseal(s.handlePKIChain)) + mux.HandleFunc("GET /v1/pki/{mount}/issuer/{name}", s.requireUnseal(s.handlePKIIssuer)) + mux.HandleFunc("/v1/policy/rules", s.requireAuth(s.handlePolicyRules)) mux.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule)) } @@ -239,15 +248,25 @@ func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) { return } var req struct { - Name string `json:"name"` - Type string `json:"type"` + Name string `json:"name"` + Type string `json:"type"` + Config map[string]interface{} `json:"config"` } if err := readJSON(r, &req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } - // Phase 1: no engine types registered yet. - http.Error(w, `{"error":"no engine types available in phase 1"}`, http.StatusNotImplemented) + if req.Name == "" || req.Type == "" { + http.Error(w, `{"error":"name and type are required"}`, http.StatusBadRequest) + return + } + + if err := s.engines.Mount(r.Context(), req.Name, engine.EngineType(req.Type), req.Config); err != nil { + s.logger.Error("mount engine", "name", req.Name, "type", req.Type, "error", err) + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) } func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) { @@ -274,8 +293,140 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) return } - // Phase 1 stub. - http.Error(w, `{"error":"no engine types available in phase 1"}`, http.StatusNotImplemented) + + var req struct { + Mount string `json:"mount"` + Operation string `json:"operation"` + Path string `json:"path"` + Data map[string]interface{} `json:"data"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + if req.Mount == "" || req.Operation == "" { + http.Error(w, `{"error":"mount and operation are required"}`, http.StatusBadRequest) + return + } + + info := TokenInfoFromContext(r.Context()) + engReq := &engine.Request{ + Operation: req.Operation, + Path: req.Path, + Data: req.Data, + CallerInfo: &engine.CallerInfo{ + Username: info.Username, + Roles: info.Roles, + IsAdmin: info.IsAdmin, + }, + } + + resp, err := s.engines.HandleRequest(r.Context(), req.Mount, engReq) + if err != nil { + status := http.StatusInternalServerError + // Map known errors to appropriate status codes. + switch { + case errors.Is(err, engine.ErrMountNotFound): + status = http.StatusNotFound + case strings.Contains(err.Error(), "forbidden"): + status = http.StatusForbidden + case strings.Contains(err.Error(), "authentication required"): + status = http.StatusUnauthorized + case strings.Contains(err.Error(), "not found"): + status = http.StatusNotFound + } + http.Error(w, `{"error":"`+err.Error()+`"}`, status) + return + } + + writeJSON(w, http.StatusOK, resp.Data) +} + +// --- Public PKI Handlers --- + +func (s *Server) handlePKIRoot(w http.ResponseWriter, r *http.Request) { + mountName := r.PathValue("mount") + caEng, err := s.getCAEngine(mountName) + if err != nil { + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) + return + } + + certPEM, err := caEng.GetRootCertPEM() + if err != nil { + http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable) + return + } + + w.Header().Set("Content-Type", "application/x-pem-file") + w.Write(certPEM) +} + +func (s *Server) handlePKIChain(w http.ResponseWriter, r *http.Request) { + mountName := r.PathValue("mount") + issuerName := r.URL.Query().Get("issuer") + if issuerName == "" { + http.Error(w, `{"error":"issuer query parameter required"}`, http.StatusBadRequest) + return + } + + caEng, err := s.getCAEngine(mountName) + if err != nil { + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) + return + } + + chainPEM, err := caEng.GetChainPEM(issuerName) + if err != nil { + if errors.Is(err, ca.ErrIssuerNotFound) { + http.Error(w, `{"error":"issuer not found"}`, http.StatusNotFound) + return + } + http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable) + return + } + + w.Header().Set("Content-Type", "application/x-pem-file") + w.Write(chainPEM) +} + +func (s *Server) handlePKIIssuer(w http.ResponseWriter, r *http.Request) { + mountName := r.PathValue("mount") + issuerName := r.PathValue("name") + + caEng, err := s.getCAEngine(mountName) + if err != nil { + http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) + return + } + + certPEM, err := caEng.GetIssuerCertPEM(issuerName) + if err != nil { + if errors.Is(err, ca.ErrIssuerNotFound) { + http.Error(w, `{"error":"issuer not found"}`, http.StatusNotFound) + return + } + http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable) + return + } + + w.Header().Set("Content-Type", "application/x-pem-file") + w.Write(certPEM) +} + +func (s *Server) getCAEngine(mountName string) (*ca.CAEngine, error) { + mount, err := s.engines.GetMount(mountName) + if err != nil { + return nil, err + } + if mount.Type != engine.EngineTypeCA { + return nil, errors.New("mount is not a CA engine") + } + caEng, ok := mount.Engine.(*ca.CAEngine) + if !ok { + return nil, errors.New("mount is not a CA engine") + } + return caEng, nil } func (s *Server) handlePolicyRules(w http.ResponseWriter, r *http.Request) { diff --git a/metacrypt.toml.example b/metacrypt.toml.example deleted file mode 100644 index ef2d90e..0000000 --- a/metacrypt.toml.example +++ /dev/null @@ -1,19 +0,0 @@ -[server] -listen_addr = ":8443" -tls_cert = "certs/server.crt" -tls_key = "certs/server.key" - -[database] -path = "metacrypt.db" - -[mcias] -server_url = "https://mcias.metacircular.net:8443" -# ca_cert = "certs/ca.crt" - -[seal] -# argon2_time = 3 -# argon2_memory = 131072 # 128 MiB in KiB -# argon2_threads = 4 - -[log] -level = "info"