Files
metacrypt/engines/sshca.md

581 lines
25 KiB
Markdown
Raw Permalink 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-p256`, `ecdsa-p384` |
| `max_ttl` | `"87600h"` | Maximum certificate validity |
| `default_ttl` | `"24h"` | Default certificate validity |
RSA is intentionally excluded — Ed25519 and ECDSA are preferred for SSH CAs.
This avoids the need for a `key_size` parameter and simplifies key generation.
## 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_version.json KRL version counter
```
## 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
krlVersion uint64 // monotonically increasing
mu sync.RWMutex
}
```
Key material (`caKey`, `caSigner`) is zeroized on `Seal()`.
## Lifecycle
### Initialize
1. Parse and validate config: ensure `key_algorithm` is one of `ed25519`,
`ecdsa-p256`, `ecdsa-p384`. Parse `max_ttl` and `default_ttl` as
`time.Duration`.
2. Store config in barrier as `{mountPath}config.json`.
3. Generate CA key pair:
- `ed25519`: `ed25519.GenerateKey(rand.Reader)`
- `ecdsa-p256`: `ecdsa.GenerateKey(elliptic.P256(), rand.Reader)`
- `ecdsa-p384`: `ecdsa.GenerateKey(elliptic.P384(), rand.Reader)`
4. Marshal private key to PEM using `x509.MarshalPKCS8PrivateKey`
`pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})`.
5. Store private key PEM in barrier at `{mountPath}ca/key.pem`.
6. Generate SSH public key via `ssh.NewPublicKey(pubKey)`, marshal with
`ssh.MarshalAuthorizedKey`. Store at `{mountPath}ca/pubkey.pub`.
7. Load key into memory: `ssh.NewSignerFromKey(caKey)``caSigner`.
8. Initialize `krlVersion` to 0, store in barrier.
### Unseal
1. Load config JSON from barrier, unmarshal into `*SSHCAConfig`.
2. Load `{mountPath}ca/key.pem` from barrier, decode PEM, parse with
`x509.ParsePKCS8PrivateKey``caKey`.
3. Create `caSigner` via `ssh.NewSignerFromKey(caKey)`.
4. Load `krl_version.json` from barrier → `krlVersion`.
### Seal
1. Zeroize `caKey` using the shared `zeroizeKey` helper (see Implementation
References below).
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 |
### HandleRequest dispatch
Follow the CA engine's pattern (`internal/engine/ca/ca.go:284-317`):
```go
func (e *SSHCAEngine) HandleRequest(ctx context.Context, req *engine.Request) (*engine.Response, error) {
switch req.Operation {
case "get-ca-pubkey":
return e.handleGetCAPublicKey(ctx)
case "sign-host":
return e.handleSignHost(ctx, req)
case "sign-user":
return e.handleSignUser(ctx, req)
case "create-profile":
return e.handleCreateProfile(ctx, req)
case "update-profile":
return e.handleUpdateProfile(ctx, req)
case "get-profile":
return e.handleGetProfile(ctx, req)
case "list-profiles":
return e.handleListProfiles(ctx, req)
case "delete-profile":
return e.handleDeleteProfile(ctx, req)
case "get-cert":
return e.handleGetCert(ctx, req)
case "list-certs":
return e.handleListCerts(ctx, req)
case "revoke-cert":
return e.handleRevokeCert(ctx, req)
case "delete-cert":
return e.handleDeleteCert(ctx, req)
default:
return nil, fmt.Errorf("sshca: unknown operation: %s", req.Operation)
}
}
```
### 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 (`req.CallerInfo.IsUser()`); admins bypass policy checks.
2. Parse the supplied SSH public key with `ssh.ParsePublicKey(ssh.ParseAuthorizedKey(...))`.
3. Parse TTL: if provided parse as `time.Duration`, cap at `config.MaxTTL`.
If not provided, use `config.DefaultTTL`.
4. Policy check: for each hostname, check policy on
`sshca/{mount}/id/{hostname}`, action `sign`. Use `req.CheckPolicy`.
Fail early before generating a serial or building the cert.
5. Generate a 64-bit serial: `var buf [8]byte; rand.Read(buf[:]); serial := binary.BigEndian.Uint64(buf[:])`.
6. Build `ssh.Certificate`:
```go
cert := &ssh.Certificate{
Key: parsedPubKey,
Serial: serial,
CertType: ssh.HostCert,
KeyId: fmt.Sprintf("host:%s:%d", hostnames[0], serial),
ValidPrincipals: hostnames,
ValidAfter: uint64(time.Now().Unix()),
ValidBefore: uint64(time.Now().Add(ttl).Unix()),
Permissions: ssh.Permissions{Extensions: extensions},
}
```
7. Sign: `cert.SignCert(rand.Reader, e.caSigner)`.
8. Store `CertRecord` in barrier at `{mountPath}certs/{serial}.json`.
9. Return: `{"certificate": ssh.MarshalAuthorizedKey(cert), "serial": serial}`.
### 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 from barrier 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. If the profile specifies
`allowed_principals`, verify all requested principals are in the list.
4. If the profile specifies `max_ttl`, enforce it (cap the requested TTL).
5. Policy check: `sshca/{mount}/id/{principal}` for each principal, action `sign`.
Default rule: a user can only sign certs for their own username as principal,
unless a policy grants access to other principals. Implement by checking
`req.CallerInfo.Username == principal` as the default-allow case.
Fail early before generating a serial or building the cert.
6. Generate a 64-bit serial using `crypto/rand`.
7. Build `ssh.Certificate` with `CertType: ssh.UserCert`, principals, validity.
8. Set `Permissions.CriticalOptions` from profile (if any) and
`Permissions.Extensions` from merged extensions. Default extensions when
none specified: `{"permit-pty": ""}`.
9. Sign with `caSigner`.
10. Store `CertRecord` in barrier (includes profile name if used).
11. Return signed certificate in OpenSSH format + serial.
### 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
```
#### 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 authorized_keys format
KeyID string `json:"key_id"` // certificate KeyId field
Profile string `json:"profile,omitempty"` // signing profile used (if any)
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"`
}
```
Serial is stored as `uint64` (not string) since SSH certificate serials are
uint64 natively. Barrier path uses the decimal string representation:
`fmt.Sprintf("%d", serial)`.
## 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 a KRL that SSH servers
fetch periodically and reference via `RevokedKeys` in `sshd_config`.
### KRL Generation — Custom Implementation
**Important**: `golang.org/x/crypto/ssh` does **not** provide KRL generation
helpers. It can parse KRLs but not build them. The engine must implement KRL
serialization directly per the OpenSSH KRL format specification
(`PROTOCOL.krl` in the OpenSSH source).
The KRL format is a binary structure:
```
MAGIC = "OPENSSH_KRL\x00" (12 bytes)
VERSION = uint32 (format version, always 1)
KRL_VERSION = uint64 (monotonically increasing per rebuild)
GENERATED_DATE = uint64 (Unix timestamp)
FLAGS = uint64 (0)
RESERVED = string (empty)
COMMENT = string (empty)
SECTIONS... (one or more typed sections)
```
For serial-based revocation (the simplest and most compact representation):
```
Section type: KRL_SECTION_CERTIFICATES (0x01)
CA key blob: ssh.MarshalAuthorizedKey(caSigner.PublicKey())
Subsection type: KRL_SECTION_CERT_SERIAL_LIST (0x20)
Revoked serials: sorted list of uint64 serials
```
Implement as a `buildKRL` function:
```go
func (e *SSHCAEngine) buildKRL(revokedSerials []uint64) []byte {
// 1. Sort serials.
// 2. Write MAGIC header.
// 3. Write KRL_VERSION (e.krlVersion), GENERATED_DATE (now), FLAGS (0).
// 4. Write RESERVED (empty string), COMMENT (empty string).
// 5. Write section header: type=0x01 (KRL_SECTION_CERTIFICATES).
// 6. Write CA public key blob.
// 7. Write subsection: type=0x20 (KRL_SECTION_CERT_SERIAL_LIST),
// followed by each serial as uint64 big-endian.
// 8. Return assembled bytes.
}
```
Use `encoding/binary` with `binary.BigEndian` for all integer encoding.
SSH strings are length-prefixed: `uint32(len) + bytes`.
The KRL version counter is persisted in barrier at `{mountPath}krl_version.json`
and incremented on each rebuild. On unseal, the counter is loaded from barrier.
The KRL is rebuilt (not stored in barrier — it's a derived artifact) on:
- `revoke-cert` — collects all revoked serials, rebuilds.
- `delete-cert` — if the cert was revoked, rebuilds from remaining revoked certs.
- Engine unseal — rebuilds from all revoked certs.
### 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) |
The response includes:
- `Content-Type: application/octet-stream`
- `ETag` header: `fmt.Sprintf("%d", e.krlVersion)`, enabling conditional fetches.
- `Cache-Control: max-age=60` to encourage periodic refresh.
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
```
## 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 (binary) |
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}/certs` | List cert records |
| 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 |
### REST Route Registration
Add to `internal/server/routes.go` in `registerRoutes`, following the CA
engine's pattern with `chi.URLParam`:
```go
// SSH CA public routes (no auth, unseal required).
r.Get("/v1/sshca/{mount}/ca", s.requireUnseal(s.handleSSHCAPublicKey))
r.Get("/v1/sshca/{mount}/krl", s.requireUnseal(s.handleSSHCAKRL))
// SSH CA typed routes (auth required).
r.Post("/v1/sshca/{mount}/sign-host", s.requireAuth(s.handleSSHCASignHost))
r.Post("/v1/sshca/{mount}/sign-user", s.requireAuth(s.handleSSHCASignUser))
r.Post("/v1/sshca/{mount}/profiles", s.requireAdmin(s.handleSSHCACreateProfile))
r.Get("/v1/sshca/{mount}/profiles", s.requireAuth(s.handleSSHCAListProfiles))
r.Get("/v1/sshca/{mount}/profiles/{name}", s.requireAuth(s.handleSSHCAGetProfile))
r.Put("/v1/sshca/{mount}/profiles/{name}", s.requireAdmin(s.handleSSHCAUpdateProfile))
r.Delete("/v1/sshca/{mount}/profiles/{name}", s.requireAdmin(s.handleSSHCADeleteProfile))
r.Get("/v1/sshca/{mount}/certs", s.requireAuth(s.handleSSHCAListCerts))
r.Get("/v1/sshca/{mount}/cert/{serial}", s.requireAuth(s.handleSSHCAGetCert))
r.Post("/v1/sshca/{mount}/cert/{serial}/revoke", s.requireAdmin(s.handleSSHCARevokeCert))
r.Delete("/v1/sshca/{mount}/cert/{serial}", s.requireAdmin(s.handleSSHCADeleteCert))
```
Each handler extracts `chi.URLParam(r, "mount")`, builds an `engine.Request`
with the appropriate operation name and data, and calls
`s.engines.HandleRequest(...)`. Follow the `handleGetCert`/`handleRevokeCert`
pattern in the existing code.
All operations are also accessible via the generic `POST /v1/engine/request`.
### gRPC Interceptor Maps
Add to `sealRequiredMethods`, `authRequiredMethods`, and `adminRequiredMethods`
in `internal/grpcserver/server.go`:
```go
// sealRequiredMethods:
"/metacrypt.v2.SSHCAService/GetCAPublicKey": true,
"/metacrypt.v2.SSHCAService/SignHost": true,
"/metacrypt.v2.SSHCAService/SignUser": true,
"/metacrypt.v2.SSHCAService/CreateProfile": true,
"/metacrypt.v2.SSHCAService/UpdateProfile": true,
"/metacrypt.v2.SSHCAService/GetProfile": true,
"/metacrypt.v2.SSHCAService/ListProfiles": true,
"/metacrypt.v2.SSHCAService/DeleteProfile": true,
"/metacrypt.v2.SSHCAService/GetCert": true,
"/metacrypt.v2.SSHCAService/ListCerts": true,
"/metacrypt.v2.SSHCAService/RevokeCert": true,
"/metacrypt.v2.SSHCAService/DeleteCert": true,
"/metacrypt.v2.SSHCAService/GetKRL": true,
// authRequiredMethods (all except GetCAPublicKey and GetKRL):
"/metacrypt.v2.SSHCAService/SignHost": true,
"/metacrypt.v2.SSHCAService/SignUser": true,
"/metacrypt.v2.SSHCAService/CreateProfile": true,
"/metacrypt.v2.SSHCAService/UpdateProfile": true,
"/metacrypt.v2.SSHCAService/GetProfile": true,
"/metacrypt.v2.SSHCAService/ListProfiles": true,
"/metacrypt.v2.SSHCAService/DeleteProfile": true,
"/metacrypt.v2.SSHCAService/GetCert": true,
"/metacrypt.v2.SSHCAService/ListCerts": true,
"/metacrypt.v2.SSHCAService/RevokeCert": true,
"/metacrypt.v2.SSHCAService/DeleteCert": true,
// adminRequiredMethods:
"/metacrypt.v2.SSHCAService/CreateProfile": true,
"/metacrypt.v2.SSHCAService/UpdateProfile": true,
"/metacrypt.v2.SSHCAService/DeleteProfile": true,
"/metacrypt.v2.SSHCAService/RevokeCert": true,
"/metacrypt.v2.SSHCAService/DeleteCert": true,
```
Also add SSH CA operations to `adminOnlyOperations` in `routes.go` (keys are
`engineType:operation` to avoid cross-engine name collisions):
```go
// SSH CA engine.
"sshca:create-profile": true,
"sshca:update-profile": true,
"sshca:delete-profile": true,
"sshca:revoke-cert": true,
"sshca:delete-cert": true,
```
## 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. **Move `zeroizeKey` to shared location**: Copy the `zeroizeKey` function
from `internal/engine/ca/ca.go` (lines 14811498) to a new file
`internal/engine/helpers.go` in the `engine` package. Export it as
`engine.ZeroizeKey`. Update the CA engine to call `engine.ZeroizeKey`
instead of its local copy. This avoids a circular import (sshca cannot
import ca).
2. **`internal/engine/sshca/`** — Implement `SSHCAEngine`:
- `types.go` — `SSHCAConfig`, `CertRecord`, `SigningProfile` structs.
- `sshca.go` — `NewSSHCAEngine` factory, lifecycle methods (`Type`,
`Initialize`, `Unseal`, `Seal`), `HandleRequest` dispatch.
- `sign.go` — `handleSignHost`, `handleSignUser`.
- `profiles.go` — Profile CRUD handlers.
- `certs.go` — `handleGetCert`, `handleListCerts`, `handleRevokeCert`,
`handleDeleteCert`.
- `krl.go` — `buildKRL`, `rebuildKRL`, `handleGetKRL`,
`collectRevokedSerials`.
3. **Register factory** in `cmd/metacrypt/server.go` (line 76):
```go
engineRegistry.RegisterFactory(engine.EngineTypeSSHCA, sshca.NewSSHCAEngine)
```
4. **Proto definitions** — `proto/metacrypt/v2/sshca.proto`, run `make proto`.
5. **gRPC handlers** — `internal/grpcserver/sshca.go`. Follow
`internal/grpcserver/ca.go` pattern: `sshcaServer` struct wrapping
`GRPCServer`, helper function for error mapping, typed RPC methods.
Register with `pb.RegisterSSHCAServiceServer(s.srv, &sshcaServer{s: s})`
in `server.go`.
6. **REST routes** — Add to `internal/server/routes.go` per the route
registration section above.
7. **Tests** — `internal/engine/sshca/sshca_test.go`: unit tests with
in-memory barrier following the CA test pattern. Test:
- Initialize + unseal lifecycle
- sign-host: valid signing, TTL enforcement, serial uniqueness
- sign-user: own-principal default, profile merging, profile TTL cap
- Profile CRUD
- Certificate list/get/revoke/delete
- KRL rebuild correctness (revoked serials present, unrevoked absent)
- Seal zeroizes key material
## Dependencies
- `golang.org/x/crypto/ssh` (already in `go.mod` via transitive deps)
- `encoding/binary` (stdlib, for KRL serialization)
## 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.
- RSA keys are excluded to reduce attack surface and simplify the implementation.
## Implementation References
These existing code patterns should be followed exactly:
| Pattern | Reference File | Lines |
|---------|---------------|-------|
| HandleRequest switch dispatch | `internal/engine/ca/ca.go` | 284317 |
| zeroizeKey helper | `internal/engine/ca/ca.go` | 14811498 |
| CertRecord storage (JSON in barrier) | `internal/engine/ca/ca.go` | cert storage pattern |
| REST route registration with chi | `internal/server/routes.go` | 3850 |
| gRPC handler structure | `internal/grpcserver/ca.go` | full file |
| gRPC interceptor maps | `internal/grpcserver/server.go` | 107192 |
| Engine factory registration | `cmd/metacrypt/server.go` | 76 |
| adminOnlyOperations map | `internal/server/routes.go` | 259279 |