Implement CA/PKI engine with two-tier X.509 certificate issuance

Add the first concrete engine implementation: a CA (PKI) engine that generates
a self-signed root CA at mount time, issues scoped intermediate CAs ("issuers"),
and signs leaf certificates using configurable profiles (server, client, peer).

Engine framework updates:
- Add CallerInfo struct for auth context in engine requests
- Add config parameter to Engine.Initialize for mount-time configuration
- Export Mount.Engine field; add GetEngine/GetMount on Registry

CA engine (internal/engine/ca/):
- Two-tier PKI: root CA → issuers → leaf certificates
- 10 operations: get-root, get-chain, get-issuer, create/delete/list issuers,
  issue, get-cert, list-certs, renew
- Certificate profiles with user-overridable TTL, key usages, and key algorithm
- Private keys never stored in barrier; zeroized from memory on seal
- Supports ECDSA, RSA, and Ed25519 key types via goutils/certlib/certgen

Server routes:
- Wire up engine mount/request handlers (replace Phase 1 stubs)
- Add public PKI routes (/v1/pki/{mount}/ca, /ca/chain, /issuer/{name})
  for unauthenticated TLS trust bootstrapping

Also includes: ARCHITECTURE.md, deploy config updates, operational tooling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 21:57:52 -07:00
parent 4ddd32b117
commit 8f77050a84
26 changed files with 2980 additions and 129 deletions

3
.gitignore vendored
View File

