Files
metacrypt/engines/sshca.md
Kyle Isom 64d921827e 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>
2026-03-16 18:27:44 -07:00

354 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.