779 lines
34 KiB
Markdown
779 lines
34 KiB
Markdown
# 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` | 284–317 |
|
||
| zeroizeKey helper | `internal/engine/ca/ca.go` | 1481–1498 |
|
||
| REST route registration with chi | `internal/server/routes.go` | 38–50 |
|
||
| gRPC handler structure | `internal/grpcserver/ca.go` | full file |
|
||
| gRPC interceptor maps | `internal/grpcserver/server.go` | 107–205 |
|
||
| Engine factory registration | `cmd/metacrypt/server.go` | 76 |
|
||
| adminOnlyOperations map | `internal/server/routes.go` | 265–285 |
|