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.
|
||||
Reference in New Issue
Block a user