@@ -25,3 +25,6 @@ certs/
# OS
.DS_Store
Thumbs.db
# test develop configs
/srv/**

699
ARCHITECTURE.md Normal file
View File

@@ -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 <token>` 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.

View File

@@ -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
```

View File

@@ -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"]

194
PKI-ENGINE-PLAN.md Normal file
View File

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

View File

@@ -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

View File

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

View File

@@ -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")

View File

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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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

3
go.mod
View File

@@ -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

1023
internal/engine/ca/ca.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,649 @@
package ca
import (
"context"
"crypto/x509"
"encoding/pem"
"strings"
"sync"
"testing"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
)
// memBarrier is an in-memory barrier for testing.
type memBarrier struct {
mu sync.RWMutex
data map[string][]byte
}
func newMemBarrier() *memBarrier {
return &memBarrier{data: make(map[string][]byte)}
}
func (m *memBarrier) Unseal(_ []byte) error { return nil }
func (m *memBarrier) Seal() error { return nil }
func (m *memBarrier) IsSealed() bool { return false }
func (m *memBarrier) Get(_ context.Context, path string) ([]byte, error) {
m.mu.RLock()
defer m.mu.RUnlock()
v, ok := m.data[path]
if !ok {
return nil, barrier.ErrNotFound
}
cp := make([]byte, len(v))
copy(cp, v)
return cp, nil
}
func (m *memBarrier) Put(_ context.Context, path string, value []byte) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := make([]byte, len(value))
copy(cp, value)
m.data[path] = cp
return nil
}
func (m *memBarrier) Delete(_ context.Context, path string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.data, path)
return nil
}
func (m *memBarrier) List(_ context.Context, prefix string) ([]string, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var paths []string
for k := range m.data {
if strings.HasPrefix(k, prefix) {
paths = append(paths, strings.TrimPrefix(k, prefix))
}
}
return paths, nil
}
func adminCaller() *engine.CallerInfo {
return &engine.CallerInfo{Username: "admin", Roles: []string{"admin"}, IsAdmin: true}
}
func userCaller() *engine.CallerInfo {
return &engine.CallerInfo{Username: "user", Roles: []string{"user"}, IsAdmin: false}
}
func setupEngine(t *testing.T) (*CAEngine, *memBarrier) {
t.Helper()
b := newMemBarrier()
eng := NewCAEngine().(*CAEngine)
ctx := context.Background()
config := map[string]interface{}{
"organization": "TestOrg",
"key_algorithm": "ecdsa",
"key_size": float64(256),
"root_expiry": "87600h",
}
if err := eng.Initialize(ctx, b, "engine/ca/test/", config); err != nil {
t.Fatalf("Initialize: %v", err)
}
return eng, b
}
func TestInitializeGeneratesRootCA(t *testing.T) {
eng, _ := setupEngine(t)
if eng.rootCert == nil {
t.Fatal("root cert is nil")
}
if eng.rootKey == nil {
t.Fatal("root key is nil")
}
if !eng.rootCert.IsCA {
t.Error("root cert is not a CA")
}
if eng.rootCert.Subject.CommonName != "TestOrg Root CA" {
t.Errorf("root CN: got %q, want %q", eng.rootCert.Subject.CommonName, "TestOrg Root CA")
}
if eng.rootCert.MaxPathLen != 1 {
t.Errorf("root MaxPathLen: got %d, want 1", eng.rootCert.MaxPathLen)
}
}
func TestUnsealSealLifecycle(t *testing.T) {
eng, b := setupEngine(t)
mountPath := "engine/ca/test/"
// Seal and verify state is cleared.
if err := eng.Seal(); err != nil {
t.Fatalf("Seal: %v", err)
}
if eng.rootCert != nil {
t.Error("rootCert should be nil after seal")
}
if eng.rootKey != nil {
t.Error("rootKey should be nil after seal")
}
// Unseal and verify state is restored.
ctx := context.Background()
if err := eng.Unseal(ctx, b, mountPath); err != nil {
t.Fatalf("Unseal: %v", err)
}
if eng.rootCert == nil {
t.Error("rootCert should be non-nil after unseal")
}
if eng.rootKey == nil {
t.Error("rootKey should be non-nil after unseal")
}
if !eng.rootCert.IsCA {
t.Error("root cert should be CA after unseal")
}
}
func TestCreateIssuer(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
req := &engine.Request{
Operation: "create-issuer",
CallerInfo: adminCaller(),
Data: map[string]interface{}{
"name": "infra",
},
}
resp, err := eng.HandleRequest(ctx, req)
if err != nil {
t.Fatalf("create-issuer: %v", err)
}
if resp.Data["name"] != "infra" {
t.Errorf("issuer name: got %v, want %q", resp.Data["name"], "infra")
}
// Verify the issuer cert is an intermediate CA signed by root.
certPEM := resp.Data["cert_pem"].(string)
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
t.Fatal("failed to decode issuer cert PEM")
}
issuerCert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse issuer cert: %v", err)
}
if !issuerCert.IsCA {
t.Error("issuer cert should be a CA")
}
// MaxPathLen 0 with MaxPathLenZero=false parses as -1 in Go's x509.
// Either 0 or -1 is acceptable for a path-length-constrained intermediate.
if issuerCert.MaxPathLen > 0 {
t.Errorf("issuer MaxPathLen: got %d, want 0 or -1", issuerCert.MaxPathLen)
}
if issuerCert.Subject.CommonName != "infra" {
t.Errorf("issuer CN: got %q, want %q", issuerCert.Subject.CommonName, "infra")
}
// Verify issuer is in memory.
if _, ok := eng.issuers["infra"]; !ok {
t.Error("issuer not found in memory")
}
}
func TestCreateIssuerRejectsNonAdmin(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
req := &engine.Request{
Operation: "create-issuer",
CallerInfo: userCaller(),
Data: map[string]interface{}{
"name": "infra",
},
}
_, err := eng.HandleRequest(ctx, req)
if err == nil {
t.Fatal("expected error for non-admin create-issuer")
}
if err != ErrForbidden {
t.Errorf("expected ErrForbidden, got: %v", err)
}
}
func TestCreateIssuerRejectsNilCallerInfo(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
req := &engine.Request{
Operation: "create-issuer",
Data: map[string]interface{}{
"name": "infra",
},
}
_, err := eng.HandleRequest(ctx, req)
if err != ErrUnauthorized {
t.Errorf("expected ErrUnauthorized, got: %v", err)
}
}
func TestIssueCertificate(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
// Create an issuer first.
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "create-issuer",
CallerInfo: adminCaller(),
Data: map[string]interface{}{"name": "infra"},
})
if err != nil {
t.Fatalf("create-issuer: %v", err)
}
// Issue a certificate.
resp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "issue",
Path: "infra",
CallerInfo: userCaller(),
Data: map[string]interface{}{
"issuer": "infra",
"common_name": "web.example.com",
"profile": "server",
"dns_names": []interface{}{"web.example.com", "www.example.com"},
},
})
if err != nil {
t.Fatalf("issue: %v", err)
}
if resp.Data["cn"] != "web.example.com" {
t.Errorf("cn: got %v", resp.Data["cn"])
}
if resp.Data["serial"] == nil || resp.Data["serial"] == "" {
t.Error("serial should not be empty")
}
if resp.Data["cert_pem"] == nil {
t.Error("cert_pem should not be nil")
}
if resp.Data["key_pem"] == nil {
t.Error("key_pem should not be nil")
}
if resp.Data["chain_pem"] == nil {
t.Error("chain_pem should not be nil")
}
// Verify the leaf cert.
certPEM := resp.Data["cert_pem"].(string)
block, _ := pem.Decode([]byte(certPEM))
leafCert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse leaf cert: %v", err)
}
if leafCert.IsCA {
t.Error("leaf cert should not be a CA")
}
if leafCert.Subject.CommonName != "web.example.com" {
t.Errorf("leaf CN: got %q", leafCert.Subject.CommonName)
}
if len(leafCert.DNSNames) != 2 {
t.Errorf("leaf DNSNames: got %v", leafCert.DNSNames)
}
}
func TestIssueCertificateWithOverrides(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "create-issuer",
CallerInfo: adminCaller(),
Data: map[string]interface{}{"name": "infra"},
})
if err != nil {
t.Fatalf("create-issuer: %v", err)
}
// Issue with custom TTL and key usages.
resp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "issue",
CallerInfo: userCaller(),
Data: map[string]interface{}{
"issuer": "infra",
"common_name": "peer.example.com",
"profile": "peer",
"ttl": "720h",
"key_usages": []interface{}{"digital signature"},
"ext_key_usages": []interface{}{"client auth"},
},
})
if err != nil {
t.Fatalf("issue with overrides: %v", err)
}
certPEM := resp.Data["cert_pem"].(string)
block, _ := pem.Decode([]byte(certPEM))
leafCert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse leaf: %v", err)
}
// Verify client auth EKU.
hasClientAuth := false
for _, eku := range leafCert.ExtKeyUsage {
if eku == x509.ExtKeyUsageClientAuth {
hasClientAuth = true
}
}
if !hasClientAuth {
t.Error("expected client auth EKU")
}
}
func TestIssueRejectsNilCallerInfo(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "create-issuer",
CallerInfo: adminCaller(),
Data: map[string]interface{}{"name": "infra"},
})
if err != nil {
t.Fatalf("create-issuer: %v", err)
}
_, err = eng.HandleRequest(ctx, &engine.Request{
Operation: "issue",
Data: map[string]interface{}{
"issuer": "infra",
"common_name": "test.example.com",
},
})
if err != ErrUnauthorized {
t.Errorf("expected ErrUnauthorized, got: %v", err)
}
}
func TestPrivateKeyNotStoredInBarrier(t *testing.T) {
eng, b := setupEngine(t)
ctx := context.Background()
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "create-issuer",
CallerInfo: adminCaller(),
Data: map[string]interface{}{"name": "infra"},
})
if err != nil {
t.Fatalf("create-issuer: %v", err)
}
resp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "issue",
CallerInfo: userCaller(),
Data: map[string]interface{}{
"issuer": "infra",
"common_name": "test.example.com",
"profile": "server",
},
})
if err != nil {
t.Fatalf("issue: %v", err)
}
serial := resp.Data["serial"].(string)
// Check that the cert record does not contain a private key.
recordData, err := b.Get(ctx, "engine/ca/test/certs/"+serial+".json")
if err != nil {
t.Fatalf("get cert record: %v", err)
}
if strings.Contains(string(recordData), "PRIVATE KEY") {
t.Error("cert record should not contain private key")
}
}
func TestRenewCertificate(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "create-issuer",
CallerInfo: adminCaller(),
Data: map[string]interface{}{"name": "infra"},
})
if err != nil {
t.Fatalf("create-issuer: %v", err)
}
// Issue original cert.
issueResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "issue",
CallerInfo: userCaller(),
Data: map[string]interface{}{
"issuer": "infra",
"common_name": "renew.example.com",
"profile": "server",
"dns_names": []interface{}{"renew.example.com"},
},
})
if err != nil {
t.Fatalf("issue: %v", err)
}
origSerial := issueResp.Data["serial"].(string)
// Renew.
renewResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "renew",
CallerInfo: userCaller(),
Data: map[string]interface{}{
"serial": origSerial,
},
})
if err != nil {
t.Fatalf("renew: %v", err)
}
newSerial := renewResp.Data["serial"].(string)
if newSerial == origSerial {
t.Error("renewed cert should have different serial")
}
if renewResp.Data["cn"] != "renew.example.com" {
t.Errorf("renewed CN: got %v", renewResp.Data["cn"])
}
if renewResp.Data["cert_pem"] == nil {
t.Error("renewed cert_pem should not be nil")
}
if renewResp.Data["key_pem"] == nil {
t.Error("renewed key_pem should not be nil")
}
}
func TestGetAndListCerts(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "create-issuer",
CallerInfo: adminCaller(),
Data: map[string]interface{}{"name": "infra"},
})
if err != nil {
t.Fatalf("create-issuer: %v", err)
}
// Issue two certs.
for _, cn := range []string{"a.example.com", "b.example.com"} {
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "issue",
CallerInfo: userCaller(),
Data: map[string]interface{}{
"issuer": "infra",
"common_name": cn,
"profile": "server",
},
})
if err != nil {
t.Fatalf("issue %s: %v", cn, err)
}
}
// List certs.
listResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "list-certs",
CallerInfo: userCaller(),
})
if err != nil {
t.Fatalf("list-certs: %v", err)
}
certs, ok := listResp.Data["certs"].([]map[string]interface{})
if !ok {
t.Fatalf("certs type: %T", listResp.Data["certs"])
}
if len(certs) != 2 {
t.Errorf("expected 2 certs, got %d", len(certs))
}
// Get a specific cert.
serial := certs[0]["serial"].(string)
getResp, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "get-cert",
CallerInfo: userCaller(),
Data: map[string]interface{}{
"serial": serial,
},
})
if err != nil {
t.Fatalf("get-cert: %v", err)
}
if getResp.Data["serial"] != serial {
t.Errorf("get-cert serial: got %v, want %v", getResp.Data["serial"], serial)
}
}
func TestUnsealRestoresIssuers(t *testing.T) {
eng, b := setupEngine(t)
ctx := context.Background()
mountPath := "engine/ca/test/"
// Create issuer.
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "create-issuer",
CallerInfo: adminCaller(),
Data: map[string]interface{}{"name": "infra"},
})
if err != nil {
t.Fatalf("create-issuer: %v", err)
}
// Seal.
eng.Seal()
// Unseal.
if err := eng.Unseal(ctx, b, mountPath); err != nil {
t.Fatalf("Unseal: %v", err)
}
// Verify issuer was restored.
if _, ok := eng.issuers["infra"]; !ok {
t.Error("issuer 'infra' not restored after unseal")
}
// Verify we can issue from the restored issuer.
_, err = eng.HandleRequest(ctx, &engine.Request{
Operation: "issue",
CallerInfo: userCaller(),
Data: map[string]interface{}{
"issuer": "infra",
"common_name": "after-unseal.example.com",
"profile": "server",
},
})
if err != nil {
t.Fatalf("issue after unseal: %v", err)
}
}
func TestDeleteIssuer(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
_, err := eng.HandleRequest(ctx, &engine.Request{
Operation: "create-issuer",
CallerInfo: adminCaller(),
Data: map[string]interface{}{"name": "infra"},
})
if err != nil {
t.Fatalf("create-issuer: %v", err)
}
_, err = eng.HandleRequest(ctx, &engine.Request{
Operation: "delete-issuer",
CallerInfo: adminCaller(),
Data: map[string]interface{}{"name": "infra"},
})
if err != nil {
t.Fatalf("delete-issuer: %v", err)
}
if _, ok := eng.issuers["infra"]; ok {
t.Error("issuer should be deleted")
}
}
func TestPublicMethods(t *testing.T) {
eng, _ := setupEngine(t)
ctx := context.Background()
// Test GetRootCertPEM.
rootPEM, err := eng.GetRootCertPEM()
if err != nil {
t.Fatalf("GetRootCertPEM: %v", err)
}
block, _ := pem.Decode(rootPEM)
if block == nil {
t.Fatal("failed to decode root PEM")
}
// Create issuer for chain/issuer tests.
_, err = eng.HandleRequest(ctx, &engine.Request{
Operation: "create-issuer",
CallerInfo: adminCaller(),
Data: map[string]interface{}{"name": "infra"},
})
if err != nil {
t.Fatalf("create-issuer: %v", err)
}
// Test GetIssuerCertPEM.
issuerPEM, err := eng.GetIssuerCertPEM("infra")
if err != nil {
t.Fatalf("GetIssuerCertPEM: %v", err)
}
block, _ = pem.Decode(issuerPEM)
if block == nil {
t.Fatal("failed to decode issuer PEM")
}
// Test GetChainPEM.
chainPEM, err := eng.GetChainPEM("infra")
if err != nil {
t.Fatalf("GetChainPEM: %v", err)
}
// Chain should contain two certificates.
certCount := strings.Count(string(chainPEM), "BEGIN CERTIFICATE")
if certCount != 2 {
t.Errorf("chain should contain 2 certs, got %d", certCount)
}
// Test nonexistent issuer.
_, err = eng.GetIssuerCertPEM("nonexistent")
if err != ErrIssuerNotFound {
t.Errorf("expected ErrIssuerNotFound, got: %v", err)
}
}

View File

@@ -0,0 +1,41 @@
package ca
import "git.wntrmute.dev/kyle/goutils/certlib/certgen"
// Default certificate profiles.
var defaultProfiles = map[string]certgen.Profile{
"server": {
KeyUse: []string{"digital signature", "key encipherment"},
ExtKeyUsages: []string{"server auth"},
Expiry: "2160h", // 90 days
},
"client": {
KeyUse: []string{"digital signature"},
ExtKeyUsages: []string{"client auth"},
Expiry: "2160h", // 90 days
},
"peer": {
KeyUse: []string{"digital signature", "key encipherment"},
ExtKeyUsages: []string{"server auth", "client auth"},
Expiry: "2160h", // 90 days
},
}
// GetProfile returns a copy of the named default profile.
func GetProfile(name string) (certgen.Profile, bool) {
p, ok := defaultProfiles[name]
if !ok {
return certgen.Profile{}, false
}
// Return a copy so callers can modify.
cp := certgen.Profile{
IsCA: p.IsCA,
PathLen: p.PathLen,
Expiry: p.Expiry,
KeyUse: make([]string, len(p.KeyUse)),
ExtKeyUsages: make([]string, len(p.ExtKeyUsages)),
}
copy(cp.KeyUse, p.KeyUse)
copy(cp.ExtKeyUsages, p.ExtKeyUsages)
return cp, true
}

View File

@@ -0,0 +1,37 @@
package ca
import "time"
// CAConfig is the CA engine configuration stored in the barrier.
type CAConfig struct {
Organization string `json:"organization"`
Country string `json:"country,omitempty"`
KeyAlgorithm string `json:"key_algorithm"` // "ecdsa", "rsa", "ed25519"
KeySize int `json:"key_size"` // e.g. 384 for ECDSA, 4096 for RSA
RootExpiry string `json:"root_expiry"` // e.g. "87600h" (10 years)
}
// IssuerConfig is per-issuer configuration stored in the barrier.
type IssuerConfig struct {
Name string `json:"name"`
KeyAlgorithm string `json:"key_algorithm"`
KeySize int `json:"key_size"`
Expiry string `json:"expiry"` // issuer cert expiry, e.g. "43800h" (5 years)
MaxTTL string `json:"max_ttl"` // max leaf cert TTL, e.g. "8760h" (1 year)
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
// CertRecord is metadata for an issued certificate, stored in the barrier.
// The private key is NOT stored.
type CertRecord struct {
Serial string `json:"serial"`
Issuer string `json:"issuer"`
CN string `json:"cn"`
SANs []string `json:"sans,omitempty"`
Profile string `json:"profile"`
CertPEM string `json:"cert_pem"`
IssuedBy string `json:"issued_by"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at"`
}

View File

@@ -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{}
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.
@@ -61,7 +69,7 @@ type Mount struct {
Name string `json:"name"`
Type EngineType `json:"type"`
MountPath string `json:"mount_path"`
engine Engine
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)
}
}

View File

@@ -15,8 +15,14 @@ type mockEngine struct {
}
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) 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
@@ -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)

View File

@@ -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))
}
@@ -241,13 +250,23 @@ func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) {
var req struct {
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) {

View File

@@ -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"