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>
39 KiB
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
- System Overview
- Key Hierarchy & Cryptographic Design
- Seal/Unseal Lifecycle
- Encrypted Storage Barrier
- Authentication & Authorization
- Engine Architecture
- API Surface
- Web Interface
- Database Schema
- Configuration
- Deployment
- Security Model
- 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:
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:
- Zeroizes all DEKs and the MEK from memory
- Seals the storage barrier (all reads/writes return
ErrSealed) - Seals all mounted engines
- 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
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
Putgenerates a new random nonce - Atomic upsert: Uses
INSERT ... ON CONFLICT UPDATEfor Put - Glob listing:
List(prefix)returns relative paths matching the prefix - Thread-safe: All operations guarded by
sync.RWMutex - Fail-closed: Returns
ErrSealedfor any operation when the barrier is sealed
Authentication & Authorization
Authentication (MCIAS Delegation)
Metacrypt does not manage user accounts. All authentication is delegated to MCIAS:
- Client sends
POST /v1/auth/loginwith{username, password, totp_code} - Metacrypt forwards credentials to the MCIAS client library
- On success, MCIAS returns a bearer token and expiration
- Token is returned to the client (also set as
metacrypt_tokencookie for web UI) - 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:
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:
- If the requester has the
adminrole, allow immediately (bypass) - Collect all rules where username, role, resource, and action match
- Sort matching rules by priority (ascending; lower number = higher priority)
- Return the effect of the highest-priority matching rule
- 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
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:
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
- Look up issuer by name
- Start from named profile defaults, apply user overrides
- Generate leaf key pair, build CSR, sign with issuer via
profile.SignRequest - Store
CertRecordin barrier (cert PEM + metadata; no private key) - 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:
{"error": "description of what went wrong"}
HTTP status codes:
401— missing or invalid token403— insufficient privileges412— service not initialized503— 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.
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: RetainsMount,Unmount,ListMounts; drops the genericExecuteRPC.MountRequest.configismap<string, string>instead ofgoogle.protobuf.Struct.- Timestamps: All
issued_at/expires_atfields usegoogle.protobuf.Timestampinstead of RFC3339 strings. - Message types:
CertRecord(full certificate data) andCertSummary(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 bygoogle.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_*).
[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:
- Builder stage: Go compilation with symbols stripped (
-s -w) - Runtime stage: Alpine 3.21, non-root
metacryptuser
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
- The MEK never leaves process memory and is never logged or serialized in plaintext.
- The seal password is never stored; only its Argon2id-derived output is used transiently.
- All barrier writes produce fresh ciphertexts (random nonce per encryption).
- The service is fail-closed: a sealed barrier rejects all operations.
- Admin privileges are determined solely by MCIAS role membership; Metacrypt has no local user database.
- Issued certificate private keys are returned to the caller but never stored in the barrier. Only cert metadata is persisted.
- CA and issuer private keys are encrypted at rest in the barrier and
zeroized from memory on seal (explicit overwrite of ECDSA
D, RSADand 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.