From 77f98d017e0438b39f3ff800dc17fc06108d17e5 Mon Sep 17 00:00:00 2001 From: UnbreakableMJ Date: Tue, 23 Jun 2026 07:30:21 +0300 Subject: [PATCH] feat(agent): fingerprint unlock (Linux/fprintd, off by default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `vault unlock --fingerprint` (and a TUI unlock-screen mode) re-unlock the keyring-held session after a fingerprint verified over D-Bus to the system `fprintd`, instead of re-typing the master password. This builds on the opt-in `agent.session_keyring`: a fingerprint yields only match/no-match, never key material, so it *gates the resume* of the key already in the kernel session keyring rather than wrapping a key at rest. With `agent.fingerprint_unlock` on, the agent no longer silently auto-resumes (it waits for a verified touch), idle-lock zeroises the in-memory key but KEEPS the keyring entry so a touch works after a timeout (lifetime `agent.fingerprint_ttl_secs`), and a manual `vault lock` still clears it. Verification happens inside the agent so a client can't bypass it over the socket; enrollment stays OS-level (`fprintd-enroll`) — Vault stores no template. Posture (PRD §7.3): the keyring entry is possessor-gated, so this is convenience + user-presence, NOT a cryptographic boundary beyond `session_keyring`, and strictly weaker than a master-password unlock. Default off, Linux-only, behind a new off-by-default `fingerprint` cargo feature (zbus). - vault-ipc: `Request::UnlockFingerprint` + `FingerprintUnavailable`/`Failed` - vault-config: `agent.fingerprint_unlock` / `fingerprint_ttl_secs` + spawn flags - vault-agent: fprintd zbus client (`fingerprint.rs`), dispatch, idle-lock/resume gating, `resume_from_keyring` helper - vault-cli: `vault unlock --fingerprint` - vault-tui: unlock-screen fingerprint mode (Tab cycles password/PIN/fingerprint) - docs/fingerprint.md, PRD §7.3, CHANGELOG, `just fingerprint` Tests cover config round-trip + flags, the resume gate, and the TUI mode/request. Full `just ci` green (clippy `--all-features` incl. the feature; deny/audit clean with zbus). Verified live: CLI fingerprint unlock against a real reader. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++ Cargo.lock | 324 +++++++++++++++++++++++++- Cargo.toml | 6 + PRD.md | 13 ++ crates/vault-agent/Cargo.toml | 7 + crates/vault-agent/src/fingerprint.rs | 131 +++++++++++ crates/vault-agent/src/main.rs | 40 ++-- crates/vault-agent/src/server.rs | 43 +++- crates/vault-agent/src/state.rs | 66 +++++- crates/vault-agent/src/unlock.rs | 33 +++ crates/vault-cli/src/main.rs | 33 ++- crates/vault-config/src/lib.rs | 87 +++++++ crates/vault-ipc/src/proto.rs | 24 ++ crates/vault-tui/src/app.rs | 63 +++-- crates/vault-tui/src/main.rs | 7 +- crates/vault-tui/src/ui.rs | 41 +++- docs/fingerprint.md | 83 +++++++ justfile | 6 + 18 files changed, 962 insertions(+), 59 deletions(-) create mode 100644 crates/vault-agent/src/fingerprint.rs create mode 100644 docs/fingerprint.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 42404ee..ecbea4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,20 @@ range may break in any release. ### Added +- **Fingerprint unlock (Linux, off by default).** With `agent.session_keyring` + and the new `agent.fingerprint_unlock` enabled, `vault unlock --fingerprint` + (and a TUI unlock-screen mode) re-unlock the keyring-held session after a + fingerprint verified — inside the agent, over D-Bus to the system `fprintd` + (via the new off-by-default `fingerprint` cargo feature + `zbus`). Idle-lock + zeroises the in-memory key but **keeps** the keyring entry so a touch works + after a timeout (lifetime: `agent.fingerprint_ttl_secs`); the agent no longer + silently auto-resumes, and manual `vault lock` still clears everything. + Enrollment stays OS-level (`fprintd-enroll`); Vault only verifies and stores + no template. **Posture (PRD §7.3):** this is convenience + user-presence, not + a cryptographic boundary — the keyring entry is possessor-gated, so it's no + stronger than `session_keyring` and weaker than a master-password unlock. See + `docs/fingerprint.md`. + - **Organization / Collection items now decrypt (org-key support).** Vault previously skipped every organization-owned cipher — the bulk of a vault that uses Collections — because it held no key for them. At unlock the agent now diff --git a/Cargo.lock b/Cargo.lock index aef86d0..5b947b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,40 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -352,6 +386,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -367,6 +410,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.28.1" @@ -556,6 +605,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -578,6 +654,27 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -675,6 +772,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1299,6 +1409,15 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1491,6 +1610,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -1501,6 +1630,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1645,6 +1780,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2114,6 +2258,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2461,6 +2616,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -2506,8 +2662,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -2519,6 +2675,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -2528,9 +2693,30 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", ] [[package]] @@ -2591,9 +2777,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -2626,6 +2824,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2716,6 +2925,7 @@ dependencies = [ "anyhow", "arboard", "clap", + "futures-util", "linux-keyutils", "region", "secmem-proc", @@ -2730,6 +2940,7 @@ dependencies = [ "vault-core", "vault-ipc", "vault-store", + "zbus", "zeroize", ] @@ -3456,6 +3667,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + [[package]] name = "wiremock" version = "0.6.5" @@ -3637,6 +3857,62 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.50" @@ -3736,3 +4012,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow 1.0.3", +] diff --git a/Cargo.toml b/Cargo.toml index c857262..ed4ed99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,12 @@ dirs = "5" # breakage). MIT/Apache-2.0; GPL-3.0-or-later compatible. Linux-only, gated # behind `cfg(target_os = "linux")` in vault-agent. linux-keyutils = "0.2" +# fprintd over D-Bus for the off-by-default `fingerprint` unlock feature +# (vault-agent, Linux). Pure-Rust D-Bus; MIT; GPL-3.0-or-later compatible. +# `futures-util` drives the VerifyStatus signal stream. Both optional, pulled +# only by `--features fingerprint`. +zbus = { version = "5", default-features = false, features = ["tokio"] } +futures-util = { version = "0.3", default-features = false } time = { version = "0.3", features = ["serde", "serde-well-known"] } fs2 = "0.4" # Terminal control — disable echo while reading secrets at an interactive diff --git a/PRD.md b/PRD.md index 717f60b..435749b 100644 --- a/PRD.md +++ b/PRD.md @@ -156,6 +156,19 @@ envelope; exit codes documented in `docs/exit_codes.md`. sole, default-off carve-out to G4's "master key never resident outside the agent process"; off, the key never leaves the process. +- **Fingerprint unlock (opt-in, Linux).** An extension of the same carve-out: + with `agent.fingerprint_unlock` (requires `session_keyring`, the off-by-default + `fingerprint` feature, and the system `fprintd`), the agent **gates the keyring + resume behind a verified fingerprint** instead of resuming silently — idle-lock + then zeroises the in-memory key but *keeps* the keyring entry so a touch + re-unlocks (lifetime `agent.fingerprint_ttl_secs`), while manual `vault lock` + still clears it. The biometric is verified in the agent (D-Bus), and enrollment + is OS-level (`fprintd-enroll`) — Vault stores no template. **Posture:** because + the keyring entry is possessor-gated, a fingerprint adds *user-presence and + convenience*, **not** cryptographic strength beyond `session_keyring`; it is + strictly weaker than a master-password unlock. Default off. See + `docs/fingerprint.md`. + ### 7.4 Storage - Vault items persisted as Bitwarden-format ciphertext (AES-256-CBC + HMAC-SHA256) under `$XDG_DATA_HOME/vault/`. diff --git a/crates/vault-agent/Cargo.toml b/crates/vault-agent/Cargo.toml index d78b6fd..e88aacc 100644 --- a/crates/vault-agent/Cargo.toml +++ b/crates/vault-agent/Cargo.toml @@ -30,6 +30,10 @@ clipboard = ["dep:arboard"] # Post-quantum transport (off by default): prefer X25519MLKEM768 on the HTTPS # client. Passes through to vault-api; see `docs/pqc.md`. pqc = ["vault-api/pqc"] +# Fingerprint unlock (off by default, Linux): verify a finger via `fprintd` over +# D-Bus, then resume the keyring-held session. Pulls zbus + futures-util. See +# `docs/fingerprint.md`. +fingerprint = ["dep:zbus", "dep:futures-util"] [dependencies] anyhow = { workspace = true } @@ -42,6 +46,9 @@ uuid = { workspace = true } url = { workspace = true } thiserror = { workspace = true } arboard = { workspace = true, optional = true } +# fprintd D-Bus client for the `fingerprint` feature (used Linux-only in code). +zbus = { workspace = true, optional = true } +futures-util = { workspace = true, optional = true } # Process hardening: disable core dumps + ptrace at startup (safe rustix-backed # API, keeps the crate's #![forbid(unsafe_code)]). See `harden.rs`. secmem-proc = "0.3" diff --git a/crates/vault-agent/src/fingerprint.rs b/crates/vault-agent/src/fingerprint.rs new file mode 100644 index 0000000..35695cf --- /dev/null +++ b/crates/vault-agent/src/fingerprint.rs @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Fingerprint verification via the system `fprintd` service (Linux, D-Bus). +//! +//! Used only to *gate* the keyring-held session resume — Vault never enrolls or +//! stores fingerprint templates (enrollment is OS-level via `fprintd-enroll`), +//! and a fingerprint yields only match/no-match, never key material. The match +//! decision is made **inside the agent** so a client can't bypass it over the +//! UDS. See `docs/fingerprint.md` and the `Request::UnlockFingerprint` contract. +//! +//! Real implementation is behind `cfg(all(target_os = "linux", feature = +//! "fingerprint"))`; every other build gets a stub that reports +//! [`Outcome::Unavailable`], so default/headless/macOS builds carry no D-Bus +//! dependency and fingerprint unlock degrades cleanly. + +/// Result of one fingerprint verification attempt. +// `Match`/`NoMatch` are produced only by the real (feature+Linux) implementation; +// the stub yields only `Unavailable`, so allow them to be "unconstructed" there. +#[cfg_attr( + not(all(target_os = "linux", feature = "fingerprint")), + allow(dead_code) +)] +#[derive(Debug)] +pub enum Outcome { + /// A finger matched an enrolled print. + Match, + /// A finger was read but did not match (or the user gave up / it timed out). + NoMatch, + /// Verification couldn't run at all — no `fingerprint` feature, no reader / + /// `fprintd` / enrolled finger, `PolicyKit` denied (e.g. an inactive/SSH + /// session), or a D-Bus error. Carries an operator-facing reason. + Unavailable(String), +} + +#[cfg(all(target_os = "linux", feature = "fingerprint"))] +pub use imp::verify; + +#[cfg(all(target_os = "linux", feature = "fingerprint"))] +mod imp { + use std::time::Duration; + + use futures_util::StreamExt as _; + + use super::Outcome; + + /// Max wall-clock to wait for a swipe before giving up (→ `NoMatch`). + const VERIFY_TIMEOUT: Duration = Duration::from_secs(30); + + #[zbus::proxy( + interface = "net.reactivated.Fprint.Manager", + default_service = "net.reactivated.Fprint", + default_path = "/net/reactivated/Fprint/Manager" + )] + trait FprintManager { + fn get_default_device(&self) -> zbus::Result; + } + + #[zbus::proxy( + interface = "net.reactivated.Fprint.Device", + default_service = "net.reactivated.Fprint" + )] + trait FprintDevice { + /// `""` claims the device for the calling user (`PolicyKit`-gated). + fn claim(&self, username: &str) -> zbus::Result<()>; + /// `"any"` verifies against any enrolled finger. + fn verify_start(&self, finger_name: &str) -> zbus::Result<()>; + fn verify_stop(&self) -> zbus::Result<()>; + fn release(&self) -> zbus::Result<()>; + + #[zbus(signal)] + fn verify_status(&self, result: String, done: bool) -> zbus::Result<()>; + } + + /// Verify a finger against the user's enrolled prints. Any D-Bus / setup + /// failure (no device, no enrolled finger, `PolicyKit` denial) surfaces as + /// [`Outcome::Unavailable`]; only a read that doesn't match (or a timeout) is + /// [`Outcome::NoMatch`]. + pub async fn verify() -> Outcome { + match run().await { + Ok(outcome) => outcome, + Err(e) => Outcome::Unavailable(e.to_string()), + } + } + + async fn run() -> zbus::Result { + let conn = zbus::Connection::system().await?; + let manager = FprintManagerProxy::new(&conn).await?; + let device_path = manager.get_default_device().await?; + let device = FprintDeviceProxy::builder(&conn) + .path(device_path)? + .build() + .await?; + device.claim("").await?; + // Release the device whatever the verify result. + let result = verify_on(&device).await; + let _ = device.release().await; + result + } + + async fn verify_on(device: &FprintDeviceProxy<'_>) -> zbus::Result { + // Subscribe before VerifyStart so the first status can't be missed. + let mut status = device.receive_verify_status().await?; + device.verify_start("any").await?; + let waited = tokio::time::timeout(VERIFY_TIMEOUT, async { + while let Some(signal) = status.next().await { + let args = signal.args()?; + if args.done { + let outcome = if args.result == "verify-match" { + Outcome::Match + } else { + Outcome::NoMatch + }; + // Pin the error type for inference. + return Ok::(outcome); + } + // Intermediate retries (verify-retry-scan, swipe-too-short, …): + // fprintd keeps the session open, so keep waiting. + } + Ok(Outcome::NoMatch) + }) + .await; + let _ = device.verify_stop().await; + // Timeout elapsed → no match; otherwise propagate the inner result. + waited.unwrap_or(Ok(Outcome::NoMatch)) + } +} + +#[cfg(not(all(target_os = "linux", feature = "fingerprint")))] +pub async fn verify() -> Outcome { + Outcome::Unavailable("agent built without the `fingerprint` feature".to_owned()) +} diff --git a/crates/vault-agent/src/main.rs b/crates/vault-agent/src/main.rs index ea9e6a4..096450b 100644 --- a/crates/vault-agent/src/main.rs +++ b/crates/vault-agent/src/main.rs @@ -21,6 +21,7 @@ use vault_ipc::default_socket_path; #[cfg(feature = "clipboard")] mod clipboard; +mod fingerprint; mod harden; mod sealed; mod server; @@ -73,6 +74,16 @@ struct Args { /// idle-lock TTL (opt-in; PRD §7.3 carve-out). No effect on non-Linux. #[arg(long)] session_keyring: bool, + /// Gate the keyring session behind a fingerprint (Linux, `fprintd`): the + /// agent stays locked until a verified touch, idle-lock keeps the keyring + /// entry, and its lifetime is `--fingerprint-ttl-secs`. Requires + /// `--session-keyring` and the `fingerprint` feature; otherwise a no-op. + #[arg(long)] + fingerprint_unlock: bool, + /// Keyring-session lifetime under fingerprint unlock — how long after the + /// last unlock a touch can still re-unlock; `0` = until logout / manual lock. + #[arg(long, default_value_t = 0)] + fingerprint_ttl_secs: u64, /// Seconds between background `/sync`es while unlocked; `0` disables. Keeps /// the cache fresh without a manual `vault sync`. #[arg(long, default_value_t = 0)] @@ -123,6 +134,8 @@ async fn main() -> anyhow::Result<()> { let agent = AgentState::new(args.idle_lock_secs); let mut agent = agent; agent.session_keyring = args.session_keyring; + agent.fingerprint_unlock = args.fingerprint_unlock; + agent.fingerprint_ttl_secs = args.fingerprint_ttl_secs; agent.sync_interval_secs = args.sync_interval_secs; // Opt-in: resume an unlocked session left in the kernel keyring by a prior // agent (e.g. after a crash / restart), within its idle-lock deadline. @@ -172,31 +185,20 @@ fn resolve_clear_secs(flag: Option, env: Option<&str>) -> u64 { /// vault from it + the on-disk cache so the agent comes up unlocked. Any /// failure (disabled, no entry, expired, missing cache) leaves it locked. fn try_resume(agent: &mut AgentState) { - if !agent.session_keyring { - return; - } - let Some(blob) = session::load() else { - return; - }; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_secs()); - // deadline_unix == 0 means "no expiry" (idle-lock disabled). - if blob.deadline_unix != 0 && now >= blob.deadline_unix { - session::clear(); + // Fingerprint unlock deliberately does NOT auto-resume: the agent stays + // locked until a verified touch (`Request::UnlockFingerprint`). The keyring + // entry is left in place for that. + if !agent.session_keyring || agent.fingerprint_unlock { return; } - let Some(cache) = unlock::load_cache(&blob.server, &blob.email) else { - return; - }; - let user_enc = zeroize::Zeroizing::new(blob.user_enc); - let user_mac = zeroize::Zeroizing::new(blob.user_mac); - match unlock::vault_from_user_key(&cache, &blob.server, &blob.email, &user_enc, &user_mac) { - Ok(vault) => { + match unlock::resume_from_keyring() { + Ok(Some(vault)) => { agent.vault = Some(vault); agent.touch(); eprintln!("vault-agent: resumed session from kernel keyring"); } + // No resumable session (absent / expired / no cache) — stay locked. + Ok(None) => {} Err(e) => eprintln!("vault-agent: keyring resume failed: {e}"), } } diff --git a/crates/vault-agent/src/server.rs b/crates/vault-agent/src/server.rs index 42c59ec..7330c71 100644 --- a/crates/vault-agent/src/server.rs +++ b/crates/vault-agent/src/server.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use tokio::net::{UnixListener, UnixStream}; use tokio::sync::Mutex; -use vault_ipc::proto::{Request, Response}; +use vault_ipc::proto::{Error as IpcError, Request, Response}; use vault_ipc::{read_frame, write_frame}; use crate::state::AgentState; @@ -122,6 +122,34 @@ async fn dispatch(req: Request, state: &Arc>) -> Response { Err(e) => Response::Error(e), } } + Request::UnlockFingerprint { + server: _, + email: _, + } => { + // Verify the fingerprint *inside the agent* (a client can't bypass it + // over the socket), then resume the keyring-held session. Single + // account: the keyring blob carries its own server/email, so the + // request's account is advisory. + match crate::fingerprint::verify().await { + crate::fingerprint::Outcome::Match => { + let mut s = state.lock().await; + match s.resume_after_fingerprint() { + Ok(()) => { + s.persist_session(); + s.touch(); + Response::Ok + } + Err(e) => Response::Error(e), + } + } + crate::fingerprint::Outcome::NoMatch => { + Response::Error(IpcError::FingerprintFailed) + } + crate::fingerprint::Outcome::Unavailable(why) => { + Response::Error(IpcError::FingerprintUnavailable(why)) + } + } + } Request::PinSet { pin } => { let pin = zeroize::Zeroizing::new(pin); let mut s = state.lock().await; @@ -380,9 +408,16 @@ pub async fn idle_lock_loop(state: Arc>) { sleep(Duration::from_secs(15)).await; let mut s = state.lock().await; if s.idle_lock_due() { - // Idle-lock is a security event: forget the keyring session too. - s.lock_and_clear_session(); - eprintln!("vault-agent: idle-lock triggered"); + if s.fingerprint_unlock { + // Fingerprint unlock: zeroise the in-memory key but KEEP the + // keyring entry so a verified touch can re-unlock within its TTL. + s.lock(); + eprintln!("vault-agent: idle-lock (key zeroised; keyring kept for fingerprint)"); + } else { + // Idle-lock is a security event: forget the keyring session too. + s.lock_and_clear_session(); + eprintln!("vault-agent: idle-lock triggered"); + } } if s.shutdown_requested { break; diff --git a/crates/vault-agent/src/state.rs b/crates/vault-agent/src/state.rs index 362357a..56a00bf 100644 --- a/crates/vault-agent/src/state.rs +++ b/crates/vault-agent/src/state.rs @@ -230,6 +230,15 @@ pub struct AgentState { /// on unlock so a restarted agent can resume without the master password /// (opt-in; PRD §7.3 carve-out). Off by default; no-op on non-Linux. pub session_keyring: bool, + /// Gate the keyring session behind a fingerprint (Linux). When set: idle-lock + /// zeroises the in-memory key but **keeps** the keyring entry, the agent does + /// not silently auto-resume, and the keyring entry's lifetime is governed by + /// [`fingerprint_ttl_secs`](Self::fingerprint_ttl_secs) rather than the idle + /// timeout. Requires `session_keyring` + the `fingerprint` feature. + pub fingerprint_unlock: bool, + /// Keyring-session lifetime under fingerprint unlock (the touch window after + /// the last unlock); `0` = no kernel timeout (until logout / manual lock). + pub fingerprint_ttl_secs: u64, /// Seconds between agent-side background `/sync`es while unlocked; `0` /// disables. Drives [`server::scheduled_sync_loop`](crate::server::scheduled_sync_loop). pub sync_interval_secs: u64, @@ -296,6 +305,8 @@ impl AgentState { last_activity: Instant::now(), idle_lock_secs, session_keyring: false, + fingerprint_unlock: false, + fingerprint_ttl_secs: 0, sync_interval_secs: 0, last_session_refresh: None, shutdown_requested: false, @@ -343,12 +354,19 @@ impl AgentState { let Some(v) = self.vault.as_ref() else { return; }; - // idle_lock_secs == 0 disables idle-lock, so the session has no - // deadline (deadline_unix == 0) and no kernel timeout either. - let deadline_unix = if self.idle_lock_secs == 0 { + // Under fingerprint unlock the keyring entry must outlive the (short) + // in-memory idle-lock so a touch can re-unlock, so its lifetime is the + // fingerprint TTL; otherwise it's the idle-lock TTL. `0` for either => + // no deadline / no kernel timeout (idle-lock disabled, or "until logout"). + let ttl = if self.fingerprint_unlock { + self.fingerprint_ttl_secs + } else { + self.idle_lock_secs + }; + let deadline_unix = if ttl == 0 { 0 } else { - now_unix().saturating_add(self.idle_lock_secs) + now_unix().saturating_add(ttl) }; let blob = crate::session::SessionBlob { server: v.server.clone(), @@ -357,10 +375,36 @@ impl AgentState { user_mac: *v.user_mac, deadline_unix, }; - crate::session::store(&blob, self.idle_lock_secs); + crate::session::store(&blob, ttl); self.last_session_refresh = Some(Instant::now()); } + /// Resume an unlocked vault from the kernel keyring after a **verified + /// fingerprint** (the biometric check happens in the dispatch, before this). + /// Requires fingerprint unlock enabled and a live keyring session. + /// + /// # Errors + /// + /// [`IpcError::FingerprintUnavailable`] when fingerprint unlock is off or no + /// resumable session remains (expired / cleared / `session_keyring` off); + /// otherwise the typed failure from rebuilding the vault. + pub fn resume_after_fingerprint(&mut self) -> Result<(), IpcError> { + if !self.fingerprint_unlock { + return Err(IpcError::FingerprintUnavailable( + "fingerprint unlock is not enabled".to_owned(), + )); + } + match crate::unlock::resume_from_keyring()? { + Some(vault) => { + self.vault = Some(vault); + Ok(()) + } + None => Err(IpcError::FingerprintUnavailable( + "no resumable session — unlock with your master password".to_owned(), + )), + } + } + /// Whether the idle-lock policy says it's time to drop keys. #[must_use] pub fn idle_lock_due(&self) -> bool { @@ -1278,6 +1322,18 @@ mod tests { assert!(!s.is_unlocked()); } + #[test] + fn fingerprint_resume_refused_when_disabled() { + // With fingerprint unlock off (the default), a resume request is a clean + // "unavailable" — never a silent unlock and never a different error. + let mut s = AgentState::new(900); + assert!(matches!( + s.resume_after_fingerprint(), + Err(IpcError::FingerprintUnavailable(_)) + )); + assert!(!s.is_unlocked(), "must never auto-unlock"); + } + #[test] fn idle_lock_due_only_when_unlocked_and_expired() { let mut s = AgentState::new(60); diff --git a/crates/vault-agent/src/unlock.rs b/crates/vault-agent/src/unlock.rs index 24c5273..9c78661 100644 --- a/crates/vault-agent/src/unlock.rs +++ b/crates/vault-agent/src/unlock.rs @@ -333,6 +333,39 @@ pub fn load_cache(server: &str, email: &str) -> Option { vault_store::load_from_dir(&dir).ok() } +/// Rebuild an unlocked vault from a session blob in the kernel keyring plus the +/// on-disk cache. Shared by the silent startup resume ([`try_resume`] in +/// `main.rs`) and the fingerprint-gated resume +/// ([`AgentState::resume_after_fingerprint`](crate::state::AgentState::resume_after_fingerprint)). +/// +/// `Ok(None)` means "no resumable session" — absent, past its deadline, or no +/// cache — which is the normal, silent outcome; an expired entry is also +/// cleared. `Ok(Some(_))` resumed; `Err` is a genuine failure (e.g. the cached +/// payload won't decrypt under the keyring key). +/// +/// # Errors +/// +/// Propagates the typed failure from [`vault_from_user_key`]. +pub fn resume_from_keyring() -> Result, IpcError> { + let Some(blob) = crate::session::load() else { + return Ok(None); + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + // deadline_unix == 0 means "no expiry". + if blob.deadline_unix != 0 && now >= blob.deadline_unix { + crate::session::clear(); + return Ok(None); + } + let Some(cache) = load_cache(&blob.server, &blob.email) else { + return Ok(None); + }; + let user_enc = Zeroizing::new(blob.user_enc); + let user_mac = Zeroizing::new(blob.user_mac); + vault_from_user_key(&cache, &blob.server, &blob.email, &user_enc, &user_mac).map(Some) +} + /// Path to the account's cache directory. pub fn cache_dir(server: &str, email: &str) -> Option { Some(vault_store::default_data_dir()?.join(account_dir_name(server, email))) diff --git a/crates/vault-cli/src/main.rs b/crates/vault-cli/src/main.rs index db8d206..16e0dbb 100644 --- a/crates/vault-cli/src/main.rs +++ b/crates/vault-cli/src/main.rs @@ -152,6 +152,12 @@ enum Cmd { /// (read-only, offline session — sync/edits need a master unlock). #[arg(long)] pin: bool, + /// Re-unlock by fingerprint (Linux): resume the keyring-held session + /// after a verified touch, instead of the master password. Requires the + /// agent's `agent.session_keyring` + `agent.fingerprint_unlock` and a + /// live keyring session. + #[arg(long, conflicts_with = "pin")] + fingerprint: bool, /// Authenticator (TOTP) code for a 2FA-enabled account (master unlock /// only), supplied up front instead of being prompted. Falls back to /// `$BW_TOTP`. @@ -714,9 +720,10 @@ async fn run(cmd: Cmd, ep: Endpoint<'_>) -> Result<(), u8> { server, email, pin, + fingerprint, totp, json, - } => cmd_unlock(ep, server, email, pin, totp, json).await, + } => cmd_unlock(ep, server, email, pin, fingerprint, totp, json).await, Cmd::Pin { action } => cmd_pin(ep, action).await, Cmd::Apikey { action } => cmd_apikey(ep, action).await, Cmd::Lock { json } => cmd_ack(ep, Request::Lock, "locked", json).await, @@ -973,10 +980,32 @@ async fn cmd_unlock( server: Option, email: Option, pin: bool, + fingerprint: bool, totp: Option, json: bool, ) -> Result<(), u8> { let acct = resolve_account(server, email)?; + if fingerprint { + // The agent verifies the finger and resumes the keyring session; the CLI + // just nudges the user, since the request→reply IPC can't stream a prompt. + if std::io::stderr().is_terminal() { + eprintln!("Touch the fingerprint sensor…"); + } + let req = Request::UnlockFingerprint { + server: acct.server, + email: acct.email, + }; + let mut stream = connect(ep).await?; + let resp = exchange(&mut stream, &req).await?; + return match resp { + Response::Ok => { + print_ack("unlocked", json); + Ok(()) + } + Response::Error(e) => report_error(&e), + other => unexpected(&other), + }; + } if pin { let pin = read_secret("PIN: ")?.ok_or_else(|| { eprintln!("vault: empty PIN"); @@ -1944,6 +1973,8 @@ fn report_error(e: &IpcError) -> Result<(), u8> { IpcError::BadPin { .. } => 12, IpcError::PinLockedOut => 13, IpcError::PinNotSet => 14, + IpcError::FingerprintFailed => 15, + IpcError::FingerprintUnavailable(_) => 16, IpcError::Network(_) | IpcError::Internal(_) | IpcError::Decrypt(_) diff --git a/crates/vault-config/src/lib.rs b/crates/vault-config/src/lib.rs index 6fa2c88..9ce121b 100644 --- a/crates/vault-config/src/lib.rs +++ b/crates/vault-config/src/lib.rs @@ -27,6 +27,8 @@ pub const KNOWN_KEYS: &[&str] = &[ "clipboard.backend", "agent.idle_lock_secs", "agent.session_keyring", + "agent.fingerprint_unlock", + "agent.fingerprint_ttl_secs", "sync.interval_secs", "ui.reduced_motion", "tui.vim", @@ -81,6 +83,16 @@ pub struct AgentCfg { /// session keyring (opt-in; Linux-only). `true` passes `--session-keyring` /// to the auto-spawned agent. pub session_keyring: Option, + /// Gate the keyring-stored session behind a fingerprint (Linux, `fprintd`): + /// the agent stays locked on restart / after idle-lock and re-unlocks only + /// on a verified touch, and idle-lock keeps the keyring entry so the touch + /// works after a timeout. Requires `session_keyring` and the agent's + /// `fingerprint` feature. `true` passes `--fingerprint-unlock`. + pub fingerprint_unlock: Option, + /// Lifetime (seconds) of the keyring session under fingerprint unlock — how + /// long after the last unlock a touch can still re-unlock; `0` = until the + /// keyring is cleared (logout / manual lock). Passes `--fingerprint-ttl-secs`. + pub fingerprint_ttl_secs: Option, } /// `[sync]` table. @@ -163,6 +175,18 @@ impl Config { self.agent.session_keyring } + /// Effective `agent.fingerprint_unlock`, if set. + #[must_use] + pub const fn fingerprint_unlock(&self) -> Option { + self.agent.fingerprint_unlock + } + + /// Effective `agent.fingerprint_ttl_secs`, if set. + #[must_use] + pub const fn fingerprint_ttl_secs(&self) -> Option { + self.agent.fingerprint_ttl_secs + } + /// Effective `sync.interval_secs`, if set. #[must_use] pub const fn sync_interval_secs(&self) -> Option { @@ -209,6 +233,10 @@ impl Config { "clipboard.backend" => Ok(self.clipboard.backend.clone()), "agent.idle_lock_secs" => Ok(self.agent.idle_lock_secs.map(|v| v.to_string())), "agent.session_keyring" => Ok(self.agent.session_keyring.map(|v| v.to_string())), + "agent.fingerprint_unlock" => Ok(self.agent.fingerprint_unlock.map(|v| v.to_string())), + "agent.fingerprint_ttl_secs" => { + Ok(self.agent.fingerprint_ttl_secs.map(|v| v.to_string())) + } "sync.interval_secs" => Ok(self.sync.interval_secs.map(|v| v.to_string())), "ui.reduced_motion" => Ok(self.ui.reduced_motion.map(|v| v.to_string())), "tui.vim" => Ok(self.tui.vim.map(|v| v.to_string())), @@ -240,6 +268,14 @@ impl Config { self.agent.session_keyring = Some(parse_bool(key, raw)?); Ok(()) } + "agent.fingerprint_unlock" => { + self.agent.fingerprint_unlock = Some(parse_bool(key, raw)?); + Ok(()) + } + "agent.fingerprint_ttl_secs" => { + self.agent.fingerprint_ttl_secs = Some(parse_u64(key, raw)?); + Ok(()) + } "sync.interval_secs" => { self.sync.interval_secs = Some(parse_u64(key, raw)?); Ok(()) @@ -279,6 +315,14 @@ impl Config { self.agent.session_keyring = None; Ok(()) } + "agent.fingerprint_unlock" => { + self.agent.fingerprint_unlock = None; + Ok(()) + } + "agent.fingerprint_ttl_secs" => { + self.agent.fingerprint_ttl_secs = None; + Ok(()) + } "sync.interval_secs" => { self.sync.interval_secs = None; Ok(()) @@ -318,6 +362,13 @@ pub fn agent_args(cfg: &Config) -> Vec { if cfg.session_keyring() == Some(true) { args.push(OsString::from("--session-keyring")); } + if cfg.fingerprint_unlock() == Some(true) { + args.push(OsString::from("--fingerprint-unlock")); + } + if let Some(secs) = cfg.fingerprint_ttl_secs() { + args.push(OsString::from("--fingerprint-ttl-secs")); + args.push(OsString::from(secs.to_string())); + } if let Some(secs) = cfg.sync_interval_secs() { args.push(OsString::from("--sync-interval-secs")); args.push(OsString::from(secs.to_string())); @@ -635,6 +686,42 @@ mod tests { assert!(c.set("sync.interval_secs", "soon").is_err()); } + #[test] + fn fingerprint_keys_round_trip_and_emit_flags() { + let mut c = Config::default(); + assert_eq!(c.fingerprint_unlock(), None); + assert_eq!(c.fingerprint_ttl_secs(), None); + assert!( + !agent_args(&c).iter().any(|a| a == "--fingerprint-unlock"), + "unset → no flag" + ); + + c.set("agent.fingerprint_unlock", "true").expect("set"); + c.set("agent.fingerprint_ttl_secs", "7200").expect("set"); + assert_eq!(c.fingerprint_unlock(), Some(true)); + assert_eq!(c.fingerprint_ttl_secs(), Some(7200)); + + let args = agent_args(&c); + assert!(args.contains(&OsString::from("--fingerprint-unlock"))); + let i = args + .iter() + .position(|a| a == "--fingerprint-ttl-secs") + .expect("ttl flag present"); + assert_eq!(args[i + 1], OsString::from("7200")); + + // `false` must not emit the boolean flag. + c.set("agent.fingerprint_unlock", "false").expect("set"); + assert!(!agent_args(&c).iter().any(|a| a == "--fingerprint-unlock")); + + // Survives a toml round-trip; unset clears; bad value rejected. + let text = toml::to_string_pretty(&c).expect("serialise"); + let back: Config = toml::from_str(&text).expect("parse"); + assert_eq!(back.fingerprint_ttl_secs(), Some(7200)); + c.unset("agent.fingerprint_ttl_secs").expect("unset"); + assert_eq!(c.fingerprint_ttl_secs(), None); + assert!(c.set("agent.fingerprint_unlock", "maybe").is_err()); + } + #[test] fn session_keyring_round_trips() { let mut c = Config::default(); diff --git a/crates/vault-ipc/src/proto.rs b/crates/vault-ipc/src/proto.rs index ec52080..e864388 100644 --- a/crates/vault-ipc/src/proto.rs +++ b/crates/vault-ipc/src/proto.rs @@ -224,6 +224,20 @@ pub enum Request { pin: Vec, }, + /// Resume an unlocked session by verifying a fingerprint (Linux, `fprintd`), + /// then rebuilding the vault from the key held in the kernel session keyring. + /// Carries **no secret**: the biometric is verified inside the agent (so a + /// client can't bypass it over the socket), and the key is the one + /// `agent.session_keyring` already stored. Requires `session_keyring` + + /// `agent.fingerprint_unlock`, the `fingerprint` agent feature, and a live + /// keyring entry — otherwise [`Error::FingerprintUnavailable`]. + UnlockFingerprint { + /// Server origin. + server: String, + /// Account email. + email: String, + }, + /// Report whether a Bitwarden API key is stored for the account. Carries /// the account so the credential file can be found while the agent is /// locked. Never returns the secret. @@ -658,6 +672,16 @@ pub enum Error { /// `unlock --pin` (or `pin status` action) but no PIN is enrolled. #[error("no PIN is set — run `vault pin set` after unlocking")] PinNotSet, + /// Fingerprint unlock can't run: the agent lacks the `fingerprint` feature, + /// no reader / `fprintd` / enrolled finger, the session isn't active, or + /// there's no live keyring session to resume (idle TTL elapsed, locked, or + /// `session_keyring`/`fingerprint_unlock` not enabled). Fall back to the + /// master password (or PIN). + #[error("fingerprint unlock unavailable: {0}")] + FingerprintUnavailable(String), + /// A fingerprint was read but did not match an enrolled finger. + #[error("fingerprint not recognized")] + FingerprintFailed, /// Any other internal error — message is for the operator, not for parsing. #[error("internal: {0}")] Internal(String), diff --git a/crates/vault-tui/src/app.rs b/crates/vault-tui/src/app.rs index fd0f92b..55881d4 100644 --- a/crates/vault-tui/src/app.rs +++ b/crates/vault-tui/src/app.rs @@ -249,6 +249,9 @@ pub enum Screen { /// State for the in-TUI unlock prompt, shown when the agent is locked and an /// account is registered. The typed secret is zeroised on drop (`TextInput`). +// The flags are independent screen state (chosen mode, which modes are +// available, the 2FA step) — a flag bag, not a state machine begging for an enum. +#[allow(clippy::struct_excessive_bools)] #[derive(Clone, Debug)] pub struct UnlockState { /// Server origin from the registered profile. @@ -263,6 +266,10 @@ pub struct UnlockState { pub use_pin: bool, /// Whether a PIN is enrolled (drives offering the `Tab` toggle). pub pin_enabled: bool, + /// Whether the prompt is in fingerprint mode (touchless — `Enter` scans). + pub use_fingerprint: bool, + /// Whether fingerprint unlock is configured (drives offering it in the toggle). + pub fingerprint_enabled: bool, /// Last failed-unlock message, shown under the field. pub error: Option, /// In the second step of a 2FA unlock: the `secret` field now holds the @@ -279,7 +286,14 @@ impl UnlockState { pub fn request(&self) -> vault_ipc::proto::Request { use vault_ipc::proto::Request; let secret = self.secret.as_str().as_bytes().to_vec(); - if self.use_pin { + if self.use_fingerprint { + // Touchless: the agent verifies the finger and resumes the keyring + // session; no secret crosses the socket. + Request::UnlockFingerprint { + server: self.server.clone(), + email: self.email.clone(), + } + } else if self.use_pin { Request::UnlockPin { server: self.server.clone(), email: self.email.clone(), @@ -1263,17 +1277,34 @@ impl App { app } - /// Toggle between master-password and PIN entry (no-op unless a PIN is - /// enrolled); clears the field and any error on switch. - pub fn toggle_pin(&mut self) { - if let Some(u) = self.unlock.as_mut() - && u.pin_enabled - && !u.awaiting_2fa - { - u.use_pin = !u.use_pin; - u.secret.clear(); - u.error = None; + /// Cycle the unlock mode: master password → PIN (if enrolled) → fingerprint + /// (if configured) → … . A no-op when no alternative is available or mid-2FA. + /// Clears the field and any error on switch. + pub fn cycle_unlock_mode(&mut self) { + let Some(u) = self.unlock.as_mut() else { + return; + }; + if u.awaiting_2fa { + return; + } + // `(use_pin, use_fingerprint)` for each available mode, in cycle order. + let mut modes = vec![(false, false)]; // master password — always present + if u.pin_enabled { + modes.push((true, false)); + } + if u.fingerprint_enabled { + modes.push((false, true)); + } + if modes.len() < 2 { + return; // nothing to switch to } + let cur = (u.use_pin, u.use_fingerprint); + let idx = modes.iter().position(|m| *m == cur).unwrap_or(0); + let (next_pin, next_fp) = modes[(idx + 1) % modes.len()]; + u.use_pin = next_pin; + u.use_fingerprint = next_fp; + u.secret.clear(); + u.error = None; } /// Record a failed-unlock message and clear the typed secret. @@ -3130,6 +3161,8 @@ mod tests { secret: TextInput::default(), use_pin: false, pin_enabled, + use_fingerprint: false, + fingerprint_enabled: false, error: None, awaiting_2fa: false, password: Zeroizing::new(Vec::new()), @@ -3161,7 +3194,7 @@ mod tests { } // Toggle to PIN, re-type → UnlockPin (no device_id field). - app.toggle_pin(); + app.cycle_unlock_mode(); app.input_insert_str("4321"); match app.unlock.as_ref().unwrap().request() { Request::UnlockPin { server, email, pin } => { @@ -3200,7 +3233,7 @@ mod tests { other => panic!("expected Unlock, got {other:?}"), } // Tab must not switch to PIN mid-2FA even with a PIN enrolled. - app.toggle_pin(); + app.cycle_unlock_mode(); assert!(app.unlock.as_ref().unwrap().awaiting_2fa); assert!(!app.unlock.as_ref().unwrap().use_pin); } @@ -3208,7 +3241,7 @@ mod tests { #[test] fn toggle_pin_is_noop_without_enrollment_and_clears_on_switch() { let mut app = App::unlock_screen(status(), unlock_state(false)); - app.toggle_pin(); + app.cycle_unlock_mode(); assert!( !app.unlock.as_ref().unwrap().use_pin, "no PIN enrolled → no switch" @@ -3216,7 +3249,7 @@ mod tests { let mut app = App::unlock_screen(status(), unlock_state(true)); app.input_insert_str("abc"); - app.toggle_pin(); + app.cycle_unlock_mode(); let u = app.unlock.as_ref().unwrap(); assert!(u.use_pin); assert!(u.secret.is_empty(), "switch clears the field"); diff --git a/crates/vault-tui/src/main.rs b/crates/vault-tui/src/main.rs index dfb914d..9a69ac7 100644 --- a/crates/vault-tui/src/main.rs +++ b/crates/vault-tui/src/main.rs @@ -225,6 +225,9 @@ async fn locked_screen(socket: &Path, s: Status) -> App { client::request(socket, &Request::PinStatus { server: server.clone(), email: email.clone() }).await, Ok(Response::PinStatus(p)) if p.enabled ); + // Fingerprint mode is offered when configured; the agent still reports + // "unavailable" if it can't actually run, so this is just the menu gate. + let fingerprint_enabled = cfg.fingerprint_unlock().unwrap_or(false); App::unlock_screen( s, app::UnlockState { @@ -234,6 +237,8 @@ async fn locked_screen(socket: &Path, s: Status) -> App { secret: app::TextInput::default(), use_pin: false, pin_enabled, + use_fingerprint: false, + fingerprint_enabled, error: None, awaiting_2fa: false, password: zeroize::Zeroizing::new(Vec::new()), @@ -275,7 +280,7 @@ async fn handle_unlock_key(state: &mut App, key: KeyEvent, socket: &Path) { } match key.code { KeyCode::Esc => state.quit(), - KeyCode::Tab | KeyCode::BackTab => state.toggle_pin(), + KeyCode::Tab | KeyCode::BackTab => state.cycle_unlock_mode(), KeyCode::Backspace => { if let Some(u) = state.unlock.as_mut() { u.secret.backspace(); diff --git a/crates/vault-tui/src/ui.rs b/crates/vault-tui/src/ui.rs index b438ce9..02cfde0 100644 --- a/crates/vault-tui/src/ui.rs +++ b/crates/vault-tui/src/ui.rs @@ -117,14 +117,13 @@ fn render_unlock(frame: &mut Frame, app: &App, area: Rect) { let steel = hex(steelbore::STEEL_BLUE); let label = if u.awaiting_2fa { "Authenticator code" + } else if u.use_fingerprint { + "Fingerprint" } else if u.use_pin { "PIN" } else { "Password" }; - // Mask the secret, with the caret at the cursor position. - let masked = "•".repeat(u.secret.as_str().chars().count()); - let field = with_cursor(&masked, Some(masked.len())); let mut lines = vec![ Line::from(Span::styled( @@ -132,11 +131,22 @@ fn render_unlock(frame: &mut Frame, app: &App, area: Rect) { Style::default().fg(amber).add_modifier(Modifier::BOLD), )), Line::from(""), - Line::from(vec![ + ]; + if u.use_fingerprint { + // Touchless: no secret field — prompt for the scan instead. + lines.push(Line::from(Span::styled( + "Fingerprint — press Enter, then touch the sensor", + Style::default().fg(steel), + ))); + } else { + // Mask the secret, with the caret at the cursor position. + let masked = "•".repeat(u.secret.as_str().chars().count()); + let field = with_cursor(&masked, Some(masked.len())); + lines.push(Line::from(vec![ Span::styled(format!("{label}: "), Style::default().fg(steel)), Span::styled(field, Style::default().fg(amber)), - ]), - ]; + ])); + } if let Some(err) = u.error.as_deref() { lines.push(Line::from("")); lines.push(Line::from(Span::styled( @@ -146,11 +156,20 @@ fn render_unlock(frame: &mut Frame, app: &App, area: Rect) { } lines.push(Line::from("")); let hint = if u.awaiting_2fa { - "Enter submit code · Esc quit" - } else if u.pin_enabled { - "Enter unlock · Tab password/PIN · Esc quit" + "Enter submit code · Esc quit".to_owned() } else { - "Enter unlock · Esc quit" + let mut modes = vec!["password"]; + if u.pin_enabled { + modes.push("PIN"); + } + if u.fingerprint_enabled { + modes.push("fingerprint"); + } + if modes.len() > 1 { + format!("Enter unlock · Tab {} · Esc quit", modes.join("/")) + } else { + "Enter unlock · Esc quit".to_owned() + } }; lines.push(Line::from(Span::styled( hint, @@ -877,6 +896,8 @@ mod tests { secret: TextInput::default(), use_pin: false, pin_enabled: true, + use_fingerprint: false, + fingerprint_enabled: false, error: None, awaiting_2fa: false, password: zeroize::Zeroizing::new(Vec::new()), diff --git a/docs/fingerprint.md b/docs/fingerprint.md new file mode 100644 index 0000000..cd5ed68 --- /dev/null +++ b/docs/fingerprint.md @@ -0,0 +1,83 @@ + + +# Fingerprint unlock (`fingerprint` feature, Linux) + +Re-unlock Vault with a fingerprint touch instead of re-typing the master +password. Off by default; Linux + `fprintd` only. + +## What it is — and what it is **not** + +Fingerprint unlock **gates the resume of a key that already lives in the kernel +session keyring** (`agent.session_keyring`). A fingerprint yields only +*match / no-match*, never key material, so this is **not** a PIN-style at-rest +wrap and gives **no cryptographic protection** beyond `session_keyring`: + +- The keyring entry is **possessor-gated, not fingerprint-gated** — any process + in your login session can read it directly, bypassing Vault and the + fingerprint. So fingerprint unlock is **convenience + user-presence**, no + stronger than `session_keyring`, and **weaker than a master-password unlock** + (which derives the key fresh and never persists it). +- Real biometric-gated-at-rest protection would need TPM2/FIDO2 hardware + sealing — out of scope here. + +This is the same default-off PRD §7.3 / G4 carve-out as `session_keyring`, plus +a biometric gate on resume. Leave it off (the default) and nothing changes. + +## How it behaves + +With `agent.fingerprint_unlock` on (requires `agent.session_keyring`): + +- The agent **does not silently auto-resume** on restart / after idle-lock — it + stays locked until a verified touch. +- **Idle-lock** zeroises the in-memory key but **keeps** the keyring entry, so a + touch re-unlocks after an idle timeout. The entry's lifetime is + `agent.fingerprint_ttl_secs` (`0` = until logout / manual lock). +- **Manual `vault lock`** still clears the keyring — a fingerprint cannot bypass + an explicit lock; the master password is required afterwards. +- The agent verifies the finger **itself** (D-Bus → `fprintd`), so a client + can't bypass it by talking to the socket. + +## Setup + +```sh +# 1. Build/install an agent with the feature (Linux): +cargo install --path crates/vault-agent --features fingerprint --force + +# 2. Enroll a finger at the OS level (Vault never stores templates): +fprintd-enroll + +# 3. Enable the keyring store + the fingerprint gate: +vault config set agent.session_keyring true +vault config set agent.fingerprint_unlock true +# Optional: how long a touch can re-unlock after the last unlock (seconds); +# 0 = until logout / manual lock. +vault config set agent.fingerprint_ttl_secs 7200 +``` + +(The CLI auto-spawns the agent with `--session-keyring --fingerprint-unlock +--fingerprint-ttl-secs …` from these keys. A custom systemd unit must pass the +same flags.) + +## Use + +```sh +vault unlock # first unlock of the session: master password (+ 2FA) +# … later, after a restart or idle-lock: +vault unlock --fingerprint # touch the sensor → unlocked +``` + +In `vault-tui`, the unlock screen offers a **fingerprint** mode (cycle with +`Tab` when enabled): press `Enter`, then touch the sensor. + +## When it falls back + +`vault unlock --fingerprint` (and the TUI mode) report **"unavailable"** and you +use the master password (or PIN) instead when: + +- the agent was built without the `fingerprint` feature, or it's not Linux; +- there's no reader / `fprintd` / enrolled finger, or the session isn't active + (e.g. SSH — PolicyKit denies); +- no resumable keyring session remains (TTL elapsed, logout, manual lock, or + `session_keyring` is off). + +A finger that's read but doesn't match reports **"fingerprint not recognized"**. diff --git a/justfile b/justfile index 5dad543..3a15213 100644 --- a/justfile +++ b/justfile @@ -66,5 +66,11 @@ pqc: cargo build -p vault-agent --features pqc cargo test -p vault-api --features pqc +# Build + lint the off-by-default fingerprint-unlock feature (docs/fingerprint.md; +# Linux/fprintd). Mirrors what CI's feature check would run for it. +fingerprint: + cargo build -p vault-agent --features fingerprint + cargo clippy -p vault-agent --features fingerprint -- -D warnings + # Everything the CI runner checks, in order. Run before pushing. ci: fmt clippy test headless version-gate deny audit reuse