581 lines
25 KiB
Markdown
581 lines
25 KiB
Markdown
# 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 1–5 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 1481–1498) 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` | 284–317 |
|
||
| zeroizeKey helper | `internal/engine/ca/ca.go` | 1481–1498 |
|
||
| CertRecord storage (JSON in barrier) | `internal/engine/ca/ca.go` | cert storage pattern |
|
||
| REST route registration with chi | `internal/server/routes.go` | 38–50 |
|
||
| gRPC handler structure | `internal/grpcserver/ca.go` | full file |
|
||
| gRPC interceptor maps | `internal/grpcserver/server.go` | 107–192 |
|
||
| Engine factory registration | `cmd/metacrypt/server.go` | 76 |
|
||
| adminOnlyOperations map | `internal/server/routes.go` | 259–279 |
|