25 KiB
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
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
- Parse and validate config: ensure
key_algorithmis one ofed25519,ecdsa-p256,ecdsa-p384. Parsemax_ttlanddefault_ttlastime.Duration. - Store config in barrier as
{mountPath}config.json. - 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)
- Marshal private key to PEM using
x509.MarshalPKCS8PrivateKey→pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}). - Store private key PEM in barrier at
{mountPath}ca/key.pem. - Generate SSH public key via
ssh.NewPublicKey(pubKey), marshal withssh.MarshalAuthorizedKey. Store at{mountPath}ca/pubkey.pub. - Load key into memory:
ssh.NewSignerFromKey(caKey)→caSigner. - Initialize
krlVersionto 0, store in barrier.
Unseal
- Load config JSON from barrier, unmarshal into
*SSHCAConfig. - Load
{mountPath}ca/key.pemfrom barrier, decode PEM, parse withx509.ParsePKCS8PrivateKey→caKey. - Create
caSignerviassh.NewSignerFromKey(caKey). - Load
krl_version.jsonfrom barrier →krlVersion.
Seal
- Zeroize
caKeyusing the sharedzeroizeKeyhelper (see Implementation References below). - 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):
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:
- Authenticate caller (
req.CallerInfo.IsUser()); admins bypass policy checks. - Parse the supplied SSH public key with
ssh.ParsePublicKey(ssh.ParseAuthorizedKey(...)). - Parse TTL: if provided parse as
time.Duration, cap atconfig.MaxTTL. If not provided, useconfig.DefaultTTL. - Policy check: for each hostname, check policy on
sshca/{mount}/id/{hostname}, actionsign. Usereq.CheckPolicy. Fail early before generating a serial or building the cert. - Generate a 64-bit serial:
var buf [8]byte; rand.Read(buf[:]); serial := binary.BigEndian.Uint64(buf[:]). - Build
ssh.Certificate: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}, } - Sign:
cert.SignCert(rand.Reader, e.caSigner). - Store
CertRecordin barrier at{mountPath}certs/{serial}.json. - 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:
- Authenticate caller (
IsUser()); admins bypass. - Parse the supplied SSH public key.
- If
profileis specified, load the signing profile from barrier and check policy (sshca/{mount}/profile/{profile_name}, actionread). 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 specifiesallowed_principals, verify all requested principals are in the list. - If the profile specifies
max_ttl, enforce it (cap the requested TTL). - Policy check:
sshca/{mount}/id/{principal}for each principal, actionsign. Default rule: a user can only sign certs for their own username as principal, unless a policy grants access to other principals. Implement by checkingreq.CallerInfo.Username == principalas the default-allow case. Fail early before generating a serial or building the cert. - Generate a 64-bit serial using
crypto/rand. - Build
ssh.CertificatewithCertType: ssh.UserCert, principals, validity. - Set
Permissions.CriticalOptionsfrom profile (if any) andPermissions.Extensionsfrom merged extensions. Default extensions when none specified:{"permit-pty": ""}. - Sign with
caSigner. - Store
CertRecordin barrier (includes profile name if used). - 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
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
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:
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-streamETagheader:fmt.Sprintf("%d", e.krlVersion), enabling conditional fetches.Cache-Control: max-age=60to 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)
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:
// 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:
// 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):
// 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-authoritylines) - Sign host/user certificate form
- Certificate list with detail view
Implementation Steps
-
Move
zeroizeKeyto shared location: Copy thezeroizeKeyfunction frominternal/engine/ca/ca.go(lines 1481–1498) to a new fileinternal/engine/helpers.goin theenginepackage. Export it asengine.ZeroizeKey. Update the CA engine to callengine.ZeroizeKeyinstead of its local copy. This avoids a circular import (sshca cannot import ca). -
internal/engine/sshca/— ImplementSSHCAEngine:types.go—SSHCAConfig,CertRecord,SigningProfilestructs.sshca.go—NewSSHCAEnginefactory, lifecycle methods (Type,Initialize,Unseal,Seal),HandleRequestdispatch.sign.go—handleSignHost,handleSignUser.profiles.go— Profile CRUD handlers.certs.go—handleGetCert,handleListCerts,handleRevokeCert,handleDeleteCert.krl.go—buildKRL,rebuildKRL,handleGetKRL,collectRevokedSerials.
-
Register factory in
cmd/metacrypt/server.go(line 76):engineRegistry.RegisterFactory(engine.EngineTypeSSHCA, sshca.NewSSHCAEngine) -
Proto definitions —
proto/metacrypt/v2/sshca.proto, runmake proto. -
gRPC handlers —
internal/grpcserver/sshca.go. Followinternal/grpcserver/ca.gopattern:sshcaServerstruct wrappingGRPCServer, helper function for error mapping, typed RPC methods. Register withpb.RegisterSSHCAServiceServer(s.srv, &sshcaServer{s: s})inserver.go. -
REST routes — Add to
internal/server/routes.goper the route registration section above. -
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 ingo.modvia 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_ttlis 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 bypassingsshd_configrestrictions. - 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 |