Files
metacrypt/engines/sshca.md
Kyle Isom 64d921827e Add MEK rotation, per-engine DEKs, and v2 ciphertext format (audit #6, #22)
Implement a two-level key hierarchy: the MEK now wraps per-engine DEKs
stored in a new barrier_keys table, rather than encrypting all barrier
entries directly. A v2 ciphertext format (0x02) embeds the key ID so the
barrier can resolve which DEK to use on decryption. v1 ciphertext remains
supported for backward compatibility.

Key changes:
- crypto: EncryptV2/DecryptV2/ExtractKeyID for v2 ciphertext with key IDs
- barrier: key registry (CreateKey, RotateKey, ListKeys, MigrateToV2, ReWrapKeys)
- seal: RotateMEK re-wraps DEKs without re-encrypting data
- engine: Mount auto-creates per-engine DEK
- REST + gRPC: barrier/keys, barrier/rotate-mek, barrier/rotate-key, barrier/migrate
- proto: BarrierService (v1 + v2) with ListKeys, RotateMEK, RotateKey, Migrate
- db: migration v2 adds barrier_keys table

Also includes: security audit report, CSRF protection, engine design specs
(sshca, transit, user), path-bound AAD migration tool, policy engine
enhancements, and ARCHITECTURE.md updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 18:27:44 -07:00

16 KiB
Raw 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, rsa
key_size 0 Key size (ignored for ed25519; 256/384/521 for ECDSA, 2048/4096 for RSA)
max_ttl "87600h" Maximum certificate validity
default_ttl "24h" Default certificate validity

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.bin                 Current KRL (OpenSSH format)

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
    mu        sync.RWMutex
}

Key material (caKey, caSigner) is zeroized on Seal().

Lifecycle

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.

Unseal

  1. Load config from barrier.
  2. Load CA private key from barrier, parse into crypto.PrivateKey.
  3. Wrap as ssh.Signer.

Seal

  1. Zeroize caKey (same zeroizeKey helper used by CA engine).
  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

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 (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.

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

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

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 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 format
    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"`
}

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.

KRL Generation

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:

  • 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 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.

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, OpenSSH format)

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.

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

A helper script or systemd timer can fetch the KRL:

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)

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 (OpenSSH format)

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}/cert/{serial} Get cert record
POST /v1/sshca/{mount}/cert/{serial}/revoke Revoke cert
DELETE /v1/sshca/{mount}/cert/{serial} Delete cert record

All operations are also accessible via the generic POST /v1/engine/request.

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. 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 definitionsproto/metacrypt/v2/sshca.proto, run make proto.
  4. gRPC handlersinternal/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.

Dependencies

  • golang.org/x/crypto/ssh (already in go.mod via transitive deps)

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.