Implement a two-level key hierarchy: the MEK now wraps per-engine DEKs stored in a new barrier_keys table, rather than encrypting all barrier entries directly. A v2 ciphertext format (0x02) embeds the key ID so the barrier can resolve which DEK to use on decryption. v1 ciphertext remains supported for backward compatibility. Key changes: - crypto: EncryptV2/DecryptV2/ExtractKeyID for v2 ciphertext with key IDs - barrier: key registry (CreateKey, RotateKey, ListKeys, MigrateToV2, ReWrapKeys) - seal: RotateMEK re-wraps DEKs without re-encrypting data - engine: Mount auto-creates per-engine DEK - REST + gRPC: barrier/keys, barrier/rotate-mek, barrier/rotate-key, barrier/migrate - proto: BarrierService (v1 + v2) with ListKeys, RotateMEK, RotateKey, Migrate - db: migration v2 adds barrier_keys table Also includes: security audit report, CSRF protection, engine design specs (sshca, transit, user), path-bound AAD migration tool, policy engine enhancements, and ARCHITECTURE.md updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
353
engines/sshca.md
Normal file
353
engines/sshca.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# SSH CA Engine Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
The SSH CA engine signs SSH host and user certificates using Go's
|
||||
`golang.org/x/crypto/ssh` package. It follows the same architecture as the CA
|
||||
engine: a single CA key pair signs certificates directly (no intermediate
|
||||
hierarchy, since SSH certificates are flat).
|
||||
|
||||
## Engine Type
|
||||
|
||||
`sshca` — registered constant already exists in `internal/engine/engine.go`.
|
||||
|
||||
## Mount Configuration
|
||||
|
||||
Passed as `config` at mount time:
|
||||
|
||||
| Field | Default | Description |
|
||||
|-----------------|------------------|------------------------------------------|
|
||||
| `key_algorithm` | `"ed25519"` | CA key type: ed25519, ecdsa, rsa |
|
||||
| `key_size` | `0` | Key size (ignored for ed25519; 256/384/521 for ECDSA, 2048/4096 for RSA) |
|
||||
| `max_ttl` | `"87600h"` | Maximum certificate validity |
|
||||
| `default_ttl` | `"24h"` | Default certificate validity |
|
||||
|
||||
## Barrier Storage Layout
|
||||
|
||||
```
|
||||
engine/sshca/{mount}/config.json Engine configuration
|
||||
engine/sshca/{mount}/ca/key.pem CA private key (PEM, PKCS8)
|
||||
engine/sshca/{mount}/ca/pubkey.pub CA public key (SSH authorized_keys format)
|
||||
engine/sshca/{mount}/profiles/{name}.json Signing profiles
|
||||
engine/sshca/{mount}/certs/{serial}.json Signed cert records
|
||||
engine/sshca/{mount}/krl.bin Current KRL (OpenSSH format)
|
||||
```
|
||||
|
||||
## In-Memory State
|
||||
|
||||
```go
|
||||
type SSHCAEngine struct {
|
||||
barrier barrier.Barrier
|
||||
config *SSHCAConfig
|
||||
caKey crypto.PrivateKey // CA signing key
|
||||
caSigner ssh.Signer // ssh.Signer wrapping caKey
|
||||
mountPath string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
```
|
||||
|
||||
Key material (`caKey`, `caSigner`) is zeroized on `Seal()`.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
### Initialize
|
||||
|
||||
1. Parse and store config in barrier as `config.json`.
|
||||
2. Generate CA key pair using the configured algorithm.
|
||||
3. Store private key PEM and SSH public key in barrier.
|
||||
4. Load key into memory as `ssh.Signer`.
|
||||
|
||||
### Unseal
|
||||
|
||||
1. Load config from barrier.
|
||||
2. Load CA private key from barrier, parse into `crypto.PrivateKey`.
|
||||
3. Wrap as `ssh.Signer`.
|
||||
|
||||
### Seal
|
||||
|
||||
1. Zeroize `caKey` (same `zeroizeKey` helper used by CA engine).
|
||||
2. Nil out `caSigner`, `config`.
|
||||
|
||||
## Operations
|
||||
|
||||
| Operation | Auth Required | Description |
|
||||
|-------------------|---------------|-----------------------------------------------------|
|
||||
| `get-ca-pubkey` | None | Return CA public key in SSH authorized_keys format |
|
||||
| `sign-host` | User+Policy | Sign an SSH host certificate |
|
||||
| `sign-user` | User+Policy | Sign an SSH user certificate |
|
||||
| `create-profile` | Admin | Create a signing profile |
|
||||
| `update-profile` | Admin | Update a signing profile |
|
||||
| `get-profile` | User/Admin | Get signing profile details |
|
||||
| `list-profiles` | User/Admin | List signing profiles |
|
||||
| `delete-profile` | Admin | Delete a signing profile |
|
||||
| `get-cert` | User/Admin | Get cert record by serial |
|
||||
| `list-certs` | User/Admin | List issued cert summaries |
|
||||
| `revoke-cert` | Admin | Revoke a certificate (soft flag) |
|
||||
| `delete-cert` | Admin | Delete a certificate record |
|
||||
|
||||
### sign-host
|
||||
|
||||
Request data:
|
||||
|
||||
| Field | Required | Description |
|
||||
|---------------|----------|------------------------------------------------|
|
||||
| `public_key` | Yes | SSH public key to sign (authorized_keys format) |
|
||||
| `hostnames` | Yes | Valid principals (hostnames) |
|
||||
| `ttl` | No | Validity duration (default: `default_ttl`) |
|
||||
| `extensions` | No | Map of extensions to include |
|
||||
|
||||
Flow:
|
||||
1. Authenticate caller (`IsUser()`); admins bypass policy/ownership checks.
|
||||
2. Parse the supplied SSH public key.
|
||||
3. Generate a 64-bit serial using `crypto/rand`.
|
||||
4. Build `ssh.Certificate` with `CertType: ssh.HostCert`, principals, validity, serial.
|
||||
5. Policy check: `sshca/{mount}/id/{hostname}` for each principal, with ownership
|
||||
rules (same as CA engine — hostname not held by another user's active cert).
|
||||
6. Sign with `caSigner`.
|
||||
7. Store `CertRecord` in barrier (certificate bytes, metadata; **no private key**).
|
||||
8. Return signed certificate in OpenSSH format.
|
||||
|
||||
### sign-user
|
||||
|
||||
Request data:
|
||||
|
||||
| Field | Required | Description |
|
||||
|------------------|----------|------------------------------------------------|
|
||||
| `public_key` | Yes | SSH public key to sign (authorized_keys format) |
|
||||
| `principals` | Yes | Valid usernames/principals |
|
||||
| `ttl` | No | Validity duration (default: `default_ttl`) |
|
||||
| `profile` | No | Signing profile name (see below) |
|
||||
| `extensions` | No | Map of extensions (e.g. `permit-pty`) |
|
||||
|
||||
Critical options are **not accepted directly** in the sign request. They can
|
||||
only be applied via a signing profile. This prevents unprivileged users from
|
||||
setting security-sensitive options like `force-command` or `source-address`.
|
||||
|
||||
Flow:
|
||||
1. Authenticate caller (`IsUser()`); admins bypass.
|
||||
2. Parse the supplied SSH public key.
|
||||
3. If `profile` is specified, load the signing profile and check policy
|
||||
(`sshca/{mount}/profile/{profile_name}`, action `read`). Merge the
|
||||
profile's critical options and extensions into the certificate. Any
|
||||
extensions in the request are merged with profile extensions; conflicts
|
||||
are resolved in favor of the profile.
|
||||
4. Generate a 64-bit serial using `crypto/rand`.
|
||||
5. Build `ssh.Certificate` with `CertType: ssh.UserCert`, principals, validity, serial.
|
||||
6. If the profile specifies `max_ttl`, enforce it (cap the requested TTL).
|
||||
7. Policy check: `sshca/{mount}/id/{principal}` for each principal.
|
||||
Default rule: a user can only sign certs for their own username as principal,
|
||||
unless a policy grants access to other principals.
|
||||
8. Sign with `caSigner`.
|
||||
9. Store `CertRecord` in barrier (includes profile name if used).
|
||||
10. Return signed certificate in OpenSSH format.
|
||||
|
||||
### Signing Profiles
|
||||
|
||||
A signing profile is a named, admin-defined template that controls what goes
|
||||
into a signed user certificate. Profiles are the only way to set critical
|
||||
options, and access to each profile is policy-gated.
|
||||
|
||||
#### Profile Configuration
|
||||
|
||||
```go
|
||||
type SigningProfile struct {
|
||||
Name string `json:"name"`
|
||||
CriticalOptions map[string]string `json:"critical_options"` // e.g. {"force-command": "/usr/bin/rsync", "source-address": "10.0.0.0/8"}
|
||||
Extensions map[string]string `json:"extensions"` // merged with request extensions
|
||||
MaxTTL string `json:"max_ttl,omitempty"` // overrides engine max_ttl if shorter
|
||||
AllowedPrincipals []string `json:"allowed_principals,omitempty"` // if set, restricts principals
|
||||
}
|
||||
```
|
||||
|
||||
#### Storage
|
||||
|
||||
```
|
||||
engine/sshca/{mount}/profiles/{name}.json
|
||||
```
|
||||
|
||||
#### Operations
|
||||
|
||||
| Operation | Auth Required | Description |
|
||||
|------------------|---------------|------------------------------------------|
|
||||
| `create-profile` | Admin | Create a signing profile |
|
||||
| `update-profile` | Admin | Update a signing profile |
|
||||
| `get-profile` | User/Admin | Get profile details |
|
||||
| `list-profiles` | User/Admin | List available profiles |
|
||||
| `delete-profile` | Admin | Delete a signing profile |
|
||||
|
||||
#### Policy Gating
|
||||
|
||||
Access to a profile is controlled via policy on resource
|
||||
`sshca/{mount}/profile/{profile_name}`, action `read`. A user must have
|
||||
policy access to both the profile and the requested principals to sign
|
||||
a certificate using that profile.
|
||||
|
||||
Example use cases:
|
||||
- **`restricted-sftp`**: `force-command: "internal-sftp"`, `source-address: "10.0.0.0/8"` — grants users SFTP-only access from internal networks.
|
||||
- **`deploy`**: `force-command: "/usr/local/bin/deploy"`, `source-address: "10.0.1.0/24"` — CI/CD deploy key with restricted command.
|
||||
- **`unrestricted`**: empty critical options — for trusted users who need full shell access (admin-only policy).
|
||||
|
||||
## CertRecord
|
||||
|
||||
```go
|
||||
type CertRecord struct {
|
||||
Serial uint64 `json:"serial"`
|
||||
CertType string `json:"cert_type"` // "host" or "user"
|
||||
Principals []string `json:"principals"`
|
||||
CertData string `json:"cert_data"` // OpenSSH format
|
||||
IssuedBy string `json:"issued_by"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Revoked bool `json:"revoked,omitempty"`
|
||||
RevokedAt time.Time `json:"revoked_at,omitempty"`
|
||||
RevokedBy string `json:"revoked_by,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
## Key Revocation List (KRL)
|
||||
|
||||
SSH servers cannot query Metacrypt in real time to check whether a certificate
|
||||
has been revoked. Instead, the SSH CA engine generates an OpenSSH-format KRL
|
||||
(Key Revocation List) that SSH servers fetch periodically and reference via
|
||||
`RevokedKeys` in `sshd_config`.
|
||||
|
||||
### KRL Generation
|
||||
|
||||
The engine maintains a KRL in memory, rebuilt whenever a certificate is revoked
|
||||
or deleted. The KRL is a binary blob in OpenSSH KRL format
|
||||
(`golang.org/x/crypto/ssh` provides marshalling helpers), containing:
|
||||
|
||||
- **Serial revocations**: Revoked certificate serial numbers, keyed to the CA
|
||||
public key. This is the most compact representation.
|
||||
- **KRL version**: Monotonically increasing counter, incremented on each
|
||||
rebuild. SSH servers can use this to detect stale KRLs.
|
||||
- **Generated-at timestamp**: Included in the KRL for freshness checking.
|
||||
|
||||
The KRL is stored in the barrier at `engine/sshca/{mount}/krl.bin` and cached
|
||||
in memory. It is rebuilt on:
|
||||
- `revoke-cert` — adds the serial to the KRL.
|
||||
- `delete-cert` — if the cert was revoked, the KRL is regenerated from all
|
||||
remaining revoked certs.
|
||||
- Engine unseal — loaded from barrier into memory.
|
||||
|
||||
### Distribution
|
||||
|
||||
KRL distribution is a pull model. SSH servers fetch the current KRL via an
|
||||
unauthenticated endpoint (analogous to the public CA key endpoint):
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|-------------------------------------|--------------------------------|
|
||||
| GET | `/v1/sshca/{mount}/krl` | Current KRL (binary, OpenSSH format) |
|
||||
|
||||
The response includes:
|
||||
- `Content-Type: application/octet-stream`
|
||||
- `ETag` header derived from the KRL version, enabling conditional fetches.
|
||||
- `Cache-Control: max-age=60` to encourage periodic refresh without
|
||||
overwhelming the server.
|
||||
|
||||
SSH servers should be configured to fetch the KRL on a cron schedule (e.g.
|
||||
every 1–5 minutes) and write it to a local file referenced by `sshd_config`:
|
||||
|
||||
```
|
||||
RevokedKeys /etc/ssh/metacrypt_krl
|
||||
```
|
||||
|
||||
A helper script or systemd timer can fetch the KRL:
|
||||
|
||||
```bash
|
||||
curl -s -o /etc/ssh/metacrypt_krl \
|
||||
https://metacrypt.example.com:8443/v1/sshca/ssh/krl
|
||||
```
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation | Auth Required | Description |
|
||||
|------------|---------------|----------------------------------------------|
|
||||
| `get-krl` | None | Return the current KRL in OpenSSH format |
|
||||
|
||||
## gRPC Service (proto/metacrypt/v2/sshca.proto)
|
||||
|
||||
```protobuf
|
||||
service SSHCAService {
|
||||
rpc GetCAPublicKey(GetCAPublicKeyRequest) returns (GetCAPublicKeyResponse);
|
||||
rpc SignHost(SignHostRequest) returns (SignHostResponse);
|
||||
rpc SignUser(SignUserRequest) returns (SignUserResponse);
|
||||
rpc CreateProfile(CreateProfileRequest) returns (CreateProfileResponse);
|
||||
rpc UpdateProfile(UpdateProfileRequest) returns (UpdateProfileResponse);
|
||||
rpc GetProfile(GetProfileRequest) returns (GetProfileResponse);
|
||||
rpc ListProfiles(ListProfilesRequest) returns (ListProfilesResponse);
|
||||
rpc DeleteProfile(DeleteProfileRequest) returns (DeleteProfileResponse);
|
||||
rpc GetCert(SSHGetCertRequest) returns (SSHGetCertResponse);
|
||||
rpc ListCerts(SSHListCertsRequest) returns (SSHListCertsResponse);
|
||||
rpc RevokeCert(SSHRevokeCertRequest) returns (SSHRevokeCertResponse);
|
||||
rpc DeleteCert(SSHDeleteCertRequest) returns (SSHDeleteCertResponse);
|
||||
rpc GetKRL(GetKRLRequest) returns (GetKRLResponse);
|
||||
}
|
||||
```
|
||||
|
||||
## REST Endpoints
|
||||
|
||||
Public (unseal required, no auth):
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|-------------------------------------|--------------------------------|
|
||||
| GET | `/v1/sshca/{mount}/ca` | CA public key (SSH format) |
|
||||
| GET | `/v1/sshca/{mount}/krl` | Current KRL (OpenSSH format) |
|
||||
|
||||
Typed endpoints (auth required):
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|--------------------------------------------|----------------------|
|
||||
| POST | `/v1/sshca/{mount}/sign-host` | Sign host cert |
|
||||
| POST | `/v1/sshca/{mount}/sign-user` | Sign user cert |
|
||||
| POST | `/v1/sshca/{mount}/profiles` | Create profile |
|
||||
| GET | `/v1/sshca/{mount}/profiles` | List profiles |
|
||||
| GET | `/v1/sshca/{mount}/profiles/{name}` | Get profile |
|
||||
| PUT | `/v1/sshca/{mount}/profiles/{name}` | Update profile |
|
||||
| DELETE | `/v1/sshca/{mount}/profiles/{name}` | Delete profile |
|
||||
| GET | `/v1/sshca/{mount}/cert/{serial}` | Get cert record |
|
||||
| POST | `/v1/sshca/{mount}/cert/{serial}/revoke` | Revoke cert |
|
||||
| DELETE | `/v1/sshca/{mount}/cert/{serial}` | Delete cert record |
|
||||
|
||||
All operations are also accessible via the generic `POST /v1/engine/request`.
|
||||
|
||||
## Web UI
|
||||
|
||||
Add to `/dashboard` the ability to mount an SSH CA engine.
|
||||
|
||||
Add an `/sshca` page (or section on the existing PKI page) displaying:
|
||||
- CA public key (for `TrustedUserCAKeys` / `@cert-authority` lines)
|
||||
- Sign host/user certificate form
|
||||
- Certificate list with detail view
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **`internal/engine/sshca/`** — Implement `SSHCAEngine` (types, lifecycle,
|
||||
operations). Reuse `zeroizeKey` from `internal/engine/ca/` (move to shared
|
||||
helper or duplicate).
|
||||
2. **Register factory** in `cmd/metacrypt/main.go`:
|
||||
`registry.RegisterFactory(engine.EngineTypeSSHCA, sshca.NewSSHCAEngine)`.
|
||||
3. **Proto definitions** — `proto/metacrypt/v2/sshca.proto`, run `make proto`.
|
||||
4. **gRPC handlers** — `internal/grpcserver/sshca.go`.
|
||||
5. **REST routes** — Add to `internal/server/routes.go`.
|
||||
6. **Web UI** — Add template + webserver routes.
|
||||
7. **Tests** — Unit tests with in-memory barrier following the CA test pattern.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `golang.org/x/crypto/ssh` (already in `go.mod` via transitive deps)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- CA private key encrypted at rest in barrier, zeroized on seal.
|
||||
- Signed certificates do not contain private keys.
|
||||
- Serial numbers are always generated server-side using `crypto/rand` (64-bit);
|
||||
user-provided serials are not accepted.
|
||||
- `max_ttl` is enforced server-side; the engine rejects TTL values exceeding it.
|
||||
- User cert signing defaults to allowing only the caller's own username as
|
||||
principal, preventing privilege escalation.
|
||||
- Critical options (`force-command`, `source-address`, etc.) are only settable
|
||||
via admin-defined signing profiles, never directly in the sign request. This
|
||||
prevents unprivileged users from bypassing `sshd_config` restrictions.
|
||||
- Profile access is policy-gated: a user must have policy access to
|
||||
`sshca/{mount}/profile/{name}` to use a profile.
|
||||
468
engines/transit.md
Normal file
468
engines/transit.md
Normal file
@@ -0,0 +1,468 @@
|
||||
# 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` | ChaCha20-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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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 store config in barrier.
|
||||
2. No keys are created at init time (keys are created on demand).
|
||||
|
||||
### Unseal
|
||||
|
||||
1. Load config from barrier.
|
||||
2. Discover and load all named keys and their versions from the barrier.
|
||||
|
||||
### Seal
|
||||
|
||||
1. Zeroize all key material (symmetric keys overwritten with zeros,
|
||||
asymmetric keys via `zeroizeKey`).
|
||||
2. Nil out all maps.
|
||||
|
||||
## 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 |
|
||||
|
||||
### create-key
|
||||
|
||||
Request data:
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------------------|----------|----------------|----------------------------------|
|
||||
| `name` | Yes | | Key name |
|
||||
| `type` | Yes | | Key type (see table above) |
|
||||
| `exportable` | No | `false` | Whether raw key material can be exported |
|
||||
| `allow_deletion` | No | `false` | Whether key can be deleted |
|
||||
|
||||
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 | Hash algorithm (default varies by key type) |
|
||||
|
||||
The engine rejects `sign` requests for HMAC key types with an error.
|
||||
|
||||
Response: `{ "signature": "metacrypt:v1:..." }`
|
||||
|
||||
### 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 older 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 |
|
||||
|
||||
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 |
|
||||
|
||||
### 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
|
||||
loops over items internally, loading the key once and reusing it for all items
|
||||
in the batch.
|
||||
|
||||
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).
|
||||
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 |
|
||||
|
||||
All operations are also accessible via the generic `POST /v1/engine/request`.
|
||||
|
||||
## 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, exportable)
|
||||
- Key detail view with version history
|
||||
- Encrypt/decrypt form for interactive testing
|
||||
- Key rotation button (admin)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **`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).
|
||||
2. **Register factory** in `cmd/metacrypt/main.go`.
|
||||
3. **Proto definitions** — `proto/metacrypt/v2/transit.proto`, run `make proto`.
|
||||
4. **gRPC handlers** — `internal/grpcserver/transit.go`.
|
||||
5. **REST routes** — Add to `internal/server/routes.go`.
|
||||
6. **Web UI** — Add template + webserver routes.
|
||||
7. **Tests** — Unit tests for each operation, key rotation, rewrap correctness.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `golang.org/x/crypto/chacha20poly1305` (for ChaCha20-Poly1305 key type)
|
||||
- Standard library `crypto/aes`, `crypto/cipher`, `crypto/ecdsa`,
|
||||
`crypto/ed25519`, `crypto/hmac`, `crypto/sha256`, `crypto/sha512`
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- All key material encrypted at rest in the barrier, zeroized on seal.
|
||||
- Symmetric keys generated with `crypto/rand`.
|
||||
- Ciphertext format includes version to support key rotation without data loss.
|
||||
- `exportable` flag is immutable after creation — cannot be enabled later.
|
||||
- `allow_deletion` is immutable after creation.
|
||||
- `max_key_versions` pruning only removes old versions, never the current one.
|
||||
- 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.
|
||||
302
engines/user.md
Normal file
302
engines/user.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# User Engine Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
The user engine provides end-to-end encryption between Metacircular platform
|
||||
users. Each user has a key pair managed by Metacrypt; the service handles key
|
||||
exchange, encryption, and decryption so that messages/data are encrypted at
|
||||
rest and only readable by the intended recipients.
|
||||
|
||||
The design uses hybrid encryption: an asymmetric key pair per user for key
|
||||
exchange, combined with symmetric encryption for message payloads. This enables
|
||||
multi-recipient encryption without sharing symmetric keys directly.
|
||||
|
||||
## Engine Type
|
||||
|
||||
`user` — registered constant already exists in `internal/engine/engine.go`.
|
||||
|
||||
## Mount Configuration
|
||||
|
||||
Passed as `config` at mount time:
|
||||
|
||||
| Field | Default | Description |
|
||||
|-----------------|-------------|------------------------------------------|
|
||||
| `key_algorithm` | `"x25519"` | Key exchange algorithm: x25519, ecdh-p256, ecdh-p384 |
|
||||
| `sym_algorithm` | `"aes256-gcm"` | Symmetric algorithm for message encryption |
|
||||
|
||||
## Trust Model
|
||||
|
||||
Server-trust: Metacrypt holds all private keys in the barrier, same as every
|
||||
other engine. Access control is enforced at the application layer — no API
|
||||
surface exports private keys, and only the engine accesses them internally
|
||||
during encrypt/decrypt operations. An operator with barrier access could
|
||||
theoretically extract keys, which is accepted and consistent with the barrier
|
||||
trust model used throughout Metacrypt.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### User Provisioning
|
||||
|
||||
Any MCIAS user can have a keypair, whether or not they have ever logged in to
|
||||
Metacrypt. Keypairs are created in three ways:
|
||||
|
||||
1. **Self-registration** — an authenticated user calls `register`.
|
||||
2. **Admin provisioning** — an admin calls `provision` with a username. The
|
||||
user does not need to have logged in.
|
||||
3. **Auto-provisioning on encrypt** — when a sender encrypts to a recipient
|
||||
who has no keypair, the engine generates one automatically. The recipient
|
||||
can decrypt when they eventually authenticate.
|
||||
|
||||
### User Key Pairs
|
||||
|
||||
Each provisioned user has a key exchange key pair. The private key is stored
|
||||
encrypted in the barrier and is only used by the engine on behalf of the owning
|
||||
user (enforced in `HandleRequest`). The public key is available to any
|
||||
authenticated user (needed to encrypt messages to that user).
|
||||
|
||||
### Encryption Flow (Sender → Recipient)
|
||||
|
||||
1. Sender calls `encrypt` with plaintext, recipient username(s), and optional
|
||||
metadata.
|
||||
2. Engine generates a random symmetric data encryption key (DEK).
|
||||
3. Engine encrypts the plaintext with the DEK.
|
||||
4. For each recipient: engine performs key agreement (sender private key +
|
||||
recipient public key → shared secret), derives a wrapping key via HKDF,
|
||||
and wraps the DEK.
|
||||
5. Returns an envelope containing ciphertext + per-recipient wrapped DEKs.
|
||||
|
||||
### Decryption Flow
|
||||
|
||||
1. Recipient calls `decrypt` with the envelope.
|
||||
2. Engine finds the recipient's wrapped DEK entry.
|
||||
3. Engine performs key agreement (recipient private key + sender public key →
|
||||
shared secret), derives the wrapping key, unwraps the DEK.
|
||||
4. Engine decrypts the ciphertext with the DEK.
|
||||
5. Returns plaintext.
|
||||
|
||||
### Envelope Format
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"sender": "alice",
|
||||
"sym_algorithm": "aes256-gcm",
|
||||
"ciphertext": "<base64(nonce + encrypted_payload + tag)>",
|
||||
"recipients": {
|
||||
"bob": "<base64(wrapped_dek)>",
|
||||
"carol": "<base64(wrapped_dek)>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The envelope is base64-encoded as a single opaque blob for transport.
|
||||
|
||||
## Barrier Storage Layout
|
||||
|
||||
```
|
||||
engine/user/{mount}/config.json Engine configuration
|
||||
engine/user/{mount}/users/{username}/priv.pem User private key
|
||||
engine/user/{mount}/users/{username}/pub.pem User public key
|
||||
engine/user/{mount}/users/{username}/config.json Per-user metadata
|
||||
```
|
||||
|
||||
## In-Memory State
|
||||
|
||||
```go
|
||||
type UserEngine struct {
|
||||
barrier barrier.Barrier
|
||||
config *UserConfig
|
||||
users map[string]*userState
|
||||
mountPath string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type userState struct {
|
||||
privKey crypto.PrivateKey // key exchange private key
|
||||
pubKey crypto.PublicKey // key exchange public key
|
||||
config *UserKeyConfig
|
||||
}
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
### Initialize
|
||||
|
||||
1. Parse and store config in barrier.
|
||||
2. No user keys are created at init time (created on demand or via `register`).
|
||||
|
||||
### Unseal
|
||||
|
||||
1. Load config from barrier.
|
||||
2. Discover and load all user key pairs from barrier.
|
||||
|
||||
### Seal
|
||||
|
||||
1. Zeroize all private key material.
|
||||
2. Nil out all maps.
|
||||
|
||||
## Operations
|
||||
|
||||
| Operation | Auth Required | Description |
|
||||
|------------------|---------------|-------------------------------------------------|
|
||||
| `register` | User (self) | Create a key pair for the authenticated user |
|
||||
| `provision` | Admin | Create a key pair for any MCIAS user by username |
|
||||
| `get-public-key` | User/Admin | Get any user's public key |
|
||||
| `list-users` | User/Admin | List registered users |
|
||||
| `encrypt` | User+Policy | Encrypt data for one or more recipients |
|
||||
| `decrypt` | User (self) | Decrypt an envelope addressed to the caller |
|
||||
| `rotate-key` | User (self) | Rotate the caller's key pair |
|
||||
| `delete-user` | Admin | Remove a user's key pair |
|
||||
|
||||
### register
|
||||
|
||||
Creates a key pair for the authenticated caller. No-op if the caller already
|
||||
has a keypair (returns existing public key).
|
||||
|
||||
Request data: none (uses `CallerInfo.Username`).
|
||||
|
||||
Response: `{ "public_key": "<base64>" }`
|
||||
|
||||
### provision
|
||||
|
||||
Admin-only. Creates a key pair for the given username. The user does not need
|
||||
to have logged in to Metacrypt. No-op if the user already has a keypair.
|
||||
|
||||
Request data: `{ "username": "<mcias_username>" }`
|
||||
|
||||
Response: `{ "public_key": "<base64>" }`
|
||||
|
||||
### encrypt
|
||||
|
||||
Request data:
|
||||
|
||||
| Field | Required | Description |
|
||||
|--------------|----------|-------------------------------------------|
|
||||
| `recipients` | Yes | List of usernames to encrypt for |
|
||||
| `plaintext` | Yes | Base64-encoded plaintext |
|
||||
| `metadata` | No | Arbitrary string metadata (authenticated) |
|
||||
|
||||
Flow:
|
||||
1. Caller must be provisioned (has a key pair). Auto-provision if not.
|
||||
2. For each recipient without a keypair: auto-provision them.
|
||||
3. Load sender's private key and each recipient's public key.
|
||||
4. Generate random DEK, encrypt plaintext with DEK.
|
||||
5. For each recipient: ECDH(sender_priv, recipient_pub) → shared_secret,
|
||||
HKDF(shared_secret, salt, info) → wrapping_key, AES-KeyWrap(wrapping_key,
|
||||
DEK) → wrapped_dek.
|
||||
6. Build and return envelope.
|
||||
|
||||
Authorization:
|
||||
- Admins: grant-all.
|
||||
- Users: can encrypt to any registered user by default.
|
||||
- Policy can restrict which users a sender can encrypt to:
|
||||
resource `user/{mount}/recipient/{username}`, action `write`.
|
||||
|
||||
### decrypt
|
||||
|
||||
Request data:
|
||||
|
||||
| Field | Required | Description |
|
||||
|------------|----------|--------------------------------|
|
||||
| `envelope` | Yes | Base64-encoded envelope blob |
|
||||
|
||||
Flow:
|
||||
1. Parse envelope, find the caller's wrapped DEK entry.
|
||||
2. Load sender's public key and caller's private key.
|
||||
3. ECDH(caller_priv, sender_pub) → shared_secret → wrapping_key → DEK.
|
||||
4. Decrypt ciphertext with DEK.
|
||||
5. Return plaintext.
|
||||
|
||||
A user can only decrypt envelopes addressed to themselves.
|
||||
|
||||
### rotate-key
|
||||
|
||||
Generates a new key pair for the caller. The old private key is zeroized and
|
||||
deleted. Old envelopes encrypted with the previous key cannot be decrypted
|
||||
after rotation — callers should re-encrypt any stored data before rotating.
|
||||
|
||||
## gRPC Service (proto/metacrypt/v2/user.proto)
|
||||
|
||||
```protobuf
|
||||
service UserService {
|
||||
rpc Register(UserRegisterRequest) returns (UserRegisterResponse);
|
||||
rpc Provision(UserProvisionRequest) returns (UserProvisionResponse);
|
||||
rpc GetPublicKey(UserGetPublicKeyRequest) returns (UserGetPublicKeyResponse);
|
||||
rpc ListUsers(UserListUsersRequest) returns (UserListUsersResponse);
|
||||
rpc Encrypt(UserEncryptRequest) returns (UserEncryptResponse);
|
||||
rpc Decrypt(UserDecryptRequest) returns (UserDecryptResponse);
|
||||
rpc RotateKey(UserRotateKeyRequest) returns (UserRotateKeyResponse);
|
||||
rpc DeleteUser(UserDeleteUserRequest) returns (UserDeleteUserResponse);
|
||||
}
|
||||
```
|
||||
|
||||
## REST Endpoints
|
||||
|
||||
All auth required:
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|-----------------------------------------|------------------------|
|
||||
| POST | `/v1/user/{mount}/register` | Register caller |
|
||||
| POST | `/v1/user/{mount}/provision` | Provision user (admin) |
|
||||
| GET | `/v1/user/{mount}/keys` | List registered users |
|
||||
| GET | `/v1/user/{mount}/keys/{username}` | Get user's public key |
|
||||
| DELETE | `/v1/user/{mount}/keys/{username}` | Delete user (admin) |
|
||||
| POST | `/v1/user/{mount}/encrypt` | Encrypt for recipients |
|
||||
| POST | `/v1/user/{mount}/decrypt` | Decrypt envelope |
|
||||
| POST | `/v1/user/{mount}/rotate` | Rotate caller's key |
|
||||
|
||||
All operations are also accessible via the generic `POST /v1/engine/request`.
|
||||
|
||||
## Web UI
|
||||
|
||||
Add to `/dashboard` the ability to mount a user engine.
|
||||
|
||||
Add a `/user-crypto` page displaying:
|
||||
- Registration status / register button
|
||||
- Public key display
|
||||
- Encrypt form (select recipients, enter message)
|
||||
- Decrypt form (paste envelope)
|
||||
- Key rotation button with warning
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **`internal/engine/user/`** — Implement `UserEngine`:
|
||||
- `types.go` — Config types, envelope format.
|
||||
- `user.go` — Lifecycle (Initialize, Unseal, Seal, HandleRequest).
|
||||
- `crypto.go` — ECDH key agreement, HKDF derivation, DEK wrap/unwrap,
|
||||
symmetric encrypt/decrypt.
|
||||
- `keys.go` — User registration, key rotation, deletion.
|
||||
2. **Register factory** in `cmd/metacrypt/main.go`.
|
||||
3. **Proto definitions** — `proto/metacrypt/v2/user.proto`, run `make proto`.
|
||||
4. **gRPC handlers** — `internal/grpcserver/user.go`.
|
||||
5. **REST routes** — Add to `internal/server/routes.go`.
|
||||
6. **Web UI** — Add template + webserver routes.
|
||||
7. **Tests** — Unit tests: register, encrypt/decrypt roundtrip, multi-recipient,
|
||||
key rotation invalidates old envelopes, authorization checks.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `golang.org/x/crypto/hkdf` (for key derivation from ECDH shared secret)
|
||||
- `crypto/ecdh` (Go 1.20+, for X25519 and NIST curve key exchange)
|
||||
- Standard library `crypto/aes`, `crypto/cipher`, `crypto/rand`
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Private keys encrypted at rest in the barrier, zeroized on seal.
|
||||
- DEK is random per-encryption; never reused.
|
||||
- HKDF derivation includes sender and recipient identities in the info string
|
||||
to prevent key confusion attacks:
|
||||
`info = "metacrypt-user-v1:" + sender + ":" + recipient`.
|
||||
- Envelope includes sender identity so the recipient can derive the correct
|
||||
shared secret.
|
||||
- Key rotation is destructive — old data cannot be decrypted. The engine should
|
||||
warn and require explicit confirmation (admin or self only).
|
||||
- Server-trust model: the server holds all private keys in the barrier. No API
|
||||
surface exports private keys. Access control is application-enforced — the
|
||||
engine only uses a private key on behalf of its owner during encrypt/decrypt.
|
||||
- Auto-provisioned users have keypairs waiting for them; their private keys are
|
||||
protected identically to explicitly registered users.
|
||||
- Metadata in the envelope is authenticated (included as additional data in
|
||||
AEAD) but not encrypted — it is visible to anyone holding the envelope.
|
||||
- Post-quantum readiness: the `key_algorithm` config supports future hybrid
|
||||
schemes (e.g. X25519 + ML-KEM). The envelope version field enables migration.
|
||||
Reference in New Issue
Block a user