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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,3 +25,6 @@ certs/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# test develop configs
|
||||||
|
/srv/**
|
||||||
699
ARCHITECTURE.md
Normal file
699
ARCHITECTURE.md
Normal 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.
|
||||||
29
CLAUDE.md
29
CLAUDE.md
@@ -20,3 +20,32 @@ go vet ./... # Static analysis
|
|||||||
- **Storage**: SQLite database with an encrypted storage barrier (similar to HashiCorp Vault)
|
- **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
|
- **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
|
- **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
|
||||||
|
```
|
||||||
|
|||||||
13
Dockerfile
13
Dockerfile
@@ -13,22 +13,23 @@ FROM alpine:3.21
|
|||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata \
|
RUN apk add --no-cache ca-certificates tzdata \
|
||||||
&& addgroup -S metacrypt \
|
&& 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 --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:
|
# It must contain:
|
||||||
# metacrypt.toml — configuration file
|
# metacrypt.toml — configuration file
|
||||||
# certs/ — TLS certificate and key
|
# certs/ — TLS certificate and key
|
||||||
# metacrypt.db — created automatically on first run
|
# metacrypt.db — created automatically on first run
|
||||||
VOLUME /data
|
VOLUME /srv/metacrypt
|
||||||
WORKDIR /data
|
WORKDIR /srv/metacrypt
|
||||||
|
|
||||||
EXPOSE 8443
|
EXPOSE 8443
|
||||||
|
|
||||||
USER metacrypt
|
USER metacrypt
|
||||||
|
|
||||||
ENTRYPOINT ["metacrypt"]
|
ENTRYPOINT ["metacrypt"]
|
||||||
CMD ["server", "--config", "/data/metacrypt.toml"]
|
CMD ["server", "--config", "/srv/metacrypt/metacrypt.toml"]
|
||||||
|
|||||||
194
PKI-ENGINE-PLAN.md
Normal file
194
PKI-ENGINE-PLAN.md
Normal 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) |
|
||||||
57
RUNBOOK.md
57
RUNBOOK.md
@@ -51,9 +51,10 @@ This creates:
|
|||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/usr/local/bin/metacrypt` | Binary |
|
| `/usr/local/bin/metacrypt` | Binary |
|
||||||
| `/etc/metacrypt/metacrypt.toml` | Configuration |
|
| `/srv/metacrypt/metacrypt.toml` | Configuration |
|
||||||
| `/etc/metacrypt/certs/` | TLS certificates |
|
| `/srv/metacrypt/certs/` | TLS certificates |
|
||||||
| `/var/lib/metacrypt/` | Database and backups |
|
| `/srv/metacrypt/backups/` | Database backups |
|
||||||
|
| `/srv/metacrypt/metacrypt.db` | Database (created on first run) |
|
||||||
|
|
||||||
### Docker Install
|
### Docker Install
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ make docker
|
|||||||
docker compose -f deploy/docker/docker-compose.yml up -d
|
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 |
|
| File | Required | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -81,8 +82,8 @@ To prepare a Docker volume:
|
|||||||
docker volume create metacrypt-data
|
docker volume create metacrypt-data
|
||||||
|
|
||||||
# Copy files into the volume
|
# Copy files into the volume
|
||||||
docker run --rm -v metacrypt-data:/data -v $(pwd)/deploy/examples:/src alpine \
|
docker run --rm -v metacrypt-data:/srv/metacrypt -v $(pwd)/deploy/examples:/src alpine \
|
||||||
sh -c "cp /src/metacrypt-docker.toml /data/metacrypt.toml && mkdir -p /data/certs"
|
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
|
# 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
|
1. `--config` flag
|
||||||
2. `METACRYPT_CONFIG` environment variable (via viper)
|
2. `METACRYPT_CONFIG` environment variable (via viper)
|
||||||
3. `metacrypt.toml` in the current directory
|
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`).
|
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)
|
### Option A: CLI (recommended for servers)
|
||||||
|
|
||||||
```bash
|
```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.
|
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
|
docker compose -f deploy/docker/docker-compose.yml up -d
|
||||||
|
|
||||||
# Manual
|
# 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.
|
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
|
```bash
|
||||||
# CLI
|
# 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)
|
# Using the backup script (with 30-day retention)
|
||||||
deploy/scripts/backup.sh 30
|
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
|
### Restoring from Backup
|
||||||
|
|
||||||
1. Stop the service: `systemctl stop metacrypt`
|
1. Stop the service: `systemctl stop metacrypt`
|
||||||
2. Replace the database: `cp /var/lib/metacrypt/backups/metacrypt-20260314.db /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 /var/lib/metacrypt/metacrypt.db && chmod 0600 /var/lib/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`
|
4. Start the service: `systemctl start metacrypt`
|
||||||
5. Unseal with the original seal password
|
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 |
|
| 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 |
|
| TLS certificate expiry | External cert checker | < 30 days to expiry |
|
||||||
| Database file size | `stat /var/lib/metacrypt/metacrypt.db` | Unexpectedly large growth |
|
| Database file size | `stat /srv/metacrypt/metacrypt.db` | Unexpectedly large growth |
|
||||||
| Backup age | `find /var/lib/metacrypt/backups -name '*.db' -mtime +2` | No backup in 48 hours |
|
| 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 |
|
| 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 |
|
| Symptom | Cause | Fix |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `config: server.tls_cert is required` | Missing or invalid config | Check config file path and contents |
|
| `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` |
|
| `server: tls: failed to find any PEM data` | Bad cert/key files | Verify PEM format: `openssl x509 -in server.crt -text -noout` |
|
||||||
|
|
||||||
### Unseal fails
|
### Unseal fails
|
||||||
@@ -326,14 +327,14 @@ journalctl -u metacrypt --priority=err --since="1 hour ago"
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check database integrity
|
# Check database integrity
|
||||||
sqlite3 /var/lib/metacrypt/metacrypt.db "PRAGMA integrity_check;"
|
sqlite3 /srv/metacrypt/metacrypt.db "PRAGMA integrity_check;"
|
||||||
|
|
||||||
# Check WAL mode
|
# Check WAL mode
|
||||||
sqlite3 /var/lib/metacrypt/metacrypt.db "PRAGMA journal_mode;"
|
sqlite3 /srv/metacrypt/metacrypt.db "PRAGMA journal_mode;"
|
||||||
# Should return: wal
|
# Should return: wal
|
||||||
|
|
||||||
# Check file permissions
|
# Check file permissions
|
||||||
ls -la /var/lib/metacrypt/metacrypt.db
|
ls -la /srv/metacrypt/metacrypt.db
|
||||||
# Should be: -rw------- metacrypt metacrypt
|
# Should be: -rw------- metacrypt metacrypt
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -360,18 +361,18 @@ Sealing the service (`POST /v1/seal`) explicitly zeroizes all key material from
|
|||||||
|
|
||||||
| Path | Mode | Owner |
|
| Path | Mode | Owner |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/etc/metacrypt/metacrypt.toml` | 0640 | metacrypt:metacrypt |
|
| `/srv/metacrypt/metacrypt.toml` | 0640 | metacrypt:metacrypt |
|
||||||
| `/etc/metacrypt/certs/server.key` | 0600 | metacrypt:metacrypt |
|
| `/srv/metacrypt/certs/server.key` | 0600 | metacrypt:metacrypt |
|
||||||
| `/var/lib/metacrypt/metacrypt.db` | 0600 | metacrypt:metacrypt |
|
| `/srv/metacrypt/metacrypt.db` | 0600 | metacrypt:metacrypt |
|
||||||
| `/var/lib/metacrypt/backups/` | 0700 | metacrypt:metacrypt |
|
| `/srv/metacrypt/backups/` | 0700 | metacrypt:metacrypt |
|
||||||
|
|
||||||
### systemd Hardening
|
### 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
|
### 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:
|
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
|
2. Stop the service
|
||||||
3. Re-initialize with a new database and password
|
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)
|
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:
|
If the server is lost but you have a database backup and the seal password:
|
||||||
|
|
||||||
1. Install Metacrypt on a new server (see Installation)
|
1. Install Metacrypt on a new server (see Installation)
|
||||||
2. Copy the backup database to `/var/lib/metacrypt/metacrypt.db`
|
2. Copy the backup database to `/srv/metacrypt/metacrypt.db`
|
||||||
3. Fix ownership: `chown metacrypt:metacrypt /var/lib/metacrypt/metacrypt.db`
|
3. Fix ownership: `chown metacrypt:metacrypt /srv/metacrypt/metacrypt.db`
|
||||||
4. Start the service and unseal with the original password
|
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.
|
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
|
### Upgrading Metacrypt
|
||||||
|
|
||||||
1. Build or download the new binary
|
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`
|
3. Replace the binary: `install -m 0755 metacrypt /usr/local/bin/metacrypt`
|
||||||
4. Restart: `systemctl restart metacrypt`
|
4. Restart: `systemctl restart metacrypt`
|
||||||
5. Unseal and verify
|
5. Unseal and verify
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func init() {
|
|||||||
func runInit(cmd *cobra.Command, args []string) error {
|
func runInit(cmd *cobra.Command, args []string) error {
|
||||||
configPath := cfgFile
|
configPath := cfgFile
|
||||||
if configPath == "" {
|
if configPath == "" {
|
||||||
configPath = "metacrypt.toml"
|
configPath = "/srv/metacrypt/metacrypt.toml"
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(configPath)
|
cfg, err := config.Load(configPath)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ var rootCmd = &cobra.Command{
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
cobra.OnInitialize(initConfig)
|
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() {
|
func initConfig() {
|
||||||
@@ -25,7 +25,7 @@ func initConfig() {
|
|||||||
viper.SetConfigName("metacrypt")
|
viper.SetConfigName("metacrypt")
|
||||||
viper.SetConfigType("toml")
|
viper.SetConfigType("toml")
|
||||||
viper.AddConfigPath(".")
|
viper.AddConfigPath(".")
|
||||||
viper.AddConfigPath("/etc/metacrypt")
|
viper.AddConfigPath("/srv/metacrypt")
|
||||||
}
|
}
|
||||||
viper.AutomaticEnv()
|
viper.AutomaticEnv()
|
||||||
viper.SetEnvPrefix("METACRYPT")
|
viper.SetEnvPrefix("METACRYPT")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/db"
|
"git.wntrmute.dev/kyle/metacrypt/internal/db"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
"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/policy"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
|
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/server"
|
"git.wntrmute.dev/kyle/metacrypt/internal/server"
|
||||||
@@ -36,7 +37,7 @@ func runServer(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
configPath := cfgFile
|
configPath := cfgFile
|
||||||
if configPath == "" {
|
if configPath == "" {
|
||||||
configPath = "metacrypt.toml"
|
configPath = "/srv/metacrypt/metacrypt.toml"
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(configPath)
|
cfg, err := config.Load(configPath)
|
||||||
@@ -71,6 +72,7 @@ func runServer(cmd *cobra.Command, args []string) error {
|
|||||||
authenticator := auth.NewAuthenticator(mcClient)
|
authenticator := auth.NewAuthenticator(mcClient)
|
||||||
policyEngine := policy.NewEngine(b)
|
policyEngine := policy.NewEngine(b)
|
||||||
engineRegistry := engine.NewRegistry(b)
|
engineRegistry := engine.NewRegistry(b)
|
||||||
|
engineRegistry.RegisterFactory(engine.EngineTypeCA, ca.NewCAEngine)
|
||||||
|
|
||||||
srv := server.New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger)
|
srv := server.New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger)
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func init() {
|
|||||||
func runSnapshot(cmd *cobra.Command, args []string) error {
|
func runSnapshot(cmd *cobra.Command, args []string) error {
|
||||||
configPath := cfgFile
|
configPath := cfgFile
|
||||||
if configPath == "" {
|
if configPath == "" {
|
||||||
configPath = "metacrypt.toml"
|
configPath = "/srv/metacrypt/metacrypt.toml"
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(configPath)
|
cfg, err := config.Load(configPath)
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8443:8443"
|
- "8443:8443"
|
||||||
volumes:
|
volumes:
|
||||||
- metacrypt-data:/data
|
- metacrypt-data:/srv/metacrypt
|
||||||
# To populate /data before first run, use an init container or
|
# To populate /srv/metacrypt before first run, use an init container or
|
||||||
# bind-mount a host directory instead of a named volume:
|
# bind-mount a host directory instead of a named volume:
|
||||||
# volumes:
|
# volumes:
|
||||||
# - ./data:/data
|
# - ./data:/srv/metacrypt
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
# Metacrypt configuration for Docker deployment.
|
# 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]
|
[server]
|
||||||
listen_addr = ":8443"
|
listen_addr = ":8443"
|
||||||
tls_cert = "/data/certs/server.crt"
|
tls_cert = "/srv/metacrypt/certs/server.crt"
|
||||||
tls_key = "/data/certs/server.key"
|
tls_key = "/srv/metacrypt/certs/server.key"
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
path = "/data/metacrypt.db"
|
path = "/srv/metacrypt/metacrypt.db"
|
||||||
|
|
||||||
[mcias]
|
[mcias]
|
||||||
server_url = "https://mcias.metacircular.net:8443"
|
server_url = "https://mcias.metacircular.net:8443"
|
||||||
# ca_cert = "/data/certs/mcias-ca.crt"
|
# ca_cert = "/srv/metacrypt/certs/mcias-ca.crt"
|
||||||
|
|
||||||
[seal]
|
[seal]
|
||||||
# argon2_time = 3
|
# argon2_time = 3
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
# Metacrypt production configuration
|
# 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]
|
[server]
|
||||||
# Address to listen on. Use "0.0.0.0:8443" to listen on all interfaces.
|
# Address to listen on. Use "0.0.0.0:8443" to listen on all interfaces.
|
||||||
listen_addr = ":8443"
|
listen_addr = ":8443"
|
||||||
|
|
||||||
# TLS certificate and key. Metacrypt always terminates TLS.
|
# TLS certificate and key. Metacrypt always terminates TLS.
|
||||||
tls_cert = "/etc/metacrypt/certs/server.crt"
|
tls_cert = "/srv/metacrypt/certs/server.crt"
|
||||||
tls_key = "/etc/metacrypt/certs/server.key"
|
tls_key = "/srv/metacrypt/certs/server.key"
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
# SQLite database path. Created automatically on first run.
|
# SQLite database path. Created automatically on first run.
|
||||||
# The directory must be writable by the metacrypt user.
|
# The directory must be writable by the metacrypt user.
|
||||||
path = "/var/lib/metacrypt/metacrypt.db"
|
path = "/srv/metacrypt/metacrypt.db"
|
||||||
|
|
||||||
[mcias]
|
[mcias]
|
||||||
# MCIAS server URL for authentication.
|
# 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.
|
# CA certificate for verifying the MCIAS server's TLS certificate.
|
||||||
# Omit if MCIAS uses a publicly trusted 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]
|
[seal]
|
||||||
# Argon2id parameters for key derivation.
|
# Argon2id parameters for key derivation.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
#
|
#
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
CONFIG="${METACRYPT_CONFIG:-/etc/metacrypt/metacrypt.toml}"
|
CONFIG="${METACRYPT_CONFIG:-/srv/metacrypt/metacrypt.toml}"
|
||||||
BACKUP_DIR="${METACRYPT_BACKUP_DIR:-/var/lib/metacrypt/backups}"
|
BACKUP_DIR="${METACRYPT_BACKUP_DIR:-/srv/metacrypt/backups}"
|
||||||
RETENTION_DAYS="${1:-30}"
|
RETENTION_DAYS="${1:-30}"
|
||||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
BACKUP_FILE="${BACKUP_DIR}/metacrypt-${TIMESTAMP}.db"
|
BACKUP_FILE="${BACKUP_DIR}/metacrypt-${TIMESTAMP}.db"
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ set -euo pipefail
|
|||||||
|
|
||||||
BINARY="${1:?Usage: $0 /path/to/metacrypt}"
|
BINARY="${1:?Usage: $0 /path/to/metacrypt}"
|
||||||
INSTALL_DIR="/usr/local/bin"
|
INSTALL_DIR="/usr/local/bin"
|
||||||
CONFIG_DIR="/etc/metacrypt"
|
SRV_DIR="/srv/metacrypt"
|
||||||
DATA_DIR="/var/lib/metacrypt"
|
BACKUP_DIR="${SRV_DIR}/backups"
|
||||||
BACKUP_DIR="${DATA_DIR}/backups"
|
CERTS_DIR="${SRV_DIR}/certs"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
DEPLOY_DIR="$(dirname "$SCRIPT_DIR")"
|
DEPLOY_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
@@ -19,25 +19,24 @@ if ! getent group metacrypt >/dev/null 2>&1; then
|
|||||||
groupadd --system metacrypt
|
groupadd --system metacrypt
|
||||||
fi
|
fi
|
||||||
if ! getent passwd metacrypt >/dev/null 2>&1; then
|
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
|
fi
|
||||||
|
|
||||||
echo "==> Installing binary"
|
echo "==> Installing binary"
|
||||||
install -m 0755 "$BINARY" "$INSTALL_DIR/metacrypt"
|
install -m 0755 "$BINARY" "$INSTALL_DIR/metacrypt"
|
||||||
|
|
||||||
echo "==> Creating directories"
|
echo "==> Creating directories"
|
||||||
install -d -m 0750 -o metacrypt -g metacrypt "$CONFIG_DIR"
|
install -d -m 0750 -o metacrypt -g metacrypt "$SRV_DIR"
|
||||||
install -d -m 0750 -o metacrypt -g metacrypt "$CONFIG_DIR/certs"
|
install -d -m 0750 -o metacrypt -g metacrypt "$CERTS_DIR"
|
||||||
install -d -m 0700 -o metacrypt -g metacrypt "$DATA_DIR"
|
|
||||||
install -d -m 0700 -o metacrypt -g metacrypt "$BACKUP_DIR"
|
install -d -m 0700 -o metacrypt -g metacrypt "$BACKUP_DIR"
|
||||||
|
|
||||||
echo "==> Installing configuration"
|
echo "==> Installing configuration"
|
||||||
if [ ! -f "$CONFIG_DIR/metacrypt.toml" ]; then
|
if [ ! -f "$SRV_DIR/metacrypt.toml" ]; then
|
||||||
install -m 0640 -o metacrypt -g metacrypt "$DEPLOY_DIR/examples/metacrypt.toml" "$CONFIG_DIR/metacrypt.toml"
|
install -m 0640 -o metacrypt -g metacrypt "$DEPLOY_DIR/examples/metacrypt.toml" "$SRV_DIR/metacrypt.toml"
|
||||||
echo " Installed default config to $CONFIG_DIR/metacrypt.toml"
|
echo " Installed default config to $SRV_DIR/metacrypt.toml"
|
||||||
echo " >>> Edit this file before starting the service <<<"
|
echo " >>> Edit this file before starting the service <<<"
|
||||||
else
|
else
|
||||||
echo " Config already exists at $CONFIG_DIR/metacrypt.toml — skipping"
|
echo " Config already exists at $SRV_DIR/metacrypt.toml — skipping"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Installing systemd units"
|
echo "==> Installing systemd units"
|
||||||
@@ -49,8 +48,8 @@ systemctl daemon-reload
|
|||||||
echo "==> Done"
|
echo "==> Done"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Next steps:"
|
echo "Next steps:"
|
||||||
echo " 1. Place TLS cert and key in $CONFIG_DIR/certs/"
|
echo " 1. Place TLS cert and key in $CERTS_DIR/"
|
||||||
echo " 2. Edit $CONFIG_DIR/metacrypt.toml"
|
echo " 2. Edit $SRV_DIR/metacrypt.toml"
|
||||||
echo " 3. Initialize: metacrypt init --config $CONFIG_DIR/metacrypt.toml"
|
echo " 3. Initialize: metacrypt init --config $SRV_DIR/metacrypt.toml"
|
||||||
echo " 4. Start: systemctl enable --now metacrypt"
|
echo " 4. Start: systemctl enable --now metacrypt"
|
||||||
echo " 5. Backups: systemctl enable --now metacrypt-backup.timer"
|
echo " 5. Backups: systemctl enable --now metacrypt-backup.timer"
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ After=metacrypt.service
|
|||||||
Type=oneshot
|
Type=oneshot
|
||||||
User=metacrypt
|
User=metacrypt
|
||||||
Group=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
|
NoNewPrivileges=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ReadWritePaths=/var/lib/metacrypt
|
ReadWritePaths=/srv/metacrypt
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Type=simple
|
|||||||
User=metacrypt
|
User=metacrypt
|
||||||
Group=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
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
@@ -30,8 +30,8 @@ LockPersonality=true
|
|||||||
MemoryDenyWriteExecute=true
|
MemoryDenyWriteExecute=true
|
||||||
RestrictRealtime=true
|
RestrictRealtime=true
|
||||||
|
|
||||||
# Allow write access to the database directory and log
|
# Allow write access to the data directory
|
||||||
ReadWritePaths=/var/lib/metacrypt
|
ReadWritePaths=/srv/metacrypt
|
||||||
|
|
||||||
# Limit file descriptor count
|
# Limit file descriptor count
|
||||||
LimitNOFILE=65535
|
LimitNOFILE=65535
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -4,7 +4,10 @@ go 1.25.0
|
|||||||
|
|
||||||
replace git.wntrmute.dev/kyle/mcias/clients/go => /Users/kyle/src/mcias/clients/go
|
replace git.wntrmute.dev/kyle/mcias/clients/go => /Users/kyle/src/mcias/clients/go
|
||||||
|
|
||||||
|
replace git.wntrmute.dev/kyle/goutils => /Users/kyle/src/goutils
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.wntrmute.dev/kyle/goutils v0.0.0-00010101000000-000000000000
|
||||||
git.wntrmute.dev/kyle/mcias/clients/go 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/pelletier/go-toml/v2 v2.2.4
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
|||||||
1023
internal/engine/ca/ca.go
Normal file
1023
internal/engine/ca/ca.go
Normal file
File diff suppressed because it is too large
Load Diff
649
internal/engine/ca/ca_test.go
Normal file
649
internal/engine/ca/ca_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
internal/engine/ca/profiles.go
Normal file
41
internal/engine/ca/profiles.go
Normal 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
|
||||||
|
}
|
||||||
37
internal/engine/ca/types.go
Normal file
37
internal/engine/ca/types.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -27,11 +27,19 @@ var (
|
|||||||
ErrUnknownType = errors.New("engine: unknown engine type")
|
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.
|
// Request is a request to an engine.
|
||||||
type Request struct {
|
type Request struct {
|
||||||
Operation string
|
Operation string
|
||||||
Path string
|
Path string
|
||||||
Data map[string]interface{}
|
Data map[string]interface{}
|
||||||
|
CallerInfo *CallerInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response is a response from an engine.
|
// Response is a response from an engine.
|
||||||
@@ -44,7 +52,7 @@ type Engine interface {
|
|||||||
// Type returns the engine type.
|
// Type returns the engine type.
|
||||||
Type() EngineType
|
Type() EngineType
|
||||||
// Initialize sets up the engine for first use.
|
// 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 opens the engine using state from the barrier.
|
||||||
Unseal(ctx context.Context, b barrier.Barrier, mountPath string) error
|
Unseal(ctx context.Context, b barrier.Barrier, mountPath string) error
|
||||||
// Seal closes the engine and zeroizes key material.
|
// Seal closes the engine and zeroizes key material.
|
||||||
@@ -58,10 +66,10 @@ type Factory func() Engine
|
|||||||
|
|
||||||
// Mount represents a mounted engine instance.
|
// Mount represents a mounted engine instance.
|
||||||
type Mount struct {
|
type Mount struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type EngineType `json:"type"`
|
Type EngineType `json:"type"`
|
||||||
MountPath string `json:"mount_path"`
|
MountPath string `json:"mount_path"`
|
||||||
engine Engine
|
Engine Engine `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry manages mounted engine instances.
|
// 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.
|
// 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()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
@@ -105,7 +113,7 @@ func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType
|
|||||||
eng := factory()
|
eng := factory()
|
||||||
mountPath := fmt.Sprintf("engine/%s/%s/", engineType, name)
|
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)
|
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,
|
Name: name,
|
||||||
Type: engineType,
|
Type: engineType,
|
||||||
MountPath: mountPath,
|
MountPath: mountPath,
|
||||||
engine: eng,
|
Engine: eng,
|
||||||
}
|
}
|
||||||
return nil
|
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.
|
// Unmount removes and seals an engine mount.
|
||||||
func (r *Registry) Unmount(name string) error {
|
func (r *Registry) Unmount(name string) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
@@ -128,7 +160,7 @@ func (r *Registry) Unmount(name string) error {
|
|||||||
return ErrMountNotFound
|
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)
|
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 nil, ErrMountNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return mount.engine.HandleRequest(ctx, req)
|
return mount.Engine.HandleRequest(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SealAll seals all mounted engines.
|
// SealAll seals all mounted engines.
|
||||||
@@ -171,7 +203,7 @@ func (r *Registry) SealAll() error {
|
|||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
for name, mount := range r.mounts {
|
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)
|
return fmt.Errorf("engine: seal %q: %w", name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,22 +14,28 @@ type mockEngine struct {
|
|||||||
unsealed bool
|
unsealed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockEngine) Type() EngineType { return m.engineType }
|
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) Initialize(_ context.Context, _ barrier.Barrier, _ string, _ map[string]interface{}) error {
|
||||||
func (m *mockEngine) Unseal(_ context.Context, _ barrier.Barrier, _ string) error { m.unsealed = true; return nil }
|
m.initialized = true
|
||||||
func (m *mockEngine) Seal() error { m.unsealed = false; return nil }
|
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) {
|
func (m *mockEngine) HandleRequest(_ context.Context, _ *Request) (*Response, error) {
|
||||||
return &Response{Data: map[string]interface{}{"ok": true}}, nil
|
return &Response{Data: map[string]interface{}{"ok": true}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockBarrier struct{}
|
type mockBarrier struct{}
|
||||||
|
|
||||||
func (m *mockBarrier) Unseal(_ []byte) error { return nil }
|
func (m *mockBarrier) Unseal(_ []byte) error { return nil }
|
||||||
func (m *mockBarrier) Seal() error { return nil }
|
func (m *mockBarrier) Seal() error { return nil }
|
||||||
func (m *mockBarrier) IsSealed() bool { return false }
|
func (m *mockBarrier) IsSealed() bool { return false }
|
||||||
func (m *mockBarrier) Get(_ context.Context, _ string) ([]byte, error) { return nil, barrier.ErrNotFound }
|
func (m *mockBarrier) Get(_ context.Context, _ string) ([]byte, error) { return nil, barrier.ErrNotFound }
|
||||||
func (m *mockBarrier) Put(_ context.Context, _ string, _ []byte) error { return nil }
|
func (m *mockBarrier) Put(_ context.Context, _ string, _ []byte) error { return nil }
|
||||||
func (m *mockBarrier) Delete(_ context.Context, _ string) error { return nil }
|
func (m *mockBarrier) Delete(_ context.Context, _ string) error { return nil }
|
||||||
func (m *mockBarrier) List(_ context.Context, _ string) ([]string, error) { return nil, nil }
|
func (m *mockBarrier) List(_ context.Context, _ string) ([]string, error) { return nil, nil }
|
||||||
|
|
||||||
func TestRegistryMountUnmount(t *testing.T) {
|
func TestRegistryMountUnmount(t *testing.T) {
|
||||||
@@ -39,7 +45,7 @@ func TestRegistryMountUnmount(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ctx := context.Background()
|
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)
|
t.Fatalf("Mount: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +58,7 @@ func TestRegistryMountUnmount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Duplicate mount should fail.
|
// 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)
|
t.Fatalf("expected ErrMountExists, got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +81,7 @@ func TestRegistryUnmountNotFound(t *testing.T) {
|
|||||||
|
|
||||||
func TestRegistryUnknownType(t *testing.T) {
|
func TestRegistryUnknownType(t *testing.T) {
|
||||||
reg := NewRegistry(&mockBarrier{})
|
reg := NewRegistry(&mockBarrier{})
|
||||||
err := reg.Mount(context.Background(), "test", EngineTypeTransit)
|
err := reg.Mount(context.Background(), "test", EngineTypeTransit, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for unknown engine type")
|
t.Fatal("expected error for unknown engine type")
|
||||||
}
|
}
|
||||||
@@ -88,7 +94,7 @@ func TestRegistryHandleRequest(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
reg.Mount(ctx, "test", EngineTypeTransit)
|
reg.Mount(ctx, "test", EngineTypeTransit, nil)
|
||||||
|
|
||||||
resp, err := reg.HandleRequest(ctx, "test", &Request{Operation: "encrypt"})
|
resp, err := reg.HandleRequest(ctx, "test", &Request{Operation: "encrypt"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,8 +117,8 @@ func TestRegistrySealAll(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
reg.Mount(ctx, "eng1", EngineTypeTransit)
|
reg.Mount(ctx, "eng1", EngineTypeTransit, nil)
|
||||||
reg.Mount(ctx, "eng2", EngineTypeTransit)
|
reg.Mount(ctx, "eng2", EngineTypeTransit, nil)
|
||||||
|
|
||||||
if err := reg.SealAll(); err != nil {
|
if err := reg.SealAll(); err != nil {
|
||||||
t.Fatalf("SealAll: %v", err)
|
t.Fatalf("SealAll: %v", err)
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
|
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
|
"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/policy"
|
||||||
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
|
"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/unmount", s.requireAdmin(s.handleEngineUnmount))
|
||||||
mux.HandleFunc("/v1/engine/request", s.requireAuth(s.handleEngineRequest))
|
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/rules", s.requireAuth(s.handlePolicyRules))
|
||||||
mux.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule))
|
mux.HandleFunc("/v1/policy/rule", s.requireAuth(s.handlePolicyRule))
|
||||||
}
|
}
|
||||||
@@ -239,15 +248,25 @@ func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
Config map[string]interface{} `json:"config"`
|
||||||
}
|
}
|
||||||
if err := readJSON(r, &req); err != nil {
|
if err := readJSON(r, &req); err != nil {
|
||||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Phase 1: no engine types registered yet.
|
if req.Name == "" || req.Type == "" {
|
||||||
http.Error(w, `{"error":"no engine types available in phase 1"}`, http.StatusNotImplemented)
|
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) {
|
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)
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
return
|
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) {
|
func (s *Server) handlePolicyRules(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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"
|
|
||||||
Reference in New Issue
Block a user