SSH Agent Backend
This page describes the SSH agent authentication backend implemented in core-auth/src/ssh.rs
and core-auth/src/ssh_types.rs. The backend connects to the user’s SSH agent, signs a
deterministic challenge, derives a KEK from the signature via BLAKE3, and wraps or unwraps
the vault master key using AES-256-GCM.
SshAgentBackend
The SshAgentBackend struct is a zero-sized type. All state lives in the SSH agent process
and on-disk enrollment blobs.
Trait Implementation
| Method | Behavior |
|---|---|
factor_id() | Returns AuthFactorId::SshAgent |
name() | Returns "SSH Agent" |
backend_id() | Returns "ssh-agent" |
is_enrolled(profile, config_dir) | Checks whether {config_dir}/vaults/{profile}.ssh-enrollment exists |
can_unlock(profile, config_dir) | Enrolled, blob is parseable, and the enrolled key’s fingerprint is present in the running agent |
requires_interaction() | Returns AuthInteraction::None |
The can_unlock() check connects to the SSH agent via spawn_blocking (the
ssh-agent-client-rs crate uses synchronous Unix socket I/O) and searches the agent’s
identity list for a key matching the fingerprint stored in the enrollment blob.
Challenge Construction
The challenge is a deterministic 32-byte value derived from the profile name and salt:
context = "pds v2 ssh-challenge {profile_name}"
challenge = BLAKE3::derive_key(context, salt)
The same profile name and salt always produce the same challenge. Different profiles or salts produce different challenges. This determinism is essential because the backend must produce the same challenge at both enrollment and unlock time.
Signature to KEK Derivation
After the SSH agent signs the challenge, the raw signature bytes are fed into a second BLAKE3
derive_key call:
context = "pds v2 ssh-vault-kek {profile_name}"
kek = BLAKE3::derive_key(context, signature_bytes)
The raw signature bytes are zeroized immediately after KEK derivation. The KEK is a 32-byte value used as an AES-256-GCM key to wrap or unwrap the master key.
This two-step derivation (challenge from salt, KEK from signature) ensures:
- The KEK is bound to both the profile identity and the specific SSH key.
- The signature is never stored – only the wrapped master key is persisted.
- The BLAKE3 derivation provides domain separation between the challenge and KEK contexts.
Supported Key Types
Defined in core-auth/src/ssh_types.rs, the SshKeyType enum restricts which SSH key types
can be used:
| Type | Wire name | Determinism |
|---|---|---|
Ed25519 | ssh-ed25519 | Deterministic by specification (RFC 8032) |
Rsa | ssh-rsa | PKCS#1 v1.5 padding uses no randomness; ssh-agent-client-rs hard-codes SHA-512 |
Excluded key types:
- ECDSA (
ecdsa-sha2-nistp256, etc.): Non-deterministic. Uses a randomkvalue per signature. A different signature on each unlock would produce a different KEK and fail to unwrap the enrollment blob. - RSA-PSS: Non-deterministic. Uses a random salt per signature.
SshKeyType::from_algorithm() converts from ssh_key::Algorithm, rejecting non-deterministic
types with AuthError::UnsupportedKeyType. SshKeyType::from_wire_name() parses the SSH wire
format string.
EnrollmentBlob
The EnrollmentBlob struct persists the SSH-agent enrollment on disk at
{config_dir}/vaults/{profile}.ssh-enrollment.
Binary Format
Offset Length Field
0 1 Version byte (0x01)
1 2 Key fingerprint length N (big-endian u16)
3 N Key fingerprint (ASCII, e.g. "SHA256:...")
3+N 1 Key type length M (u8)
4+N M Key type wire name (ASCII, e.g. "ssh-ed25519")
4+N+M 12 Nonce (random)
16+N+M 48 Ciphertext (32-byte master key + 16-byte GCM tag)
The version constant ENROLLMENT_VERSION is 0x01.
Security
- Fingerprint length is capped at 256 bytes during deserialization to prevent allocation attacks from malformed blobs.
- File permissions are set to
0o600before atomic rename. - Revocation overwrites the file with zeros before deletion.
Unlock Flow
- Read and deserialize the enrollment blob from disk.
- Derive the 32-byte challenge:
BLAKE3::derive_key("pds v2 ssh-challenge {profile}", salt). - Connect to the SSH agent (via
spawn_blockingto avoid blocking the tokio runtime). - Find the identity matching the enrolled fingerprint.
- Sign the challenge with the enrolled key.
- Derive the KEK:
BLAKE3::derive_key("pds v2 ssh-vault-kek {profile}", signature_bytes). - Zeroize the raw signature bytes.
- Construct an
EncryptionKeyfrom the KEK, then zeroize the KEK bytes. - Decrypt the master key from the enrollment blob’s ciphertext using AES-256-GCM.
- Return an
UnlockOutcomewithipc_strategy: DirectMasterKey,factor_id: SshAgent, and audit metadata including the SSH fingerprint and key type.
Enrollment Flow
- Connect to the SSH agent, list all identities, filter to eligible key types (Ed25519, RSA).
- Select a key by
selected_key_index(required –NonereturnsNoEligibleKey). - Sign the challenge with the selected key.
- Derive the KEK from the signature (same derivation as unlock).
- Zeroize the signature bytes.
- Generate a 12-byte random nonce via
getrandom. - Encrypt the master key with AES-256-GCM using the KEK and nonce.
- Zeroize the KEK bytes.
- Build and serialize the
EnrollmentBlobwith the key fingerprint, key type, nonce, and ciphertext. - Write to disk atomically via a
.ssh-enrollment.tmpintermediate, with0o600permissions.
Key Selection
The CLI sesame ssh enroll command in open-sesame/src/ssh.rs supports three methods for
selecting which SSH key to enroll:
Fingerprint via –ssh-key Flag
sesame ssh enroll --ssh-key SHA256:abc123...
The fingerprint is matched against loaded agent keys, with or without the SHA256: prefix.
Public Key File via –ssh-key Flag
sesame ssh enroll --ssh-key ~/.ssh/id_ed25519.pub
The file is read, parsed as an OpenSSH public key, and its SHA256 fingerprint is computed.
Path traversal via ~/ is resolved through canonicalize() and verified to remain within
$HOME. Files larger than 64 KB are rejected.
Interactive Menu
When --ssh-key is omitted and stdin is a terminal, dialoguer::Select presents a menu of
eligible keys from the agent, showing fingerprint and algorithm. In non-interactive mode
(piped stdin), --ssh-key is required.
Agent Connection
The connect_agent() function in core-auth/src/ssh.rs attempts two socket paths in order:
-
$SSH_AUTH_SOCK: The standard environment variable, set byssh-agent,sshdforwarding, or systemd environment propagation. -
~/.ssh/agent.sock: A fallback stable symlink path. On Konductor VMs,/etc/profile.d/konductor-ssh-agent.shcreates~/.ssh/agent.sockpointing to the forwarded agent socket (/tmp/ssh-XXXX/agent.PID) on each SSH login. This gives systemd user services a stable path to the forwarded agent, since$SSH_AUTH_SOCKpoints to a per-session temporary directory that changes on each login.
The function is intentionally synchronous – local Unix socket connect is sub-millisecond.
All agent operations in the async VaultAuthBackend methods are wrapped in
tokio::task::spawn_blocking to avoid blocking the tokio runtime.
Agent Forwarding
For remote or containerized environments where the SSH key lives on the operator’s workstation:
- The SSH agent socket is forwarded via
ssh -AorForwardAgent yesin SSH config. $SSH_AUTH_SOCKis set bysshdto the forwarded socket path.- The stable symlink pattern (
~/.ssh/agent.sock) provides systemd user services access to the forwarded agent, since systemd services do not inherit the per-session$SSH_AUTH_SOCK. - The Konductor profile.d hook creates and maintains this symlink automatically on each SSH login.
This architecture allows vault unlock via SSH agent even when running inside a VM or container, provided the SSH agent is forwarded from the host.