From 931b4c42a54e576d0b37f2865b4cfff5507dd962 Mon Sep 17 00:00:00 2001 From: Antoine Carnec Date: Fri, 19 Dec 2025 12:32:36 +0000 Subject: [PATCH 1/8] Add cryptographic primitives for PIN unlock using Argon2id and XChaCha20-Poly1305. --- Cargo.lock | 64 +++++++++ Cargo.toml | 4 +- src/error.rs | 3 + src/lib.rs | 2 + src/pin.rs | 1 + src/pin/crypto.rs | 344 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 src/pin.rs create mode 100644 src/pin/crypto.rs diff --git a/Cargo.lock b/Cargo.lock index bb3ed7ab..97c2dd50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,16 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -326,6 +336,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "cipher" version = "0.4.4" @@ -334,6 +368,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -477,6 +512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1531,6 +1567,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.2" @@ -1722,6 +1764,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.11.0" @@ -1934,6 +1987,7 @@ dependencies = [ "base64", "block-padding", "cbc", + "chacha20poly1305", "clap", "clap_complete", "clap_complete_fig", @@ -2927,6 +2981,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 2cc1eb04..29b200e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,10 +86,12 @@ zeroize = "1.8.1" arboard = { version = "3.5", default-features = false, features = [ "wayland-data-control", ], optional = true } +chacha20poly1305 = { version = "0.10.1", optional = true } [features] -default = ["clipboard"] +default = ["clipboard", "pin"] clipboard = ["arboard"] +pin = ["chacha20poly1305"] [lints.clippy] cargo = { level = "warn", priority = -1 } diff --git a/src/error.rs b/src/error.rs index b7789a92..cbd523ba 100644 --- a/src/error.rs +++ b/src/error.rs @@ -163,6 +163,9 @@ pub enum Error { #[error("pinentry error: {error}")] PinentryErrorMessage { error: String }, + #[error("PIN error: {message}")] + PinError { message: String }, + #[error("error reading pinentry output")] PinentryReadOutput { source: tokio::io::Error }, diff --git a/src/lib.rs b/src/lib.rs index b0f7c788..3c4a0869 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,3 +15,5 @@ mod prelude; pub mod protocol; pub mod pwgen; pub mod wordlist; +#[cfg(feature = "pin")] +pub mod pin; diff --git a/src/pin.rs b/src/pin.rs new file mode 100644 index 00000000..274f0edc --- /dev/null +++ b/src/pin.rs @@ -0,0 +1 @@ +pub mod crypto; diff --git a/src/pin/crypto.rs b/src/pin/crypto.rs new file mode 100644 index 00000000..7c06de13 --- /dev/null +++ b/src/pin/crypto.rs @@ -0,0 +1,344 @@ +#![cfg(feature = "pin")] +/* +This module implements cryptography operations relating to the PIN feature. + +PIN cryptography: derive a key-encryption key (KEK) from a user PIN and wrap/unwrap the +per-profile data-encryption keys (DEKs). + +# Overview +This module enables an optional low-entropy PIN to protect the locally-stored DEK material. +A KEK is derived using Argon2id from: +- the user PIN (may be absent), +- a device-local secret (`local_secret`) mixed in as an Argon2 "secret", +- a random salt, +- and caller-supplied Argon2 parameters. + +The derived KEK (32 bytes) is then used with XChaCha20-Poly1305 to wrap (`encrypt_in_place`) the +DEK bytes (concatenated `enc_key || mac_key`). The AEAD additional authenticated data (AAD) is +a `context` string that binds the wrapped keys to the intended profile (and org, for org keys). + +# Threat model / security properties +- Protects DEK material at rest against an attacker who reads storage but does not know the PIN. +- The `local_secret` strengthens the construction by device-binding the KDF input. Offline + guessing is infeasible without this secret. +*/ +use crate::error::{Error, Result}; +use crate::locked::{Keys, Password, Vec}; +use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, +}; +use std::collections::HashMap; + +use chacha20poly1305::{ + aead::{AeadCore, Buffer, KeyInit}, + AeadInPlace, XChaCha20Poly1305, XNonce, +}; +use serde::{Deserialize, Serialize}; + +pub const KEK_LEN: usize = 32; + +#[derive(Serialize, Deserialize, Clone)] +pub struct WrappedKey { + #[serde(with = "base64")] + wrapped_keys: std::vec::Vec, + #[serde(with = "base64")] + nonce: [u8; 24], + // Bind the key to the correct profile + context: String, +} + +impl WrappedKey { + pub fn bytes(&self) -> &[u8] { + self.wrapped_keys.as_slice() + } + pub fn new(wrapped_keys: std::vec::Vec, nonce: XNonce, context: String) -> Self { + Self { + wrapped_keys, + nonce: (*nonce.as_slice()).try_into().expect("XNonce is defined to be 24 bytes"), + context, + } + } + fn nonce(&self) -> XNonce { + (self.nonce).into() + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Argon2Params { + #[serde(rename = "argon2_memory")] + pub memory: u32, + #[serde(rename = "argon2_iterations")] + pub iterations: u32, + #[serde(rename = "argon2_parallelism")] + pub parallelism: u32, +} + +impl Argon2Params { + pub fn new() -> Self { + Self { + memory: 64 * 1024, + iterations: 3, + parallelism: 4, + } + } + pub fn to_params(&self) -> Result { + argon2::Params::new( + self.memory, + self.iterations, + self.parallelism, + Some(KEK_LEN), // Size of the derived key + ) + .map_err(|_| Error::Argon2) + } +} + +impl Default for Argon2Params { + fn default() -> Self { + Self::new() + } +} + +pub fn derive_kek_from_pin( + pin: Option<&Password>, + local_secret: &Vec, + salt: &SaltString, + kdf_params: &Argon2Params, +) -> Result { + let argon2_config = Argon2::new_with_secret( + local_secret.data(), + argon2::Algorithm::Argon2id, + argon2::Version::V0x13, + kdf_params.to_params()?, + ) + .map_err(|_| Error::Argon2)?; + + let mut pin_key = Vec::new(); + pin_key.extend(std::iter::repeat_n(0, KEK_LEN)); + + Argon2::hash_password_into( + &argon2_config, + pin.as_ref().map_or(&[], |pin| pin.password()), + salt.as_str().as_bytes(), + pin_key.data_mut(), + ) + .map_err(|_| Error::Argon2)?; + + Ok(pin_key) +} + +fn wrap_single_key( + cipher: &XChaCha20Poly1305, + keys: &Keys, + context: &String, +) -> Result { + let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); + + let ciphertext = { + let mut buf = Vec::new(); + buf.extend(keys.enc_key().iter().copied()); + buf.extend(keys.mac_key().iter().copied()); + + cipher + .encrypt_in_place(&nonce, context.as_bytes(), &mut buf) + .map_err(|e| Error::PinError { + message: e.to_string(), + })?; + + buf.data().to_vec() + }; + + Ok(WrappedKey::new(ciphertext, nonce, context.to_owned())) +} +pub fn wrap_dek( + pin_key: &Vec, + keys: &Keys, + org_keys: &HashMap, +) -> Result<(WrappedKey, HashMap)> { + let cipher = + XChaCha20Poly1305::new_from_slice(pin_key.data()).map_err(|_| { + Error::PinError { + message: "Kek has invalid length".to_string(), + } + })?; + + let context_string = + format!("pin-wrapped-dek|profile={}", crate::dirs::profile()); + let wrapped_keys = wrap_single_key(&cipher, keys, &context_string)?; + + let wrapped_org_keys: HashMap = org_keys + .iter() + .map(|(org, k)| { + let context_string_org = + format!("{}|org={}", context_string.clone(), org.as_str()); + wrap_single_key(&cipher, k, &context_string_org) + .map(|wk| (org.clone(), wk)) + }) + .collect::>()?; + + Ok((wrapped_keys, wrapped_org_keys)) +} + +// Need to implement the below traits +// in order to decrypt in place (to not have to allocate secret to an insecure buffer) +impl AsRef<[u8]> for Vec { + fn as_ref(&self) -> &[u8] { + self.data() + } +} + +impl AsMut<[u8]> for Vec { + fn as_mut(&mut self) -> &mut [u8] { + self.data_mut() + } +} +impl Buffer for Vec { + fn extend_from_slice( + &mut self, + other: &[u8], + ) -> chacha20poly1305::aead::Result<()> { + self.extend(other.iter().copied()); + Ok(()) + } + + fn truncate(&mut self, len: usize) { + self.truncate(len); + } +} + +fn unwrap_single_key( + cipher: &XChaCha20Poly1305, + wrapped_keys: &WrappedKey, +) -> Result { + let mut key = Vec::new(); + key.extend(wrapped_keys.bytes().to_vec().into_iter()); + + cipher + .decrypt_in_place( + &wrapped_keys.nonce(), + wrapped_keys.context.as_bytes(), + &mut key, + ) + .map_err(|_| Error::IncorrectPassword { + message: "incorrect pin".to_string(), + })?; + + Ok(Keys::new(key)) +} + +pub fn unwrap_dek( + pin_key: &Vec, + wrapped_keys: &WrappedKey, + wrapped_org_keys: &HashMap, +) -> Result<(Keys, HashMap)> { + let cipher = XChaCha20Poly1305::new_from_slice(pin_key.data()).map_err( + |_| Error::PinError { + message: + "invalid keylen; couldn't initialize chacha20poly1305 cipher" + .into(), + }, + )?; + + let keys = unwrap_single_key(&cipher, wrapped_keys)?; + + let wrapped_org_keys: HashMap = wrapped_org_keys + .iter() + .map(|(org, k)| { + unwrap_single_key(&cipher, k).map(|wk| (org.clone(), wk)) + }) + .collect::>()?; + + Ok((keys, wrapped_org_keys)) +} + +// Enables serde to (de)serialize with base64 +mod base64 { + use serde::{Deserialize, Serialize}; + use serde::{Deserializer, Serializer}; + + pub fn serialize>( + v: &T, + s: S, + ) -> Result { + let base64 = crate::base64::encode(v); + String::serialize(&base64, s) + } + + pub fn deserialize<'de, D, T>(d: D) -> Result + where + D: Deserializer<'de>, + T: TryFrom>, + { + let base64 = String::deserialize(d)?; + + let bytes: Vec = crate::base64::decode(base64.as_bytes()) + .map_err(serde::de::Error::custom)?; + + T::try_from(bytes).map_err(|_e| { + serde::de::Error::custom("Error deserializing pin state") + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_vec(bytes: &[u8]) -> Vec { + let mut vec = Vec::new(); + vec.extend(bytes.iter().copied()); + vec + } + + fn concat_key_bytes(a: &[u8], b: &[u8]) -> zeroize::Zeroizing> { + let mut out = + zeroize::Zeroizing::new(std::vec::Vec::with_capacity(a.len() + b.len())); + out.extend_from_slice(a); + out.extend_from_slice(b); + out + } + + #[test] + fn pin_encrypt_decrypt() { + let key_content = [b'0'; 64]; + let dek = Keys::new(create_vec(&key_content)); + + let org_keys: HashMap = + [("test_corp".to_string(), dek.clone())] + .into_iter() + .collect(); + + let pin = Password::new(create_vec(b"1234".as_ref())); + let local_secret = create_vec([b'0'; 32].as_ref()); + let salt = SaltString::generate(&mut OsRng); + let kdf_params = Argon2Params { + memory: 128, + iterations: 1, + parallelism: 1, + }; + let derived_kek = derive_kek_from_pin( + Some(&pin), + &local_secret, + &salt, + &kdf_params, + ) + .unwrap(); + + let (wrapped_dek, wrapped_org_keys) = + wrap_dek(&derived_kek, &dek, &org_keys).unwrap(); + + let (key_to_test, org_keys_to_test) = + unwrap_dek(&derived_kek, &wrapped_dek, &wrapped_org_keys) + .unwrap(); + + let key_to_test_raw = + concat_key_bytes(key_to_test.enc_key(), key_to_test.mac_key()); + assert_eq!(key_to_test_raw.as_slice(), &key_content); + + let key_to_test_raw2 = { + let key = org_keys_to_test.get("test_corp").unwrap(); + concat_key_bytes(key.enc_key(), key.mac_key()) + }; + assert_eq!(key_to_test_raw2.as_slice(), &key_content) + } +} From 996ba443ea01ce1919641754e503ff51c6def87c Mon Sep 17 00:00:00 2001 From: Antoine Carnec Date: Fri, 19 Dec 2025 13:08:48 +0000 Subject: [PATCH 2/8] Add backend abstraction layer with PinBackend trait and configuration types. --- src/pin.rs | 1 + src/pin/backend.rs | 138 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src/pin/backend.rs diff --git a/src/pin.rs b/src/pin.rs index 274f0edc..1d0f8594 100644 --- a/src/pin.rs +++ b/src/pin.rs @@ -1 +1,2 @@ +pub mod backend; pub mod crypto; diff --git a/src/pin/backend.rs b/src/pin/backend.rs new file mode 100644 index 00000000..61f34135 --- /dev/null +++ b/src/pin/backend.rs @@ -0,0 +1,138 @@ +#![cfg(feature = "pin")] + +use crate::pin::crypto::{Argon2Params, WrappedKey}; +use anyhow::{anyhow, Context}; +use argon2::password_hash::SaltString; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::path::PathBuf; + +#[derive(Serialize, Deserialize, clap::ValueEnum, Clone, Debug)] +pub enum Backend { + Age, + OSKeyring, +} + +pub trait BackendConfig {} + +pub trait PinBackend { + type Config: BackendConfig; + fn retrieve_local_secret( + &self, + config: &Self::Config, + ) -> anyhow::Result; + + fn store_local_secret( + &self, + kek: &crate::locked::Vec, + config: &Self::Config, + ) -> anyhow::Result<()>; + + fn clear_local_secret(&self) -> anyhow::Result<()>; +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct PinBackendConfig { + pub enable_pin: bool, + #[serde(flatten)] + pub kdf_params: Option, +} + +impl BackendConfig for PinBackendConfig {} + +impl Default for PinBackendConfig { + fn default() -> Self { + Self::new() + } +} + +impl PinBackendConfig { + pub fn new() -> Self { + Self { + enable_pin: false, + kdf_params: Some(Argon2Params::new()), + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct PinState { + wrapped_keys: WrappedKey, + wrapped_org_keys: HashMap, + salt: String, + kdf_params: Argon2Params, + pub empty_pin: bool, + pub backend: Backend, +} + +impl PinState { + pub fn new( + wrapped_keys: WrappedKey, + wrapped_org_keys: HashMap, + salt: &SaltString, + kdf_params: Argon2Params, + empty_pin: bool, + backend: Backend, + ) -> anyhow::Result { + let slf = Self { + wrapped_keys, + wrapped_org_keys, + salt: salt.to_string(), + kdf_params, + empty_pin, + backend, + }; + Ok(slf) + } + + pub fn unpack( + &self, + ) -> anyhow::Result<( + WrappedKey, + HashMap, + SaltString, + Argon2Params, + bool, + Backend, + )> { + Ok(( + self.wrapped_keys.clone(), + self.wrapped_org_keys.clone(), + { + match SaltString::from_b64(self.salt.as_str()) { + Ok(salt) => salt, + Err(e) => { + anyhow::bail!("Error deserializing salt: {}", e) + } + } + }, + self.kdf_params.clone(), + self.empty_pin, + self.backend.clone(), + )) + } + + pub fn read_from_file(path: PathBuf) -> anyhow::Result { + let file = std::fs::File::open(path) + .context("Could not open pin state file")?; + let reader = std::io::BufReader::new(file); + serde_json::from_reader(reader).map_err(|e| anyhow!(e)) + } + + pub fn write_to_file(&self, path: PathBuf) -> anyhow::Result<()> { + let file = std::fs::OpenOptions::new() + .create(true) + .mode(0o600) + .write(true) + .truncate(true) + .open(path)?; + + let mut writer = std::io::BufWriter::new(file); + serde_json::to_writer(&mut writer, self)?; + writer.flush()?; + writer.get_ref().sync_all()?; + Ok(()) + } +} From 225ebf820ecd1c8b53ffe142fe408f79e44413a4 Mon Sep 17 00:00:00 2001 From: Antoine Carnec Date: Fri, 19 Dec 2025 13:10:23 +0000 Subject: [PATCH 3/8] Implement age backend supporting plugin-based identities (yubikey, tpm, se). --- Cargo.lock | 505 ++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 3 +- src/dirs.rs | 10 + src/pin/backend.rs | 55 +++++ src/pin/backend/age.rs | 238 +++++++++++++++++++ 5 files changed, 804 insertions(+), 7 deletions(-) create mode 100644 src/pin/backend/age.rs diff --git a/Cargo.lock b/Cargo.lock index 97c2dd50..b8c3f24a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,52 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "age" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf640be7658959746f1f0f2faab798f6098a9436a8e18e148d18bc9875e13c4b" +dependencies = [ + "age-core", + "base64 0.21.7", + "bech32", + "chacha20poly1305", + "cookie-factory", + "hmac", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "nom", + "pin-project", + "rand 0.8.5", + "rust-embed", + "scrypt", + "sha2", + "subtle", + "which", + "wsl", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2bf6a89c984ca9d850913ece2da39e1d200563b0a94b002b253beee4c5acf99" +dependencies = [ + "base64 0.21.7", + "chacha20poly1305", + "cookie-factory", + "hkdf", + "io_tee", + "nom", + "rand 0.8.5", + "secrecy 0.10.3", + "sha2", + "tempfile", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -121,6 +167,12 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "argon2" version = "0.5.3" @@ -237,6 +289,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -249,6 +307,21 @@ version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + [[package]] name = "bitflags" version = "1.3.2" @@ -468,6 +541,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + [[package]] name = "core-foundation" version = "0.10.0" @@ -529,6 +611,7 @@ dependencies = [ "fiat-crypto", "rustc_version", "subtle", + "zeroize", ] [[package]] @@ -663,6 +746,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -749,12 +838,65 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml", +] + [[package]] name = "fixedbitset" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fluent" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb74634707bebd0ce645a981148e8fb8c7bccd4c33c652aeffd28bf2f96d555a" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "fnv" version = "1.0.7" @@ -960,6 +1102,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.3.1" @@ -1056,7 +1207,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -1074,6 +1225,72 @@ dependencies = [ "tracing", ] +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669ffc2c93f97e6ddf06ddbe999fcd6782e3342978bb85f7d3c087c7978404c4" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04b2969d0b3fc6143776c535184c19722032b43e6a642d710fa3f88faec53c2d" +dependencies = [ + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error2", + "proc-macro2", + "quote", + "strsim", + "syn", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -1201,6 +1418,25 @@ dependencies = [ "generic-array", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "io-uring" version = "0.7.10" @@ -1212,6 +1448,12 @@ dependencies = [ "libc", ] +[[package]] +name = "io_tee" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" + [[package]] name = "ipnet" version = "2.11.0" @@ -1725,6 +1967,26 @@ dependencies = [ "indexmap", ] +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1817,6 +2079,28 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -1846,7 +2130,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "socket2 0.5.9", "thiserror 2.0.16", @@ -1866,7 +2150,7 @@ dependencies = [ "lru-slab", "rand 0.9.2", "ring", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "rustls-pki-types", "slab", @@ -1978,13 +2262,14 @@ name = "rbw" version = "1.14.1" dependencies = [ "aes", + "age", "anyhow", "arboard", "argon2", "arrayvec", "axum", "base32", - "base64", + "base64 0.22.1", "block-padding", "cbc", "chacha20poly1305", @@ -2104,7 +2389,7 @@ version = "0.12.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -2204,12 +2489,52 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947d7f3fad52b283d261c4c99a084937e2fe492248cb9a68a8435a861b8798ca" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa2c8c9e8711e10f9c4fd2d64317ef13feaab820a4c51541f1a8c8e2e851ab2" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b161f275cb337fe0a44d924a5f4df0ed69c2c39519858f931ce61c779d3475" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -2299,6 +2624,24 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.27" @@ -2314,6 +2657,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + [[package]] name = "sec1" version = "0.7.3" @@ -2337,6 +2691,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "3.2.0" @@ -2360,6 +2723,21 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.2.1", +] + +[[package]] +name = "self_cell" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16c2f82143577edb4921b71ede051dac62ca3c16084e918bf7b40c96ae10eb33" + [[package]] name = "semver" version = "1.0.26" @@ -2554,7 +2932,7 @@ dependencies = [ "byteorder", "futures", "log", - "secrecy", + "secrecy 0.8.0", "service-binding", "signature", "ssh-encoding", @@ -2838,6 +3216,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "totp-rs" version = "5.7.0" @@ -2957,12 +3344,40 @@ dependencies = [ "utf-8", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.1", +] + [[package]] name = "typenum" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -3048,6 +3463,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3233,6 +3658,33 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.52.0" @@ -3251,6 +3703,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -3406,6 +3867,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "wsl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dab7ac864710bdea6594becbea5b5050333cf34fefb0dc319567eb347950d4" + [[package]] name = "x11rb" version = "0.13.1" @@ -3423,6 +3890,18 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "yoke" version = "0.8.0" @@ -3493,6 +3972,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 29b200e6..1ad0d576 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,11 +87,12 @@ arboard = { version = "3.5", default-features = false, features = [ "wayland-data-control", ], optional = true } chacha20poly1305 = { version = "0.10.1", optional = true } +age = { version = "0.11.1", features = ["armor", "plugin"], optional = true } [features] default = ["clipboard", "pin"] clipboard = ["arboard"] -pin = ["chacha20poly1305"] +pin = ["chacha20poly1305", "age"] [lints.clippy] cargo = { level = "warn", priority = -1 } diff --git a/src/dirs.rs b/src/dirs.rs index 079fc880..3ff8df6b 100644 --- a/src/dirs.rs +++ b/src/dirs.rs @@ -71,6 +71,16 @@ pub fn ssh_agent_socket_file() -> std::path::PathBuf { runtime_dir().join("ssh-agent-socket") } +#[cfg(feature = "pin")] +pub fn pin_age_wrapped_local_secret_file() -> std::path::PathBuf { + cache_dir().join(format!("{}-pin-wrapped-local-secret.age", &profile())) +} + +#[cfg(feature = "pin")] +pub fn pin_state_file() -> std::path::PathBuf { + cache_dir().join(format!("{}-pin-state.json", &profile())) +} + fn config_dir() -> std::path::PathBuf { let project_dirs = directories::ProjectDirs::from("", "", &profile()).unwrap(); diff --git a/src/pin/backend.rs b/src/pin/backend.rs index 61f34135..48d42b58 100644 --- a/src/pin/backend.rs +++ b/src/pin/backend.rs @@ -1,5 +1,8 @@ #![cfg(feature = "pin")] +pub mod age; + +use crate::pin::backend::age::{AgeConfig, AgePinBackend}; use crate::pin::crypto::{Argon2Params, WrappedKey}; use anyhow::{anyhow, Context}; use argon2::password_hash::SaltString; @@ -33,11 +36,62 @@ pub trait PinBackend { fn clear_local_secret(&self) -> anyhow::Result<()>; } +impl PinBackend for Backend { + type Config = PinBackendConfig; + fn retrieve_local_secret( + &self, + config: &PinBackendConfig, + ) -> anyhow::Result { + match self { + Self::Age => { + let config = config + .age + .as_ref() + .ok_or_else(|| anyhow!("age config not set"))?; + AgePinBackend.retrieve_local_secret(config) + } + Self::OSKeyring => { + anyhow::bail!("OSKeyring backend not yet implemented") + } + } + } + + fn store_local_secret( + &self, + kek: &crate::locked::Vec, + config: &PinBackendConfig, + ) -> anyhow::Result<()> { + match self { + Self::Age => { + let config = config + .age + .as_ref() + .ok_or_else(|| anyhow!("age config not set"))?; + AgePinBackend.store_local_secret(kek, config) + } + Self::OSKeyring => { + anyhow::bail!("OSKeyring backend not yet implemented") + } + } + } + + fn clear_local_secret(&self) -> anyhow::Result<()> { + match self { + Self::Age => AgePinBackend.clear_local_secret(), + Self::OSKeyring => { + anyhow::bail!("OSKeyring backend not yet implemented") + } + } + } +} + #[derive(Serialize, Deserialize, Debug)] pub struct PinBackendConfig { pub enable_pin: bool, #[serde(flatten)] pub kdf_params: Option, + #[serde(flatten)] + pub age: Option, } impl BackendConfig for PinBackendConfig {} @@ -53,6 +107,7 @@ impl PinBackendConfig { Self { enable_pin: false, kdf_params: Some(Argon2Params::new()), + age: Some(AgeConfig::new()), } } } diff --git a/src/pin/backend/age.rs b/src/pin/backend/age.rs new file mode 100644 index 00000000..30fa6eaf --- /dev/null +++ b/src/pin/backend/age.rs @@ -0,0 +1,238 @@ +use std::fs; +use std::io::{BufReader, Read, Write}; + +use crate::dirs; +use crate::locked::Vec; +use crate::pin; +use crate::pin::backend::{BackendConfig, PinBackend}; +use age::{plugin, Decryptor}; +use anyhow::{anyhow, Context}; +use serde::{Deserialize, Serialize}; +use std::fs::File; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +pub const SUPPORTED_AGE_PLUGINS: [&str; 3] = [ + "yubikey", // https://github.com/str4d/age-plugin-yubikey + "tpm", // https://github.com/Foxboron/age-plugin-tpm + "se", // https://github.com/remko/age-plugin-se +]; + +#[derive(Serialize, Deserialize)] +pub struct AgePinBackend; + +#[derive(Serialize, Deserialize, Debug)] +pub struct AgeConfig { + #[serde(rename = "age_identity_file_path")] + pub identity_file_path: PathBuf, +} + +impl Default for AgeConfig { + fn default() -> Self { + Self::new() + } +} + +impl AgeConfig { + pub fn new() -> Self { + Self { + identity_file_path: "".into(), + } + } + fn _validate(&self) -> anyhow::Result<()> { + match fs::exists::<&PathBuf>(&self.identity_file_path) { + Ok(_) => Ok(()), + Err(_) => Err(anyhow!("Age identity file not found")), + } + + // TODO check if the file is parseable and the plugin is supported + // this is enough for now + } +} + +impl BackendConfig for AgeConfig {} + +impl PinBackend for AgePinBackend { + type Config = AgeConfig; + fn retrieve_local_secret( + &self, + config: &AgeConfig, + ) -> anyhow::Result { + let age_file_path = dirs::pin_age_wrapped_local_secret_file(); + + let (identity, _) = age_identity(config).context("could not parse age identity")?; + let reader = BufReader::new(File::open(age_file_path)?); + let decryptor = Decryptor::new(reader)?; + + // let identities: [&dyn age::Identity; 1] = [&identity.as_ref()]; + let mut decrypted_reader = decryptor + .decrypt(std::iter::once(identity.as_ref())) + .context("Failed to decrypt age wrapped local secret")?; + + let mut kek = Vec::new(); + kek.extend(std::iter::repeat_n(0, pin::crypto::KEK_LEN)); + + decrypted_reader.read_exact(kek.data_mut())?; + + Ok(kek) + } + + + fn store_local_secret( + &self, + local_secret: &Vec, + config: &AgeConfig, + ) -> anyhow::Result<()> { + let (_, recipient) = age_identity(config)?; + let final_path = dirs::pin_age_wrapped_local_secret_file(); + let parent_dir = final_path.parent().context("No parent dir")?; + + let mut temp_file = tempfile::NamedTempFile::new_in(parent_dir)?; + fs::set_permissions(temp_file.path(), fs::Permissions::from_mode(0o600))?; + + let encryptor = age::Encryptor::with_recipients(std::iter::once(recipient.as_ref()))?; + + let mut writer = encryptor.wrap_output(temp_file.as_file_mut())?; + writer.write_all(local_secret.data())?; + writer.finish()?; + + temp_file.as_file().sync_all()?; + + // Atomic swap (replaces old file only if everything above succeeded) + temp_file.persist(final_path).map_err(|e| e.error)?; + + Ok(()) + } + + fn clear_local_secret(&self) -> anyhow::Result<()> { + fs::remove_file(dirs::pin_age_wrapped_local_secret_file()) + .context("Failed to remove the age wrapped local secret.")?; + Ok(()) + } +} + +fn age_identity( + config: &pin::backend::age::AgeConfig, +) -> anyhow::Result<(Box, Box)> { + let age_identity_str = fs::read_to_string(&config.identity_file_path)?; + let cleaned_string: String = age_identity_str + .lines() + .filter(|s| !s.trim_start().starts_with('#')) + .filter(|s| !s.trim().is_empty()) + .map(str::trim) + .collect::>() + .join("\n"); + + if let Ok(identity) = cleaned_string.parse::() { + if SUPPORTED_AGE_PLUGINS.iter().any(|&x| x == identity.plugin()) { + let id_plugin = plugin::IdentityPluginV1::new( + identity.plugin(), + std::slice::from_ref(&identity), + age::NoCallbacks, + ).context("Failed to initialize age plugin identity")?; + + let rec_plugin = plugin::RecipientPluginV1::new( + identity.plugin(), + &[], // no extra recipients + std::slice::from_ref(&identity), + age::NoCallbacks, + ).context("Failed to initialize age plugin recipient")?; + + return Ok((Box::new(id_plugin), Box::new(rec_plugin))); + } + anyhow::bail!("Plugin '{}' is not supported", identity.plugin()); + } + + // Fallback parse a regular age identity during tests + #[cfg(test)] + { + if let Ok(standard_id) = cleaned_string.parse::() { + let recipient = standard_id.to_public(); + return Ok((Box::new(standard_id), Box::new(recipient))); + } + } + + anyhow::bail!("Invalid age-plugin identity file") +} +#[cfg(test)] +mod tests { + use super::*; + + use crate::config::Config; + + const TEST_IDENTITY: &str = "AGE-SECRET-KEY-1J6CR00H6EZHNT6R7PP0RM2ADCV2F49Z32XFJLP89VGK6Z4NJRHYQV82S8U"; + + fn create_temp_file_of_contents( + contents: &[u8], + ) -> tempfile::NamedTempFile { + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(contents).unwrap(); + file + } + + fn create_vec(bytes: &[u8]) -> crate::locked::Vec { + let mut vec = crate::locked::Vec::new(); + vec.extend(bytes.iter().copied()); + vec + } + + #[test] + fn pin_age_parse_identity_file() { + let identity_file = + create_temp_file_of_contents(TEST_IDENTITY.as_bytes()); + + let pin_config = pin::backend::PinBackendConfig { + enable_pin: true, + keyring: None, + age: Some(AgeConfig { + identity_file_path: identity_file.path().into(), + }), + kdf_params: Some(pin::crypto::Argon2Params::new()), + }; + + match age_identity(&pin_config.age.unwrap()) { + Ok(_) => (), + Err(_) => assert!(false), + } + } + + #[test] + fn pin_age_plugin_store_retrieve() { + let identity_file = + create_temp_file_of_contents(TEST_IDENTITY.as_bytes()); + let config = Config { + email: None, + sso_id: None, + base_url: None, + identity_url: None, + ui_url: None, + notifications_url: None, + lock_timeout: 60 * 60 * 24, + sync_interval: 1000, + pinentry: "".to_string(), + client_cert_path: None, + device_id: None, + pin_config: Some(pin::backend::PinBackendConfig { + enable_pin: true, + keyring: None, + age: Some(AgeConfig { + identity_file_path: identity_file.path().into(), + }), + kdf_params: Some(pin::crypto::Argon2Params::new()), + }), + }; + + let dummy_kek = create_vec(&[b'0'; 32]); + + let backend = AgePinBackend; + + let age_config = config.pin_config.unwrap().age.unwrap(); + + backend.store_local_secret(&dummy_kek, &age_config).unwrap(); + + let decrypted_kek = + backend.retrieve_local_secret(&age_config).unwrap(); + + assert_eq!(decrypted_kek.data(), [b'0'; 32].as_ref()) + } +} From 9306735827cae8ce5aa52d1d72fc6577874a1515 Mon Sep 17 00:00:00 2001 From: Antoine Carnec Date: Fri, 19 Dec 2025 13:11:32 +0000 Subject: [PATCH 4/8] Implement OS keyring backend using native platform keychains. --- Cargo.lock | 44 ++++++++++++++++++++++++++++++++++---- Cargo.toml | 1 + src/pin/backend.rs | 21 +++++++++++++----- src/pin/backend/keyring.rs | 41 +++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 src/pin/backend/keyring.rs diff --git a/Cargo.lock b/Cargo.lock index b8c3f24a..8dad635c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,6 +550,16 @@ dependencies = [ "futures", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.0" @@ -681,7 +691,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1546,6 +1556,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "security-framework 2.11.1", + "security-framework 3.2.0", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2287,6 +2309,7 @@ dependencies = [ "hmac", "humantime", "is-terminal", + "keyring", "libc", "log", "open", @@ -2588,7 +2611,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.2.0", ] [[package]] @@ -2700,6 +2723,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.2.0" @@ -2707,7 +2743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" dependencies = [ "bitflags 2.9.0", - "core-foundation", + "core-foundation 0.10.0", "core-foundation-sys", "libc", "security-framework-sys", @@ -3676,7 +3712,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1ad0d576..b8eaf662 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,7 @@ arboard = { version = "3.5", default-features = false, features = [ ], optional = true } chacha20poly1305 = { version = "0.10.1", optional = true } age = { version = "0.11.1", features = ["armor", "plugin"], optional = true } +keyring = { version = "3.6.3", features = ["apple-native"] } [features] default = ["clipboard", "pin"] diff --git a/src/pin/backend.rs b/src/pin/backend.rs index 48d42b58..36fe0a88 100644 --- a/src/pin/backend.rs +++ b/src/pin/backend.rs @@ -1,8 +1,10 @@ #![cfg(feature = "pin")] pub mod age; +pub mod keyring; use crate::pin::backend::age::{AgeConfig, AgePinBackend}; +use crate::pin::backend::keyring::{KeyringConfig, KeyringPinBackend}; use crate::pin::crypto::{Argon2Params, WrappedKey}; use anyhow::{anyhow, Context}; use argon2::password_hash::SaltString; @@ -51,7 +53,11 @@ impl PinBackend for Backend { AgePinBackend.retrieve_local_secret(config) } Self::OSKeyring => { - anyhow::bail!("OSKeyring backend not yet implemented") + let config = config + .keyring + .as_ref() + .ok_or_else(|| anyhow!("keyring config not set"))?; + KeyringPinBackend.retrieve_local_secret(config) } } } @@ -70,7 +76,11 @@ impl PinBackend for Backend { AgePinBackend.store_local_secret(kek, config) } Self::OSKeyring => { - anyhow::bail!("OSKeyring backend not yet implemented") + let config = config + .keyring + .as_ref() + .ok_or_else(|| anyhow!("keyring config not set"))?; + KeyringPinBackend.store_local_secret(kek, config) } } } @@ -78,9 +88,7 @@ impl PinBackend for Backend { fn clear_local_secret(&self) -> anyhow::Result<()> { match self { Self::Age => AgePinBackend.clear_local_secret(), - Self::OSKeyring => { - anyhow::bail!("OSKeyring backend not yet implemented") - } + Self::OSKeyring => KeyringPinBackend.clear_local_secret(), } } } @@ -92,6 +100,8 @@ pub struct PinBackendConfig { pub kdf_params: Option, #[serde(flatten)] pub age: Option, + #[serde(flatten)] + pub keyring: Option, } impl BackendConfig for PinBackendConfig {} @@ -108,6 +118,7 @@ impl PinBackendConfig { enable_pin: false, kdf_params: Some(Argon2Params::new()), age: Some(AgeConfig::new()), + keyring: None, } } } diff --git a/src/pin/backend/keyring.rs b/src/pin/backend/keyring.rs new file mode 100644 index 00000000..eea12301 --- /dev/null +++ b/src/pin/backend/keyring.rs @@ -0,0 +1,41 @@ +use crate::pin::backend::{BackendConfig, PinBackend}; +use keyring::Entry; +use serde::{Deserialize, Serialize}; +use zeroize::{Zeroize, Zeroizing}; + +pub struct KeyringPinBackend; + +#[derive(Serialize, Deserialize, Debug)] +pub struct KeyringConfig {} +impl BackendConfig for KeyringConfig {} + +impl PinBackend for KeyringPinBackend { + type Config = KeyringConfig; + fn retrieve_local_secret( + &self, + _: &KeyringConfig, + ) -> anyhow::Result { + let entry = Entry::new("rbw", crate::dirs::profile().as_str())?; + let mut entry = entry.get_secret().map(Zeroizing::new)?; + let mut local_secret = crate::locked::Vec::new(); + local_secret.extend(entry.iter().copied()); + entry.zeroize(); + Ok(local_secret) + } + fn store_local_secret( + &self, + kek: &crate::locked::Vec, + _: &KeyringConfig, + ) -> anyhow::Result<()> { + let entry = Entry::new("rbw", crate::dirs::profile().as_str())?; + entry.set_secret(kek.data())?; + Ok(()) + } + + fn clear_local_secret(&self) -> anyhow::Result<()> { + let entry = Entry::new("rbw", crate::dirs::profile().as_str())?; + entry + .delete_credential() + .map_err(|_| anyhow::anyhow!("Could not delete credential")) + } +} From 7f8e1bd33324bcadd3f17c0dab1df3a7f4ebc880 Mon Sep 17 00:00:00 2001 From: Antoine Carnec Date: Fri, 19 Dec 2025 13:12:39 +0000 Subject: [PATCH 5/8] Add PIN flow orchestration (register, unlock, clear, status). --- src/config.rs | 4 ++ src/pin.rs | 1 + src/pin/flow.rs | 152 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 src/pin/flow.rs diff --git a/src/config.rs b/src/config.rs index 248c603c..acc55ae3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -22,6 +22,8 @@ pub struct Config { // backcompat, no longer generated in new configs #[serde(skip_serializing)] pub device_id: Option, + #[cfg(feature = "pin")] + pub pin_config: Option, } impl Default for Config { @@ -38,6 +40,8 @@ impl Default for Config { pinentry: default_pinentry(), client_cert_path: None, device_id: None, + #[cfg(feature = "pin")] + pin_config: Some(crate::pin::backend::PinBackendConfig::new()), } } } diff --git a/src/pin.rs b/src/pin.rs index 1d0f8594..4f8c2c28 100644 --- a/src/pin.rs +++ b/src/pin.rs @@ -1,2 +1,3 @@ pub mod backend; pub mod crypto; +pub mod flow; diff --git a/src/pin/flow.rs b/src/pin/flow.rs new file mode 100644 index 00000000..7ae13a7d --- /dev/null +++ b/src/pin/flow.rs @@ -0,0 +1,152 @@ +#![cfg(feature = "pin")] +use crate::config::Config; +use crate::error::Error; +use crate::locked::{Keys, Password, Vec}; +use crate::pin::backend::{Backend, PinBackend, PinState}; +use crate::pin::crypto::Argon2Params; +use crate::{dirs, error, pin}; +use anyhow::Context; +use argon2::password_hash::SaltString; +use rand::rngs::OsRng; +use rand::{CryptoRng, RngCore}; +use std::collections::HashMap; + +pub fn status() -> anyhow::Result<()> { + let state_exists = + std::fs::exists(dirs::pin_state_file()).is_ok_and(|b| b); + + let enabled_msg = format!("Pin enabled: {state_exists}"); + + let mut backend_msg = String::new(); + if let Ok(pin_state) = load_pin_state() { + let backend = pin_state.backend; + let backend_name = match backend { + Backend::Age => "age", + Backend::OSKeyring => "keyring", + }; + backend_msg.push_str(format!("Backend: {backend_name}").as_str()); + } + let parts = [enabled_msg, backend_msg]; + let msg = parts.join("\n"); + println!("{msg}"); + Ok(()) +} + +pub fn unlock_with_pin( + pin: Option<&Password>, + pin_state: &PinState, + config: Config, +) -> error::Result<(Keys, HashMap)> { + let (wrapped_key, wrapped_org_keys, salt, kdf_params, _, backend) = + pin_state.unpack().map_err(|_| Error::PinError { + message: "couldn't deserialize pin state".into(), + })?; + + let pin_config = config.pin_config.ok_or_else(|| Error::PinError { + message: "pin config not set".to_string(), + })?; + let local_secret = + backend.retrieve_local_secret(&pin_config).map_err(|e| { + Error::PinError { + message: format!("couldn't retrieve local secret: {}", e).into(), + } + })?; + + let kek = pin::crypto::derive_kek_from_pin( + pin, + &local_secret, + &salt, + &kdf_params, + )?; + + let (keys, org_keys) = + pin::crypto::unwrap_dek(&kek, &wrapped_key, &wrapped_org_keys)?; + + Ok((keys, org_keys)) +} + +pub fn register( + keys: &Keys, + org_keys: &HashMap, + pin: Option<&Password>, + config: &Config, + backend: Backend, +) -> anyhow::Result<()> { + let pin_config = config + .pin_config + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Pin Config not set"))?; + + pin_config + .enable_pin + .then_some(()) + .ok_or_else(|| anyhow::anyhow!("enable_pin not set in config"))?; + + let local_secret = generate_local_secret(OsRng); + backend.store_local_secret(&local_secret, pin_config)?; + + let default_kdf_params = Argon2Params::new(); + let kdf_params = if let Some(pin_config) = config.pin_config.as_ref() { + &pin_config.kdf_params.clone().unwrap_or(default_kdf_params) + } else { + &default_kdf_params + }; + + let salt = SaltString::generate(&mut OsRng); + let kek = pin::crypto::derive_kek_from_pin( + pin, + &local_secret, + &salt, + kdf_params, + )?; + + let (wrapped_keys, wrapped_org_keys) = + pin::crypto::wrap_dek(&kek, keys, org_keys)?; + + let state_to_save = PinState::new( + wrapped_keys, + wrapped_org_keys, + &salt, + kdf_params.clone(), + pin.is_none(), + backend, + )?; + + state_to_save.write_to_file(dirs::pin_state_file())?; + + Ok(()) +} + +pub fn clear() -> anyhow::Result<()> { + // Try to clear the secret if we can read the state. + if let Ok(state) = load_pin_state().context("reading pin state file") { + state + .backend + .clear_local_secret() + .context("clearing local secret")?; + } + + match std::fs::remove_file(dirs::pin_state_file()) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e).context("removing pin state file"), + } +} + +pub fn empty_pin() -> bool { + load_pin_state().map(|s| s.empty_pin).unwrap_or(false) +} + +// simply generate 32 bytes +fn generate_local_secret(mut rng: T) -> Vec { + let mut buf = Vec::new(); + buf.extend(std::iter::repeat_n(0, 32)); + rng.fill_bytes(buf.data_mut()); + buf +} + +pub fn load_pin_state() -> anyhow::Result { + let pin_state = PinState::read_from_file(dirs::pin_state_file())?; + + Ok(pin_state) +} From 443bc5566b84e3c7804cbd24934c963b2ab1e5e2 Mon Sep 17 00:00:00 2001 From: Antoine Carnec Date: Fri, 19 Dec 2025 13:17:39 +0000 Subject: [PATCH 6/8] Add CLI commands for PIN management (set, clear, status). --- src/bin/rbw-agent/agent.rs | 9 +++++++++ src/bin/rbw/actions.rs | 8 ++++++++ src/bin/rbw/commands.rs | 12 ++++++++++++ src/bin/rbw/main.rs | 17 +++++++++++++++++ src/pin.rs | 1 + src/pin/cli.rs | 34 ++++++++++++++++++++++++++++++++++ src/protocol.rs | 5 +++++ 7 files changed, 86 insertions(+) create mode 100644 src/pin/cli.rs diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index 1691ed51..680f34c0 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -182,6 +182,15 @@ async fn handle_request( crate::actions::version(sock).await?; false } + #[cfg(feature = "pin")] + rbw::protocol::Action::PinRegister { .. } => { + // Placeholder - actual implementation will be added in the next commit + sock.send(&rbw::protocol::Response::Error { + error: "PIN register action not yet implemented in agent".to_string(), + }) + .await?; + false + } }; let mut state = state.lock().await; diff --git a/src/bin/rbw/actions.rs b/src/bin/rbw/actions.rs index 787ddcfb..c7a4e072 100644 --- a/src/bin/rbw/actions.rs +++ b/src/bin/rbw/actions.rs @@ -14,6 +14,14 @@ pub fn unlock() -> anyhow::Result<()> { simple_action(rbw::protocol::Action::Unlock) } +#[cfg(feature = "pin")] +pub fn register_pin( + empty_pin: bool, + backend: rbw::pin::backend::Backend, +) -> anyhow::Result<()> { + simple_action(rbw::protocol::Action::PinRegister { empty_pin, backend }) +} + pub fn unlocked() -> anyhow::Result<()> { match crate::sock::Sock::connect() { Ok(mut sock) => { diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index bb49cf2d..7715e7b0 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1047,6 +1047,18 @@ pub fn unlock() -> anyhow::Result<()> { Ok(()) } +#[cfg(feature = "pin")] +pub fn register_pin( + empty_pin: bool, + backend: rbw::pin::backend::Backend, +) -> anyhow::Result<()> { + ensure_agent()?; + crate::actions::login()?; + crate::actions::unlock()?; + crate::actions::register_pin(empty_pin, backend)?; + Ok(()) +} + pub fn unlocked() -> anyhow::Result<()> { // ensure_agent()?; crate::actions::unlocked()?; diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs index 47c1adc4..230b842a 100644 --- a/src/bin/rbw/main.rs +++ b/src/bin/rbw/main.rs @@ -235,6 +235,13 @@ enum Opt { about = "Generate completion script for the given shell" )] GenCompletions { shell: CompletionShell }, + + #[cfg(feature = "pin")] + #[command(about = "Manage local PIN unlock")] + Pin { + #[command(subcommand)] + cmd: rbw::pin::cli::Pin, + }, } impl Opt { @@ -261,6 +268,8 @@ impl Opt { Self::Purge => "purge".to_string(), Self::StopAgent => "stop-agent".to_string(), Self::GenCompletions { .. } => "gen-completions".to_string(), + #[cfg(feature = "pin")] + Self::Pin { cmd } => format!("pin {}", cmd.subcommand_name()), } } } @@ -447,6 +456,14 @@ fn main() { Opt::Lock => commands::lock(), Opt::Purge => commands::purge(), Opt::StopAgent => commands::stop_agent(), + #[cfg(feature = "pin")] + Opt::Pin { cmd } => match cmd { + rbw::pin::cli::Pin::Set { empty_pin, backend } => { + commands::register_pin(empty_pin, backend.clone()) + } + rbw::pin::cli::Pin::Clear => rbw::pin::flow::clear(), + rbw::pin::cli::Pin::Status => rbw::pin::flow::status(), + }, Opt::GenCompletions { shell } => { match shell { CompletionShell::Bash => { diff --git a/src/pin.rs b/src/pin.rs index 4f8c2c28..51d1ce1e 100644 --- a/src/pin.rs +++ b/src/pin.rs @@ -1,3 +1,4 @@ pub mod backend; +pub mod cli; pub mod crypto; pub mod flow; diff --git a/src/pin/cli.rs b/src/pin/cli.rs new file mode 100644 index 00000000..0dbf4862 --- /dev/null +++ b/src/pin/cli.rs @@ -0,0 +1,34 @@ +#[cfg(feature = "pin")] +#[derive(Debug, clap::Parser)] +pub enum Pin { + #[command(about = "Set up the PIN for local unlock")] + Set { + #[arg( + long, + default_value_t = false + )] + /// Whether to allow using an empty pin. + /// + /// Only recommended for a device + user input bound local secret + /// e.g Using age backend with the plugins `yubikey, se` + empty_pin: bool, + #[arg(long, value_enum, help = "Backend to store local_secret")] + backend: crate::pin::backend::Backend, + }, + #[command(about = "Clear the PIN")] + Clear, + + #[command(about = "Show status of PIN")] + Status, +} + +impl Pin { + pub fn subcommand_name(&self) -> String { + match self { + Self::Set { .. } => "set", + Self::Status => "status", + Self::Clear => "clear", + } + .to_string() + } +} diff --git a/src/protocol.rs b/src/protocol.rs index 03160754..49c47b77 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -183,6 +183,11 @@ pub enum Action { }, Quit, Version, + #[cfg(feature = "pin")] + PinRegister { + empty_pin: bool, + backend: crate::pin::backend::Backend, + }, } #[derive(serde::Serialize, serde::Deserialize, Debug)] From 6398984802726de35e915b732887baee5176f4df Mon Sep 17 00:00:00 2001 From: Antoine Carnec Date: Fri, 19 Dec 2025 13:20:58 +0000 Subject: [PATCH 7/8] Implement PIN unlock in agent with automatic fallback to master password. --- src/bin/rbw-agent/actions.rs | 81 ++++++++++++++++++++++++++++++++++++ src/bin/rbw-agent/agent.rs | 15 ++++--- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs index 4789b18b..92427565 100644 --- a/src/bin/rbw-agent/actions.rs +++ b/src/bin/rbw-agent/actions.rs @@ -378,6 +378,44 @@ async fn unlock_state( environment: &rbw::protocol::Environment, ) -> anyhow::Result<()> { if state.lock().await.needs_unlock() { + #[cfg(feature = "pin")] + { + // Try PIN unlock first if PIN is configured + let config = rbw::config::Config::load_async().await?; + if let Some(_pin_config) = &config.pin_config { + match rbw::pin::flow::load_pin_state() { + Ok(pin_state) => { + let pin = rbw::pinentry::getpin( + &config.pinentry, + "PIN", + &format!( + "Unlock the local database for '{}'", + rbw::dirs::profile() + ), + None, + environment, + true, + ) + .await + .context("failed to read PIN from pinentry")?; + + match rbw::pin::flow::unlock_with_pin(Some(&pin), &pin_state, config) { + Ok((keys, org_keys)) => { + unlock_success(state, keys, org_keys).await?; + return Ok(()); + } + Err(_) => { + // PIN unlock failed, fall through to master password + } + } + } + Err(_) => { + // PIN not configured, fall through to master password + } + } + } + } + let db = load_db().await?; let Some(kdf) = db.kdf else { @@ -820,6 +858,49 @@ async fn config_pinentry() -> anyhow::Result { Ok(config.pinentry) } +#[cfg(feature = "pin")] +pub async fn pin_register( + sock: &mut crate::sock::Sock, + state: std::sync::Arc>, + empty_pin: bool, + backend: rbw::pin::backend::Backend, + environment: &rbw::protocol::Environment, +) -> anyhow::Result<()> { + let config = rbw::config::Config::load_async().await?; + + let (keys, org_keys) = { + let state_guard = state.lock().await; + if state_guard.needs_unlock() { + return Err(anyhow::anyhow!("agent is locked")); + } + + let keys = state_guard.priv_key.as_ref().unwrap().clone(); + let org_keys = state_guard.org_keys.as_ref().unwrap().clone(); + (keys, org_keys) + }; + + let pin = if empty_pin { + None + } else { + Some(rbw::pinentry::getpin( + &config.pinentry, + "PIN", + "Enter PIN for quick unlock", + None, + environment, + true, + ) + .await + .context("failed to read PIN from pinentry")?) + }; + + rbw::pin::flow::register(&keys, &org_keys, pin.as_ref(), &config, backend)?; + + respond_ack(sock).await?; + + Ok(()) +} + pub async fn subscribe_to_notifications( state: std::sync::Arc>, ) -> anyhow::Result<()> { diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs index 680f34c0..fe8b560f 100644 --- a/src/bin/rbw-agent/agent.rs +++ b/src/bin/rbw-agent/agent.rs @@ -183,13 +183,16 @@ async fn handle_request( false } #[cfg(feature = "pin")] - rbw::protocol::Action::PinRegister { .. } => { - // Placeholder - actual implementation will be added in the next commit - sock.send(&rbw::protocol::Response::Error { - error: "PIN register action not yet implemented in agent".to_string(), - }) + rbw::protocol::Action::PinRegister { empty_pin, backend } => { + crate::actions::pin_register( + sock, + state.clone(), + *empty_pin, + backend.clone(), + &environment, + ) .await?; - false + true } }; From 4448bd693e2c843047cf0c846ff0825cd8910d32 Mon Sep 17 00:00:00 2001 From: Antoine Carnec Date: Fri, 19 Dec 2025 13:35:59 +0000 Subject: [PATCH 8/8] Add PIN feature documentation to README. --- README.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/README.md b/README.md index d8a96635..e05e0333 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,71 @@ between by using the `RBW_PROFILE` environment variable. Setting it to a name switch between several different vaults - each will use its own separate configuration, local vault, and agent. +## Quick Unlock (PIN) + +`rbw` supports unlocking your vault with a short PIN instead of your master password. The PIN protects a device-bound local secret that is used to encrypt your vault keys. This provides quick access while maintaining security: an attacker who gains read access to your storage cannot decrypt your vault without both the PIN and the device-bound local secret. + +### Supported Backends + +Two backend options are available for storing the local secret: + +#### `age` Backend + +Uses [age](https://github.com/FiloSottile/age) encryption with plugin-based identities. Only age plugins are supported (not regular age identities) to ensure the local secret is bound to hardware. + +**Supported age plugins:** +- [`age-plugin-yubikey`](https://github.com/str4d/age-plugin-yubikey) - Uses YubiKey PIV +- [`age-plugin-tpm`](https://github.com/Foxboron/age-plugin-tpm) - Uses TPM 2.0 +- [`age-plugin-se`](https://github.com/remko/age-plugin-se) - Uses macOS/iOS Secure Enclave + +**Setup:** + +1. Install your chosen age plugin and generate an identity +2. Configure the path to the identity file in rbw: + ```sh + rbw config set age_identity_file_path /path/to/your/age/identity.txt + ``` +3. Register the PIN: + ```sh + rbw pin set --backend age + ``` + +#### `os-keyring` Backend + +Uses your operating system's native keyring (macOS Keychain, Windows Credential Manager, or Linux Secret Service). + +**Setup:** +```sh +rbw pin set --backend os-keyring +``` + +### Usage + +Once configured, `rbw unlock` will prompt for your PIN instead of your master password. If PIN unlock fails, it automatically falls back to the master password. + +**Manage PIN:** +```sh +# Set up PIN with a backend +rbw pin set --backend age +rbw pin set --backend os-keyring + +# Check PIN status +rbw pin status + +# Remove PIN +rbw pin clear +``` + +### Empty PIN with Hardware Tokens + +For hardware token workflows where the security comes entirely from the hardware (e.g., YubiKey requiring touch), you can use an empty PIN: + +```sh +rbw pin set --backend age --empty-pin +``` + +This skips PIN entry but still requires the hardware token for unlock. + ## Usage Commands can generally be used directly, and will handle logging in or