Skip to content

feat(agent): SSH agent backend (ok agent) — OKI-0014 - #159

Merged
p1024k merged 18 commits into
developfrom
feat/ssh-agent
Aug 5, 2026
Merged

feat(agent): SSH agent backend (ok agent) — OKI-0014#159
p1024k merged 18 commits into
developfrom
feat/ssh-agent

Conversation

@p1024k

@p1024k p1024k commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

oak-keyring SSH Agent Backend (ok agent) — OKI-0014

Adds an SSH agent backend. ok agent is a daemon that unlocks the vault, listens on a Unix socket, speaks the ssh-agent protocol, and signs with vault-stored SSH private keys in-process. Private keys never leave the daemon; ssh/git/AI use SSH_AUTH_SOCK only and never touch keys or the master password.

ok agent                 # unlock, then prints: SSH_AUTH_SOCK=/path
export SSH_AUTH_SOCK=... # from above
ssh-add -l               # real OpenSSH client lists vault SSH keys

Algorithms

ed25519, RSA (SHA-2 256/512), ECDSA (nistp256/384/521).

Design (spec OKS-0014 / plan OKP-0014)

  • Self-rolled ssh-agent protocol on tokio UnixListener — the only ssh-agent server crate pins tokio 0.1.
  • Zero-cache: private key decrypted + signer built per SIGN_REQUEST, dropped (zeroized) after — never retained.
  • Authorization: load all SSH records at startup (--only/--allow to narrow); no per-use prompt (档1).
  • Independent daemon lock (.agent.lock) coexists with TUI; WAL + busy_timeout handles concurrency.
  • SshSign audit per successful sign (via a no-audit decrypt path — no RecordViewPassword spam).
  • Graceful shutdown: SIGTERM/SIGINT/--idle-lock → zeroize keys + remove socket/pidfile.
  • Master password via rpassword (never in argv / shell history / AI context).

Security

  • Keys zeroize on shutdown (verified drop cascade: VaultServiceImplCryptoManager::DropLockedSecretBytes::drop).
  • run_agent applies process protections (parity with the TUI path).
  • socket 0600, parent dir 0700.
  • No key material in any error Display.

Verification

  • cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test (~3192 tests) all green.
  • Real OpenSSH ssh-add interop verified end-to-end; per-algorithm signatures verified against their RustCrypto crates.
  • Each of 12 tasks passed an implementer + reviewer cycle; final whole-branch review: Ready to merge (no Critical/Important).

Docs

docs/projects/oak-keyring/active/OKI-0014-ssh-agent/ (initiative + spec OKS-0014 + plan OKP-0014, in the docs repo).

Notes

  • ssh-key 0.6.7 has an RSA TryFrom<&RsaKeypair> bug (passes p twice); worked around via rsa::RsaPrivateKey::from_components(n, e, d, [p, q]).
  • Pre-existing cargo audit advisories (opendal/keepass/anyhow transitives) are unrelated to this feature and out of scope.
  • A list of deferred Minor polish items is tracked in the worktree progress ledger (sdd/progress.md).

p1024k added 16 commits July 22, 2026 17:49
Introduce `ok agent` (`--only`/`--allow`/`--idle-lock`): prompt for the
master password via rpassword (spawn_blocking), unlock the file-backed
SQLCipher vault mirroring executor::vault::handle_unlock, and serve the
ssh-agent over a Unix socket; prints SSH_AUTH_SOCK before serving.

- main.rs: clap Parser with optional subcommand; no subcommand == unchanged
  TUI path; --version handled natively (ok <version>, empty stderr).
- agent/cli.rs: AgentArgs + run() (tty) + unlock_and_serve() (tty-free
  testable core) + unlock_vault(); SecureStr password, never logged.
- agent/paths.rs: pure socket_path() resolver (XDG_RUNTIME_DIR > TMPDIR >
  /tmp); 0700 parent enforced by server.serve().
- idle_lock parsed/accepted, NOT enforced (Task 11) — loud startup warn.
- tests/agent_e2e_test.rs: real ssh-add -l/-L interop against the testable
  core over a file-backed vault (multi-thread runtime to avoid deadlock).

cargo fmt clean; clippy -D warnings clean; cargo test 3152 passed/0 failed.
Add RsaSigner producing RFC 8332 rsa-sha2-256 / rsa-sha2-512 PKCS#1 v1.5
signatures, mirroring Ed25519Signer's structure and secret hygiene. SHA-512
wins when both flags are set (matches OpenSSH); no flags defaults to SHA-256
(SHA-1 ssh-rsa is a spec Non-Goal — modern ssh refuses it).

Bypass ssh-key 0.6.7's buggy TryFrom<&RsaKeypair> (passes p twice instead of
p,q) by building rsa::RsaPrivateKey::from_components(n,e,d,[p,q]) directly;
re-implement the 2048-bit minimum locally.

Server: rename sign_with_ed25519 -> sign and add an RSA dispatch arm; wire
flags SSH_AGENT_SIGN_RSA_SHA2_256 (0x02) / _512 (0x04) map into SignFlags
before the algo match. ed25519 ignores the RSA bits (no regression).

