Add MEK rotation, per-engine DEKs, and v2 ciphertext format (audit #6, #22)

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:
2026-03-16 18:27:44 -07:00
parent ac4577f778
commit 64d921827e
44 changed files with 5184 additions and 90 deletions

353
engines/sshca.md Normal file
View 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 15 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.