# 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.