Tests: 12 RSA signer tests + 3 RSA protocol-level roundtrips, both SHA-2
flag paths + default + encrypted-key path, all verifying with the rsa crate.
ed25519 + e2e suites still green (36/36 agent tests; 2628/2628 lib tests).
Add EcdsaSigner mirroring Ed25519Signer/RsaSigner: from_openssh maps
KeypairData::Ecdsa to the curve crate's SigningKey (p256/p384/p521),
sign produces the SSH wire blob `string ecdsa-sha2-nistp{256,384,521}`
+ `string <DER ECDSA sig>` with the curve's SHA-2 digest. ECDSA ignores
SignFlags. Dispatch wired into server.rs sign(); identity.rs now maps
NistP521 (previously rejected) so the agent advertises P-521 keys.

Signatures are RFC 6979 deterministic (the ecdsa crate's default Signer
impl), consistent with the deterministic ed25519/RSA-PKCS1v15 paths;
they verify identically to OpenSSH's randomized signatures.

p521 0.13 (features=["ecdsa"]) added and ssh-key "p521" feature enabled.
TDD: per-curve sign+verify (p256/p384/p521), passphrase path, cross-key
rejection, flags-ignored, determinism, and protocol SIGN_REQUEST
roundtrips for p256 and p521. cargo fmt + clippy -D warnings clean.
After a successful SIGN_REQUEST, write one AuditOperation::SshSign row
(record_id + name + resolved wire algorithm, e.g. ssh-ed25519 /
rsa-sha2-256 / ecdsa-sha2-nistp256). All three algorithm families
(ed25519/RSA/ECDSA) write SshSign.

The audit write is best-effort: on failure it is logged with
tracing::warn! and the successful SIGN_RESPONSE is still returned,
so an audit failure never blocks a successful signature.

Decrypt-audit decision: the sign path now decrypts key material via
decrypt_field_no_audit (new), so each sign writes ONLY SshSign.
RecordViewPassword is misleading here (the user never "viewed" a
password; the agent used the private key internally to sign). The
internal decrypt_field_with_audit(_, _, None) hook already existed;
decrypt_field_no_audit exposes it with caller-owns-audit semantics.

Adds SshAlgo::wire_name(flags) to resolve the precise wire algorithm
name (incl. RSA SHA-2 variant) for the audit detail.
Add AgentLock (src/agent/lock.rs) mirroring InstanceLock but using a
distinct .agent.lock file, so the SSH agent daemon and the TUI hold
independent advisory locks on independent inodes within the same data_dir.

- AgentLock::acquire uses fs4 try_lock_exclusive on <data_dir>/.agent.lock;
  AgentLockError{AlreadyRunning, Io} via thiserror mirrors InstanceLockError.
- Acquire the lock in cli::unlock_and_serve before unlocking the vault or
  binding the socket; hold it for the daemon lifetime (drops on return).
  AlreadyRunning surfaces via the new AgentCliError::Lock variant and exits
  loud through main.rs::run_agent (no silent second instance).
- Declare pub mod lock in agent/mod.rs.

Tests (TDD RED->GREEN):
- Inline unit tests: first acquire ok, second acquire -> AlreadyRunning,
  drop releases (reacquire ok), nested data_dir created, AgentLock +
  InstanceLock coexist on the same dir (distinct .agent.lock / .instance.lock).
- Integration (agent_e2e_test.rs): while a real agent daemon runs through
  unlock_and_serve on a file-backed SQLCipher vault, a second AgentLock on
  the same dir fails AlreadyRunning, InstanceLock succeeds (runtime
  coexistence), and AgentLock is reacquirable after the daemon releases it.
Task 11 (OKI-0014 / OKS-0014 / OKP-0014).

- serve() now races the accept loop against SIGTERM/SIGINT (cfg(unix)) and
  an optional idle-lock timer via tokio::select!. On any shutdown source it
  removes the socket + pidfile and drops the vault session (keys cleared by
  existing zeroize-on-drop).
- Add pidfile lifecycle: paths::pidfile_path() + pidfile_for_socket(); the
  pidfile is written before bind (closes a socket-vs-pidfile race) and removed
  on every shutdown path, including early bind error.
- --idle-lock <secs> is now enforced: an idle timer resets on each successful
  SIGN_REQUEST (try_send into an mpsc activity channel from the sync dispatch
  scope); no sign for <secs> triggers the same graceful cleanup. None disables
  the timer. The old 'NOT enforced' startup warning is removed.
- Reposition AgentLock acquire ABOVE the password prompt in run() (Task 10
  Minor-1): a second 'ok agent' fails with AlreadyRunning before asking for
  the password. unlock_and_serve (test path) acquires then delegates to a new
  unlock_and_serve_with_lock core that takes the guard; production run()
  acquires early and calls the same core.
- Add AgentServerError::Pidfile variant (fail loud on pidfile write failure).
- New tests/agent_shutdown_test.rs: SIGTERM, SIGINT, and idle-lock all trigger
  graceful shutdown with socket + pidfile removal; pidfile written at startup
  with the running PID. Serialized via a tokio::sync::Mutex to keep the
  process-wide signal receivers from racing.

cargo fmt && cargo clippy --all-targets -- -D warnings clean.
All agent suites green (shutdown 3, lib agent:: 35, e2e 3, protocol 10).
Add CHANGELOG entry and a short usage section to README/README-ZH/
INSTALL/INSTALL-ZH for the `ok agent` ssh-agent backend. No version
change (managed by the version-management skill); no code change.

Task 12 (final) of OKI-0014 / OKS-0014 / OKP-0014.
@p1024k
p1024k merged commit f98efac into develop Aug 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant