Skip to main content

SSH Certificate Authority -- Sign SSH Keys on Demand

Signs SSH public keys with a CA certificate, enabling certificate-based SSH authentication. No passwords, no authorized_keys distribution -- clients present a signed certificate and the server trusts the CA.

Capabilities

CapabilityDescription
secret_generationSigns SSH public keys, returning a certificate
secret_validationVerifies a certificate was signed by this CA and is not expired

Configuration

ParameterRequiredDescriptionExample
ca_private_keyOptionalPEM-encoded CA private key. If omitted, a new Ed25519 CA key pair is generated at bootstrap-----BEGIN OPENSSH PRIVATE KEY-----...
ca_public_keyOptionalCorresponding CA public key. Required if ca_private_key is providedssh-ed25519 AAAA...
default_ttlOptionalDefault certificate TTL when not specified in request. Defaults to 8h24h
max_ttlOptionalMaximum allowed TTL for any certificate. Defaults to 720h (30 days)720h

When both ca_private_key and ca_public_key are omitted, the engine generates a new Ed25519 CA key pair at bootstrap and stores it in the engine's config. The CA public key must be added to target servers in /etc/ssh/sshd_config:

TrustedUserCAKeys /etc/ssh/ca.pub

Roles

RolePrincipalsMax TTLUse Case
user["username"] (from request)24hDeveloper SSH access
deploy["deploy", "ubuntu"]8hCI/CD deployment pipelines
admin["root"]1hEmergency root access

Operations

generate

Signs an SSH public key with the CA, producing a certificate.

Request:

{
"method": "generate",
"params": {
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG...",
"principals": ["ubuntu", "deploy"],
"ttl": "8h",
"key_id": "deploy-pipeline-42"
}
}

What happens internally:

  1. Parses the user's SSH public key
  2. Generates a random serial number
  3. Builds an ssh.Certificate with the requested principals and TTL
  4. Signs it with the CA private key
  5. Returns the marshalled certificate

Response:

{
"data": {
"signed_certificate": "[email protected] AAAAIHNzaC1lZDI...",
"serial": 8837462519,
"key_id": "deploy-pipeline-42",
"principals": ["ubuntu", "deploy"],
"valid_after": "2026-04-02T10:00:00Z",
"valid_until": "2026-04-02T18:00:00Z",
"ca_public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI..."
}
}

validate

Verifies a certificate was signed by this CA and is still valid.

Request:

{
"method": "validate",
"params": {
"certificate": "[email protected] AAAAIHNzaC1lZDI..."
}
}

Response (valid):

{
"data": {
"valid": true,
"serial": 8837462519,
"key_id": "deploy-pipeline-42",
"principals": ["ubuntu", "deploy"],
"valid_after": "2026-04-02T10:00:00Z",
"valid_until": "2026-04-02T18:00:00Z"
}
}

Response (invalid):

{
"data": {
"valid": false,
"message": "certificate has expired"
}
}

Complete Go Plugin Source

package main

import (
"crypto/ed25519"
"crypto/rand"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"time"

"getarcan.dev/arcan/sdk"
"golang.org/x/crypto/ssh"
)

type SSHEngine struct {
caSigner ssh.Signer
caPubKey ssh.PublicKey
}

type generateParams struct {
PublicKey string `json:"public_key"`
Principals []string `json:"principals"`
TTL string `json:"ttl"`
KeyID string `json:"key_id"`
}

type validateParams struct {
Certificate string `json:"certificate"`
}

func (e *SSHEngine) Describe() sdk.Descriptor {
return sdk.Descriptor{
Name: "ssh",
Version: "0.1.0",
DisplayName: "SSH Certificate Authority",
Description: "Signs SSH public keys with a CA certificate for certificate-based auth",
Capabilities: []string{"secret_generation", "secret_validation"},
}
}

func (e *SSHEngine) Ping() error {
if e.caSigner == nil {
return fmt.Errorf("CA key not loaded")
}
return nil
}

func (e *SSHEngine) Generate(params json.RawMessage) (*sdk.SecretResult, error) {
var p generateParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
if p.PublicKey == "" {
return nil, fmt.Errorf("public_key is required")
}

pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(p.PublicKey))
if err != nil {
return nil, fmt.Errorf("invalid public key: %w", err)
}

ttl := 8 * time.Hour
if p.TTL != "" {
ttl, err = time.ParseDuration(p.TTL)
if err != nil {
return nil, fmt.Errorf("invalid ttl: %w", err)
}
}

var serialBytes [8]byte
if _, err := rand.Read(serialBytes[:]); err != nil {
return nil, fmt.Errorf("generating serial: %w", err)
}
serial := binary.BigEndian.Uint64(serialBytes[:])

