Update engine specs, audit doc, and server tests for SSH CA, transit, and user engines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 20:16:23 -07:00
parent 7237b2951e
commit 128f5abc4d
6 changed files with 1309 additions and 182 deletions

View File

@@ -17,11 +17,13 @@ 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) |
| `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
```
@@ -30,19 +32,20 @@ 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)
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
mu sync.RWMutex
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
}
```
@@ -52,20 +55,34 @@ Key material (`caKey`, `caSigner`) is zeroized on `Seal()`.
### 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`.
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 from barrier.
2. Load CA private key from barrier, parse into `crypto.PrivateKey`.
3. Wrap as `ssh.Signer`.
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` (same `zeroizeKey` helper used by CA engine).
1. Zeroize `caKey` using the shared `zeroizeKey` helper (see Implementation
References below).
2. Nil out `caSigner`, `config`.
## Operations
@@ -85,6 +102,43 @@ Key material (`caKey`, `caSigner`) is zeroized on `Seal()`.
| `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:
@@ -97,15 +151,30 @@ Request data:
| `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.
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
@@ -126,20 +195,26 @@ 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
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.
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.
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.
8. Sign with `caSigner`.
9. Store `CertRecord` in barrier (includes profile name if used).
10. Return signed certificate in OpenSSH format.
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
@@ -151,11 +226,11 @@ options, and access to each profile is policy-gated.
```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
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
}
```
@@ -165,16 +240,6 @@ type SigningProfile struct {
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
@@ -194,7 +259,9 @@ 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
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"`
@@ -204,31 +271,71 @@ type CertRecord struct {
}
```
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 an OpenSSH-format KRL
(Key Revocation List) that SSH servers fetch periodically and reference via
`RevokedKeys` in `sshd_config`.
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
### KRL Generation — Custom Implementation
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:
**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).
- **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 format is a binary structure:
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.
```
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
@@ -237,13 +344,12 @@ unauthenticated endpoint (analogous to the public CA key endpoint):
| Method | Path | Description |
|--------|-------------------------------------|--------------------------------|
| GET | `/v1/sshca/{mount}/krl` | Current KRL (binary, OpenSSH format) |
| GET | `/v1/sshca/{mount}/krl` | Current KRL (binary) |
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.
- `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`:
@@ -252,19 +358,6 @@ 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
@@ -292,7 +385,7 @@ 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) |
| GET | `/v1/sshca/{mount}/krl` | Current KRL (binary) |
Typed endpoints (auth required):
@@ -305,12 +398,96 @@ Typed endpoints (auth required):
| 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.
@@ -322,20 +499,54 @@ Add an `/sshca` page (or section on the existing PKI page) displaying:
## 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.
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
@@ -351,3 +562,19 @@ Add an `/sshca` page (or section on the existing PKI page) displaying:
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 |