Files
metacrypt/engines/transit.md

779 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Transit Engine Implementation Plan
## Overview
The transit engine provides encryption-as-a-service: applications send plaintext
to Metacrypt and receive ciphertext (or vice versa), without ever handling raw
encryption keys. This enables envelope encryption, key rotation, and centralized
key management.
The design is inspired by HashiCorp Vault's transit secrets engine.
## Engine Type
`transit` — registered constant already exists in `internal/engine/engine.go`.
## Mount Configuration
Passed as `config` at mount time:
| Field | Default | Description |
|--------------------|---------|------------------------------------------------|
| `max_key_versions` | `0` | Maximum key versions to retain (0 = unlimited) |
No engine-wide key algorithm is configured; each named key specifies its own.
## Core Concepts
### Named Keys
The transit engine manages **named encryption keys**. Each key has:
- A unique name (e.g. `"payments"`, `"session-tokens"`)
- A key type (symmetric or asymmetric)
- One or more **versions** (for key rotation)
- Policy flags (exportable, allow-deletion)
### Key Types
| Type | Algorithm | Operations |
|-----------------|-------------------|------------------|
| `aes256-gcm` | AES-256-GCM | Encrypt, Decrypt |
| `chacha20-poly` | XChaCha20-Poly1305 | Encrypt, Decrypt |
| `ed25519` | Ed25519 | Sign, Verify |
| `ecdsa-p256` | ECDSA P-256 | Sign, Verify |
| `ecdsa-p384` | ECDSA P-384 | Sign, Verify |
| `hmac-sha256` | HMAC-SHA256 | HMAC |
| `hmac-sha512` | HMAC-SHA512 | HMAC |
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),
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
Each key has a current version and may retain older versions. Encryption always
uses the latest version. Decryption selects the version from the ciphertext
header.
Each key tracks a `min_decryption_version` (default 1). Decryption requests
for ciphertext encrypted with a version below this minimum are rejected. This
lets operators complete a rotation cycle:
1. Rotate the key (creates version N+1).
2. Rewrap all existing ciphertext to the latest version.
3. Set `min_decryption_version` to N+1.
4. Old key versions at or below the minimum can then be pruned via
`max_key_versions` or `trim-key`.
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
Transit ciphertexts use a versioned prefix:
```
metacrypt:v{version}:{base64(nonce + ciphertext + tag)}
```
The `v{version}` identifies which key version to use for decryption.
## Barrier Storage Layout
```
engine/transit/{mount}/config.json Engine configuration
engine/transit/{mount}/keys/{name}/config.json Key configuration + policy
engine/transit/{mount}/keys/{name}/v{N}.key Key material for version N
```
## In-Memory State
```go
type TransitEngine struct {
barrier barrier.Barrier
config *TransitConfig
keys map[string]*keyState // loaded named keys
mountPath string
mu sync.RWMutex
}
type keyState struct {
config *KeyConfig
versions map[int]*keyVersion
minDecryptionVersion int // reject decrypt for versions below this
}
type keyVersion struct {
version int
key []byte // symmetric key material
privKey crypto.PrivateKey // asymmetric private key (nil for symmetric)
pubKey crypto.PublicKey // asymmetric public key (nil for symmetric)
}
```
## Lifecycle
### Initialize
1. Parse and validate config: parse `max_key_versions` as integer (must be ≥ 0).
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
1. Load config JSON from barrier, unmarshal into `*TransitConfig`.
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
1. Zeroize all key material: symmetric keys overwritten with zeros via
`crypto.Zeroize(key)`, asymmetric keys via `engine.ZeroizeKey(privKey)`
(shared helper, see sshca.md Implementation References).
2. Nil out `keys` map and `config`.
## Operations
| Operation | Auth Required | Description |
|------------------|---------------|-----------------------------------------------|
| `create-key` | Admin | Create a new named key |
| `delete-key` | Admin | Delete a named key (if `allow_deletion` set) |
| `get-key` | User/Admin | Get key metadata (no raw material) |
| `list-keys` | User/Admin | List named keys |
| `rotate-key` | Admin | Create a new version of a named key |
| `update-key-config` | Admin | Update mutable key config (e.g. `min_decryption_version`) |
| `trim-key` | Admin | Delete versions older than `min_decryption_version` |
| `encrypt` | User+Policy | Encrypt plaintext with a named key |
| `decrypt` | User+Policy | Decrypt ciphertext with a named key |
| `rewrap` | User+Policy | Re-encrypt ciphertext with the latest key version |
| `batch-encrypt` | User+Policy | Encrypt multiple plaintexts with a named key |
| `batch-decrypt` | User+Policy | Decrypt multiple ciphertexts with a named key |
| `batch-rewrap` | User+Policy | Re-encrypt multiple ciphertexts with latest version |
| `sign` | User+Policy | Sign data with an asymmetric key (Ed25519, ECDSA) |
| `verify` | User+Policy | Verify an asymmetric signature |
| `hmac` | User+Policy | Compute HMAC with an HMAC key |
| `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
Request data:
| Field | Required | Default | Description |
|-------------------|----------|----------------|----------------------------------|
| `name` | Yes | | Key name |
| `type` | Yes | | Key type (see table above) |
| `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.
### encrypt
Request data:
| Field | Required | Description |
|-------------|----------|---------------------------------------------------|
| `key` | Yes | Named key to use |
| `plaintext` | Yes | Base64-encoded plaintext |
| `context` | No | Base64-encoded context for AEAD additional data |
Response: `{ "ciphertext": "metacrypt:v1:..." }`
### decrypt
Request data:
| Field | Required | Description |
|--------------|----------|---------------------------------------------------|
| `key` | Yes | Named key to use |
| `ciphertext` | Yes | Ciphertext string from encrypt |
| `context` | No | Base64-encoded context (must match encrypt context) |
Response: `{ "plaintext": "<base64>" }`
### sign
Asymmetric keys only (Ed25519, ECDSA). HMAC keys must use the `hmac` operation
instead — HMAC is a MAC, not a digital signature, and does not provide
non-repudiation.
Request data:
| Field | Required | Description |
|-------------|----------|--------------------------------------------|
| `key` | Yes | Named key (Ed25519 or ECDSA type) |
| `input` | Yes | Base64-encoded data to sign |
| `algorithm` | No | Reserved for future prehash options (currently ignored) |
The engine rejects `sign` requests for HMAC and symmetric key types with an
error. Only Ed25519 and ECDSA keys are accepted.
Response: `{ "signature": "metacrypt:v{version}:...", "key_version": N }`
### verify
Asymmetric keys only. Rejects HMAC key types (use `hmac` to recompute and
compare instead).
Request data:
| Field | Required | Description |
|-------------|----------|--------------------------------------------|
| `key` | Yes | Named key (Ed25519 or ECDSA type) |
| `input` | Yes | Base64-encoded original data |
| `signature` | Yes | Signature string from sign |
Response: `{ "valid": true }`
### update-key-config
Admin-only. Updates mutable key configuration fields.
Request data:
| Field | Required | Description |
|--------------------------|----------|------------------------------------------|
| `key` | Yes | Named key |
| `min_decryption_version` | No | Minimum version allowed for decryption |
`min_decryption_version` can only be increased, never decreased. It cannot
exceed the current version (you must always be able to decrypt with the latest).
### trim-key
Admin-only. Permanently deletes key versions **strictly less than**
`min_decryption_version`. This is irreversible — ciphertext encrypted with
trimmed versions can never be decrypted.
Request data:
| Field | Required | Description |
|-------|----------|-------------|
| `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, ...] }`
## Batch Operations
The transit engine supports batch variants of `encrypt`, `decrypt`, and
`rewrap` for high-throughput use cases (e.g. encrypting many database fields,
re-encrypting after key rotation). Without batch support, callers are pushed
toward caching keys locally, defeating the purpose of transit encryption.
### Design
Each batch request targets a **single named key** with an array of items.
Results are returned in the same order. Errors are **per-item** (partial
success model) — a single bad ciphertext does not fail the entire batch.
Single-key-per-batch simplifies authorization: one policy check per batch
request rather than per item. Callers needing multiple keys issue multiple
batch requests.
### batch-encrypt
Request data:
| Field | Required | Description |
|---------|----------|---------------------------------------------------|
| `key` | Yes | Named key to use |
| `items` | Yes | Array of encrypt items (see below) |
Each item:
| Field | Required | Description |
|-------------|----------|-----------------------------------------------|
| `plaintext` | Yes | Base64-encoded plaintext |
| `context` | No | Base64-encoded context for AEAD additional data |
| `reference` | No | Caller-defined reference string (echoed back) |
Response: `{ "results": [...] }`
Each result:
| Field | Description |
|--------------|------------------------------------------------------|
| `ciphertext` | `"metacrypt:v1:..."` on success, empty on error |
| `reference` | Echoed from the request item (if provided) |
| `error` | Error message on failure, empty on success |
### batch-decrypt
Request data:
| Field | Required | Description |
|---------|----------|---------------------------------------------------|
| `key` | Yes | Named key to use |
| `items` | Yes | Array of decrypt items (see below) |
Each item:
| Field | Required | Description |
|--------------|----------|-----------------------------------------------|
| `ciphertext` | Yes | Ciphertext string from encrypt |
| `context` | No | Base64-encoded context (must match encrypt) |
| `reference` | No | Caller-defined reference string (echoed back) |
Response: `{ "results": [...] }`
Each result:
| Field | Description |
|-------------|------------------------------------------------------|
| `plaintext` | Base64-encoded plaintext on success, empty on error |
| `reference` | Echoed from the request item (if provided) |
| `error` | Error message on failure, empty on success |
### batch-rewrap
Request data:
| Field | Required | Description |
|---------|----------|---------------------------------------------------|
| `key` | Yes | Named key to use |
| `items` | Yes | Array of rewrap items (see below) |
Each item:
| Field | Required | Description |
|--------------|----------|-----------------------------------------------|
| `ciphertext` | Yes | Ciphertext to re-encrypt with latest version |
| `context` | No | Base64-encoded context (must match original) |
| `reference` | No | Caller-defined reference string (echoed back) |
Response: `{ "results": [...] }`
Each result:
| Field | Description |
|--------------|------------------------------------------------------|
| `ciphertext` | Re-encrypted ciphertext on success, empty on error |
| `reference` | Echoed from the request item (if provided) |
| `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
Batch operations are handled inside the transit engine's `HandleRequest` as
three additional operation cases (`batch-encrypt`, `batch-decrypt`,
`batch-rewrap`). No changes to the `Engine` interface are needed. The engine
acquires a read lock once, loads the key once, and processes all items in the
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
results with their source records (e.g. a database row ID) without maintaining
positional tracking.
## Authorization
Follows the same model as the CA engine:
- **Admins**: grant-all for all operations.
- **Users**: can encrypt/decrypt/sign/verify/hmac if policy allows.
- **Policy resources**: `transit/{mount}/key/{key_name}` with granular actions:
`encrypt`, `decrypt`, `sign`, `verify`, `hmac` for cryptographic operations;
`read` for metadata (get-key, list-keys, get-public-key); `write` for
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`).
- No ownership concept (transit keys are shared resources); access is purely
policy-based.
## gRPC Service (proto/metacrypt/v2/transit.proto)
```protobuf
service TransitService {
rpc CreateKey(CreateTransitKeyRequest) returns (CreateTransitKeyResponse);
rpc DeleteKey(DeleteTransitKeyRequest) returns (DeleteTransitKeyResponse);
rpc GetKey(GetTransitKeyRequest) returns (GetTransitKeyResponse);
rpc ListKeys(ListTransitKeysRequest) returns (ListTransitKeysResponse);
rpc RotateKey(RotateTransitKeyRequest) returns (RotateTransitKeyResponse);
rpc UpdateKeyConfig(UpdateTransitKeyConfigRequest) returns (UpdateTransitKeyConfigResponse);
rpc TrimKey(TrimTransitKeyRequest) returns (TrimTransitKeyResponse);
rpc Encrypt(TransitEncryptRequest) returns (TransitEncryptResponse);
rpc Decrypt(TransitDecryptRequest) returns (TransitDecryptResponse);
rpc Rewrap(TransitRewrapRequest) returns (TransitRewrapResponse);
rpc BatchEncrypt(BatchTransitEncryptRequest) returns (BatchTransitEncryptResponse);
rpc BatchDecrypt(BatchTransitDecryptRequest) returns (BatchTransitDecryptResponse);
rpc BatchRewrap(BatchTransitRewrapRequest) returns (BatchTransitRewrapResponse);
rpc Sign(TransitSignRequest) returns (TransitSignResponse);
rpc Verify(TransitVerifyRequest) returns (TransitVerifyResponse);
rpc Hmac(TransitHmacRequest) returns (TransitHmacResponse);
rpc GetPublicKey(GetTransitPublicKeyRequest) returns (GetTransitPublicKeyResponse);
}
```
## REST Endpoints
All auth required:
| Method | Path | Description |
|--------|---------------------------------------------|--------------------|
| POST | `/v1/transit/{mount}/keys` | Create key |
| GET | `/v1/transit/{mount}/keys` | List keys |
| GET | `/v1/transit/{mount}/keys/{name}` | Get key metadata |
| DELETE | `/v1/transit/{mount}/keys/{name}` | Delete key |
| POST | `/v1/transit/{mount}/keys/{name}/rotate` | Rotate key |
| PATCH | `/v1/transit/{mount}/keys/{name}/config` | Update key config |
| POST | `/v1/transit/{mount}/keys/{name}/trim` | Trim old versions |
| POST | `/v1/transit/{mount}/encrypt/{key}` | Encrypt |
| POST | `/v1/transit/{mount}/decrypt/{key}` | Decrypt |
| POST | `/v1/transit/{mount}/rewrap/{key}` | Rewrap |
| POST | `/v1/transit/{mount}/batch/encrypt/{key}` | Batch encrypt |
| POST | `/v1/transit/{mount}/batch/decrypt/{key}` | Batch decrypt |
| POST | `/v1/transit/{mount}/batch/rewrap/{key}` | Batch rewrap |
| POST | `/v1/transit/{mount}/sign/{key}` | Sign |
| POST | `/v1/transit/{mount}/verify/{key}` | Verify |
| 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`.
### 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
Add to `/dashboard` the ability to mount a transit engine.
Add a `/transit` page displaying:
- Named key list with metadata (type, version, created, allow_deletion)
- Key detail view with version history
- Encrypt/decrypt form for interactive testing
- Key rotation button (admin)
## Implementation Steps
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.
- `transit.go` — Lifecycle (Initialize, Unseal, Seal, HandleRequest).
- `encrypt.go` — Encrypt/Decrypt/Rewrap operations.
- `sign.go` — Sign/Verify/HMAC operations.
- `keys.go` — Key management (create, delete, rotate, list, get).
3. **Register factory** in `cmd/metacrypt/main.go`.
4. **Proto definitions** — `proto/metacrypt/v2/transit.proto`, run `make proto`.
5. **gRPC handlers** — `internal/grpcserver/transit.go`.
6. **REST routes** — Add to `internal/server/routes.go`.
7. **Web UI** — Add template + webserver routes.
8. **Tests** — Unit tests for each operation, key rotation, rewrap correctness.
## Dependencies
- `golang.org/x/crypto/chacha20poly1305` (for XChaCha20-Poly1305 key type)
- Standard library `crypto/aes`, `crypto/cipher`, `crypto/ecdsa`,
`crypto/ed25519`, `crypto/hmac`, `crypto/sha256`, `crypto/sha512`,
`crypto/elliptic`, `crypto/x509`, `crypto/rand`
## Security Considerations
- All key material encrypted at rest in the barrier, zeroized on seal.
- 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.
- Key export is not supported — transit keys never leave the service.
- `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.
- `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.
- Context (AAD) binding prevents ciphertext from being used in a different context.
- `min_decryption_version` enforces key rotation completion: once advanced,
old versions are unusable for decryption and can be permanently trimmed.
- RSA key types are excluded to avoid padding scheme vulnerabilities
(Bleichenbacher attacks on PKCS#1 v1.5). Asymmetric encryption belongs in
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 |