Files
metacrypt/engines/transit.md

34 KiB
Raw Blame History

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

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:
    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):

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)

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:

// 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:

// 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 definitionsproto/metacrypt/v2/transit.proto, run make proto.

  5. gRPC handlersinternal/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