Update engine specs, audit doc, and server tests for SSH CA, transit, and user engines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 20:16:23 -07:00
parent 7237b2951e
commit 128f5abc4d
6 changed files with 1309 additions and 182 deletions

121
AUDIT.md
View File

@@ -105,12 +105,9 @@ User certificates should ideally include `source-address` critical options to li
Added `min_decryption_version` per key (default 1). Decryption requests for versions below the minimum are rejected. New `update-key-config` operation (admin-only) advances the minimum (can only increase, cannot exceed current version). New `trim-key` operation permanently deletes versions older than the minimum. Both have corresponding gRPC RPCs and REST endpoints. The rotation cycle is documented: rotate → rewrap → advance min → trim. Added `min_decryption_version` per key (default 1). Decryption requests for versions below the minimum are rejected. New `update-key-config` operation (admin-only) advances the minimum (can only increase, cannot exceed current version). New `trim-key` operation permanently deletes versions older than the minimum. Both have corresponding gRPC RPCs and REST endpoints. The rotation cycle is documented: rotate → rewrap → advance min → trim.
**16. Key version pruning with `max_key_versions` has no safety check** **16. ~~Key version pruning with `max_key_versions` has no safety check~~ RESOLVED**
If `max_key_versions` is set and data encrypted with an old version hasn't been re-wrapped, pruning that version makes the data permanently unrecoverable. There should be either: Added explicit `max_key_versions` behavior: auto-pruning during `rotate-key` only deletes versions strictly less than `min_decryption_version`. If the version count exceeds the limit but no eligible candidates remain, a warning is returned. This ensures pruning never destroys versions that may still have unrewrapped ciphertext. See also #30.
- A warning/confirmation mechanism, or
- A way to scan for ciphertext referencing a version before pruning, or
- At minimum, clear documentation that pruning is destructive.
**17. ~~RSA encryption without specifying padding scheme~~ RESOLVED** **17. ~~RSA encryption without specifying padding scheme~~ RESOLVED**
@@ -144,6 +141,106 @@ A compromised or malicious user token could issue unlimited encrypt/decrypt/sign
--- ---
## Engine Design Review (2026-03-16)
**Scope**: engines/sshca.md, engines/transit.md, engines/user.md (patched specs)
### engines/sshca.md
#### Strengths
- RSA excluded — reduces attack surface, correct for SSH CA use case.
- Detailed Go code snippets for Initialize, sign-host, sign-user flows.
- KRL custom implementation correctly identified that `x/crypto/ssh` lacks KRL builders.
- Signing profiles are the only path to critical options — good privilege separation.
- Server-side serial generation with `crypto/rand` — no user-controllable serials.
#### Issues
**25. ~~Missing `list-certs` REST route~~ RESOLVED**
Added `GET /v1/sshca/{mount}/certs` to the REST endpoints table and route registration code block. API sync restored.
**26. ~~KRL section type description contradicts pseudocode~~ RESOLVED**
Fixed the description block to use `KRL_SECTION_CERTIFICATES (0x01)` for the outer section type, matching the pseudocode and the OpenSSH `PROTOCOL.krl` spec.
**27. ~~Policy check after certificate construction in sign-host~~ RESOLVED**
Reordered both `sign-host` and `sign-user` flows to perform the policy check before generating the serial and building the certificate. Serial generation now only happens after authorization succeeds.
### engines/transit.md
#### Strengths
- XChaCha20-Poly1305 (not ChaCha20-Poly1305) — correct for random nonce safety.
- All nonce sizes, hash algorithms, and signature encodings now specified.
- `trim-key` logic is detailed and safe (no-op when `min_decryption_version` is 1).
- Batch operations hold a read lock for atomicity with respect to key rotation.
- 500-item batch limit prevents resource exhaustion.
#### Issues
**28. ~~HMAC output not versioned — unverifiable after key rotation~~ RESOLVED**
HMAC output now uses the same `metacrypt:v{version}:{base64}` format as ciphertext and signatures. Verification parses the version prefix, loads the corresponding key (subject to `min_decryption_version`), and uses `hmac.Equal` for constant-time comparison.
**29. ~~`rewrap` policy action not specified~~ RESOLVED**
`rewrap` and `batch-rewrap` now map to the `decrypt` action — rewrap internally decrypts and re-encrypts, so the caller must have decrypt permission. Batch variants map to the same action as their single counterparts. Documented in the authorization section.
**30. ~~`max_key_versions` interaction with `min_decryption_version` unclear~~ RESOLVED**
Added explicit `max_key_versions` behavior section. Pruning happens during `rotate-key` and only deletes versions strictly less than `min_decryption_version`. If the limit is exceeded but no eligible candidates remain, a warning is returned. This also resolves audit finding #16.
**31. ~~Missing `get-public-key` REST route~~ RESOLVED**
Added `GET /v1/transit/{mount}/keys/{name}/public-key` to the REST endpoints table and route registration code block. API sync restored.
**32. ~~`exportable` flag with no export operation~~ RESOLVED**
Removed the `exportable` flag from `create-key`. Transit's value proposition is that keys never leave the service. If export is needed for migration, a dedicated admin-only operation can be added later with audit logging.
### engines/user.md
#### Strengths
- HKDF with per-recipient random salt — prevents wrapping key reuse across messages.
- AES-256-GCM for DEK wrapping (consistent with codebase, avoids new primitive).
- ECDH key agreement with info-string binding prevents key confusion.
- Explicit zeroization of all intermediate secrets documented.
- Envelope format includes salt per-recipient — correct for HKDF security.
#### Issues
**33. ~~Auto-provisioning creates keys for arbitrary usernames~~ RESOLVED**
The encrypt flow now validates recipient usernames against MCIAS via `auth.ValidateUsername` before auto-provisioning. Non-existent usernames are rejected with an error, preventing barrier pollution.
**34. ~~No recipient limit on encrypt~~ RESOLVED**
Added a `maxRecipients = 100` limit. Requests exceeding this limit are rejected with `400 Bad Request` before any ECDH computation.
**35. ~~No re-encryption support for key rotation~~ RESOLVED**
Added a `re-encrypt` operation that decrypts an envelope and re-encrypts it with current key pairs for all recipients. This enables safe key rotation: re-encrypt all stored envelopes first, then call `rotate-key`. Added to HandleRequest dispatch, gRPC service, REST endpoints, and route registration.
**36. ~~`UserKeyConfig` type undefined~~ RESOLVED**
Defined `UserKeyConfig` struct with `Algorithm`, `CreatedAt`, and `AutoProvisioned` fields in the in-memory state section.
### Cross-Cutting Issues (Engine Designs)
**37. ~~`adminOnlyOperations` name collision blocks user engine `rotate-key`~~ RESOLVED**
Changed the `adminOnlyOperations` map from flat operation names to engine-type-qualified keys (`engineType:operation`, e.g. `"transit:rotate-key"`). The generic endpoint now resolves the mount's engine type via `GetMount` before checking the map. Added tests verifying that `rotate-key` on a user mount succeeds for non-admin users while `rotate-key` on a transit mount correctly requires admin.
**38. ~~`engine.ZeroizeKey` helper prerequisite not cross-referenced~~ RESOLVED**
Added prerequisite step to both transit and user implementation steps referencing `engines/sshca.md` step 1 for the `engine.ZeroizeKey` shared helper.
---
## Priority Summary ## Priority Summary
| Priority | Issue | Location | | Priority | Issue | Location |
@@ -151,6 +248,7 @@ A compromised or malicious user token could issue unlimited encrypt/decrypt/sign
| ~~**Critical**~~ | ~~#4 — Policy auth contradiction (admin vs user)~~ **RESOLVED** | ARCHITECTURE.md | | ~~**Critical**~~ | ~~#4 — Policy auth contradiction (admin vs user)~~ **RESOLVED** | ARCHITECTURE.md |
| ~~**Critical**~~ | ~~#9 — User-controllable SSH cert serials~~ **RESOLVED** | sshca.md | | ~~**Critical**~~ | ~~#9 — User-controllable SSH cert serials~~ **RESOLVED** | sshca.md |
| ~~**Critical**~~ | ~~#13 — Policy path collision (`ca/` vs `sshca/`)~~ **RESOLVED** | sshca.md | | ~~**Critical**~~ | ~~#13 — Policy path collision (`ca/` vs `sshca/`)~~ **RESOLVED** | sshca.md |
| ~~**Critical**~~ | ~~#37 — `adminOnlyOperations` name collision blocks user `rotate-key`~~ **RESOLVED** | Cross-cutting |
| ~~**High**~~ | ~~#5 — No path AAD in barrier encryption~~ **RESOLVED** | ARCHITECTURE.md | | ~~**High**~~ | ~~#5 — No path AAD in barrier encryption~~ **RESOLVED** | ARCHITECTURE.md |
| ~~**High**~~ | ~~#12 — No KRL distribution for SSH revocation~~ **RESOLVED** | sshca.md | | ~~**High**~~ | ~~#12 — No KRL distribution for SSH revocation~~ **RESOLVED** | sshca.md |
| ~~**High**~~ | ~~#15 — No min key version for transit rotation~~ **RESOLVED** | transit.md | | ~~**High**~~ | ~~#15 — No min key version for transit rotation~~ **RESOLVED** | transit.md |
@@ -158,12 +256,25 @@ A compromised or malicious user token could issue unlimited encrypt/decrypt/sign
| ~~**High**~~ | ~~#11 — `critical_options` not restricted~~ **RESOLVED** | sshca.md | | ~~**High**~~ | ~~#11 — `critical_options` not restricted~~ **RESOLVED** | sshca.md |
| ~~**High**~~ | ~~#6 — Single MEK with no rotation~~ **RESOLVED** | ARCHITECTURE.md | | ~~**High**~~ | ~~#6 — Single MEK with no rotation~~ **RESOLVED** | ARCHITECTURE.md |
| ~~**High**~~ | ~~#22 — No forward secrecy / per-engine DEKs~~ **RESOLVED** | Cross-cutting | | ~~**High**~~ | ~~#22 — No forward secrecy / per-engine DEKs~~ **RESOLVED** | Cross-cutting |
| ~~**High**~~ | ~~#28 — HMAC output not versioned~~ **RESOLVED** | transit.md |
| ~~**High**~~ | ~~#30 — `max_key_versions` vs `min_decryption_version` unclear~~ **RESOLVED** | transit.md |
| ~~**High**~~ | ~~#33 — Auto-provision creates keys for arbitrary usernames~~ **RESOLVED** | user.md |
| ~~**Medium**~~ | ~~#2 — Token cache revocation gap~~ **ACCEPTED** | ARCHITECTURE.md | | ~~**Medium**~~ | ~~#2 — Token cache revocation gap~~ **ACCEPTED** | ARCHITECTURE.md |
| ~~**Medium**~~ | ~~#3 — Admin all-or-nothing access~~ **ACCEPTED** | ARCHITECTURE.md | | ~~**Medium**~~ | ~~#3 — Admin all-or-nothing access~~ **ACCEPTED** | ARCHITECTURE.md |
| ~~**Medium**~~ | ~~#8 — Unseal rate limit resets on restart~~ **ACCEPTED** | ARCHITECTURE.md | | ~~**Medium**~~ | ~~#8 — Unseal rate limit resets on restart~~ **ACCEPTED** | ARCHITECTURE.md |
| ~~**Medium**~~ | ~~#20 — `decrypt` mapped to `read` action~~ **RESOLVED** | transit.md | | ~~**Medium**~~ | ~~#20 — `decrypt` mapped to `read` action~~ **RESOLVED** | transit.md |
| ~~**Medium**~~ | ~~#24 — No CSRF protection for web UI~~ **RESOLVED** | ARCHITECTURE.md | | ~~**Medium**~~ | ~~#24 — No CSRF protection for web UI~~ **RESOLVED** | ARCHITECTURE.md |
| ~~**Medium**~~ | ~~#25 — Missing `list-certs` REST route~~ **RESOLVED** | sshca.md |
| ~~**Medium**~~ | ~~#26 — KRL section type description error~~ **RESOLVED** | sshca.md |
| ~~**Medium**~~ | ~~#27 — Policy check after cert construction~~ **RESOLVED** | sshca.md |
| ~~**Medium**~~ | ~~#29 — `rewrap` policy action not specified~~ **RESOLVED** | transit.md |
| ~~**Medium**~~ | ~~#31 — Missing `get-public-key` REST route~~ **RESOLVED** | transit.md |
| ~~**Medium**~~ | ~~#34 — No recipient limit on encrypt~~ **RESOLVED** | user.md |
| ~~**Low**~~ | ~~#1 — TLS 1.2 vs 1.3~~ **RESOLVED** | ARCHITECTURE.md | | ~~**Low**~~ | ~~#1 — TLS 1.2 vs 1.3~~ **RESOLVED** | ARCHITECTURE.md |
| ~~**Low**~~ | ~~#19 — No batch transit operations~~ **RESOLVED** | transit.md | | ~~**Low**~~ | ~~#19 — No batch transit operations~~ **RESOLVED** | transit.md |
| ~~**Low**~~ | ~~#18 — HMAC/sign semantic confusion~~ **RESOLVED** | transit.md | | ~~**Low**~~ | ~~#18 — HMAC/sign semantic confusion~~ **RESOLVED** | transit.md |
| ~~**Medium**~~ | ~~#23 — Generic endpoint bypasses typed route middleware~~ **RESOLVED** | Cross-cutting | | ~~**Medium**~~ | ~~#23 — Generic endpoint bypasses typed route middleware~~ **RESOLVED** | Cross-cutting |
| ~~**Low**~~ | ~~#32 — `exportable` flag with no export operation~~ **RESOLVED** | transit.md |
| ~~**Low**~~ | ~~#35 — No re-encryption support for user key rotation~~ **RESOLVED** | user.md |
| ~~**Low**~~ | ~~#36 — `UserKeyConfig` type undefined~~ **RESOLVED** | user.md |
| ~~**Low**~~ | ~~#38 — `ZeroizeKey` prerequisite not cross-referenced~~ **RESOLVED** | Cross-cutting |

View File

@@ -1,12 +1,31 @@
# Metacircular Dynamics — Engineering Standards # Metacircular Dynamics — Engineering Standards
Source: https://metacircular.net/roam/20260314210051-metacircular_dynamics.html
This document describes the standard repository layout, tooling, and software This document describes the standard repository layout, tooling, and software
development lifecycle (SDLC) for services built at Metacircular Dynamics. It is development lifecycle (SDLC) for services built at Metacircular Dynamics. It
derived from the conventions established in Metacrypt and codifies them as the incorporates the platform-wide project guidelines and codifies the conventions
baseline for all new and existing services. established in Metacrypt as the baseline for all services.
## Platform Rules
These four rules apply to every Metacircular service:
1. **Data Storage**: All service data goes in `/srv/<service>/` to enable
straightforward migration across systems.
2. **Deployment Architecture**: Services require systemd unit files but
prioritize container-first design to support deployment via the
Metacircular Control Plane (MCP).
3. **Identity Management**: Services must integrate with MCIAS (Metacircular
Identity and Access Service) for user management and access control. Three
role levels: `admin` (full administrative access), `user` (full
non-administrative access), `guest` (service-dependent restrictions).
4. **API Design**: Services expose both gRPC and REST interfaces, kept in
sync. Web UIs are built with htmx.
## Table of Contents ## Table of Contents
0. [Platform Rules](#platform-rules)
1. [Repository Layout](#repository-layout) 1. [Repository Layout](#repository-layout)
2. [Language & Toolchain](#language--toolchain) 2. [Language & Toolchain](#language--toolchain)
3. [Build System](#build-system) 3. [Build System](#build-system)
@@ -559,10 +578,35 @@ Services handle `SIGINT` and `SIGTERM`, shutting down cleanly:
| File | Purpose | Audience | | File | Purpose | Audience |
|------|---------|----------| |------|---------|----------|
| `README.md` | Project overview, quick-start, and contributor guide | Everyone |
| `CLAUDE.md` | AI-assisted development context | Claude Code | | `CLAUDE.md` | AI-assisted development context | Claude Code |
| `ARCHITECTURE.md` | Full system specification | Engineers | | `ARCHITECTURE.md` | Full system specification | Engineers |
| `RUNBOOK.md` | Operational procedures and incident response | Operators |
| `deploy/examples/<service>.toml` | Example configuration | Operators | | `deploy/examples/<service>.toml` | Example configuration | Operators |
### Suggested Files
These are not required for every project but should be created where applicable:
| File | When to Include | Purpose |
|------|-----------------|---------|
| `AUDIT.md` | Services handling cryptography, secrets, PII, or auth | Security audit findings with issue tracking and resolution status |
| `POLICY.md` | Services with fine-grained access control | Policy engine documentation: rule structure, evaluation algorithm, resource paths, action classification, common patterns |
### README.md
The README is the front door. A new engineer or user should be able to
understand what the service does and get it running from this file alone.
It should contain:
- Project name and one-paragraph description.
- Quick-start instructions (build, configure, run).
- Link to `ARCHITECTURE.md` for full technical details.
- Link to `RUNBOOK.md` for operational procedures.
- License and contribution notes (if applicable).
Keep it concise. The README is not the spec — that's `ARCHITECTURE.md`.
### CLAUDE.md ### CLAUDE.md
This file provides context for AI-assisted development. It should contain: This file provides context for AI-assisted development. It should contain:
@@ -596,6 +640,56 @@ This is the canonical specification for the service. It should cover:
This document is the source of truth. When the code and the spec disagree, This document is the source of truth. When the code and the spec disagree,
one of them has a bug. one of them has a bug.
### RUNBOOK.md
The runbook is written for operators, not developers. It covers what to do
when things go wrong and how to perform routine maintenance. It should
contain:
1. **Service overview** — what the service does, in one paragraph.
2. **Health checks** — how to verify the service is healthy (endpoints,
CLI commands, expected responses).
3. **Common operations** — start, stop, restart, seal/unseal, backup,
restore, log inspection.
4. **Alerting** — what alerts exist, what they mean, and how to respond.
5. **Incident procedures** — step-by-step playbooks for known failure
modes (database corruption, certificate expiry, MCIAS outage, disk
full, etc.).
6. **Escalation** — when and how to escalate beyond the runbook.
Write runbook entries as numbered steps, not prose. An operator at 3 AM
should be able to follow them without thinking.
### AUDIT.md (Suggested)
For services that handle cryptography, secrets, PII, or authentication,
maintain a security audit log. Each finding gets a numbered entry with:
- Description of the issue.
- Severity (critical, high, medium, low).
- Resolution status: open, resolved (with summary), or accepted (with
rationale for accepting the risk).
The priority summary table at the bottom provides a scannable overview.
Resolved and accepted items are struck through but retained for history.
See Metacrypt's `AUDIT.md` for the reference format.
### POLICY.md (Suggested)
For services with a policy engine or fine-grained access control, document
the policy model separately from the architecture spec. It should cover:
- Rule structure (fields, types, semantics).
- Evaluation algorithm (match logic, priority, default effect).
- Resource path conventions and glob patterns.
- Action classification.
- API endpoints for policy CRUD.
- Common policy patterns with examples.
- Role summary (what each MCIAS role gets by default).
This document is aimed at administrators who need to write policy rules,
not engineers who need to understand the implementation.
### Engine/Feature Design Documents ### Engine/Feature Design Documents
For services with a modular architecture, each module gets its own design For services with a modular architecture, each module gets its own design

View File

@@ -17,11 +17,13 @@ Passed as `config` at mount time:
| Field | Default | Description | | Field | Default | Description |
|-----------------|------------------|------------------------------------------| |-----------------|------------------|------------------------------------------|
| `key_algorithm` | `"ed25519"` | CA key type: ed25519, ecdsa, rsa | | `key_algorithm` | `"ed25519"` | CA key type: `ed25519`, `ecdsa-p256`, `ecdsa-p384` |
| `key_size` | `0` | Key size (ignored for ed25519; 256/384/521 for ECDSA, 2048/4096 for RSA) |
| `max_ttl` | `"87600h"` | Maximum certificate validity | | `max_ttl` | `"87600h"` | Maximum certificate validity |
| `default_ttl` | `"24h"` | Default certificate validity | | `default_ttl` | `"24h"` | Default certificate validity |
RSA is intentionally excluded — Ed25519 and ECDSA are preferred for SSH CAs.
This avoids the need for a `key_size` parameter and simplifies key generation.
## Barrier Storage Layout ## Barrier Storage Layout
``` ```
@@ -30,19 +32,20 @@ engine/sshca/{mount}/ca/key.pem CA private key (PEM, PKCS8)
engine/sshca/{mount}/ca/pubkey.pub CA public key (SSH authorized_keys format) engine/sshca/{mount}/ca/pubkey.pub CA public key (SSH authorized_keys format)
engine/sshca/{mount}/profiles/{name}.json Signing profiles engine/sshca/{mount}/profiles/{name}.json Signing profiles
engine/sshca/{mount}/certs/{serial}.json Signed cert records engine/sshca/{mount}/certs/{serial}.json Signed cert records
engine/sshca/{mount}/krl.bin Current KRL (OpenSSH format) engine/sshca/{mount}/krl_version.json KRL version counter
``` ```
## In-Memory State ## In-Memory State
```go ```go
type SSHCAEngine struct { type SSHCAEngine struct {
barrier barrier.Barrier barrier barrier.Barrier
config *SSHCAConfig config *SSHCAConfig
caKey crypto.PrivateKey // CA signing key caKey crypto.PrivateKey // CA signing key
caSigner ssh.Signer // ssh.Signer wrapping caKey caSigner ssh.Signer // ssh.Signer wrapping caKey
mountPath string mountPath string
mu sync.RWMutex krlVersion uint64 // monotonically increasing
mu sync.RWMutex
} }
``` ```
@@ -52,20 +55,34 @@ Key material (`caKey`, `caSigner`) is zeroized on `Seal()`.
### Initialize ### Initialize
1. Parse and store config in barrier as `config.json`. 1. Parse and validate config: ensure `key_algorithm` is one of `ed25519`,
2. Generate CA key pair using the configured algorithm. `ecdsa-p256`, `ecdsa-p384`. Parse `max_ttl` and `default_ttl` as
3. Store private key PEM and SSH public key in barrier. `time.Duration`.
4. Load key into memory as `ssh.Signer`. 2. Store config in barrier as `{mountPath}config.json`.
3. Generate CA key pair:
- `ed25519`: `ed25519.GenerateKey(rand.Reader)`
- `ecdsa-p256`: `ecdsa.GenerateKey(elliptic.P256(), rand.Reader)`
- `ecdsa-p384`: `ecdsa.GenerateKey(elliptic.P384(), rand.Reader)`
4. Marshal private key to PEM using `x509.MarshalPKCS8PrivateKey`
`pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})`.
5. Store private key PEM in barrier at `{mountPath}ca/key.pem`.
6. Generate SSH public key via `ssh.NewPublicKey(pubKey)`, marshal with
`ssh.MarshalAuthorizedKey`. Store at `{mountPath}ca/pubkey.pub`.
7. Load key into memory: `ssh.NewSignerFromKey(caKey)``caSigner`.
8. Initialize `krlVersion` to 0, store in barrier.
### Unseal ### Unseal
1. Load config from barrier. 1. Load config JSON from barrier, unmarshal into `*SSHCAConfig`.
2. Load CA private key from barrier, parse into `crypto.PrivateKey`. 2. Load `{mountPath}ca/key.pem` from barrier, decode PEM, parse with
3. Wrap as `ssh.Signer`. `x509.ParsePKCS8PrivateKey``caKey`.
3. Create `caSigner` via `ssh.NewSignerFromKey(caKey)`.
4. Load `krl_version.json` from barrier → `krlVersion`.
### Seal ### Seal
1. Zeroize `caKey` (same `zeroizeKey` helper used by CA engine). 1. Zeroize `caKey` using the shared `zeroizeKey` helper (see Implementation
References below).
2. Nil out `caSigner`, `config`. 2. Nil out `caSigner`, `config`.
## Operations ## Operations
@@ -85,6 +102,43 @@ Key material (`caKey`, `caSigner`) is zeroized on `Seal()`.
| `revoke-cert` | Admin | Revoke a certificate (soft flag) | | `revoke-cert` | Admin | Revoke a certificate (soft flag) |
| `delete-cert` | Admin | Delete a certificate record | | `delete-cert` | Admin | Delete a certificate record |
### HandleRequest dispatch
Follow the CA engine's pattern (`internal/engine/ca/ca.go:284-317`):
```go
func (e *SSHCAEngine) HandleRequest(ctx context.Context, req *engine.Request) (*engine.Response, error) {
switch req.Operation {
case "get-ca-pubkey":
return e.handleGetCAPublicKey(ctx)
case "sign-host":
return e.handleSignHost(ctx, req)
case "sign-user":
return e.handleSignUser(ctx, req)
case "create-profile":
return e.handleCreateProfile(ctx, req)
case "update-profile":
return e.handleUpdateProfile(ctx, req)
case "get-profile":
return e.handleGetProfile(ctx, req)
case "list-profiles":
return e.handleListProfiles(ctx, req)
case "delete-profile":
return e.handleDeleteProfile(ctx, req)
case "get-cert":
return e.handleGetCert(ctx, req)
case "list-certs":
return e.handleListCerts(ctx, req)
case "revoke-cert":
return e.handleRevokeCert(ctx, req)
case "delete-cert":
return e.handleDeleteCert(ctx, req)
default:
return nil, fmt.Errorf("sshca: unknown operation: %s", req.Operation)
}
}
```
### sign-host ### sign-host
Request data: Request data:
@@ -97,15 +151,30 @@ Request data:
| `extensions` | No | Map of extensions to include | | `extensions` | No | Map of extensions to include |
Flow: Flow:
1. Authenticate caller (`IsUser()`); admins bypass policy/ownership checks. 1. Authenticate caller (`req.CallerInfo.IsUser()`); admins bypass policy checks.
2. Parse the supplied SSH public key. 2. Parse the supplied SSH public key with `ssh.ParsePublicKey(ssh.ParseAuthorizedKey(...))`.
3. Generate a 64-bit serial using `crypto/rand`. 3. Parse TTL: if provided parse as `time.Duration`, cap at `config.MaxTTL`.
4. Build `ssh.Certificate` with `CertType: ssh.HostCert`, principals, validity, serial. If not provided, use `config.DefaultTTL`.
5. Policy check: `sshca/{mount}/id/{hostname}` for each principal, with ownership 4. Policy check: for each hostname, check policy on
rules (same as CA engine — hostname not held by another user's active cert). `sshca/{mount}/id/{hostname}`, action `sign`. Use `req.CheckPolicy`.
6. Sign with `caSigner`. Fail early before generating a serial or building the cert.
7. Store `CertRecord` in barrier (certificate bytes, metadata; **no private key**). 5. Generate a 64-bit serial: `var buf [8]byte; rand.Read(buf[:]); serial := binary.BigEndian.Uint64(buf[:])`.
8. Return signed certificate in OpenSSH format. 6. Build `ssh.Certificate`:
```go
cert := &ssh.Certificate{
Key: parsedPubKey,
Serial: serial,
CertType: ssh.HostCert,
KeyId: fmt.Sprintf("host:%s:%d", hostnames[0], serial),
ValidPrincipals: hostnames,
ValidAfter: uint64(time.Now().Unix()),
ValidBefore: uint64(time.Now().Add(ttl).Unix()),
Permissions: ssh.Permissions{Extensions: extensions},
}
```
7. Sign: `cert.SignCert(rand.Reader, e.caSigner)`.
8. Store `CertRecord` in barrier at `{mountPath}certs/{serial}.json`.
9. Return: `{"certificate": ssh.MarshalAuthorizedKey(cert), "serial": serial}`.
### sign-user ### sign-user
@@ -126,20 +195,26 @@ setting security-sensitive options like `force-command` or `source-address`.
Flow: Flow:
1. Authenticate caller (`IsUser()`); admins bypass. 1. Authenticate caller (`IsUser()`); admins bypass.
2. Parse the supplied SSH public key. 2. Parse the supplied SSH public key.
3. If `profile` is specified, load the signing profile and check policy 3. If `profile` is specified, load the signing profile from barrier and check
(`sshca/{mount}/profile/{profile_name}`, action `read`). Merge the policy (`sshca/{mount}/profile/{profile_name}`, action `read`). Merge the
profile's critical options and extensions into the certificate. Any profile's critical options and extensions into the certificate. Any
extensions in the request are merged with profile extensions; conflicts extensions in the request are merged with profile extensions; conflicts
are resolved in favor of the profile. are resolved in favor of the profile. If the profile specifies
4. Generate a 64-bit serial using `crypto/rand`. `allowed_principals`, verify all requested principals are in the list.
5. Build `ssh.Certificate` with `CertType: ssh.UserCert`, principals, validity, serial. 4. If the profile specifies `max_ttl`, enforce it (cap the requested TTL).
6. If the profile specifies `max_ttl`, enforce it (cap the requested TTL). 5. Policy check: `sshca/{mount}/id/{principal}` for each principal, action `sign`.
7. Policy check: `sshca/{mount}/id/{principal}` for each principal.
Default rule: a user can only sign certs for their own username as principal, Default rule: a user can only sign certs for their own username as principal,
unless a policy grants access to other principals. unless a policy grants access to other principals. Implement by checking
8. Sign with `caSigner`. `req.CallerInfo.Username == principal` as the default-allow case.
9. Store `CertRecord` in barrier (includes profile name if used). Fail early before generating a serial or building the cert.
10. Return signed certificate in OpenSSH format. 6. Generate a 64-bit serial using `crypto/rand`.
7. Build `ssh.Certificate` with `CertType: ssh.UserCert`, principals, validity.
8. Set `Permissions.CriticalOptions` from profile (if any) and
`Permissions.Extensions` from merged extensions. Default extensions when
none specified: `{"permit-pty": ""}`.
9. Sign with `caSigner`.
10. Store `CertRecord` in barrier (includes profile name if used).
11. Return signed certificate in OpenSSH format + serial.
### Signing Profiles ### Signing Profiles
@@ -151,11 +226,11 @@ options, and access to each profile is policy-gated.
```go ```go
type SigningProfile struct { type SigningProfile struct {
Name string `json:"name"` Name string `json:"name"`
CriticalOptions map[string]string `json:"critical_options"` // e.g. {"force-command": "/usr/bin/rsync", "source-address": "10.0.0.0/8"} CriticalOptions map[string]string `json:"critical_options"` // e.g. {"force-command": "/usr/bin/rsync", "source-address": "10.0.0.0/8"}
Extensions map[string]string `json:"extensions"` // merged with request extensions Extensions map[string]string `json:"extensions"` // merged with request extensions
MaxTTL string `json:"max_ttl,omitempty"` // overrides engine max_ttl if shorter MaxTTL string `json:"max_ttl,omitempty"` // overrides engine max_ttl if shorter
AllowedPrincipals []string `json:"allowed_principals,omitempty"` // if set, restricts principals AllowedPrincipals []string `json:"allowed_principals,omitempty"` // if set, restricts principals
} }
``` ```
@@ -165,16 +240,6 @@ type SigningProfile struct {
engine/sshca/{mount}/profiles/{name}.json engine/sshca/{mount}/profiles/{name}.json
``` ```
#### Operations
| Operation | Auth Required | Description |
|------------------|---------------|------------------------------------------|
| `create-profile` | Admin | Create a signing profile |
| `update-profile` | Admin | Update a signing profile |
| `get-profile` | User/Admin | Get profile details |
| `list-profiles` | User/Admin | List available profiles |
| `delete-profile` | Admin | Delete a signing profile |
#### Policy Gating #### Policy Gating
Access to a profile is controlled via policy on resource Access to a profile is controlled via policy on resource
@@ -194,7 +259,9 @@ type CertRecord struct {
Serial uint64 `json:"serial"` Serial uint64 `json:"serial"`
CertType string `json:"cert_type"` // "host" or "user" CertType string `json:"cert_type"` // "host" or "user"
Principals []string `json:"principals"` Principals []string `json:"principals"`
CertData string `json:"cert_data"` // OpenSSH format CertData string `json:"cert_data"` // OpenSSH authorized_keys format
KeyID string `json:"key_id"` // certificate KeyId field
Profile string `json:"profile,omitempty"` // signing profile used (if any)
IssuedBy string `json:"issued_by"` IssuedBy string `json:"issued_by"`
IssuedAt time.Time `json:"issued_at"` IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
@@ -204,31 +271,71 @@ type CertRecord struct {
} }
``` ```
Serial is stored as `uint64` (not string) since SSH certificate serials are
uint64 natively. Barrier path uses the decimal string representation:
`fmt.Sprintf("%d", serial)`.
## Key Revocation List (KRL) ## Key Revocation List (KRL)
SSH servers cannot query Metacrypt in real time to check whether a certificate SSH servers cannot query Metacrypt in real time to check whether a certificate
has been revoked. Instead, the SSH CA engine generates an OpenSSH-format KRL has been revoked. Instead, the SSH CA engine generates a KRL that SSH servers
(Key Revocation List) that SSH servers fetch periodically and reference via fetch periodically and reference via `RevokedKeys` in `sshd_config`.
`RevokedKeys` in `sshd_config`.
### KRL Generation ### KRL Generation — Custom Implementation
The engine maintains a KRL in memory, rebuilt whenever a certificate is revoked **Important**: `golang.org/x/crypto/ssh` does **not** provide KRL generation
or deleted. The KRL is a binary blob in OpenSSH KRL format helpers. It can parse KRLs but not build them. The engine must implement KRL
(`golang.org/x/crypto/ssh` provides marshalling helpers), containing: serialization directly per the OpenSSH KRL format specification
(`PROTOCOL.krl` in the OpenSSH source).
- **Serial revocations**: Revoked certificate serial numbers, keyed to the CA The KRL format is a binary structure:
public key. This is the most compact representation.
- **KRL version**: Monotonically increasing counter, incremented on each
rebuild. SSH servers can use this to detect stale KRLs.
- **Generated-at timestamp**: Included in the KRL for freshness checking.
The KRL is stored in the barrier at `engine/sshca/{mount}/krl.bin` and cached ```
in memory. It is rebuilt on: MAGIC = "OPENSSH_KRL\x00" (12 bytes)
- `revoke-cert` — adds the serial to the KRL. VERSION = uint32 (format version, always 1)
- `delete-cert` — if the cert was revoked, the KRL is regenerated from all KRL_VERSION = uint64 (monotonically increasing per rebuild)
remaining revoked certs. GENERATED_DATE = uint64 (Unix timestamp)
- Engine unseal — loaded from barrier into memory. FLAGS = uint64 (0)
RESERVED = string (empty)
COMMENT = string (empty)
SECTIONS... (one or more typed sections)
```
For serial-based revocation (the simplest and most compact representation):
```
Section type: KRL_SECTION_CERTIFICATES (0x01)
CA key blob: ssh.MarshalAuthorizedKey(caSigner.PublicKey())
Subsection type: KRL_SECTION_CERT_SERIAL_LIST (0x20)
Revoked serials: sorted list of uint64 serials
```
Implement as a `buildKRL` function:
```go
func (e *SSHCAEngine) buildKRL(revokedSerials []uint64) []byte {
// 1. Sort serials.
// 2. Write MAGIC header.
// 3. Write KRL_VERSION (e.krlVersion), GENERATED_DATE (now), FLAGS (0).
// 4. Write RESERVED (empty string), COMMENT (empty string).
// 5. Write section header: type=0x01 (KRL_SECTION_CERTIFICATES).
// 6. Write CA public key blob.
// 7. Write subsection: type=0x20 (KRL_SECTION_CERT_SERIAL_LIST),
// followed by each serial as uint64 big-endian.
// 8. Return assembled bytes.
}
```
Use `encoding/binary` with `binary.BigEndian` for all integer encoding.
SSH strings are length-prefixed: `uint32(len) + bytes`.
The KRL version counter is persisted in barrier at `{mountPath}krl_version.json`
and incremented on each rebuild. On unseal, the counter is loaded from barrier.
The KRL is rebuilt (not stored in barrier — it's a derived artifact) on:
- `revoke-cert` — collects all revoked serials, rebuilds.
- `delete-cert` — if the cert was revoked, rebuilds from remaining revoked certs.
- Engine unseal — rebuilds from all revoked certs.
### Distribution ### Distribution
@@ -237,13 +344,12 @@ unauthenticated endpoint (analogous to the public CA key endpoint):
| Method | Path | Description | | Method | Path | Description |
|--------|-------------------------------------|--------------------------------| |--------|-------------------------------------|--------------------------------|
| GET | `/v1/sshca/{mount}/krl` | Current KRL (binary, OpenSSH format) | | GET | `/v1/sshca/{mount}/krl` | Current KRL (binary) |
The response includes: The response includes:
- `Content-Type: application/octet-stream` - `Content-Type: application/octet-stream`
- `ETag` header derived from the KRL version, enabling conditional fetches. - `ETag` header: `fmt.Sprintf("%d", e.krlVersion)`, enabling conditional fetches.
- `Cache-Control: max-age=60` to encourage periodic refresh without - `Cache-Control: max-age=60` to encourage periodic refresh.
overwhelming the server.
SSH servers should be configured to fetch the KRL on a cron schedule (e.g. SSH servers should be configured to fetch the KRL on a cron schedule (e.g.
every 15 minutes) and write it to a local file referenced by `sshd_config`: every 15 minutes) and write it to a local file referenced by `sshd_config`:
@@ -252,19 +358,6 @@ every 15 minutes) and write it to a local file referenced by `sshd_config`:
RevokedKeys /etc/ssh/metacrypt_krl RevokedKeys /etc/ssh/metacrypt_krl
``` ```
A helper script or systemd timer can fetch the KRL:
```bash
curl -s -o /etc/ssh/metacrypt_krl \
https://metacrypt.example.com:8443/v1/sshca/ssh/krl
```
### Operations
| Operation | Auth Required | Description |
|------------|---------------|----------------------------------------------|
| `get-krl` | None | Return the current KRL in OpenSSH format |
## gRPC Service (proto/metacrypt/v2/sshca.proto) ## gRPC Service (proto/metacrypt/v2/sshca.proto)
```protobuf ```protobuf
@@ -292,7 +385,7 @@ Public (unseal required, no auth):
| Method | Path | Description | | Method | Path | Description |
|--------|-------------------------------------|--------------------------------| |--------|-------------------------------------|--------------------------------|
| GET | `/v1/sshca/{mount}/ca` | CA public key (SSH format) | | GET | `/v1/sshca/{mount}/ca` | CA public key (SSH format) |
| GET | `/v1/sshca/{mount}/krl` | Current KRL (OpenSSH format) | | GET | `/v1/sshca/{mount}/krl` | Current KRL (binary) |
Typed endpoints (auth required): Typed endpoints (auth required):
@@ -305,12 +398,96 @@ Typed endpoints (auth required):
| GET | `/v1/sshca/{mount}/profiles/{name}` | Get profile | | GET | `/v1/sshca/{mount}/profiles/{name}` | Get profile |
| PUT | `/v1/sshca/{mount}/profiles/{name}` | Update profile | | PUT | `/v1/sshca/{mount}/profiles/{name}` | Update profile |
| DELETE | `/v1/sshca/{mount}/profiles/{name}` | Delete profile | | DELETE | `/v1/sshca/{mount}/profiles/{name}` | Delete profile |
| GET | `/v1/sshca/{mount}/certs` | List cert records |
| GET | `/v1/sshca/{mount}/cert/{serial}` | Get cert record | | GET | `/v1/sshca/{mount}/cert/{serial}` | Get cert record |
| POST | `/v1/sshca/{mount}/cert/{serial}/revoke` | Revoke cert | | POST | `/v1/sshca/{mount}/cert/{serial}/revoke` | Revoke cert |
| DELETE | `/v1/sshca/{mount}/cert/{serial}` | Delete cert record | | DELETE | `/v1/sshca/{mount}/cert/{serial}` | Delete cert record |
### REST Route Registration
Add to `internal/server/routes.go` in `registerRoutes`, following the CA
engine's pattern with `chi.URLParam`:
```go
// SSH CA public routes (no auth, unseal required).
r.Get("/v1/sshca/{mount}/ca", s.requireUnseal(s.handleSSHCAPublicKey))
r.Get("/v1/sshca/{mount}/krl", s.requireUnseal(s.handleSSHCAKRL))
// SSH CA typed routes (auth required).
r.Post("/v1/sshca/{mount}/sign-host", s.requireAuth(s.handleSSHCASignHost))
r.Post("/v1/sshca/{mount}/sign-user", s.requireAuth(s.handleSSHCASignUser))
r.Post("/v1/sshca/{mount}/profiles", s.requireAdmin(s.handleSSHCACreateProfile))
r.Get("/v1/sshca/{mount}/profiles", s.requireAuth(s.handleSSHCAListProfiles))
r.Get("/v1/sshca/{mount}/profiles/{name}", s.requireAuth(s.handleSSHCAGetProfile))
r.Put("/v1/sshca/{mount}/profiles/{name}", s.requireAdmin(s.handleSSHCAUpdateProfile))
r.Delete("/v1/sshca/{mount}/profiles/{name}", s.requireAdmin(s.handleSSHCADeleteProfile))
r.Get("/v1/sshca/{mount}/certs", s.requireAuth(s.handleSSHCAListCerts))
r.Get("/v1/sshca/{mount}/cert/{serial}", s.requireAuth(s.handleSSHCAGetCert))
r.Post("/v1/sshca/{mount}/cert/{serial}/revoke", s.requireAdmin(s.handleSSHCARevokeCert))
r.Delete("/v1/sshca/{mount}/cert/{serial}", s.requireAdmin(s.handleSSHCADeleteCert))
```
Each handler extracts `chi.URLParam(r, "mount")`, builds an `engine.Request`
with the appropriate operation name and data, and calls
`s.engines.HandleRequest(...)`. Follow the `handleGetCert`/`handleRevokeCert`
pattern in the existing code.
All operations are also accessible via the generic `POST /v1/engine/request`. All operations are also accessible via the generic `POST /v1/engine/request`.
### gRPC Interceptor Maps
Add to `sealRequiredMethods`, `authRequiredMethods`, and `adminRequiredMethods`
in `internal/grpcserver/server.go`:
```go
// sealRequiredMethods:
"/metacrypt.v2.SSHCAService/GetCAPublicKey": true,
"/metacrypt.v2.SSHCAService/SignHost": true,
"/metacrypt.v2.SSHCAService/SignUser": true,
"/metacrypt.v2.SSHCAService/CreateProfile": true,
"/metacrypt.v2.SSHCAService/UpdateProfile": true,
"/metacrypt.v2.SSHCAService/GetProfile": true,
"/metacrypt.v2.SSHCAService/ListProfiles": true,
"/metacrypt.v2.SSHCAService/DeleteProfile": true,
"/metacrypt.v2.SSHCAService/GetCert": true,
"/metacrypt.v2.SSHCAService/ListCerts": true,
"/metacrypt.v2.SSHCAService/RevokeCert": true,
"/metacrypt.v2.SSHCAService/DeleteCert": true,
"/metacrypt.v2.SSHCAService/GetKRL": true,
// authRequiredMethods (all except GetCAPublicKey and GetKRL):
"/metacrypt.v2.SSHCAService/SignHost": true,
"/metacrypt.v2.SSHCAService/SignUser": true,
"/metacrypt.v2.SSHCAService/CreateProfile": true,
"/metacrypt.v2.SSHCAService/UpdateProfile": true,
"/metacrypt.v2.SSHCAService/GetProfile": true,
"/metacrypt.v2.SSHCAService/ListProfiles": true,
"/metacrypt.v2.SSHCAService/DeleteProfile": true,
"/metacrypt.v2.SSHCAService/GetCert": true,
"/metacrypt.v2.SSHCAService/ListCerts": true,
"/metacrypt.v2.SSHCAService/RevokeCert": true,
"/metacrypt.v2.SSHCAService/DeleteCert": true,
// adminRequiredMethods:
"/metacrypt.v2.SSHCAService/CreateProfile": true,
"/metacrypt.v2.SSHCAService/UpdateProfile": true,
"/metacrypt.v2.SSHCAService/DeleteProfile": true,
"/metacrypt.v2.SSHCAService/RevokeCert": true,
"/metacrypt.v2.SSHCAService/DeleteCert": true,
```
Also add SSH CA operations to `adminOnlyOperations` in `routes.go` (keys are
`engineType:operation` to avoid cross-engine name collisions):
```go
// SSH CA engine.
"sshca:create-profile": true,
"sshca:update-profile": true,
"sshca:delete-profile": true,
"sshca:revoke-cert": true,
"sshca:delete-cert": true,
```
## Web UI ## Web UI
Add to `/dashboard` the ability to mount an SSH CA engine. Add to `/dashboard` the ability to mount an SSH CA engine.
@@ -322,20 +499,54 @@ Add an `/sshca` page (or section on the existing PKI page) displaying:
## Implementation Steps ## Implementation Steps
1. **`internal/engine/sshca/`** — Implement `SSHCAEngine` (types, lifecycle, 1. **Move `zeroizeKey` to shared location**: Copy the `zeroizeKey` function
operations). Reuse `zeroizeKey` from `internal/engine/ca/` (move to shared from `internal/engine/ca/ca.go` (lines 14811498) to a new file
helper or duplicate). `internal/engine/helpers.go` in the `engine` package. Export it as
2. **Register factory** in `cmd/metacrypt/main.go`: `engine.ZeroizeKey`. Update the CA engine to call `engine.ZeroizeKey`
`registry.RegisterFactory(engine.EngineTypeSSHCA, sshca.NewSSHCAEngine)`. instead of its local copy. This avoids a circular import (sshca cannot
3. **Proto definitions**`proto/metacrypt/v2/sshca.proto`, run `make proto`. import ca).
4. **gRPC handlers**`internal/grpcserver/sshca.go`.
5. **REST routes** — Add to `internal/server/routes.go`. 2. **`internal/engine/sshca/`** — Implement `SSHCAEngine`:
6. **Web UI** — Add template + webserver routes. - `types.go` — `SSHCAConfig`, `CertRecord`, `SigningProfile` structs.
7. **Tests** — Unit tests with in-memory barrier following the CA test pattern. - `sshca.go` — `NewSSHCAEngine` factory, lifecycle methods (`Type`,
`Initialize`, `Unseal`, `Seal`), `HandleRequest` dispatch.
- `sign.go` — `handleSignHost`, `handleSignUser`.
- `profiles.go` — Profile CRUD handlers.
- `certs.go` — `handleGetCert`, `handleListCerts`, `handleRevokeCert`,
`handleDeleteCert`.
- `krl.go` — `buildKRL`, `rebuildKRL`, `handleGetKRL`,
`collectRevokedSerials`.
3. **Register factory** in `cmd/metacrypt/server.go` (line 76):
```go
engineRegistry.RegisterFactory(engine.EngineTypeSSHCA, sshca.NewSSHCAEngine)
```
4. **Proto definitions** — `proto/metacrypt/v2/sshca.proto`, run `make proto`.
5. **gRPC handlers** — `internal/grpcserver/sshca.go`. Follow
`internal/grpcserver/ca.go` pattern: `sshcaServer` struct wrapping
`GRPCServer`, helper function for error mapping, typed RPC methods.
Register with `pb.RegisterSSHCAServiceServer(s.srv, &sshcaServer{s: s})`
in `server.go`.
6. **REST routes** — Add to `internal/server/routes.go` per the route
registration section above.
7. **Tests** — `internal/engine/sshca/sshca_test.go`: unit tests with
in-memory barrier following the CA test pattern. Test:
- Initialize + unseal lifecycle
- sign-host: valid signing, TTL enforcement, serial uniqueness
- sign-user: own-principal default, profile merging, profile TTL cap
- Profile CRUD
- Certificate list/get/revoke/delete
- KRL rebuild correctness (revoked serials present, unrevoked absent)
- Seal zeroizes key material
## Dependencies ## Dependencies
- `golang.org/x/crypto/ssh` (already in `go.mod` via transitive deps) - `golang.org/x/crypto/ssh` (already in `go.mod` via transitive deps)
- `encoding/binary` (stdlib, for KRL serialization)
## Security Considerations ## Security Considerations
@@ -351,3 +562,19 @@ Add an `/sshca` page (or section on the existing PKI page) displaying:
prevents unprivileged users from bypassing `sshd_config` restrictions. prevents unprivileged users from bypassing `sshd_config` restrictions.
- Profile access is policy-gated: a user must have policy access to - Profile access is policy-gated: a user must have policy access to
`sshca/{mount}/profile/{name}` to use a profile. `sshca/{mount}/profile/{name}` to use a profile.
- RSA keys are excluded to reduce attack surface and simplify the implementation.
## Implementation References
These existing code patterns should be followed exactly:
| Pattern | Reference File | Lines |
|---------|---------------|-------|
| HandleRequest switch dispatch | `internal/engine/ca/ca.go` | 284317 |
| zeroizeKey helper | `internal/engine/ca/ca.go` | 14811498 |
| CertRecord storage (JSON in barrier) | `internal/engine/ca/ca.go` | cert storage pattern |
| REST route registration with chi | `internal/server/routes.go` | 3850 |
| gRPC handler structure | `internal/grpcserver/ca.go` | full file |
| gRPC interceptor maps | `internal/grpcserver/server.go` | 107192 |
| Engine factory registration | `cmd/metacrypt/server.go` | 76 |
| adminOnlyOperations map | `internal/server/routes.go` | 259279 |

View File

@@ -38,7 +38,7 @@ The transit engine manages **named encryption keys**. Each key has:
| Type | Algorithm | Operations | | Type | Algorithm | Operations |
|-----------------|-------------------|------------------| |-----------------|-------------------|------------------|
| `aes256-gcm` | AES-256-GCM | Encrypt, Decrypt | | `aes256-gcm` | AES-256-GCM | Encrypt, Decrypt |
| `chacha20-poly` | ChaCha20-Poly1305 | Encrypt, Decrypt | | `chacha20-poly` | XChaCha20-Poly1305 | Encrypt, Decrypt |
| `ed25519` | Ed25519 | Sign, Verify | | `ed25519` | Ed25519 | Sign, Verify |
| `ecdsa-p256` | ECDSA P-256 | Sign, Verify | | `ecdsa-p256` | ECDSA P-256 | Sign, Verify |
| `ecdsa-p384` | ECDSA P-384 | Sign, Verify | | `ecdsa-p384` | ECDSA P-384 | Sign, Verify |
@@ -49,6 +49,71 @@ RSA key types are intentionally excluded. The transit engine is not the right
place for RSA — asymmetric encryption belongs in the user engine (via ECDH), place for RSA — asymmetric encryption belongs in the user engine (via ECDH),
and RSA signing offers no advantage over Ed25519/ECDSA for this use case. and RSA signing offers no advantage over Ed25519/ECDSA for this use case.
### Cryptographic Details
**Nonce sizes:**
- `aes256-gcm`: 12-byte nonce via `cipher.AEAD.NonceSize()` (standard GCM).
- `chacha20-poly`: 24-byte nonce via `chacha20poly1305.NewX()` (XChaCha20-
Poly1305). The `X` variant is used specifically because it has a large
enough nonce (192-bit) for safe random generation without birthday-bound
concerns. Use `chacha20poly1305.NonceSizeX` (24).
**Nonce generation:** Always `crypto/rand.Read(nonce)`. Never use a counter —
keys may be used concurrently from multiple goroutines.
**Signing algorithms:**
- `ed25519`: Direct Ed25519 signing (`ed25519.Sign`). The input is the raw
message — Ed25519 performs its own internal SHA-512 hashing. No prehash.
- `ecdsa-p256`: SHA-256 hash of input, then `ecdsa.SignASN1(rand, key,
hash)`. Signature is ASN.1 DER encoded (the standard Go representation).
- `ecdsa-p384`: SHA-384 hash of input, then `ecdsa.SignASN1(rand, key,
hash)`. Signature is ASN.1 DER encoded.
The `algorithm` field in sign requests is currently unused (reserved for
future prehash options). Each key type has exactly one hash algorithm; there
is no caller choice.
**Signature format:**
```
metacrypt:v{version}:{base64(signature_bytes)}
```
The `v{version}` identifies which key version was used for signing. For
Ed25519, `signature_bytes` is the raw 64-byte signature. For ECDSA,
`signature_bytes` is the ASN.1 DER encoding.
**Verification:** `verify` parses the version from the signature string, loads
the corresponding public key version, and calls `ed25519.Verify` or
`ecdsa.VerifyASN1` as appropriate.
**HMAC:** `hmac-sha256` uses `hmac.New(sha256.New, key)`, `hmac-sha512` uses
`hmac.New(sha512.New, key)`. Output uses the same versioned prefix format as
ciphertext and signatures:
```
metacrypt:v{version}:{base64(mac_bytes)}
```
The `v{version}` identifies which HMAC key version produced the MAC. This is
essential for HMAC verification after key rotation — without the version
prefix, the engine would not know which key version to use for recomputation.
HMAC verification parses the version, loads the corresponding key (subject to
`min_decryption_version` enforcement), recomputes the MAC, and compares using
`hmac.Equal` for constant-time comparison.
**Key material sizes:**
- `aes256-gcm`: 32 bytes (`crypto/rand`).
- `chacha20-poly`: 32 bytes (`crypto/rand`).
- `ed25519`: `ed25519.GenerateKey(rand.Reader)` — 64-byte private key.
- `ecdsa-p256`: `ecdsa.GenerateKey(elliptic.P256(), rand.Reader)`.
- `ecdsa-p384`: `ecdsa.GenerateKey(elliptic.P384(), rand.Reader)`.
- `hmac-sha256`: 32 bytes (`crypto/rand`).
- `hmac-sha512`: 64 bytes (`crypto/rand`).
**Key serialization in barrier:**
- Symmetric keys: raw bytes.
- Ed25519: `ed25519.PrivateKey` raw bytes (64 bytes).
- ECDSA: PKCS8 DER via `x509.MarshalPKCS8PrivateKey`.
### Key Rotation ### Key Rotation
Each key has a current version and may retain older versions. Encryption always Each key has a current version and may retain older versions. Encryption always
@@ -67,6 +132,26 @@ lets operators complete a rotation cycle:
Until `min_decryption_version` is advanced, old versions must be retained. Until `min_decryption_version` is advanced, old versions must be retained.
### `max_key_versions` Behavior
When `max_key_versions` is set (> 0), the engine enforces a soft limit on the
number of retained versions. Pruning happens automatically during `rotate-key`,
after the new version is created:
1. Count total versions. If `<= max_key_versions`, no pruning needed.
2. Identify candidate versions for pruning: versions **strictly less than**
`min_decryption_version`.
3. Delete candidates (oldest first) until the total count is within the limit
or no more candidates remain.
4. If the total still exceeds `max_key_versions` after pruning all eligible
candidates, include a warning in the response:
`"warning": "max_key_versions exceeded; advance min_decryption_version to enable pruning"`.
This ensures `max_key_versions` **never** deletes a version at or above
`min_decryption_version`. The operator must complete the rotation cycle
(rotate → rewrap → advance min) before old versions become prunable.
`max_key_versions` is a safety net, not a foot-gun.
### Ciphertext Format ### Ciphertext Format
Transit ciphertexts use a versioned prefix: Transit ciphertexts use a versioned prefix:
@@ -114,19 +199,32 @@ type keyVersion struct {
### Initialize ### Initialize
1. Parse and store config in barrier. 1. Parse and validate config: parse `max_key_versions` as integer (must be ≥ 0).
2. No keys are created at init time (keys are created on demand). 2. Store config in barrier as `{mountPath}config.json`:
```go
configJSON, _ := json.Marshal(config)
barrier.Put(ctx, mountPath+"config.json", configJSON)
```
3. No keys are created at init time (keys are created on demand via
`create-key`).
### Unseal ### Unseal
1. Load config from barrier. 1. Load config JSON from barrier, unmarshal into `*TransitConfig`.
2. Discover and load all named keys and their versions from the barrier. 2. List all key directories under `{mountPath}keys/`.
3. For each key, load `config.json` and all `v{N}.key` entries:
- Symmetric keys (`aes256-gcm`, `chacha20-poly`, `hmac-*`): raw 32-byte
or 64-byte key material.
- Ed25519: `ed25519.PrivateKey` (64 bytes), derive public key.
- ECDSA: parse PKCS8 DER → `*ecdsa.PrivateKey`, extract `PublicKey`.
4. Populate `keys` map with all loaded key states.
### Seal ### Seal
1. Zeroize all key material (symmetric keys overwritten with zeros, 1. Zeroize all key material: symmetric keys overwritten with zeros via
asymmetric keys via `zeroizeKey`). `crypto.Zeroize(key)`, asymmetric keys via `engine.ZeroizeKey(privKey)`
2. Nil out all maps. (shared helper, see sshca.md Implementation References).
2. Nil out `keys` map and `config`.
## Operations ## Operations
@@ -150,6 +248,53 @@ type keyVersion struct {
| `hmac` | User+Policy | Compute HMAC with an HMAC key | | `hmac` | User+Policy | Compute HMAC with an HMAC key |
| `get-public-key` | User/Admin | Get public key for asymmetric keys | | `get-public-key` | User/Admin | Get public key for asymmetric keys |
### HandleRequest dispatch
Follow the CA engine's pattern (`internal/engine/ca/ca.go:284-317`):
```go
func (e *TransitEngine) HandleRequest(ctx context.Context, req *engine.Request) (*engine.Response, error) {
switch req.Operation {
case "create-key":
return e.handleCreateKey(ctx, req)
case "delete-key":
return e.handleDeleteKey(ctx, req)
case "get-key":
return e.handleGetKey(ctx, req)
case "list-keys":
return e.handleListKeys(ctx, req)
case "rotate-key":
return e.handleRotateKey(ctx, req)
case "update-key-config":
return e.handleUpdateKeyConfig(ctx, req)
case "trim-key":
return e.handleTrimKey(ctx, req)
case "encrypt":
return e.handleEncrypt(ctx, req)
case "decrypt":
return e.handleDecrypt(ctx, req)
case "rewrap":
return e.handleRewrap(ctx, req)
case "batch-encrypt":
return e.handleBatchEncrypt(ctx, req)
case "batch-decrypt":
return e.handleBatchDecrypt(ctx, req)
case "batch-rewrap":
return e.handleBatchRewrap(ctx, req)
case "sign":
return e.handleSign(ctx, req)
case "verify":
return e.handleVerify(ctx, req)
case "hmac":
return e.handleHmac(ctx, req)
case "get-public-key":
return e.handleGetPublicKey(ctx, req)
default:
return nil, fmt.Errorf("transit: unknown operation: %s", req.Operation)
}
}
```
### create-key ### create-key
Request data: Request data:
@@ -158,9 +303,14 @@ Request data:
|-------------------|----------|----------------|----------------------------------| |-------------------|----------|----------------|----------------------------------|
| `name` | Yes | | Key name | | `name` | Yes | | Key name |
| `type` | Yes | | Key type (see table above) | | `type` | Yes | | Key type (see table above) |
| `exportable` | No | `false` | Whether raw key material can be exported |
| `allow_deletion` | No | `false` | Whether key can be deleted | | `allow_deletion` | No | `false` | Whether key can be deleted |
The `exportable` flag has been intentionally omitted. Transit's value
proposition is that keys never leave the service — all cryptographic operations
happen server-side. If key export is ever needed (e.g., for migration), a
dedicated admin-only export operation can be added with appropriate audit
logging.
The key is created at version 1 with `min_decryption_version` = 1. The key is created at version 1 with `min_decryption_version` = 1.
### encrypt ### encrypt
@@ -199,11 +349,12 @@ Request data:
|-------------|----------|--------------------------------------------| |-------------|----------|--------------------------------------------|
| `key` | Yes | Named key (Ed25519 or ECDSA type) | | `key` | Yes | Named key (Ed25519 or ECDSA type) |
| `input` | Yes | Base64-encoded data to sign | | `input` | Yes | Base64-encoded data to sign |
| `algorithm` | No | Hash algorithm (default varies by key type) | | `algorithm` | No | Reserved for future prehash options (currently ignored) |
The engine rejects `sign` requests for HMAC key types with an error. The engine rejects `sign` requests for HMAC and symmetric key types with an
error. Only Ed25519 and ECDSA keys are accepted.
Response: `{ "signature": "metacrypt:v1:..." }` Response: `{ "signature": "metacrypt:v{version}:...", "key_version": N }`
### verify ### verify
@@ -236,9 +387,9 @@ exceed the current version (you must always be able to decrypt with the latest).
### trim-key ### trim-key
Admin-only. Permanently deletes key versions older than `min_decryption_version`. Admin-only. Permanently deletes key versions **strictly less than**
This is irreversible — ciphertext encrypted with trimmed versions can never be `min_decryption_version`. This is irreversible — ciphertext encrypted with
decrypted. trimmed versions can never be decrypted.
Request data: Request data:
@@ -246,6 +397,24 @@ Request data:
|-------|----------|-------------| |-------|----------|-------------|
| `key` | Yes | Named key | | `key` | Yes | Named key |
Deletion logic:
1. Load the key's `min_decryption_version` (must be > 1, otherwise no-op).
2. Enumerate all version files: `{mountPath}keys/{name}/v{N}.key`.
3. For each version `N` where `N < min_decryption_version`:
- Zeroize the in-memory key material (`crypto.Zeroize` for symmetric,
`engine.ZeroizeKey` for asymmetric).
- Delete the version from the barrier: `barrier.Delete(ctx, versionPath)`.
- Remove from the in-memory `versions` map.
4. Return the list of trimmed version numbers.
If `min_decryption_version` is 1 (the default), trim-key is a no-op and
returns an empty list. This ensures you cannot accidentally trim all versions
without first explicitly advancing the minimum.
The current version is **never** trimmable — `min_decryption_version` cannot
exceed the current version (enforced by `update-key-config`), so the latest
version is always retained.
Response: `{ "trimmed_versions": [1, 2, ...] }` Response: `{ "trimmed_versions": [1, 2, ...] }`
## Batch Operations ## Batch Operations
@@ -346,13 +515,25 @@ Each result:
| `reference` | Echoed from the request item (if provided) | | `reference` | Echoed from the request item (if provided) |
| `error` | Error message on failure, empty on success | | `error` | Error message on failure, empty on success |
### Batch Size Limits
Each batch request is limited to **500 items**. Requests exceeding this limit
are rejected before processing with a `400 Bad Request` / `InvalidArgument`
error. This prevents a single request from monopolizing the engine's lock and
memory.
The limit is a compile-time constant (`maxBatchSize = 500`) in the engine
package. It can be tuned if needed but should not be exposed as user-
configurable — it exists as a safety valve, not a feature.
### Implementation Notes ### Implementation Notes
Batch operations are handled inside the transit engine's `HandleRequest` as Batch operations are handled inside the transit engine's `HandleRequest` as
three additional operation cases (`batch-encrypt`, `batch-decrypt`, three additional operation cases (`batch-encrypt`, `batch-decrypt`,
`batch-rewrap`). No changes to the `Engine` interface are needed. The engine `batch-rewrap`). No changes to the `Engine` interface are needed. The engine
loops over items internally, loading the key once and reusing it for all items acquires a read lock once, loads the key once, and processes all items in the
in the batch. batch while holding the lock. This ensures atomicity with respect to key
rotation (all items in a batch use the same key version).
The `reference` field is opaque to the engine — it allows callers to correlate The `reference` field is opaque to the engine — it allows callers to correlate
results with their source records (e.g. a database row ID) without maintaining results with their source records (e.g. a database row ID) without maintaining
@@ -367,6 +548,10 @@ Follows the same model as the CA engine:
`encrypt`, `decrypt`, `sign`, `verify`, `hmac` for cryptographic operations; `encrypt`, `decrypt`, `sign`, `verify`, `hmac` for cryptographic operations;
`read` for metadata (get-key, list-keys, get-public-key); `write` for `read` for metadata (get-key, list-keys, get-public-key); `write` for
management (create-key, delete-key, rotate-key, update-key-config, trim-key). management (create-key, delete-key, rotate-key, update-key-config, trim-key).
`rewrap` maps to the `decrypt` action — rewrap internally decrypts with the
old version and re-encrypts with the latest, so the caller must have decrypt
permission. Batch variants (`batch-encrypt`, `batch-decrypt`, `batch-rewrap`)
map to the same action as their single counterparts.
The `any` action matches all of the above (but never `admin`). The `any` action matches all of the above (but never `admin`).
- No ownership concept (transit keys are shared resources); access is purely - No ownership concept (transit keys are shared resources); access is purely
policy-based. policy-based.
@@ -417,48 +602,151 @@ All auth required:
| POST | `/v1/transit/{mount}/sign/{key}` | Sign | | POST | `/v1/transit/{mount}/sign/{key}` | Sign |
| POST | `/v1/transit/{mount}/verify/{key}` | Verify | | POST | `/v1/transit/{mount}/verify/{key}` | Verify |
| POST | `/v1/transit/{mount}/hmac/{key}` | HMAC | | POST | `/v1/transit/{mount}/hmac/{key}` | HMAC |
| GET | `/v1/transit/{mount}/keys/{name}/public-key` | Get public key |
All operations are also accessible via the generic `POST /v1/engine/request`. All operations are also accessible via the generic `POST /v1/engine/request`.
### REST Route Registration
Add to `internal/server/routes.go` in `registerRoutes`, following the CA
engine's pattern with `chi.URLParam`:
```go
// Transit key management routes (admin).
r.Post("/v1/transit/{mount}/keys", s.requireAdmin(s.handleTransitCreateKey))
r.Get("/v1/transit/{mount}/keys", s.requireAuth(s.handleTransitListKeys))
r.Get("/v1/transit/{mount}/keys/{name}", s.requireAuth(s.handleTransitGetKey))
r.Delete("/v1/transit/{mount}/keys/{name}", s.requireAdmin(s.handleTransitDeleteKey))
r.Post("/v1/transit/{mount}/keys/{name}/rotate", s.requireAdmin(s.handleTransitRotateKey))
r.Patch("/v1/transit/{mount}/keys/{name}/config", s.requireAdmin(s.handleTransitUpdateKeyConfig))
r.Post("/v1/transit/{mount}/keys/{name}/trim", s.requireAdmin(s.handleTransitTrimKey))
// Transit crypto operations (auth + policy).
r.Post("/v1/transit/{mount}/encrypt/{key}", s.requireAuth(s.handleTransitEncrypt))
r.Post("/v1/transit/{mount}/decrypt/{key}", s.requireAuth(s.handleTransitDecrypt))
r.Post("/v1/transit/{mount}/rewrap/{key}", s.requireAuth(s.handleTransitRewrap))
r.Post("/v1/transit/{mount}/batch/encrypt/{key}", s.requireAuth(s.handleTransitBatchEncrypt))
r.Post("/v1/transit/{mount}/batch/decrypt/{key}", s.requireAuth(s.handleTransitBatchDecrypt))
r.Post("/v1/transit/{mount}/batch/rewrap/{key}", s.requireAuth(s.handleTransitBatchRewrap))
r.Post("/v1/transit/{mount}/sign/{key}", s.requireAuth(s.handleTransitSign))
r.Post("/v1/transit/{mount}/verify/{key}", s.requireAuth(s.handleTransitVerify))
r.Post("/v1/transit/{mount}/hmac/{key}", s.requireAuth(s.handleTransitHmac))
r.Get("/v1/transit/{mount}/keys/{name}/public-key", s.requireAuth(s.handleTransitGetPublicKey))
```
Each handler extracts `chi.URLParam(r, "mount")` and `chi.URLParam(r, "key")`
or `chi.URLParam(r, "name")`, builds an `engine.Request`, and calls
`s.engines.HandleRequest(...)`.
### gRPC Interceptor Maps
Add to `sealRequiredMethods`, `authRequiredMethods`, and `adminRequiredMethods`
in `internal/grpcserver/server.go`:
```go
// sealRequiredMethods — all transit RPCs:
"/metacrypt.v2.TransitService/CreateKey": true,
"/metacrypt.v2.TransitService/DeleteKey": true,
"/metacrypt.v2.TransitService/GetKey": true,
"/metacrypt.v2.TransitService/ListKeys": true,
"/metacrypt.v2.TransitService/RotateKey": true,
"/metacrypt.v2.TransitService/UpdateKeyConfig": true,
"/metacrypt.v2.TransitService/TrimKey": true,
"/metacrypt.v2.TransitService/Encrypt": true,
"/metacrypt.v2.TransitService/Decrypt": true,
"/metacrypt.v2.TransitService/Rewrap": true,
"/metacrypt.v2.TransitService/BatchEncrypt": true,
"/metacrypt.v2.TransitService/BatchDecrypt": true,
"/metacrypt.v2.TransitService/BatchRewrap": true,
"/metacrypt.v2.TransitService/Sign": true,
"/metacrypt.v2.TransitService/Verify": true,
"/metacrypt.v2.TransitService/Hmac": true,
"/metacrypt.v2.TransitService/GetPublicKey": true,
// authRequiredMethods — all transit RPCs:
"/metacrypt.v2.TransitService/CreateKey": true,
"/metacrypt.v2.TransitService/DeleteKey": true,
"/metacrypt.v2.TransitService/GetKey": true,
"/metacrypt.v2.TransitService/ListKeys": true,
"/metacrypt.v2.TransitService/RotateKey": true,
"/metacrypt.v2.TransitService/UpdateKeyConfig": true,
"/metacrypt.v2.TransitService/TrimKey": true,
"/metacrypt.v2.TransitService/Encrypt": true,
"/metacrypt.v2.TransitService/Decrypt": true,
"/metacrypt.v2.TransitService/Rewrap": true,
"/metacrypt.v2.TransitService/BatchEncrypt": true,
"/metacrypt.v2.TransitService/BatchDecrypt": true,
"/metacrypt.v2.TransitService/BatchRewrap": true,
"/metacrypt.v2.TransitService/Sign": true,
"/metacrypt.v2.TransitService/Verify": true,
"/metacrypt.v2.TransitService/Hmac": true,
"/metacrypt.v2.TransitService/GetPublicKey": true,
// adminRequiredMethods — admin-only transit RPCs:
"/metacrypt.v2.TransitService/CreateKey": true,
"/metacrypt.v2.TransitService/DeleteKey": true,
"/metacrypt.v2.TransitService/RotateKey": true,
"/metacrypt.v2.TransitService/UpdateKeyConfig": true,
"/metacrypt.v2.TransitService/TrimKey": true,
```
The `adminOnlyOperations` map in `routes.go` already contains transit entries
(qualified as `transit:create-key`, `transit:delete-key`, etc. — keys are
`engineType:operation` to avoid cross-engine name collisions).
## Web UI ## Web UI
Add to `/dashboard` the ability to mount a transit engine. Add to `/dashboard` the ability to mount a transit engine.
Add a `/transit` page displaying: Add a `/transit` page displaying:
- Named key list with metadata (type, version, created, exportable) - Named key list with metadata (type, version, created, allow_deletion)
- Key detail view with version history - Key detail view with version history
- Encrypt/decrypt form for interactive testing - Encrypt/decrypt form for interactive testing
- Key rotation button (admin) - Key rotation button (admin)
## Implementation Steps ## Implementation Steps
1. **`internal/engine/transit/`** — Implement `TransitEngine`: 1. **Prerequisite**: `engine.ZeroizeKey` must exist in
`internal/engine/helpers.go` (created as part of the SSH CA engine
implementation — see `engines/sshca.md` step 1).
2. **`internal/engine/transit/`** — Implement `TransitEngine`:
- `types.go` — Config, KeyConfig, key version types. - `types.go` — Config, KeyConfig, key version types.
- `transit.go` — Lifecycle (Initialize, Unseal, Seal, HandleRequest). - `transit.go` — Lifecycle (Initialize, Unseal, Seal, HandleRequest).
- `encrypt.go` — Encrypt/Decrypt/Rewrap operations. - `encrypt.go` — Encrypt/Decrypt/Rewrap operations.
- `sign.go` — Sign/Verify/HMAC operations. - `sign.go` — Sign/Verify/HMAC operations.
- `keys.go` — Key management (create, delete, rotate, list, get). - `keys.go` — Key management (create, delete, rotate, list, get).
2. **Register factory** in `cmd/metacrypt/main.go`. 3. **Register factory** in `cmd/metacrypt/main.go`.
3. **Proto definitions**`proto/metacrypt/v2/transit.proto`, run `make proto`. 4. **Proto definitions** — `proto/metacrypt/v2/transit.proto`, run `make proto`.
4. **gRPC handlers**`internal/grpcserver/transit.go`. 5. **gRPC handlers** — `internal/grpcserver/transit.go`.
5. **REST routes** — Add to `internal/server/routes.go`. 6. **REST routes** — Add to `internal/server/routes.go`.
6. **Web UI** — Add template + webserver routes. 7. **Web UI** — Add template + webserver routes.
7. **Tests** — Unit tests for each operation, key rotation, rewrap correctness. 8. **Tests** — Unit tests for each operation, key rotation, rewrap correctness.
## Dependencies ## Dependencies
- `golang.org/x/crypto/chacha20poly1305` (for ChaCha20-Poly1305 key type) - `golang.org/x/crypto/chacha20poly1305` (for XChaCha20-Poly1305 key type)
- Standard library `crypto/aes`, `crypto/cipher`, `crypto/ecdsa`, - Standard library `crypto/aes`, `crypto/cipher`, `crypto/ecdsa`,
`crypto/ed25519`, `crypto/hmac`, `crypto/sha256`, `crypto/sha512` `crypto/ed25519`, `crypto/hmac`, `crypto/sha256`, `crypto/sha512`,
`crypto/elliptic`, `crypto/x509`, `crypto/rand`
## Security Considerations ## Security Considerations
- All key material encrypted at rest in the barrier, zeroized on seal. - All key material encrypted at rest in the barrier, zeroized on seal.
- Symmetric keys generated with `crypto/rand`. - Symmetric keys generated with `crypto/rand`.
- XChaCha20-Poly1305 used instead of ChaCha20-Poly1305 for its 192-bit nonce,
which is safe for random nonce generation at high volume (birthday bound at
2^96 messages vs 2^48 for 96-bit nonces).
- Nonces are always random (`crypto/rand`), never counter-based, to avoid
nonce-reuse risks from concurrent access or crash recovery.
- Ciphertext format includes version to support key rotation without data loss. - Ciphertext format includes version to support key rotation without data loss.
- `exportable` flag is immutable after creation — cannot be enabled later. - Key export is not supported — transit keys never leave the service.
- `allow_deletion` is immutable after creation. - `allow_deletion` is immutable after creation; `delete-key` returns an error
if `allow_deletion` is `false`.
- `max_key_versions` pruning only removes old versions, never the current one. - `max_key_versions` pruning only removes old versions, never the current one.
- `trim-key` only deletes versions below `min_decryption_version`, and
`min_decryption_version` cannot exceed the current version. This guarantees
the current version is never trimmable.
- Rewrap operation never exposes plaintext to the caller. - Rewrap operation never exposes plaintext to the caller.
- Context (AAD) binding prevents ciphertext from being used in a different context. - Context (AAD) binding prevents ciphertext from being used in a different context.
- `min_decryption_version` enforces key rotation completion: once advanced, - `min_decryption_version` enforces key rotation completion: once advanced,
@@ -466,3 +754,25 @@ Add a `/transit` page displaying:
- RSA key types are excluded to avoid padding scheme vulnerabilities - RSA key types are excluded to avoid padding scheme vulnerabilities
(Bleichenbacher attacks on PKCS#1 v1.5). Asymmetric encryption belongs in (Bleichenbacher attacks on PKCS#1 v1.5). Asymmetric encryption belongs in
the user engine; signing uses Ed25519/ECDSA. the user engine; signing uses Ed25519/ECDSA.
- ECDSA signatures use ASN.1 DER encoding (Go's native format), not raw
concatenated (r,s) — this avoids signature malleability issues.
- Ed25519 signs raw messages (no prehash) — this is the standard Ed25519
mode, not Ed25519ph, avoiding the collision resistance reduction.
- Batch operations enforce a 500-item limit to prevent resource exhaustion.
- Batch operations hold a read lock for the entire batch to ensure all items
use the same key version, preventing TOCTOU between key rotation and
encryption.
## Implementation References
These existing code patterns should be followed exactly:
| Pattern | Reference File | Lines |
|---------|---------------|-------|
| HandleRequest switch dispatch | `internal/engine/ca/ca.go` | 284317 |
| zeroizeKey helper | `internal/engine/ca/ca.go` | 14811498 |
| REST route registration with chi | `internal/server/routes.go` | 3850 |
| gRPC handler structure | `internal/grpcserver/ca.go` | full file |
| gRPC interceptor maps | `internal/grpcserver/server.go` | 107205 |
| Engine factory registration | `cmd/metacrypt/server.go` | 76 |
| adminOnlyOperations map | `internal/server/routes.go` | 265285 |

View File

@@ -54,6 +54,72 @@ encrypted in the barrier and is only used by the engine on behalf of the owning
user (enforced in `HandleRequest`). The public key is available to any user (enforced in `HandleRequest`). The public key is available to any
authenticated user (needed to encrypt messages to that user). authenticated user (needed to encrypt messages to that user).
**Key generation by algorithm:**
- `x25519`: `ecdh.X25519().GenerateKey(rand.Reader)` (Go 1.20+ `crypto/ecdh`).
- `ecdh-p256`: `ecdh.P256().GenerateKey(rand.Reader)`.
- `ecdh-p384`: `ecdh.P384().GenerateKey(rand.Reader)`.
**Key serialization in barrier:**
- Private key: `x509.MarshalPKCS8PrivateKey(privKey)` → PEM block with type
`"PRIVATE KEY"`. Stored at `{mountPath}users/{username}/priv.pem`.
- Public key: `x509.MarshalPKIXPublicKey(pubKey)` → PEM block with type
`"PUBLIC KEY"`. Stored at `{mountPath}users/{username}/pub.pem`.
Note: `crypto/ecdh` keys implement the interfaces required by
`x509.MarshalPKCS8PrivateKey` and `x509.MarshalPKIXPublicKey` as of Go 1.20.
### Cryptographic Details
**ECDH key agreement:**
```go
sharedSecret, err := senderPrivKey.ECDH(recipientPubKey)
```
The raw shared secret is **never used directly** as a key. It is always fed
through HKDF.
**HKDF key derivation:**
```go
salt := make([]byte, 32)
rand.Read(salt)
hkdf := hkdf.New(sha256.New, sharedSecret, salt, info)
wrappingKey := make([]byte, 32) // 256-bit AES key
io.ReadFull(hkdf, wrappingKey)
```
- **Hash:** SHA-256 (sufficient for 256-bit key derivation).
- **Salt:** 32 bytes of `crypto/rand` randomness, generated fresh per
recipient per encryption. The salt is stored alongside the wrapped DEK in
the envelope (see updated envelope format below).
- **Info:** `"metacrypt-user-v1:" + sender + ":" + recipient` (UTF-8 encoded).
This binds the derived key to the specific sender-recipient pair, preventing
key confusion if the same shared secret were somehow reused.
**DEK wrapping:** The wrapping key from HKDF encrypts the DEK using AES-256-GCM
(not AES Key Wrap / RFC 3394). AES-GCM is used because:
- It is already a core primitive in the codebase.
- It provides authenticated encryption, same as AES Key Wrap.
- The DEK is 32 bytes — well within GCM's plaintext size limits.
```go
block, _ := aes.NewCipher(wrappingKey)
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize()) // 12 bytes
rand.Read(nonce)
wrappedDEK := gcm.Seal(nonce, nonce, dek, nil) // nonce || ciphertext || tag
```
**Symmetric encryption (payload):**
```go
block, _ := aes.NewCipher(dek)
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize()) // 12 bytes
rand.Read(nonce)
ciphertext := gcm.Seal(nonce, nonce, plaintext, aad)
```
- AAD: if `metadata` is provided, it is used as additional authenticated data.
This means metadata is integrity-protected but not encrypted.
- Nonce: 12 bytes from `crypto/rand`.
### Encryption Flow (Sender → Recipient) ### Encryption Flow (Sender → Recipient)
1. Sender calls `encrypt` with plaintext, recipient username(s), and optional 1. Sender calls `encrypt` with plaintext, recipient username(s), and optional
@@ -82,13 +148,24 @@ authenticated user (needed to encrypt messages to that user).
"sender": "alice", "sender": "alice",
"sym_algorithm": "aes256-gcm", "sym_algorithm": "aes256-gcm",
"ciphertext": "<base64(nonce + encrypted_payload + tag)>", "ciphertext": "<base64(nonce + encrypted_payload + tag)>",
"metadata": "<optional plaintext metadata>",
"recipients": { "recipients": {
"bob": "<base64(wrapped_dek)>", "bob": {
"carol": "<base64(wrapped_dek)>" "salt": "<base64(32-byte HKDF salt)>",
"wrapped_dek": "<base64(nonce + encrypted_dek + tag)>"
},
"carol": {
"salt": "<base64(32-byte HKDF salt)>",
"wrapped_dek": "<base64(nonce + encrypted_dek + tag)>"
}
} }
} }
``` ```
Each recipient entry includes:
- `salt`: the per-recipient random HKDF salt used during key derivation.
- `wrapped_dek`: the AES-256-GCM encryption of the DEK (nonce-prepended).
The envelope is base64-encoded as a single opaque blob for transport. The envelope is base64-encoded as a single opaque blob for transport.
## Barrier Storage Layout ## Barrier Storage Layout
@@ -116,24 +193,43 @@ type userState struct {
pubKey crypto.PublicKey // key exchange public key pubKey crypto.PublicKey // key exchange public key
config *UserKeyConfig config *UserKeyConfig
} }
type UserKeyConfig struct {
Algorithm string `json:"algorithm"` // key exchange algorithm (x25519, ecdh-p256, ecdh-p384)
CreatedAt time.Time `json:"created_at"`
AutoProvisioned bool `json:"auto_provisioned"` // true if created via auto-provisioning
}
``` ```
## Lifecycle ## Lifecycle
### Initialize ### Initialize
1. Parse and store config in barrier. 1. Parse and validate config: ensure `key_algorithm` is one of `x25519`,
2. No user keys are created at init time (created on demand or via `register`). `ecdh-p256`, `ecdh-p384`. Ensure `sym_algorithm` is `aes256-gcm`.
2. Store config in barrier as `{mountPath}config.json`:
```go
configJSON, _ := json.Marshal(config)
barrier.Put(ctx, mountPath+"config.json", configJSON)
```
3. No user keys are created at init time (created on demand via `register`,
`provision`, or auto-provisioning).
### Unseal ### Unseal
1. Load config from barrier. 1. Load config JSON from barrier, unmarshal into `*UserConfig`.
2. Discover and load all user key pairs from barrier. 2. List all user directories under `{mountPath}users/`.
3. For each user, load `priv.pem` and `pub.pem`:
- Parse private key PEM: `pem.Decode` → `x509.ParsePKCS8PrivateKey` →
type-assert to `*ecdh.PrivateKey`.
- Parse public key PEM: `pem.Decode` → `x509.ParsePKIXPublicKey` →
type-assert to `*ecdh.PublicKey`.
4. Populate `users` map with loaded key states.
### Seal ### Seal
1. Zeroize all private key material. 1. Zeroize all private key material using `engine.ZeroizeKey(privKey)`.
2. Nil out all maps. 2. Nil out `users` map and `config`.
## Operations ## Operations
@@ -145,9 +241,41 @@ type userState struct {
| `list-users` | User/Admin | List registered users | | `list-users` | User/Admin | List registered users |
| `encrypt` | User+Policy | Encrypt data for one or more recipients | | `encrypt` | User+Policy | Encrypt data for one or more recipients |
| `decrypt` | User (self) | Decrypt an envelope addressed to the caller | | `decrypt` | User (self) | Decrypt an envelope addressed to the caller |
| `re-encrypt` | User (self) | Re-encrypt an envelope with current key pairs |
| `rotate-key` | User (self) | Rotate the caller's key pair | | `rotate-key` | User (self) | Rotate the caller's key pair |
| `delete-user` | Admin | Remove a user's key pair | | `delete-user` | Admin | Remove a user's key pair |
### HandleRequest dispatch
Follow the CA engine's pattern (`internal/engine/ca/ca.go:284-317`):
```go
func (e *UserEngine) HandleRequest(ctx context.Context, req *engine.Request) (*engine.Response, error) {
switch req.Operation {
case "register":
return e.handleRegister(ctx, req)
case "provision":
return e.handleProvision(ctx, req)
case "get-public-key":
return e.handleGetPublicKey(ctx, req)
case "list-users":
return e.handleListUsers(ctx, req)
case "encrypt":
return e.handleEncrypt(ctx, req)
case "decrypt":
return e.handleDecrypt(ctx, req)
case "re-encrypt":
return e.handleReEncrypt(ctx, req)
case "rotate-key":
return e.handleRotateKey(ctx, req)
case "delete-user":
return e.handleDeleteUser(ctx, req)
default:
return nil, fmt.Errorf("user: unknown operation: %s", req.Operation)
}
}
```
### register ### register
Creates a key pair for the authenticated caller. No-op if the caller already Creates a key pair for the authenticated caller. No-op if the caller already
@@ -177,14 +305,27 @@ Request data:
| `metadata` | No | Arbitrary string metadata (authenticated) | | `metadata` | No | Arbitrary string metadata (authenticated) |
Flow: Flow:
1. Caller must be provisioned (has a key pair). Auto-provision if not. 1. Validate that `len(recipients) <= maxRecipients` (100). Reject with
2. For each recipient without a keypair: auto-provision them. `400 Bad Request` if exceeded.
3. Load sender's private key and each recipient's public key. 2. Caller must be provisioned (has a key pair). If not, auto-provision the
4. Generate random DEK, encrypt plaintext with DEK. caller (generate keypair, store in barrier). This is safe because the
5. For each recipient: ECDH(sender_priv, recipient_pub) → shared_secret, caller is already authenticated via MCIAS — their identity is verified.
HKDF(shared_secret, salt, info) → wrapping_key, AES-KeyWrap(wrapping_key, 3. For each recipient without a keypair: validate the username exists in MCIAS
DEK) → wrapped_dek. via `auth.ValidateUsername(username)`. If the user does not exist, return an
6. Build and return envelope. error: `"recipient not found: {username}"`. If the user exists, auto-provision
them. Auto-provisioning only creates a key pair; it does not grant any MCIAS
roles or permissions. The recipient's private key is only accessible when
they authenticate.
4. Load sender's private key and each recipient's public key.
5. Generate random 32-byte DEK (`crypto/rand`). Encrypt plaintext with DEK
using AES-256-GCM (metadata as AAD if present).
6. For each recipient:
- `sharedSecret := senderPrivKey.ECDH(recipientPubKey)`
- Generate 32-byte random salt.
- `wrappingKey := HKDF(sha256, sharedSecret, salt, info)`
- `wrappedDEK := AES-GCM-Encrypt(wrappingKey, DEK)`
7. Build envelope with ciphertext, per-recipient `{salt, wrapped_dek}`.
8. Zeroize DEK, all shared secrets, and all wrapping keys.
Authorization: Authorization:
- Admins: grant-all. - Admins: grant-all.
@@ -201,19 +342,48 @@ Request data:
| `envelope` | Yes | Base64-encoded envelope blob | | `envelope` | Yes | Base64-encoded envelope blob |
Flow: Flow:
1. Parse envelope, find the caller's wrapped DEK entry. 1. Parse envelope JSON, find the caller's entry in `recipients`.
2. Load sender's public key and caller's private key. If the caller is not a recipient, return an error.
3. ECDH(caller_priv, sender_pub) → shared_secret → wrapping_key → DEK. 2. Load sender's public key (from `envelope.sender`) and caller's private key.
4. Decrypt ciphertext with DEK. 3. `sharedSecret := callerPrivKey.ECDH(senderPubKey)`.
5. Return plaintext. 4. `wrappingKey := HKDF(sha256, sharedSecret, recipient.salt, info)`.
5. `dek := AES-GCM-Decrypt(wrappingKey, recipient.wrapped_dek)`.
6. Decrypt ciphertext with DEK (metadata as AAD if present in envelope).
7. Zeroize DEK, shared secret, wrapping key.
8. Return plaintext.
A user can only decrypt envelopes addressed to themselves. A user can only decrypt envelopes addressed to themselves.
### re-encrypt
Re-encrypts an envelope with the caller's current key pair. This is the safe
way to migrate data before a key rotation.
Request data:
| Field | Required | Description |
|------------|----------|--------------------------------|
| `envelope` | Yes | Base64-encoded envelope blob |
Flow:
1. Decrypt the envelope (same as `decrypt` flow).
2. Re-encrypt the plaintext for the same recipients using fresh DEKs and
current key pairs (same as `encrypt` flow, preserving metadata).
3. Return the new envelope.
The caller must be a recipient in the original envelope. The new envelope uses
current key pairs for all recipients — if any recipient has rotated their key
since the original encryption, the new envelope uses their new public key.
### rotate-key ### rotate-key
Generates a new key pair for the caller. The old private key is zeroized and Generates a new key pair for the caller. The old private key is zeroized and
deleted. Old envelopes encrypted with the previous key cannot be decrypted deleted. Old envelopes encrypted with the previous key cannot be decrypted
after rotation — callers should re-encrypt any stored data before rotating. after rotation.
**Recommended workflow**: Before rotating, re-encrypt all stored envelopes
using the `re-encrypt` operation. Then call `rotate-key`. This ensures no
data is lost.
## gRPC Service (proto/metacrypt/v2/user.proto) ## gRPC Service (proto/metacrypt/v2/user.proto)
@@ -225,6 +395,7 @@ service UserService {
rpc ListUsers(UserListUsersRequest) returns (UserListUsersResponse); rpc ListUsers(UserListUsersRequest) returns (UserListUsersResponse);
rpc Encrypt(UserEncryptRequest) returns (UserEncryptResponse); rpc Encrypt(UserEncryptRequest) returns (UserEncryptResponse);
rpc Decrypt(UserDecryptRequest) returns (UserDecryptResponse); rpc Decrypt(UserDecryptRequest) returns (UserDecryptResponse);
rpc ReEncrypt(UserReEncryptRequest) returns (UserReEncryptResponse);
rpc RotateKey(UserRotateKeyRequest) returns (UserRotateKeyResponse); rpc RotateKey(UserRotateKeyRequest) returns (UserRotateKeyResponse);
rpc DeleteUser(UserDeleteUserRequest) returns (UserDeleteUserResponse); rpc DeleteUser(UserDeleteUserRequest) returns (UserDeleteUserResponse);
} }
@@ -243,10 +414,70 @@ All auth required:
| DELETE | `/v1/user/{mount}/keys/{username}` | Delete user (admin) | | DELETE | `/v1/user/{mount}/keys/{username}` | Delete user (admin) |
| POST | `/v1/user/{mount}/encrypt` | Encrypt for recipients | | POST | `/v1/user/{mount}/encrypt` | Encrypt for recipients |
| POST | `/v1/user/{mount}/decrypt` | Decrypt envelope | | POST | `/v1/user/{mount}/decrypt` | Decrypt envelope |
| POST | `/v1/user/{mount}/re-encrypt` | Re-encrypt envelope |
| POST | `/v1/user/{mount}/rotate` | Rotate caller's key | | POST | `/v1/user/{mount}/rotate` | Rotate caller's key |
All operations are also accessible via the generic `POST /v1/engine/request`. All operations are also accessible via the generic `POST /v1/engine/request`.
### REST Route Registration
Add to `internal/server/routes.go` in `registerRoutes`, following the CA
engine's pattern with `chi.URLParam`:
```go
// User engine routes.
r.Post("/v1/user/{mount}/register", s.requireAuth(s.handleUserRegister))
r.Post("/v1/user/{mount}/provision", s.requireAdmin(s.handleUserProvision))
r.Get("/v1/user/{mount}/keys", s.requireAuth(s.handleUserListUsers))
r.Get("/v1/user/{mount}/keys/{username}", s.requireAuth(s.handleUserGetPublicKey))
r.Delete("/v1/user/{mount}/keys/{username}", s.requireAdmin(s.handleUserDeleteUser))
r.Post("/v1/user/{mount}/encrypt", s.requireAuth(s.handleUserEncrypt))
r.Post("/v1/user/{mount}/decrypt", s.requireAuth(s.handleUserDecrypt))
r.Post("/v1/user/{mount}/re-encrypt", s.requireAuth(s.handleUserReEncrypt))
r.Post("/v1/user/{mount}/rotate", s.requireAuth(s.handleUserRotateKey))
```
Each handler extracts `chi.URLParam(r, "mount")` and optionally
`chi.URLParam(r, "username")`, builds an `engine.Request`, and calls
`s.engines.HandleRequest(...)`.
### gRPC Interceptor Maps
Add to `sealRequiredMethods`, `authRequiredMethods`, and `adminRequiredMethods`
in `internal/grpcserver/server.go`:
```go
// sealRequiredMethods — all user RPCs:
"/metacrypt.v2.UserService/Register": true,
"/metacrypt.v2.UserService/Provision": true,
"/metacrypt.v2.UserService/GetPublicKey": true,
"/metacrypt.v2.UserService/ListUsers": true,
"/metacrypt.v2.UserService/Encrypt": true,
"/metacrypt.v2.UserService/Decrypt": true,
"/metacrypt.v2.UserService/ReEncrypt": true,
"/metacrypt.v2.UserService/RotateKey": true,
"/metacrypt.v2.UserService/DeleteUser": true,
// authRequiredMethods — all user RPCs:
"/metacrypt.v2.UserService/Register": true,
"/metacrypt.v2.UserService/Provision": true,
"/metacrypt.v2.UserService/GetPublicKey": true,
"/metacrypt.v2.UserService/ListUsers": true,
"/metacrypt.v2.UserService/Encrypt": true,
"/metacrypt.v2.UserService/Decrypt": true,
"/metacrypt.v2.UserService/ReEncrypt": true,
"/metacrypt.v2.UserService/RotateKey": true,
"/metacrypt.v2.UserService/DeleteUser": true,
// adminRequiredMethods — admin-only user RPCs:
"/metacrypt.v2.UserService/Provision": true,
"/metacrypt.v2.UserService/DeleteUser": true,
```
The `adminOnlyOperations` map in `routes.go` already contains user entries
(qualified as `user:provision`, `user:delete-user` — keys are
`engineType:operation` to avoid cross-engine name collisions).
## Web UI ## Web UI
Add to `/dashboard` the ability to mount a user engine. Add to `/dashboard` the ability to mount a user engine.
@@ -260,33 +491,47 @@ Add a `/user-crypto` page displaying:
## Implementation Steps ## Implementation Steps
1. **`internal/engine/user/`** — Implement `UserEngine`: 1. **Prerequisite**: `engine.ZeroizeKey` must exist in
`internal/engine/helpers.go` (created as part of the SSH CA engine
implementation — see `engines/sshca.md` step 1).
2. **`internal/engine/user/`** — Implement `UserEngine`:
- `types.go` — Config types, envelope format. - `types.go` — Config types, envelope format.
- `user.go` — Lifecycle (Initialize, Unseal, Seal, HandleRequest). - `user.go` — Lifecycle (Initialize, Unseal, Seal, HandleRequest).
- `crypto.go` — ECDH key agreement, HKDF derivation, DEK wrap/unwrap, - `crypto.go` — ECDH key agreement, HKDF derivation, DEK wrap/unwrap,
symmetric encrypt/decrypt. symmetric encrypt/decrypt.
- `keys.go` — User registration, key rotation, deletion. - `keys.go` — User registration, key rotation, deletion.
2. **Register factory** in `cmd/metacrypt/main.go`. 3. **Register factory** in `cmd/metacrypt/main.go`.
3. **Proto definitions**`proto/metacrypt/v2/user.proto`, run `make proto`. 4. **Proto definitions** — `proto/metacrypt/v2/user.proto`, run `make proto`.
4. **gRPC handlers**`internal/grpcserver/user.go`. 5. **gRPC handlers** — `internal/grpcserver/user.go`.
5. **REST routes** — Add to `internal/server/routes.go`. 6. **REST routes** — Add to `internal/server/routes.go`.
6. **Web UI** — Add template + webserver routes. 7. **Web UI** — Add template + webserver routes.
7. **Tests** — Unit tests: register, encrypt/decrypt roundtrip, multi-recipient, 8. **Tests** — Unit tests: register, encrypt/decrypt roundtrip, multi-recipient,
key rotation invalidates old envelopes, authorization checks. key rotation invalidates old envelopes, re-encrypt roundtrip, authorization
checks.
## Dependencies ## Dependencies
- `golang.org/x/crypto/hkdf` (for key derivation from ECDH shared secret) - `golang.org/x/crypto/hkdf` (for key derivation from ECDH shared secret)
- `crypto/ecdh` (Go 1.20+, for X25519 and NIST curve key exchange) - `crypto/ecdh` (Go 1.20+, for X25519 and NIST curve key exchange)
- Standard library `crypto/aes`, `crypto/cipher`, `crypto/rand` - Standard library `crypto/aes`, `crypto/cipher`, `crypto/rand`, `crypto/sha256`,
`crypto/x509`, `encoding/pem`
## Security Considerations ## Security Considerations
- Private keys encrypted at rest in the barrier, zeroized on seal. - Private keys encrypted at rest in the barrier, zeroized on seal.
- DEK is random per-encryption; never reused. - DEK is random 32 bytes per-encryption; never reused.
- HKDF derivation includes sender and recipient identities in the info string - HKDF salt is 32 bytes of `crypto/rand` randomness, generated fresh per
to prevent key confusion attacks: recipient per encryption. Stored in the envelope alongside the wrapped DEK.
`info = "metacrypt-user-v1:" + sender + ":" + recipient`. A random salt ensures that even if the same sender-recipient pair encrypts
multiple messages, the derived wrapping keys are unique.
- HKDF info string includes sender and recipient identities to prevent key
confusion attacks: `info = "metacrypt-user-v1:" + sender + ":" + recipient`.
- DEK wrapping uses AES-256-GCM (not AES Key Wrap / RFC 3394). Both provide
authenticated encryption; AES-GCM is preferred for consistency with the rest
of the codebase and avoids adding a new primitive.
- All intermediate secrets (shared secrets, wrapping keys, DEKs) are zeroized
immediately after use using `crypto.Zeroize`.
- Envelope includes sender identity so the recipient can derive the correct - Envelope includes sender identity so the recipient can derive the correct
shared secret. shared secret.
- Key rotation is destructive — old data cannot be decrypted. The engine should - Key rotation is destructive — old data cannot be decrypted. The engine should
@@ -294,9 +539,34 @@ Add a `/user-crypto` page displaying:
- Server-trust model: the server holds all private keys in the barrier. No API - Server-trust model: the server holds all private keys in the barrier. No API
surface exports private keys. Access control is application-enforced — the surface exports private keys. Access control is application-enforced — the
engine only uses a private key on behalf of its owner during encrypt/decrypt. engine only uses a private key on behalf of its owner during encrypt/decrypt.
- Auto-provisioned users have keypairs waiting for them; their private keys are - Auto-provisioning creates key pairs for unregistered recipients. Before
protected identically to explicitly registered users. creating a key pair, the engine validates that the recipient username exists
in MCIAS via `auth.ValidateUsername`. This prevents barrier pollution from
non-existent usernames. Auto-provisioning is safe because: (a) the recipient
must be a real MCIAS user, (b) no MCIAS permissions are granted, (c) the
private key is only usable after MCIAS authentication, (d) key pairs are
stored identically to explicitly registered users. Auto-provisioning is only
triggered by authenticated users during `encrypt`.
- Encrypt requests are limited to 100 recipients to prevent resource exhaustion
from ECDH + HKDF computation.
- Metadata in the envelope is authenticated (included as additional data in - Metadata in the envelope is authenticated (included as additional data in
AEAD) but not encrypted — it is visible to anyone holding the envelope. AEAD) but not encrypted — it is visible to anyone holding the envelope.
- Post-quantum readiness: the `key_algorithm` config supports future hybrid - Post-quantum readiness: the `key_algorithm` config supports future hybrid
schemes (e.g. X25519 + ML-KEM). The envelope version field enables migration. schemes (e.g. X25519 + ML-KEM). The envelope version field enables migration.
- X25519 is the default algorithm because it provides 128-bit security with
the smallest key size and fastest operations. NIST curves are offered for
compliance contexts.
## Implementation References
These existing code patterns should be followed exactly:
| Pattern | Reference File | Lines |
|---------|---------------|-------|
| HandleRequest switch dispatch | `internal/engine/ca/ca.go` | 284317 |
| zeroizeKey helper | `internal/engine/ca/ca.go` | 14811498 |
| REST route registration with chi | `internal/server/routes.go` | 3850 |
| gRPC handler structure | `internal/grpcserver/ca.go` | full file |
| gRPC interceptor maps | `internal/grpcserver/server.go` | 107205 |
| Engine factory registration | `cmd/metacrypt/server.go` | 76 |
| adminOnlyOperations map | `internal/server/routes.go` | 265285 |

View File

@@ -175,6 +175,35 @@ func makeEngineRequest(mount, operation string) string {
return `{"mount":"` + mount + `","operation":"` + operation + `","data":{}}` return `{"mount":"` + mount + `","operation":"` + operation + `","data":{}}`
} }
// stubEngine is a minimal engine implementation for testing the generic endpoint.
type stubEngine struct {
engineType engine.EngineType
}
func (e *stubEngine) Type() engine.EngineType { return e.engineType }
func (e *stubEngine) Initialize(_ context.Context, _ barrier.Barrier, _ string, _ map[string]interface{}) error {
return nil
}
func (e *stubEngine) Unseal(_ context.Context, _ barrier.Barrier, _ string) error { return nil }
func (e *stubEngine) Seal() error { return nil }
func (e *stubEngine) HandleRequest(_ context.Context, req *engine.Request) (*engine.Response, error) {
return &engine.Response{Data: map[string]interface{}{"ok": true}}, nil
}
// mountStubEngine registers a factory and mounts a stub engine of the given type.
func mountStubEngine(t *testing.T, srv *Server, name string, engineType engine.EngineType) {
t.Helper()
srv.engines.RegisterFactory(engineType, func() engine.Engine {
return &stubEngine{engineType: engineType}
})
if err := srv.engines.Mount(context.Background(), name, engineType, nil); err != nil {
// Ignore "already exists" from re-mounting the same name.
if !strings.Contains(err.Error(), "already exists") {
t.Fatalf("mount stub %q as %s: %v", name, engineType, err)
}
}
}
func withTokenInfo(r *http.Request, info *auth.TokenInfo) *http.Request { func withTokenInfo(r *http.Request, info *auth.TokenInfo) *http.Request {
return r.WithContext(context.WithValue(r.Context(), tokenInfoKey, info)) return r.WithContext(context.WithValue(r.Context(), tokenInfoKey, info))
} }
@@ -184,6 +213,7 @@ func withTokenInfo(r *http.Request, info *auth.TokenInfo) *http.Request {
func TestEngineRequestPolicyDeniesNonAdmin(t *testing.T) { func TestEngineRequestPolicyDeniesNonAdmin(t *testing.T) {
srv, sealMgr, _ := setupTestServer(t) srv, sealMgr, _ := setupTestServer(t)
unsealServer(t, sealMgr, nil) unsealServer(t, sealMgr, nil)
mountStubEngine(t, srv, "pki", engine.EngineTypeCA)
body := makeEngineRequest("pki", "list-issuers") body := makeEngineRequest("pki", "list-issuers")
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
@@ -200,6 +230,7 @@ func TestEngineRequestPolicyDeniesNonAdmin(t *testing.T) {
func TestEngineRequestPolicyAllowsAdmin(t *testing.T) { func TestEngineRequestPolicyAllowsAdmin(t *testing.T) {
srv, sealMgr, _ := setupTestServer(t) srv, sealMgr, _ := setupTestServer(t)
unsealServer(t, sealMgr, nil) unsealServer(t, sealMgr, nil)
mountStubEngine(t, srv, "pki", engine.EngineTypeCA)
body := makeEngineRequest("pki", "list-issuers") body := makeEngineRequest("pki", "list-issuers")
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
@@ -207,7 +238,7 @@ func TestEngineRequestPolicyAllowsAdmin(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
srv.handleEngineRequest(w, req) srv.handleEngineRequest(w, req)
// Admin bypasses policy; will fail with mount-not-found (404), not forbidden (403). // Admin bypasses policy; stub engine returns 200.
if w.Code == http.StatusForbidden { if w.Code == http.StatusForbidden {
t.Errorf("admin should not be forbidden by policy, got 403: %s", w.Body.String()) t.Errorf("admin should not be forbidden by policy, got 403: %s", w.Body.String())
} }
@@ -218,6 +249,7 @@ func TestEngineRequestPolicyAllowsAdmin(t *testing.T) {
func TestEngineRequestPolicyAllowsWithRule(t *testing.T) { func TestEngineRequestPolicyAllowsWithRule(t *testing.T) {
srv, sealMgr, _ := setupTestServer(t) srv, sealMgr, _ := setupTestServer(t)
unsealServer(t, sealMgr, nil) unsealServer(t, sealMgr, nil)
mountStubEngine(t, srv, "pki", engine.EngineTypeCA)
ctx := context.Background() ctx := context.Background()
_ = srv.policy.CreateRule(ctx, &policy.Rule{ _ = srv.policy.CreateRule(ctx, &policy.Rule{
@@ -235,7 +267,7 @@ func TestEngineRequestPolicyAllowsWithRule(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
srv.handleEngineRequest(w, req) srv.handleEngineRequest(w, req)
// Policy allows; will fail with mount-not-found (404), not forbidden (403). // Policy allows; stub engine returns 200.
if w.Code == http.StatusForbidden { if w.Code == http.StatusForbidden {
t.Errorf("user with allow rule should not be forbidden, got 403: %s", w.Body.String()) t.Errorf("user with allow rule should not be forbidden, got 403: %s", w.Body.String())
} }
@@ -247,15 +279,32 @@ func TestEngineRequestAdminOnlyBlocksNonAdmin(t *testing.T) {
srv, sealMgr, _ := setupTestServer(t) srv, sealMgr, _ := setupTestServer(t)
unsealServer(t, sealMgr, nil) unsealServer(t, sealMgr, nil)
for _, op := range []string{"create-issuer", "delete-cert", "create-key", "rotate-key", "create-profile", "provision"} { // Mount stub engines so the admin-only lookup can resolve engine types.
body := makeEngineRequest("test-mount", op) mountStubEngine(t, srv, "ca-mount", engine.EngineTypeCA)
mountStubEngine(t, srv, "transit-mount", engine.EngineTypeTransit)
mountStubEngine(t, srv, "sshca-mount", engine.EngineTypeSSHCA)
mountStubEngine(t, srv, "user-mount", engine.EngineTypeUser)
cases := []struct {
mount string
op string
}{
{"ca-mount", "create-issuer"},
{"ca-mount", "delete-cert"},
{"transit-mount", "create-key"},
{"transit-mount", "rotate-key"},
{"sshca-mount", "create-profile"},
{"user-mount", "provision"},
}
for _, tc := range cases {
body := makeEngineRequest(tc.mount, tc.op)
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false}) req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false})
w := httptest.NewRecorder() w := httptest.NewRecorder()
srv.handleEngineRequest(w, req) srv.handleEngineRequest(w, req)
if w.Code != http.StatusForbidden { if w.Code != http.StatusForbidden {
t.Errorf("operation %q: expected 403 for non-admin, got %d", op, w.Code) t.Errorf("%s/%s: expected 403 for non-admin, got %d", tc.mount, tc.op, w.Code)
} }
} }
} }
@@ -266,20 +315,86 @@ func TestEngineRequestAdminOnlyAllowsAdmin(t *testing.T) {
srv, sealMgr, _ := setupTestServer(t) srv, sealMgr, _ := setupTestServer(t)
unsealServer(t, sealMgr, nil) unsealServer(t, sealMgr, nil)
for _, op := range []string{"create-issuer", "delete-cert", "create-key", "rotate-key", "create-profile", "provision"} { mountStubEngine(t, srv, "ca-mount", engine.EngineTypeCA)
body := makeEngineRequest("test-mount", op) mountStubEngine(t, srv, "transit-mount", engine.EngineTypeTransit)
mountStubEngine(t, srv, "sshca-mount", engine.EngineTypeSSHCA)
mountStubEngine(t, srv, "user-mount", engine.EngineTypeUser)
cases := []struct {
mount string
op string
}{
{"ca-mount", "create-issuer"},
{"ca-mount", "delete-cert"},
{"transit-mount", "create-key"},
{"transit-mount", "rotate-key"},
{"sshca-mount", "create-profile"},
{"user-mount", "provision"},
}
for _, tc := range cases {
body := makeEngineRequest(tc.mount, tc.op)
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
req = withTokenInfo(req, &auth.TokenInfo{Username: "admin", Roles: []string{"admin"}, IsAdmin: true}) req = withTokenInfo(req, &auth.TokenInfo{Username: "admin", Roles: []string{"admin"}, IsAdmin: true})
w := httptest.NewRecorder() w := httptest.NewRecorder()
srv.handleEngineRequest(w, req) srv.handleEngineRequest(w, req)
// Admin passes the admin check; will get 404 (mount not found) not 403. // Admin passes the admin check; stub engine returns 200.
if w.Code == http.StatusForbidden { if w.Code == http.StatusForbidden {
t.Errorf("operation %q: admin should not be forbidden, got 403", op) t.Errorf("%s/%s: admin should not be forbidden, got 403", tc.mount, tc.op)
} }
} }
} }
// TestEngineRequestUserRotateKeyOnUserMount verifies that a non-admin user
// can call rotate-key on a user engine mount (not blocked by transit's admin gate).
func TestEngineRequestUserRotateKeyOnUserMount(t *testing.T) {
srv, sealMgr, _ := setupTestServer(t)
unsealServer(t, sealMgr, nil)
mountStubEngine(t, srv, "user-mount", engine.EngineTypeUser)
// Create a policy rule allowing user operations.
ctx := context.Background()
_ = srv.policy.CreateRule(ctx, &policy.Rule{
ID: "allow-user-ops",
Priority: 100,
Effect: policy.EffectAllow,
Roles: []string{"user"},
Resources: []string{"engine/*/*"},
Actions: []string{"any"},
})
body := makeEngineRequest("user-mount", "rotate-key")
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false})
w := httptest.NewRecorder()
srv.handleEngineRequest(w, req)
// rotate-key on a user mount should NOT be blocked as admin-only.
if w.Code == http.StatusForbidden {
t.Errorf("user rotate-key on user mount should not be forbidden, got 403: %s", w.Body.String())
}
}
// TestEngineRequestUserRotateKeyOnTransitMount verifies that a non-admin user
// is blocked from calling rotate-key on a transit engine mount.
func TestEngineRequestUserRotateKeyOnTransitMount(t *testing.T) {
srv, sealMgr, _ := setupTestServer(t)
unsealServer(t, sealMgr, nil)
mountStubEngine(t, srv, "transit-mount", engine.EngineTypeTransit)
body := makeEngineRequest("transit-mount", "rotate-key")
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false})
w := httptest.NewRecorder()
srv.handleEngineRequest(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("user rotate-key on transit mount should be 403, got %d", w.Code)
}
}
// TestOperationAction verifies the action classification of operations. // TestOperationAction verifies the action classification of operations.
func TestOperationAction(t *testing.T) { func TestOperationAction(t *testing.T) {
tests := map[string]string{ tests := map[string]string{