feat(agent): SSH agent backend (ok agent) — OKI-0014 - #159
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
oak-keyring SSH Agent Backend (
ok agent) — OKI-0014Adds an SSH agent backend.
ok agentis 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 useSSH_AUTH_SOCKonly and never touch keys or the master password.Algorithms
ed25519, RSA (SHA-2 256/512), ECDSA (nistp256/384/521).
Design (spec
OKS-0014/ planOKP-0014)UnixListener— the onlyssh-agentserver crate pins tokio 0.1.SIGN_REQUEST, dropped (zeroized) after — never retained.--only/--allowto narrow); no per-use prompt (档1)..agent.lock) coexists with TUI; WAL +busy_timeouthandles concurrency.SshSignaudit per successful sign (via a no-audit decrypt path — noRecordViewPasswordspam).SIGTERM/SIGINT/--idle-lock→ zeroize keys + remove socket/pidfile.rpassword(never in argv / shell history / AI context).Security
VaultServiceImpl→CryptoManager::Drop→LockedSecretBytes::drop).run_agentapplies process protections (parity with the TUI path).0600, parent dir0700.Display.Verification
cargo fmt --check,cargo clippy --all-targets -- -D warnings,cargo test(~3192 tests) all green.ssh-addinterop verified end-to-end; per-algorithm signatures verified against their RustCrypto crates.Docs
docs/projects/oak-keyring/active/OKI-0014-ssh-agent/(initiative + specOKS-0014+ planOKP-0014, in the docs repo).Notes
TryFrom<&RsaKeypair>bug (passesptwice); worked around viarsa::RsaPrivateKey::from_components(n, e, d, [p, q]).cargo auditadvisories (opendal/keepass/anyhow transitives) are unrelated to this feature and out of scope.sdd/progress.md).