Files
metacrypt/engines/sshca.md

25 KiB
Raw Permalink Blame History

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

  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.MarshalPKCS8PrivateKeypem.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.ParsePKCS8PrivateKeycaKey.
  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):

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:
    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

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-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)

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-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.goSSHCAConfig, CertRecord, SigningProfile structs.
    • sshca.goNewSSHCAEngine factory, lifecycle methods (Type, Initialize, Unseal, Seal), HandleRequest dispatch.
    • sign.gohandleSignHost, handleSignUser.
    • profiles.go — Profile CRUD handlers.
    • certs.gohandleGetCert, handleListCerts, handleRevokeCert, handleDeleteCert.
    • krl.gobuildKRL, rebuildKRL, handleGetKRL, collectRevokedSerials.
  3. Register factory in cmd/metacrypt/server.go (line 76):

    engineRegistry.RegisterFactory(engine.EngineTypeSSHCA, sshca.NewSSHCAEngine)
    
  4. Proto definitionsproto/metacrypt/v2/sshca.proto, run make proto.

  5. gRPC handlersinternal/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. Testsinternal/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