now := time.Now().UTC()
cert := &ssh.Certificate{
CertType: ssh.UserCert,
Key: pubKey,
Serial: serial,
KeyId: p.KeyID,
ValidPrincipals: p.Principals,
ValidAfter: uint64(now.Unix()),
ValidBefore: uint64(now.Add(ttl).Unix()),
Permissions: ssh.Permissions{
Extensions: map[string]string{
"permit-pty": "",
"permit-user-rc": "",
"permit-agent-forwarding": "",
},
},
}

if err := cert.SignCert(rand.Reader, e.caSigner); err != nil {
return nil, fmt.Errorf("signing certificate: %w", err)
}

return &sdk.SecretResult{
Data: map[string]any{
"signed_certificate": string(ssh.MarshalAuthorizedKey(cert)),
"serial": serial,
"key_id": p.KeyID,
"principals": p.Principals,
"valid_after": now.Format(time.RFC3339),
"valid_until": now.Add(ttl).Format(time.RFC3339),
"ca_public_key": string(ssh.MarshalAuthorizedKey(e.caPubKey)),
},
}, nil
}

func (e *SSHEngine) Validate(params json.RawMessage) (*sdk.ValidationResult, error) {
var p validateParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}

pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(p.Certificate))
if err != nil {
return &sdk.ValidationResult{Valid: false, Message: fmt.Sprintf("parse error: %v", err)}, nil
}

cert, ok := pubKey.(*ssh.Certificate)
if !ok {
return &sdk.ValidationResult{Valid: false, Message: "not an SSH certificate"}, nil
}

checker := &ssh.CertChecker{
IsUserAuthority: func(auth ssh.PublicKey) bool {
return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(e.caPubKey)
},
}

if err := checker.CheckCert("", cert); err != nil {
return &sdk.ValidationResult{Valid: false, Message: err.Error()}, nil
}

return &sdk.ValidationResult{Valid: true, Message: fmt.Sprintf(
"serial=%d principals=%v expires=%s",
cert.Serial, cert.ValidPrincipals,
time.Unix(int64(cert.ValidBefore), 0).UTC().Format(time.RFC3339),
)}, nil
}

func main() {
var signer ssh.Signer

if keyData := os.Getenv("ARCAN_SSH_CA_PRIVATE_KEY"); keyData != "" {
var err error
signer, err = ssh.ParsePrivateKey([]byte(keyData))
if err != nil {
fmt.Fprintf(os.Stderr, "failed to parse CA key: %v\n", err)
os.Exit(1)
}
} else {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to generate CA key: %v\n", err)
os.Exit(1)
}
signer, err = ssh.NewSignerFromKey(priv)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to create signer: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "WARNING: using ephemeral CA key -- set ARCAN_SSH_CA_PRIVATE_KEY for persistence\n")
}

eng := &SSHEngine{
caSigner: signer,
caPubKey: signer.PublicKey(),
}
sdk.Serve(eng)
}

Build & Install

mkdir -p engines/ssh && cd engines/ssh
go mod init getarcan.dev/engines/ssh
go get getarcan.dev/arcan/sdk
go get golang.org/x/crypto/ssh

# Build
go build -o arcan-engine-ssh .

# Install (copy to Arcan plugin directory)
cp arcan-engine-ssh ~/.arcan/plugins/

Usage

# Start the engine (Arcan core does this automatically)
echo '{"method":"ping","params":{}}' | ./arcan-engine-ssh
# => {"data":{"status":"healthy"}}

# Sign a public key
echo '{"method":"generate","params":{"public_key":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample...","principals":["ubuntu","deploy"],"ttl":"8h","key_id":"ci-job-42"}}' | ./arcan-engine-ssh
# => {"data":{"signed_certificate":"[email protected] AAAA...","serial":...}}

# Validate a certificate
echo '{"method":"validate","params":{"certificate":"[email protected] AAAA..."}}' | ./arcan-engine-ssh
# => {"data":{"valid":true,"message":"serial=... principals=[ubuntu deploy] expires=..."}}

Using the signed certificate

# Save the signed certificate
echo "$SIGNED_CERT" > ~/.ssh/id_ed25519-cert.pub

# SSH using the certificate (server must trust the CA)
ssh -o CertificateFile=~/.ssh/id_ed25519-cert.pub [email protected]

Server-side setup

# Add the CA public key to the server
echo "$CA_PUBLIC_KEY" > /etc/ssh/ca.pub

# Add to sshd_config
echo "TrustedUserCAKeys /etc/ssh/ca.pub" >> /etc/ssh/sshd_config
systemctl restart sshd

Security Notes

  • The CA private key is the root of trust. Protect it with the same rigor as a TLS root CA key.
  • Certificates include a serial number for revocation tracking. Implement a revocation list (KRL) for production use.
  • Short TTLs (1-8h) are preferred. Certificates expire automatically -- no revocation infrastructure needed for most cases.
  • The engine uses crypto/rand for all random number generation (serial numbers).
  • All timestamps use UTC (time.Now().UTC()).
  • The permit-pty extension allows interactive sessions. Remove it for non-interactive deploy keys.
  • Host certificates (CertType ssh.HostCert) are not covered here but follow the same signing pattern.