Files
metacrypt/ARCHITECTURE.md
Kyle Isom 64d921827e Add MEK rotation, per-engine DEKs, and v2 ciphertext format (audit #6, #22)
Implement a two-level key hierarchy: the MEK now wraps per-engine DEKs
stored in a new barrier_keys table, rather than encrypting all barrier
entries directly. A v2 ciphertext format (0x02) embeds the key ID so the
barrier can resolve which DEK to use on decryption. v1 ciphertext remains
supported for backward compatibility.

Key changes:
- crypto: EncryptV2/DecryptV2/ExtractKeyID for v2 ciphertext with key IDs
- barrier: key registry (CreateKey, RotateKey, ListKeys, MigrateToV2, ReWrapKeys)
- seal: RotateMEK re-wraps DEKs without re-encrypting data
- engine: Mount auto-creates per-engine DEK
- REST + gRPC: barrier/keys, barrier/rotate-mek, barrier/rotate-key, barrier/migrate
- proto: BarrierService (v1 + v2) with ListKeys, RotateMEK, RotateKey, Migrate
- db: migration v2 adds barrier_keys table

Also includes: security audit report, CSRF protection, engine design specs
(sshca, transit, user), path-bound AAD migration tool, policy engine
enhancements, and ARCHITECTURE.md updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 18:27:44 -07:00

908 lines
39 KiB
Markdown

# 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
acme/ ACME protocol handler (RFC 8555); EAB, accounts, orders
server/ REST API HTTP server, routes, middleware
grpcserver/ gRPC server, interceptors, per-service handlers
webserver/ Web UI HTTP server, routes, HTMX handlers
proto/metacrypt/
v1/ Original gRPC proto definitions (generic Execute RPC)
v2/ Typed gRPC proto definitions (per-operation RPCs, Timestamp fields)
gen/metacrypt/v1/ Generated Go gRPC/protobuf code (v1)
gen/metacrypt/v2/ Generated Go gRPC/protobuf code (v2)
web/
templates/ Go HTML templates (layout, init, unseal, login, dashboard, PKI)
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 |
| DEK wrapping | AES-256-GCM | MEK wraps per-engine DEKs |
| Zeroization | Explicit overwrite | MEK, KWK, DEKs, 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_keys table │ MEK wraps per-engine DEKs
│ ├── "system" DEK │ policy rules, mount metadata
│ ├── "engine/ca/prod" DEK │ per-engine data encryption
│ ├── "engine/ca/dev" DEK │
│ └── ... │
└────────────┬─────────────┘
┌──────────────────────────┐
│ Barrier Entries │ Each entry encrypted with its
│ ├── Policy rules │ engine's DEK via AES-256-GCM
│ ├── Engine configs │ (v2 ciphertext format)
│ └── Engine secrets │
└──────────────────────────┘
```
Each engine mount gets its own Data Encryption Key (DEK) stored in the
`barrier_keys` table, wrapped by the MEK. Non-engine data (policy rules,
mount metadata) uses a `"system"` DEK. This limits blast radius: compromise
of a single DEK only exposes one engine's data.
### Ciphertext Format
Two versioned binary formats are supported:
**v1** (legacy, `0x01`):
```
[version: 1 byte][nonce: 12 bytes][ciphertext + GCM tag]
```
**v2** (current, `0x02`):
```
[version: 1 byte][key_id_len: 1 byte][key_id: N bytes][nonce: 12 bytes][ciphertext + GCM tag]
```
The v2 format embeds a key identifier in the ciphertext, allowing the
barrier to determine which DEK to use for decryption without external
metadata. Key IDs are short path-like strings:
- `"system"` — system DEK (policy rules, mount metadata)
- `"engine/{type}/{mount}"` — per-engine DEK (e.g. `"engine/ca/prod"`)
v1 ciphertext is still accepted for backward compatibility: it is
decrypted with the MEK directly (no key ID lookup). The `migrate-barrier`
command converts all v1 entries to v2 format with per-engine DEKs.
### Key Rotation
**MEK rotation** (`POST /v1/barrier/rotate-mek`): Generates a new MEK,
re-wraps all DEKs in `barrier_keys` with the new MEK, and updates
`seal_config`. Requires the unseal password for verification. This is
O(number of engines) — no data re-encryption is needed.
**DEK rotation** (`POST /v1/barrier/rotate-key`): Generates a new DEK for
a specific key ID, re-encrypts all barrier entries under that key's prefix
with the new DEK, and updates `barrier_keys`. This is O(entries per engine).
**Migration** (`POST /v1/barrier/migrate`): Converts all v1 (MEK-encrypted)
barrier entries to v2 format with per-engine DEKs. Creates DEKs on demand
for each engine mount. Idempotent — entries already in v2 format are skipped.
The `barrier_keys` table schema:
```sql
CREATE TABLE barrier_keys (
key_id TEXT PRIMARY KEY,
version INTEGER NOT NULL DEFAULT 1,
encrypted_dek BLOB NOT NULL,
created_at DATETIME DEFAULT (datetime('now')),
rotated_at DATETIME DEFAULT (datetime('now'))
);
```
---
## 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)
│ │ • load & decrypt DEKs from barrier_keys
│ ┌──────────────────┐
└─────────────►│ Sealed │ MEK + DEKs 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 all DEKs and 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
- **Path-bound integrity**: The entry path is included as GCM additional
authenticated data (AAD), preventing an attacker with database access from
swapping encrypted blobs between paths
- **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 // "any", "read", "write", "encrypt", "decrypt", "sign", "verify", "hmac", "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. The special action `any`
matches all actions except `admin` — admin must always be granted explicitly.
Rules are validated on creation: effects must be `allow` or `deny`, and
actions must be from the recognized set.
---
## 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` | Admin | Issue leaf cert from named issuer |
| `get-cert` | User/Admin | Get cert record by serial |
| `list-certs` | User/Admin | List issued cert summaries |
| `renew` | 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
Metacrypt exposes two API surfaces: a JSON REST API and a gRPC API. Both are
kept in sync — every operation available via REST has a corresponding gRPC RPC.
### 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 |
### Barrier Key Management (Admin Only)
| Method | Path | Description |
|--------|----------------------------|------------------------------------------|
| GET | `/v1/barrier/keys` | List DEKs with version + rotation times |
| POST | `/v1/barrier/rotate-mek` | Rotate MEK (re-wraps all DEKs) |
| POST | `/v1/barrier/rotate-key` | Rotate a specific DEK (re-encrypts data) |
| POST | `/v1/barrier/migrate` | Migrate v1 entries to v2 with per-engine DEKs |
### 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 |
### Policy (Admin Only)
| Method | Path | Description |
|--------|-----------------------|------------------------------------|
| GET | `/v1/policy/rules` | List all policy rules |
| POST | `/v1/policy/rules` | Create a new policy rule |
| GET | `/v1/policy/rule?id=` | Get a policy rule by ID |
| PUT | `/v1/policy/rule?id=` | Update a policy rule by ID |
| DELETE | `/v1/policy/rule?id=` | Delete a policy rule by ID |
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` | 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.
### CA Certificate Management (Authenticated)
| Method | Path | Description | Auth |
|--------|---------------------------------------|------------------------------------|---------|
| GET | `/v1/ca/{mount}/cert/{serial}` | Get certificate record by serial | User |
| POST | `/v1/ca/{mount}/cert/{serial}/revoke` | Revoke a certificate | Admin |
| DELETE | `/v1/ca/{mount}/cert/{serial}` | Delete a certificate record | Admin |
### ACME (RFC 8555)
ACME protocol endpoints are mounted per CA engine instance and require no
authentication (per the ACME spec). External Account Binding (EAB) is
supported.
| Method | Path | Description | Auth |
|--------|-----------------------------------|--------------------------------------|---------|
| GET | `/acme/{mount}/directory` | ACME directory object | None |
| HEAD/GET | `/acme/{mount}/new-nonce` | Obtain a fresh replay nonce | None |
| POST | `/acme/{mount}/new-account` | Register or retrieve an account | None |
| POST | `/acme/{mount}/new-order` | Create a new certificate order | None |
| POST | `/acme/{mount}/authz/{id}` | Fetch/respond to an authorization | None |
| POST | `/acme/{mount}/challenge/{type}/{id}` | Respond to a challenge | None |
| POST | `/acme/{mount}/finalize/{id}` | Finalize an order (submit CSR) | None |
| POST | `/acme/{mount}/cert/{id}` | Download issued certificate | None |
| POST | `/acme/{mount}/revoke-cert` | Revoke a certificate via ACME | None |
ACME management endpoints require MCIAS authentication:
| Method | Path | Description | Auth |
|--------|-------------------------------|--------------------------------------|---------|
| POST | `/v1/acme/{mount}/eab` | Create EAB credentials for a user | User |
| PUT | `/v1/acme/{mount}/config` | Set default issuer for ACME mount | Admin |
| GET | `/v1/acme/{mount}/accounts` | List ACME accounts | Admin |
| GET | `/v1/acme/{mount}/orders` | List ACME orders | Admin |
### 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
### gRPC API
Metacrypt also exposes a gRPC API defined in `proto/metacrypt/`. Two API
versions exist:
#### v1 (legacy proto definitions)
The v1 API uses a generic `Execute` RPC for all engine operations. The v1
proto definitions are retained for reference; the active server implementation
uses v2.
```protobuf
rpc Execute(ExecuteRequest) returns (ExecuteResponse);
message ExecuteRequest {
string mount = 1;
string operation = 2;
string path = 3;
google.protobuf.Struct data = 4; // JSON-like map
}
message ExecuteResponse {
google.protobuf.Struct data = 1; // JSON-like map
}
```
Timestamps are represented as RFC3339 strings within the `Struct` payload.
The `EngineService` also provides `Mount`, `Unmount`, and `ListMounts` RPCs
for engine lifecycle management.
#### v2 (implemented)
The v2 API (`proto/metacrypt/v2/`) replaces the generic `Execute` RPC with
strongly-typed, per-operation RPCs and uses `google.protobuf.Timestamp` for
all time fields. The gRPC server is fully implemented against v2. Key changes
from v1:
- **`CAService`**: 14 typed RPCs — `ImportRoot`, `GetRoot`, `CreateIssuer`,
`DeleteIssuer`, `ListIssuers`, `GetIssuer`, `GetChain`, `IssueCert`,
`GetCert`, `ListCerts`, `RenewCert`, `SignCSR`, `RevokeCert`, `DeleteCert`.
- **`EngineService`**: Retains `Mount`, `Unmount`, `ListMounts`; drops the
generic `Execute` RPC. `MountRequest.config` is `map<string, string>`
instead of `google.protobuf.Struct`.
- **Timestamps**: All `issued_at` / `expires_at` fields use
`google.protobuf.Timestamp` instead of RFC3339 strings.
- **Message types**: `CertRecord` (full certificate data) and `CertSummary`
(lightweight, for list responses) replace the generic struct maps.
- **`BarrierService`**: `ListKeys`, `RotateMEK`, `RotateKey`, `Migrate`
admin-only key management RPCs for MEK/DEK rotation and v1→v2 migration.
- **`ACMEService`**: `CreateEAB`, `SetConfig`, `ListAccounts`, `ListOrders`.
- **`AuthService`**: String timestamps replaced by `google.protobuf.Timestamp`.
The v2 proto definitions pass `buf lint` with no warnings. Generated Go code
lives in `gen/metacrypt/v2/`.
#### gRPC Interceptors (v2)
The gRPC server (`internal/grpcserver/`) uses three interceptor maps to gate
access:
| Interceptor map | Effect |
|-----------------------|-----------------------------------------------------|
| `sealRequiredMethods` | Returns `UNAVAILABLE` if the barrier is sealed |
| `authRequiredMethods` | Validates MCIAS bearer token; populates caller info |
| `adminRequiredMethods`| Requires `IsAdmin == true` on the caller |
All CA write operations, engine lifecycle RPCs, policy mutations, and ACME
management RPCs are gated on unseal state, authentication, and (where
appropriate) admin privilege.
---
## 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 |
| `/dashboard/mount-ca` | Mount a new CA engine (POST, admin) |
| `/pki` | PKI overview: list issuers, download CA/issuer PEMs |
| `/pki/import-root` | Import an existing root CA (POST) |
| `/pki/create-issuer` | Create a new intermediate issuer (POST) |
| `/pki/issue` | Issue a leaf certificate (POST) |
| `/pki/download/{token}` | Download issued cert bundle as .tar.gz |
| `/pki/issuer/{name}` | Issuer detail: certificates issued by that issuer |
| `/pki/cert/{serial}` | Certificate detail page |
| `/pki/cert/{serial}/download` | Download certificate files |
| `/pki/cert/{serial}/revoke` | Revoke a certificate (POST) |
| `/pki/cert/{serial}/delete` | Delete a certificate record (POST) |
| `/pki/{issuer}` | Issuer detail (alternate path) |
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.
The PKI pages communicate with the backend via the internal gRPC client
(`internal/webserver/client.go`), which uses the v2 typed gRPC stubs
(`CAService`, `EngineService`, `SystemService`, etc.).
The issuer detail page supports filtering certificates by common name
(case-insensitive substring match) and sorting by common name (default) or
expiry date.
---
## 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
grpc_addr = ":8444" # optional; gRPC server disabled if unset
tls_cert = "/path/cert.pem" # required
tls_key = "/path/key.pem" # required
[web]
listen_addr = "127.0.0.1:8080" # optional; web UI server address
vault_grpc = "127.0.0.1:9443" # gRPC address of the vault server
vault_ca_cert = "/path/ca.pem" # optional; CA cert to verify vault TLS
[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.3 (cipher suites managed by Go's TLS 1.3 implementation)
- 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.3 |
| 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
- **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.