Skip to content

PAM module (sudo via passkey)

pam-ssh-agent-webauthn is a PAM module that authenticates users via a WebAuthn passkey forwarded over the SSH agent. Drop it into /etc/pam.d/sudo and your sudo prompts get answered by a passkey approval (touch / biometric / PIN, depending on the authenticator) instead of a password.

It lives in its own repo and is standalone — no runtime dependency on ShellWatch. It works with any SSH agent that exposes webauthn-sk-ecdsa-sha2-nistp256@openssh.com keys and produces WebAuthn-format signatures. ShellWatch is one such agent; OpenSSH’s own forwarded agent is another (if you’re holding webauthn-keys).

Combined with the SSH agent proxy and ssh -A (agent forwarding), this turns sudo on a remote box into a touch-to-confirm operation:

  • You SSH to a target host with ssh -A user@host.
  • You run sudo apt update on the target.
  • The target’s PAM stack invokes pam_ssh_webauthn.so, which talks to the forwarded SSH agent (your local shellwatch-agent), which talks to ShellWatch, which fires a PendingAction to your browser.
  • You tap your YubiKey. PAM verifies the assertion locally against /etc/security/authorized_keys, returns PAM_SUCCESS, and sudo proceeds.

The signature is end-to-end — produced by your authenticator, verified by PAM on the target. Neither ShellWatch nor the SSH agent forwarder can forge it; they can only deny or delay.

When PAM invokes the module:

  1. Reads authorized public keys from a configured file (default: /etc/security/authorized_keys).
  2. Connects to the SSH agent at $SSH_AUTH_SOCK.
  3. Lists agent identities, filters for webauthn-sk-ecdsa-sha2-nistp256@openssh.com keys.
  4. Matches agent keys against authorized keys by raw blob comparison.
  5. Generates a 32-byte random challenge.
  6. Sends SSH_AGENTC_SIGN_REQUEST to the agent.
  7. Receives the signature with WebAuthn fields (origin, clientDataJSON, extensions).
  8. Validates the challenge is base64url-encoded inside clientDataJSON.
  9. Constructs signed_data = SHA256(application) || flags || counter || extensions || SHA256(clientDataJSON).
  10. Verifies the ECDSA P-256 signature over signed_data.
  11. Returns PAM_SUCCESS or PAM_AUTH_ERR.

It does not handle standard SSH key types (RSA, Ed25519, ECDSA) — use pam-ssh-agent for those. Standard FIDO2 SK keys (with application=ssh:) are also out of scope; this module is specifically for browser-registered (webauthn-...) credentials.

Requires Rust 1.70+ and PAM development headers.

Terminal window
# Linux
sudo apt install libpam0g-dev # Debian/Ubuntu
sudo dnf install pam-devel # Fedora/RHEL
# Build
git clone https://github.com/rado0x54/pam-ssh-agent-webauthn.git
cd pam-ssh-agent-webauthn
cargo build --release
# Output:
# target/release/libpam_ssh_webauthn.so (Linux)
# target/release/libpam_ssh_webauthn.dylib (macOS — for testing)

Pre-built binaries are also attached to each release — drop the .so straight into the modules directory and skip the build step.

For FIPS / OpenSSL-backed crypto:

Terminal window
cargo build --release --features native-crypto

1. Drop the shared library into the PAM modules directory

Section titled “1. Drop the shared library into the PAM modules directory”
Terminal window
# Linux (Debian/Ubuntu, x86_64)
sudo cp target/release/libpam_ssh_webauthn.so \
/usr/lib/x86_64-linux-gnu/security/pam_ssh_webauthn.so
# Fedora/RHEL
sudo cp target/release/libpam_ssh_webauthn.so \
/usr/lib64/security/pam_ssh_webauthn.so
# macOS (testing only)
sudo cp target/release/libpam_ssh_webauthn.dylib \
/usr/lib/pam/pam_ssh_webauthn.so

Create /etc/security/authorized_keys with the WebAuthn public keys that should be allowed to authenticate:

webauthn-sk-ecdsa-sha2-nistp256@openssh.com AAAA... my-yubikey

Get the public-key blob from ShellWatch under Settings → Passkeys — each registered passkey shows its OpenSSH-format public key.

The blob must match the agent’s blob exactly, including the application/relying-party ID. A key registered on one ShellWatch deployment is not interchangeable with one registered on another (they have different relying-party IDs).

Other key types in the file are silently ignored.

Add to /etc/pam.d/sudo before other auth lines (order matters — sufficient short-circuits on success):

auth sufficient pam_ssh_webauthn.so

With a custom key file path:

auth sufficient pam_ssh_webauthn.so file=/path/to/authorized_keys

To override the agent socket (instead of $SSH_AUTH_SOCK):

auth sufficient pam_ssh_webauthn.so socket=/path/to/agent.sock

sudo strips most environment variables by default. Add an explicit allowlist:

/etc/sudoers.d/ssh-auth-sock
Defaults env_keep += "SSH_AUTH_SOCK"

The included authenticator example runs the full flow without involving PAM — useful for sanity-checking that the agent and authorized-keys file line up:

Terminal window
export SSH_AUTH_SOCK=/path/to/agent.sock
cargo run --example authenticator -- /path/to/authorized_keys

The list_keys example dumps the agent’s exposed key blobs:

Terminal window
SSH_AUTH_SOCK=/path/to/agent.sock cargo run --example list_keys

Validation checks (matching OpenSSH’s webauthn_check_prepare_hash)

Section titled “Validation checks (matching OpenSSH’s webauthn_check_prepare_hash)”
  • Algorithm: webauthn-sk-ecdsa-sha2-nistp256@openssh.com or sk-ecdsa-sha2-nistp256@openssh.com.
  • User Presence (UP): flag bit 0x01 must be set.
  • Attested Credential Data (AD): flag bit 0x40 must NOT be set (this is an assertion, not a registration).
  • Extension Data (ED): flag bit 0x80 must be consistent with extensions presence.
  • Origin: must not contain quote characters; must match between signature blob and clientDataJSON.
  • clientDataJSON.type: must be "webauthn.get".
  • clientDataJSON.challenge: must base64url-decode to the challenge PAM sent.
  • clientDataJSON.origin: must match the origin from the signature blob.
  • Trailing data: rejected.
  • No counter / replay protection. The WebAuthn counter is integrity-protected (covered by the signature) but not checked for monotonic increase. A captured signature still can’t be replayed (each PAM invocation generates a fresh challenge), but a cloned authenticator with a reset counter would not be detected. Stateless PAM modules can’t reasonably persist counters.
  • No User Verification (UV) enforcement. UP is required, UV is not. Some authenticators don’t support UV; if you want UV, use server-side enforcement on sshd (not relevant for sudo, but matters for SSH login).
  • Agent timeout: 60 s. Generous because the sign request requires user interaction.
  • No authorized_keys_command — file-only.

SSH_AUTH_SOCK not set — sudo dropped the variable. Add the env_keep rule above.

No matching key — your authorized-keys blob doesn’t match the agent’s exact blob. Use cargo run --example list_keys to see what the agent actually presents and copy that whole blob (including the application field).

Auth times out — the sign request fired but nobody approved it within 60 s. Check that a browser tab is open in ShellWatch (or that Web Push is configured and you tapped the notification).

Silent failures — enable debug logging via PAM or syslog. The module logs to syslog facility AUTH as pam_ssh_webauthn.

TrustedThe authenticator hardware, the browser’s WebAuthn origin check, this PAM verifier.
Transport onlyThe SSH agent forwarder (ShellWatch, or any other forwarder) — can deny or delay, cannot forge.
Verified on targetECDSA P-256 signature over a fresh challenge, checked against the public key in /etc/security/authorized_keys.

This is what ShellWatch’s promise of “end-to-end, human-in-the-loop” looks like in practice — the PAM verifier on the target host independently checks the signature, so even a compromised broker can’t grant sudo.