mirror of
				https://github.com/Kruhlmann/punlock.git
				synced 2025-10-29 23:03:33 +00:00 
			
		
		
		
	Compare commits
	
		
			4 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 08e3b6025a | |||
| 4ce91d617f | |||
| 993da22357 | |||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | f7543b2e6c | 
							
								
								
									
										1676
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1676
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										20
									
								
								Cargo.toml
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								Cargo.toml
									
									
									
									
									
								
							| @@ -8,33 +8,21 @@ name = "punlock" | |||||||
| path = "src/lib.rs" | path = "src/lib.rs" | ||||||
|  |  | ||||||
| [dependencies] | [dependencies] | ||||||
| aes-gcm = "0.10.3" |  | ||||||
| anyhow = "1.0.98" | anyhow = "1.0.98" | ||||||
| argon2 = "0.5.3" | caps = "0.5.5" | ||||||
| async-trait = "0.1.88" | cfg-if = "1.0.0" | ||||||
| base64 = "0.22.1" |  | ||||||
| clap = { version = "4.5.37", features = ["derive"] } | clap = { version = "4.5.37", features = ["derive"] } | ||||||
| dialog = "0.3.0" |  | ||||||
| directories = "6.0.0" | directories = "6.0.0" | ||||||
| dirs = "6.0.0" | dirs = "6.0.0" | ||||||
| futures = "0.3.31" | futures = "0.3.31" | ||||||
| hex = "0.4.3" |  | ||||||
| hkdf = "0.12.4" |  | ||||||
| hmac = "0.12.1" |  | ||||||
| jmespath = "0.3.0" | jmespath = "0.3.0" | ||||||
| lazy_static = "1.5.0" | lazy_static = "1.5.0" | ||||||
| pbkdf2 = "0.12.2" |  | ||||||
| regex = "1.11.1" | regex = "1.11.1" | ||||||
| reqwest = { version = "0.12.15", features = ["json"] } |  | ||||||
| rpassword = "7.4.0" | rpassword = "7.4.0" | ||||||
| serde = { version = "1.0.219", features = ["derive"] } | serde = { version = "1.0.219", features = ["derive"] } | ||||||
| serde_json = "1.0.140" | serde_json = "1.0.140" | ||||||
| sha2 = "0.10.9" |  | ||||||
| tokio = { version = "1.44.2", features = ["full"] } | tokio = { version = "1.44.2", features = ["full"] } | ||||||
| toml = "0.8.20" | toml = "0.8.20" | ||||||
| tracing = "0.1.41" | tracing = "0.1.41" | ||||||
| tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } | tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } | ||||||
| urlencode = "1.0.1" | users = "0.11.0" | ||||||
|  |  | ||||||
| [target.x86_64-pc-windows-gnu] |  | ||||||
| linker = "x86_64-w64-mingw32-gcc" |  | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ | |||||||
|  |  | ||||||
| pkgs.mkShell { | pkgs.mkShell { | ||||||
|   buildInputs = [ |   buildInputs = [ | ||||||
|  |     pkgs.cargo-llvm-cov | ||||||
|     pkgs.openssl |     pkgs.openssl | ||||||
|     pkgs.pkg-config |     pkgs.pkg-config | ||||||
|     pkgs.rust-analyzer |     pkgs.rust-analyzer | ||||||
|   | |||||||
							
								
								
									
										127
									
								
								src/bitwarden.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								src/bitwarden.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,127 @@ | |||||||
|  | use std::process::Stdio; | ||||||
|  |  | ||||||
|  | use tokio::process::Command; | ||||||
|  |  | ||||||
|  | use crate::{config::PunlockConfigurationEntry, email::Email}; | ||||||
|  |  | ||||||
|  | pub struct Bitwarden<S> { | ||||||
|  |     pub email: Email, | ||||||
|  |     pub session: S, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Bitwarden<()> { | ||||||
|  |     pub fn new(email: Email) -> Self { | ||||||
|  |         Self { email, session: () } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn authenticate(self, domain: Option<String>) -> anyhow::Result<Bitwarden<String>> { | ||||||
|  |         Command::new("bw") | ||||||
|  |             .args(["logout"]) | ||||||
|  |             .stdout(Stdio::null()) | ||||||
|  |             .stderr(Stdio::null()) | ||||||
|  |             .status() | ||||||
|  |             .await | ||||||
|  |             .inspect(|_| tracing::debug!("spawn logout")) | ||||||
|  |             .inspect_err(|error| tracing::error!(?error, "spawn logout")) | ||||||
|  |             .ok(); | ||||||
|  |         if let Some(ref d) = domain { | ||||||
|  |             Command::new("bw") | ||||||
|  |                 .args(["config", "server", &d]) | ||||||
|  |                 .stdout(Stdio::null()) | ||||||
|  |                 .stderr(Stdio::null()) | ||||||
|  |                 .status() | ||||||
|  |                 .await | ||||||
|  |                 .inspect(|_| tracing::debug!(?domain, "spawn config server")) | ||||||
|  |                 .inspect_err(|error| tracing::error!(?error, ?domain, "spawn config server")) | ||||||
|  |                 .ok(); | ||||||
|  |         } | ||||||
|  |         loop { | ||||||
|  |             let prompt = match domain { | ||||||
|  |                 Some(ref d) => format!("Enter password for bitwarden[{d}] user {}: ", self.email), | ||||||
|  |                 None => format!("Enter password for bitwarden user {}: ", self.email), | ||||||
|  |             }; | ||||||
|  |             let password = rpassword::prompt_password(prompt) | ||||||
|  |                 .inspect_err(|error| tracing::error!(?error, "read password")) | ||||||
|  |                 .unwrap_or("".to_string()); | ||||||
|  |             if password.trim().is_empty() { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             let out = Command::new("bw") | ||||||
|  |                 .args(["login", self.email.as_ref(), &password, "--raw"]) | ||||||
|  |                 .stdout(Stdio::piped()) | ||||||
|  |                 .stderr(Stdio::piped()) | ||||||
|  |                 .output() | ||||||
|  |                 .await | ||||||
|  |                 .inspect(|_| tracing::debug!("spawn login")) | ||||||
|  |                 .inspect_err(|error| tracing::error!(?error, "spawn login"))?; | ||||||
|  |  | ||||||
|  |             if !out.status.success() { | ||||||
|  |                 let error = String::from_utf8_lossy(&out.stderr); | ||||||
|  |                 tracing::error!(?error, "bitwarden error"); | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             let session = String::from_utf8_lossy(&out.stdout).trim().to_string(); | ||||||
|  |             if session.is_empty() { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |             return Ok(Bitwarden::<String> { | ||||||
|  |                 email: self.email, | ||||||
|  |                 session, | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Bitwarden<String> { | ||||||
|  |     pub async fn fetch(&self, entry: &PunlockConfigurationEntry) -> anyhow::Result<String> { | ||||||
|  |         let bw = Command::new("bw") | ||||||
|  |             .args(["get", "item", &entry.id, "--session", &self.session]) | ||||||
|  |             .stdout(Stdio::piped()) | ||||||
|  |             .stderr(Stdio::piped()) | ||||||
|  |             .spawn() | ||||||
|  |             .inspect_err(|error| tracing::error!(?error, "spawn get"))?; | ||||||
|  |  | ||||||
|  |         let output = bw | ||||||
|  |             .wait_with_output() | ||||||
|  |             .await | ||||||
|  |             .inspect_err(|error| tracing::error!(?error, "spawn get"))?; | ||||||
|  |  | ||||||
|  |         if !output.status.success() { | ||||||
|  |             let err = String::from_utf8_lossy(&output.stderr); | ||||||
|  |             anyhow::bail!("`bw get item` failed: {}", err.trim()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let data: serde_json::Value = serde_json::from_slice(&output.stdout) | ||||||
|  |             .inspect_err(|error| tracing::error!(?error, json = ?output.stdout, "invalid json"))?; | ||||||
|  |  | ||||||
|  |         let expr = jmespath::compile(&entry.query) | ||||||
|  |             .inspect_err(|error| tracing::error!(?error, ?entry, "input/expression mismatch"))?; | ||||||
|  |         let result = expr | ||||||
|  |             .search(&data) | ||||||
|  |             .inspect_err(|error| tracing::error!(?error, ?expr, "query failed to apply"))?; | ||||||
|  |  | ||||||
|  |         let secret = match result.as_string() { | ||||||
|  |             Some(s) => s.to_owned(), | ||||||
|  |             None => { | ||||||
|  |                 tracing::error!(?data, ?expr, "find secret"); | ||||||
|  |                 anyhow::bail!("invalid item") | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         Ok(secret) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn logout(&self) -> anyhow::Result<()> { | ||||||
|  |         Command::new("bw") | ||||||
|  |             .args(["logout", "--session", &self.session]) | ||||||
|  |             .stdout(Stdio::null()) | ||||||
|  |             .stderr(Stdio::null()) | ||||||
|  |             .status() | ||||||
|  |             .await | ||||||
|  |             .inspect(|_| tracing::debug!("spawn logout")) | ||||||
|  |             .inspect_err(|error| tracing::error!(?error, "spawn logout"))?; | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,29 +0,0 @@ | |||||||
| use serde::Deserialize; |  | ||||||
|  |  | ||||||
| #[derive(Clone, Deserialize)] |  | ||||||
| pub struct BitwardenToken { |  | ||||||
|     #[serde(rename = "Kdf")] |  | ||||||
|     pub kdf: i64, |  | ||||||
|     #[serde(rename = "KdfIterations")] |  | ||||||
|     pub kdf_iterations: u32, |  | ||||||
|     #[serde(rename = "KdfMemory")] |  | ||||||
|     pub kdf_memory: Option<i64>, |  | ||||||
|     #[serde(rename = "KdfParallelism")] |  | ||||||
|     pub kdf_parallelism: Option<String>, |  | ||||||
|     #[serde(rename = "Key")] |  | ||||||
|     pub key: String, |  | ||||||
|     #[serde(rename = "PrivateKey")] |  | ||||||
|     pub private_key: String, |  | ||||||
|     #[serde(rename = "ResetMasterPassword")] |  | ||||||
|     pub reset_master_password: bool, |  | ||||||
|     pub access_token: String, |  | ||||||
|     pub expires_in: usize, |  | ||||||
|     pub scope: String, |  | ||||||
|     pub token_type: String, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[async_trait::async_trait] |  | ||||||
| pub trait BitwardenApi { |  | ||||||
|     async fn get_item(&self, id: &str) -> anyhow::Result<serde_json::Value>; |  | ||||||
|     fn get_token(&self) -> &BitwardenToken; |  | ||||||
| } |  | ||||||
| @@ -1,70 +0,0 @@ | |||||||
| use crate::bw::BitwardenApi; |  | ||||||
| use crate::bw::BitwardenToken; |  | ||||||
| use crate::data::BitwardenClientCredentials; |  | ||||||
| use crate::data::Domain; |  | ||||||
| use crate::data::Email; |  | ||||||
|  |  | ||||||
| use super::BitwardenUrl; |  | ||||||
|  |  | ||||||
| pub struct BitwardenHttpClient { |  | ||||||
|     token: BitwardenToken, |  | ||||||
|     client: reqwest::Client, |  | ||||||
|     url: BitwardenUrl, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| impl BitwardenHttpClient { |  | ||||||
|     pub async fn new( |  | ||||||
|         email: &Email, |  | ||||||
|         credentials: &BitwardenClientCredentials, |  | ||||||
|         domain: Domain, |  | ||||||
|     ) -> anyhow::Result<BitwardenHttpClient> { |  | ||||||
|         tracing::debug!(?email, ?domain, "logging in"); |  | ||||||
|         let url = BitwardenUrl(domain); |  | ||||||
|         let client = reqwest::Client::new(); |  | ||||||
|         let params = [ |  | ||||||
|             ("grant_type", "client_credentials"), |  | ||||||
|             ("scope", "api"), |  | ||||||
|             ("client_id", &credentials.id), |  | ||||||
|             ("client_secret", &credentials.secret), |  | ||||||
|             ("device_identifier", env!("CARGO_PKG_NAME")), |  | ||||||
|             ("device_type", env!("CARGO_PKG_NAME")), |  | ||||||
|             ("device_name", env!("CARGO_PKG_NAME")), |  | ||||||
|         ]; |  | ||||||
|         let token = client |  | ||||||
|             .post(url.as_identity_url()) |  | ||||||
|             .form(¶ms) |  | ||||||
|             .send() |  | ||||||
|             .await? |  | ||||||
|             .error_for_status() |  | ||||||
|             .inspect_err(|error| tracing::error!(?error, "http error"))? |  | ||||||
|             .json::<BitwardenToken>() |  | ||||||
|             .await |  | ||||||
|             .inspect_err(|error| tracing::error!(?error, "deserialization error"))?; |  | ||||||
|  |  | ||||||
|         tracing::debug!(?email, ?url, "login successful"); |  | ||||||
|         let bitwarden_client = Self { client, token, url }; |  | ||||||
|         Ok(bitwarden_client) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[async_trait::async_trait] |  | ||||||
| impl BitwardenApi for BitwardenHttpClient { |  | ||||||
|     async fn get_item(&self, id: &str) -> anyhow::Result<serde_json::Value> { |  | ||||||
|         tracing::debug!(?id, "request item"); |  | ||||||
|         let response = self |  | ||||||
|             .client |  | ||||||
|             .get(self.url.as_cipher_url(id)) |  | ||||||
|             .bearer_auth(&self.token.access_token) |  | ||||||
|             .send() |  | ||||||
|             .await? |  | ||||||
|             .error_for_status()? |  | ||||||
|             .json::<serde_json::Value>() |  | ||||||
|             .await?; |  | ||||||
|         tracing::debug!(?id, ?response, "item retrieved"); |  | ||||||
|         Ok(response) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     fn get_token(&self) -> &BitwardenToken { |  | ||||||
|         &self.token |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,21 +0,0 @@ | |||||||
| pub struct EncryptedBitwardenItem(pub String); |  | ||||||
|  |  | ||||||
| impl<T> From<T> for EncryptedBitwardenItem |  | ||||||
| where |  | ||||||
|     T: AsRef<str>, |  | ||||||
| { |  | ||||||
|     fn from(value: T) -> Self { |  | ||||||
|         Self(value.as_ref().to_string()) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| impl Into<String> for EncryptedBitwardenItem { |  | ||||||
|     fn into(self) -> String { |  | ||||||
|         self.0.clone() |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[async_trait::async_trait] |  | ||||||
| pub trait Bitwarden { |  | ||||||
|     async fn get_item(&self, id: &str, query: &str) -> anyhow::Result<EncryptedBitwardenItem>; |  | ||||||
| } |  | ||||||
| @@ -1,126 +0,0 @@ | |||||||
| use std::process::Stdio; |  | ||||||
|  |  | ||||||
| use tokio::process::Command; |  | ||||||
|  |  | ||||||
| use super::{bitwarden::EncryptedBitwardenItem, Bitwarden}; |  | ||||||
| use crate::data::{Email, Password}; |  | ||||||
|  |  | ||||||
| enum BitwardenCliCommand<'a> { |  | ||||||
|     SetDomain(&'a str), |  | ||||||
|     GetItem(&'a str, &'a str), |  | ||||||
|     Login(&'a str, &'a str), |  | ||||||
| } |  | ||||||
|  |  | ||||||
| impl BitwardenCliCommand<'_> { |  | ||||||
|     fn create_process(self) -> tokio::process::Command { |  | ||||||
|         match self { |  | ||||||
|             BitwardenCliCommand::GetItem(id, session) => { |  | ||||||
|                 let mut cmd = Command::new("bw"); |  | ||||||
|                 cmd.args(["get", "item", &id, "--session", &session]) |  | ||||||
|                     .stdout(Stdio::piped()) |  | ||||||
|                     .stderr(Stdio::piped()); |  | ||||||
|                 cmd |  | ||||||
|             } |  | ||||||
|             BitwardenCliCommand::SetDomain(domain) => { |  | ||||||
|                 let mut cmd = Command::new("bw"); |  | ||||||
|                 cmd.args(["config", "server", &domain]) |  | ||||||
|                     .stdout(Stdio::null()) |  | ||||||
|                     .stderr(Stdio::null()); |  | ||||||
|                 cmd |  | ||||||
|             } |  | ||||||
|             BitwardenCliCommand::Login(email, password) => { |  | ||||||
|                 let mut cmd = Command::new("bw"); |  | ||||||
|                 cmd.args(["login", &email, &password, "--raw"]) |  | ||||||
|                     .stdout(Stdio::piped()) |  | ||||||
|                     .stderr(Stdio::piped()); |  | ||||||
|                 cmd |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| pub struct BitwardenCli<S> { |  | ||||||
|     email: Email, |  | ||||||
|     session: S, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| impl BitwardenCli<()> { |  | ||||||
|     pub fn new(email: Email) -> Self { |  | ||||||
|         Self { email, session: () } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub async fn authenticate( |  | ||||||
|         self, |  | ||||||
|         domain: Option<String>, |  | ||||||
|     ) -> anyhow::Result<BitwardenCli<String>> { |  | ||||||
|         if let Some(ref d) = domain { |  | ||||||
|             BitwardenCliCommand::SetDomain(d) |  | ||||||
|                 .create_process() |  | ||||||
|                 .status() |  | ||||||
|                 .await |  | ||||||
|                 .inspect(|_| tracing::debug!(?domain, "spawn config server")) |  | ||||||
|                 .inspect_err(|error| tracing::error!(?error, ?domain, "spawn config server")) |  | ||||||
|                 .ok(); |  | ||||||
|         } |  | ||||||
|         loop { |  | ||||||
|             let prompt = match domain { |  | ||||||
|                 Some(ref d) => format!("Enter password for bitwarden[{d}] user {}: ", self.email), |  | ||||||
|                 None => format!("Enter password for bitwarden user {}: ", self.email), |  | ||||||
|             }; |  | ||||||
|             let Password(password) = Password::from_user_input(&prompt, "Enter Password"); |  | ||||||
|             let output = BitwardenCliCommand::Login(&self.email.to_string(), &password) |  | ||||||
|                 .create_process() |  | ||||||
|                 .output() |  | ||||||
|                 .await |  | ||||||
|                 .inspect(|_| tracing::debug!("spawn login")) |  | ||||||
|                 .inspect_err(|error| tracing::error!(?error, "spawn login"))?; |  | ||||||
|  |  | ||||||
|             if !output.status.success() { |  | ||||||
|                 let error = String::from_utf8_lossy(&output.stderr); |  | ||||||
|                 if !error.starts_with("You are already logged in as") { |  | ||||||
|                     tracing::error!(?error, "login"); |  | ||||||
|                     continue; |  | ||||||
|                 } |  | ||||||
|                 tracing::info!(?error, "errror"); |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             let session = String::from_utf8_lossy(&output.stdout).trim().to_string(); |  | ||||||
|             if session.is_empty() { |  | ||||||
|                 continue; |  | ||||||
|             } |  | ||||||
|             return Ok(BitwardenCli::<String> { |  | ||||||
|                 email: self.email, |  | ||||||
|                 session, |  | ||||||
|             }); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[async_trait::async_trait] |  | ||||||
| impl Bitwarden for BitwardenCli<String> { |  | ||||||
|     async fn get_item(&self, id: &str, query: &str) -> anyhow::Result<EncryptedBitwardenItem> { |  | ||||||
|         let output = BitwardenCliCommand::GetItem(&id, &self.session) |  | ||||||
|             .create_process() |  | ||||||
|             .spawn() |  | ||||||
|             .inspect_err(|error| tracing::error!(?error, "spawn get"))? |  | ||||||
|             .wait_with_output() |  | ||||||
|             .await |  | ||||||
|             .inspect_err(|error| tracing::error!(?error, "spawn get"))?; |  | ||||||
|         if !output.status.success() { |  | ||||||
|             let err = String::from_utf8_lossy(&output.stderr); |  | ||||||
|             anyhow::bail!("`bw get item` failed: {}", err.trim()); |  | ||||||
|         } |  | ||||||
|         let data: serde_json::Value = serde_json::from_slice(&output.stdout) |  | ||||||
|             .inspect_err(|error| tracing::error!(?error, json = ?output.stdout, "invalid json"))?; |  | ||||||
|         let secret: EncryptedBitwardenItem = jmespath::compile(&query) |  | ||||||
|             .inspect_err(|error| tracing::error!(?error, ?query, "input/expression mismatch"))? |  | ||||||
|             .search(&data) |  | ||||||
|             .inspect_err(|error| tracing::error!(?error, ?query, "query failed to apply"))? |  | ||||||
|             .as_string() |  | ||||||
|             .map(|s| s.to_owned()) |  | ||||||
|             .ok_or(anyhow::anyhow!("invalid item"))? |  | ||||||
|             .into(); |  | ||||||
|  |  | ||||||
|         Ok(secret) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,11 +0,0 @@ | |||||||
| pub mod api; |  | ||||||
| pub mod api_client; |  | ||||||
| pub mod bitwarden; |  | ||||||
| pub mod bitwarden_cli; |  | ||||||
| pub mod url; |  | ||||||
|  |  | ||||||
| pub use api::BitwardenApi; |  | ||||||
| pub use api::BitwardenToken; |  | ||||||
| pub use api_client::BitwardenHttpClient; |  | ||||||
| pub use bitwarden::Bitwarden; |  | ||||||
| pub use url::BitwardenUrl; |  | ||||||
| @@ -1,18 +0,0 @@ | |||||||
| use crate::data::Domain; |  | ||||||
|  |  | ||||||
| #[derive(Debug)] |  | ||||||
| pub struct BitwardenUrl(pub Domain); |  | ||||||
|  |  | ||||||
| impl BitwardenUrl { |  | ||||||
|     pub fn as_identity_url(&self) -> String { |  | ||||||
|         format!("https://{}/identity/connect/token", self.0) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub fn as_vault_url(&self) -> String { |  | ||||||
|         format!("https://{}/api/sync", self.0) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub fn as_cipher_url(&self, id: &str) -> String { |  | ||||||
|         format!("https://{}/api/ciphers/{}", self.0, id) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,16 +1,15 @@ | |||||||
| use std::convert::{TryFrom, TryInto}; | use std::convert::{TryFrom, TryInto}; | ||||||
| use std::path::Path; | use std::path::Path; | ||||||
| 
 | 
 | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
| use tokio::{fs::File, io::AsyncWriteExt}; | use tokio::{fs::File, io::AsyncWriteExt}; | ||||||
| 
 | 
 | ||||||
| use crate::statics::{ | use crate::{ | ||||||
|     DEFAULT_BITWARDEN_DOMAIN, LATEST_CONFIGURATION_VERSION, SYSTEM_CONFIG_PATH_CANDIDATES, |     email::Email, | ||||||
|  |     statics::{LATEST_CONFIGURATION_VERSION, SYSTEM_CONFIG_PATH_CANDIDATES}, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| use super::Domain; | #[derive(Deserialize, Serialize, Debug)] | ||||||
| use super::Email; |  | ||||||
| 
 |  | ||||||
| #[derive(serde::Deserialize, serde::Serialize, Debug)] |  | ||||||
| pub struct PunlockConfigurationEntry { | pub struct PunlockConfigurationEntry { | ||||||
|     pub id: String, |     pub id: String, | ||||||
|     pub query: String, |     pub query: String, | ||||||
| @@ -20,13 +19,12 @@ pub struct PunlockConfigurationEntry { | |||||||
|     pub public: bool, |     pub public: bool, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(serde::Deserialize)] | #[derive(Deserialize)] | ||||||
| pub struct PartialPunlockConfiguration { | pub struct PartialPunlockConfiguration { | ||||||
|     pub domain: Option<String>, |     pub domain: Option<String>, | ||||||
|     pub version: Option<String>, |     pub version: Option<String>, | ||||||
|     pub email: Option<String>, |     pub email: Option<String>, | ||||||
|     pub entries: Option<Vec<PunlockConfigurationEntry>>, |     pub entries: Option<Vec<PunlockConfigurationEntry>>, | ||||||
|     pub cache_token: Option<bool>, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl TryFrom<&Path> for PartialPunlockConfiguration { | impl TryFrom<&Path> for PartialPunlockConfiguration { | ||||||
| @@ -62,12 +60,11 @@ impl PartialPunlockConfiguration { | |||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(serde::Serialize)] | #[derive(Serialize)] | ||||||
| pub struct PunlockConfiguration { | pub struct PunlockConfiguration { | ||||||
|     pub cache_token: bool, |  | ||||||
|     pub version: String, |     pub version: String, | ||||||
|     pub email: Email, |     pub email: Email, | ||||||
|     pub domain: Domain, |     pub domain: Option<String>, | ||||||
|     pub entries: Vec<PunlockConfigurationEntry>, |     pub entries: Vec<PunlockConfigurationEntry>, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @@ -76,11 +73,7 @@ impl TryFrom<PartialPunlockConfiguration> for PunlockConfiguration { | |||||||
| 
 | 
 | ||||||
|     fn try_from(value: PartialPunlockConfiguration) -> anyhow::Result<Self> { |     fn try_from(value: PartialPunlockConfiguration) -> anyhow::Result<Self> { | ||||||
|         Ok(Self { |         Ok(Self { | ||||||
|             cache_token: value.cache_token.unwrap_or(false), |             domain: value.domain, | ||||||
|             domain: value |  | ||||||
|                 .domain |  | ||||||
|                 .unwrap_or(DEFAULT_BITWARDEN_DOMAIN.to_string()) |  | ||||||
|                 .into(), |  | ||||||
|             version: value |             version: value | ||||||
|                 .version |                 .version | ||||||
|                 .unwrap_or(LATEST_CONFIGURATION_VERSION.to_string()), |                 .unwrap_or(LATEST_CONFIGURATION_VERSION.to_string()), | ||||||
| @@ -1,55 +0,0 @@ | |||||||
| use std::path::Path; |  | ||||||
|  |  | ||||||
| use serde::{Deserialize, Serialize}; |  | ||||||
| use tokio::io::AsyncWriteExt; |  | ||||||
|  |  | ||||||
| use super::Password; |  | ||||||
|  |  | ||||||
| #[derive(Deserialize, Serialize)] |  | ||||||
| pub struct BitwardenClientCredentials { |  | ||||||
|     pub id: String, |  | ||||||
|     pub secret: String, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| impl BitwardenClientCredentials { |  | ||||||
|     pub async fn new(path: impl AsRef<Path>) -> Self { |  | ||||||
|         match tokio::fs::read(&path).await { |  | ||||||
|             Ok(content_vec_u8) => { |  | ||||||
|                 match String::from_utf8_lossy(&content_vec_u8).parse::<String>() { |  | ||||||
|                     Ok(content) => match toml::from_str::<BitwardenClientCredentials>(&content) { |  | ||||||
|                         Ok(config) => return config, |  | ||||||
|                         Err(error) => { |  | ||||||
|                             tracing::error!(?error, "invalid credentials file; deleting"); |  | ||||||
|                             tokio::fs::remove_file(&path).await.inspect_err(|e| tracing::error!(error = ?e, "unable to remove credentials file")).ok(); |  | ||||||
|                         } |  | ||||||
|                     }, |  | ||||||
|                     Err(error) => tracing::warn!(?error, "parse credentials file content"), |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             Err(error) => tracing::warn!(?error, "read credentials file"), |  | ||||||
|         }; |  | ||||||
|         return BitwardenClientCredentials::from_user_prompt(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub async fn write_to_disk(&self, path: impl AsRef<Path>) -> anyhow::Result<()> { |  | ||||||
|         let path = path.as_ref(); |  | ||||||
|         let contents = toml::to_string_pretty(self)?; |  | ||||||
|         let mut file = tokio::fs::File::create(path).await.inspect_err(|error| { |  | ||||||
|             tracing::error!(?path, ?error, "unable to create credentials file") |  | ||||||
|         })?; |  | ||||||
|         file.write_all(contents.as_bytes()) |  | ||||||
|             .await |  | ||||||
|             .inspect_err(|error| { |  | ||||||
|                 tracing::error!(?path, ?error, "unable to write credentials to file") |  | ||||||
|             })?; |  | ||||||
|         tracing::debug!(?path, "wrote credentials"); |  | ||||||
|         Ok(()) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     fn from_user_prompt() -> Self { |  | ||||||
|         let Password(id) = Password::from_user_input("Enter client id", "Enter client id"); |  | ||||||
|         let Password(secret) = |  | ||||||
|             Password::from_user_input("Enter client secret", "Enter client secret"); |  | ||||||
|         Self { id, secret } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,17 +0,0 @@ | |||||||
| #[derive(Clone, Debug, serde::Serialize)] |  | ||||||
| pub struct Domain(String); |  | ||||||
|  |  | ||||||
| impl<T> From<T> for Domain |  | ||||||
| where |  | ||||||
|     T: Into<String>, |  | ||||||
| { |  | ||||||
|     fn from(value: T) -> Self { |  | ||||||
|         Self(value.into()) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| impl std::fmt::Display for Domain { |  | ||||||
|     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |  | ||||||
|         write!(f, "{}", self.0) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,12 +0,0 @@ | |||||||
| pub mod config; |  | ||||||
| pub mod credentials; |  | ||||||
| pub mod domain; |  | ||||||
| pub mod email; |  | ||||||
| pub mod password; |  | ||||||
|  |  | ||||||
| pub use config::PunlockConfiguration; |  | ||||||
| pub use config::PunlockConfigurationEntry; |  | ||||||
| pub use credentials::BitwardenClientCredentials; |  | ||||||
| pub use domain::Domain; |  | ||||||
| pub use email::Email; |  | ||||||
| pub use password::Password; |  | ||||||
| @@ -1,36 +0,0 @@ | |||||||
| #[cfg(unix)] |  | ||||||
| use dialog::DialogBox; |  | ||||||
|  |  | ||||||
| pub struct Password(pub String); |  | ||||||
|  |  | ||||||
| impl Password { |  | ||||||
|     pub fn from_user_input(prompt: &str, _title: &str) -> Self { |  | ||||||
|         let mut password = "".to_string(); |  | ||||||
|         while password.trim().is_empty() { |  | ||||||
|             #[cfg(unix)] |  | ||||||
|             { |  | ||||||
|                 password = dialog::Password::new(prompt) |  | ||||||
|                     .title(_title) |  | ||||||
|                     .show() |  | ||||||
|                     .unwrap_or(None) |  | ||||||
|                     .unwrap_or("".to_string()) |  | ||||||
|                     .trim() |  | ||||||
|                     .to_string(); |  | ||||||
|             } |  | ||||||
|             #[cfg(not(unix))] |  | ||||||
|             { |  | ||||||
|                 password = rpassword::prompt_password(prompt).unwrap_or_else(|_| String::new()); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         password.into() |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| impl<T> From<T> for Password |  | ||||||
| where |  | ||||||
|     T: AsRef<str>, |  | ||||||
| { |  | ||||||
|     fn from(value: T) -> Self { |  | ||||||
|         Self(value.as_ref().to_string()) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -5,7 +5,7 @@ use serde::Serialize; | |||||||
| 
 | 
 | ||||||
| use crate::statics::EMAIL_REGEX; | use crate::statics::EMAIL_REGEX; | ||||||
| 
 | 
 | ||||||
| #[derive(Serialize, Clone, Debug)] | #[derive(Serialize)] | ||||||
| pub struct Email(String); | pub struct Email(String); | ||||||
| 
 | 
 | ||||||
| impl Email { | impl Email { | ||||||
| @@ -1,5 +1,5 @@ | |||||||
| #![feature(stmt_expr_attributes)] | pub mod bitwarden; | ||||||
| pub mod bw; | pub mod config; | ||||||
| pub mod data; | pub mod email; | ||||||
| pub mod statics; | pub mod statics; | ||||||
| pub mod store; | pub mod store; | ||||||
|   | |||||||
							
								
								
									
										159
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										159
									
								
								src/main.rs
									
									
									
									
									
								
							| @@ -1,22 +1,13 @@ | |||||||
| use aes_gcm::aes::cipher::BlockDecrypt; | use std::convert::TryInto; | ||||||
| use aes_gcm::aes::Aes256; |  | ||||||
| use aes_gcm::KeyInit; |  | ||||||
| use anyhow::Context; |  | ||||||
| use base64::Engine; |  | ||||||
| use hkdf::Hkdf; |  | ||||||
| use hmac::Hmac; |  | ||||||
| use pbkdf2::pbkdf2_hmac; |  | ||||||
| use sha2::Sha256; |  | ||||||
| use std::convert::{TryFrom, TryInto}; |  | ||||||
| use std::path::{Path, PathBuf}; | use std::path::{Path, PathBuf}; | ||||||
|  |  | ||||||
| use clap::Parser; | use clap::Parser; | ||||||
| use hmac::Mac; | use punlock::{ | ||||||
| use punlock::bw::{BitwardenApi, BitwardenHttpClient}; |     bitwarden::Bitwarden, | ||||||
| use punlock::data::config::PartialPunlockConfiguration; |     config::{PartialPunlockConfiguration, PunlockConfiguration}, | ||||||
| use punlock::data::{BitwardenClientCredentials, Password, PunlockConfiguration}; |     statics::USER_CONFIG_FILE_PATH, | ||||||
| use punlock::statics::USER_CREDENTIALS_FILE_PATH; |     store::UnmountedSecretStore, | ||||||
| use punlock::{statics::USER_CONFIG_FILE_PATH, store::SecretStore}; | }; | ||||||
| use tracing_subscriber::{ | use tracing_subscriber::{ | ||||||
|     fmt::format::FmtSpan, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, |     fmt::format::FmtSpan, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, | ||||||
| }; | }; | ||||||
| @@ -28,124 +19,8 @@ struct Cli { | |||||||
|     pub config: Option<PathBuf>, |     pub config: Option<PathBuf>, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Debug)] |  | ||||||
| struct Cipher { |  | ||||||
|     pub iv: Vec<u8>, |  | ||||||
|     pub ct: Vec<u8>, |  | ||||||
|     pub mac: Vec<u8>, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| impl TryFrom<&str> for Cipher { |  | ||||||
|     type Error = anyhow::Error; |  | ||||||
|  |  | ||||||
|     fn try_from(value: &str) -> Result<Self, Self::Error> { |  | ||||||
|         let rest = value.splitn(2, '.').nth(1).expect("invalid format, no dot"); |  | ||||||
|         let parts: Vec<&str> = rest.split('|').collect(); |  | ||||||
|         assert_eq!(parts.len(), 3, "expected three parts"); |  | ||||||
|         let iv = base64::engine::general_purpose::STANDARD.decode(parts[0])?; |  | ||||||
|         let ct = base64::engine::general_purpose::STANDARD.decode(parts[1])?; |  | ||||||
|         let mac = base64::engine::general_purpose::STANDARD.decode(parts[2])?; |  | ||||||
|  |  | ||||||
|         Ok(Self { iv, ct, mac }) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| fn decrypt_aes_cbc_pkcs7(key: &[u8; 32], iv: &[u8], ct: &[u8]) -> anyhow::Result<Vec<u8>> { |  | ||||||
|     let iv: &[u8; 16] = iv.try_into()?; |  | ||||||
|     if ct.len() % 16 != 0 { |  | ||||||
|         anyhow::bail!("ciphertext length must be a multiple of 16"); |  | ||||||
|     } |  | ||||||
|     let cipher = Aes256::new(key.into()); |  | ||||||
|     let mut prev = *iv; |  | ||||||
|     let mut plaintext = Vec::with_capacity(ct.len()); |  | ||||||
|     for chunk in ct.chunks(16) { |  | ||||||
|         let mut block = <[u8; 16]>::try_from(chunk).expect("chunk is 16 bytes"); |  | ||||||
|         cipher.decrypt_block(&mut block.into()); |  | ||||||
|         for i in 0..16 { |  | ||||||
|             block[i] ^= prev[i]; |  | ||||||
|         } |  | ||||||
|         plaintext.extend_from_slice(&block); |  | ||||||
|         prev = <[u8; 16]>::try_from(chunk).unwrap(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // 4) Strip & verify PKCS#7 padding |  | ||||||
|     let pad_len = *plaintext.last().context("decrypted data is empty")? as usize; |  | ||||||
|     if pad_len == 0 || pad_len > 16 { |  | ||||||
|         anyhow::bail!("invalid padding length"); |  | ||||||
|     } |  | ||||||
|     let len = plaintext.len(); |  | ||||||
|     let pad_start = len - pad_len; |  | ||||||
|     if !plaintext[pad_start..] |  | ||||||
|         .iter() |  | ||||||
|         .all(|&b| b as usize == pad_len) |  | ||||||
|     { |  | ||||||
|         anyhow::bail!("invalid PKCS#7 padding"); |  | ||||||
|     } |  | ||||||
|     plaintext.truncate(pad_start); |  | ||||||
|  |  | ||||||
|     Ok(plaintext) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| fn testdecrypt( |  | ||||||
|     config: &PunlockConfiguration, |  | ||||||
|     bitwarden: &BitwardenHttpClient, |  | ||||||
| ) -> anyhow::Result<()> { |  | ||||||
|     let Password(master_password) = Password::from_user_input("Enter master password", ""); |  | ||||||
|     let str = "2.lt/eAKnlHsPcUCR5bGf/Kg==|8xFsF52BQx14Pb6MuW7ByYLE3ptmbTER+FxDhwGBj10=|6CDbFNKPyjXpOYblz64XFV88ofgDUKpM0YVaGLnWVt0="; |  | ||||||
|     let mut master_key = [0u8; 32]; |  | ||||||
|     pbkdf2_hmac::<Sha256>( |  | ||||||
|         master_password.as_bytes(), |  | ||||||
|         config.email.to_string().as_bytes(), |  | ||||||
|         bitwarden.get_token().kdf_iterations, |  | ||||||
|         &mut master_key, |  | ||||||
|     ); |  | ||||||
|     tracing::info!(master_key = hex::encode(&master_key), "master key"); |  | ||||||
|  |  | ||||||
|     // --- HKDF expand --- |  | ||||||
|  |  | ||||||
|     let (_prk_bytes, hk) = Hkdf::<Sha256>::extract(None, &master_key); |  | ||||||
|     let mut stretch_enc = [0u8; 32]; |  | ||||||
|     let mut stretch_mac = [0u8; 32]; |  | ||||||
|     println!("stretch_enc = {:x?}", stretch_enc); |  | ||||||
|     println!("stretch_mac = {:x?}", stretch_mac); |  | ||||||
|     hk.expand(b"enc", &mut stretch_enc) |  | ||||||
|         .map_err(|e| anyhow::anyhow!("{e}")) |  | ||||||
|         .context("HKDF expand(enc) failed")?; |  | ||||||
|     hk.expand(b"mac", &mut stretch_mac) |  | ||||||
|         .map_err(|e| anyhow::anyhow!("{e}")) |  | ||||||
|         .context("HKDF expand(mac) failed")?; |  | ||||||
|  |  | ||||||
|     // --- Parse blob --- |  | ||||||
|     let cipher: Cipher = str.try_into()?; |  | ||||||
|  |  | ||||||
|     // --- Verify HMAC --- |  | ||||||
|     type HmacSha256 = Hmac<Sha256>; |  | ||||||
|     let mut h = <HmacSha256 as Mac>::new_from_slice(&stretch_mac)?; |  | ||||||
|     h.update(&cipher.iv); |  | ||||||
|     h.update(&cipher.ct); |  | ||||||
|     let computed = h.clone().finalize().into_bytes(); |  | ||||||
|     println!("computed MAC = {}", hex::encode(&computed)); |  | ||||||
|     println!("expected MAC = {}", hex::encode(&cipher.mac)); |  | ||||||
|     h.verify_slice(&cipher.mac) |  | ||||||
|         .map_err(|e| anyhow::anyhow!("{e}")) |  | ||||||
|         .context("HMAC verification failed")?; |  | ||||||
|  |  | ||||||
|     // --- AES-CBC decrypt (64 bytes) --- |  | ||||||
|     let clear = decrypt_aes_cbc_pkcs7(&stretch_enc, &cipher.iv, &cipher.ct) |  | ||||||
|         .map_err(|e| anyhow::anyhow!("{e}")) |  | ||||||
|         .context("AES-CBC decrypt failed")?; |  | ||||||
|  |  | ||||||
|     // --- Split into real vault keys --- |  | ||||||
|     let vault_enc_key: [u8; 32] = clear[0..32].try_into().unwrap(); |  | ||||||
|     let vault_mac_key: [u8; 32] = clear[32..64].try_into().unwrap(); |  | ||||||
|  |  | ||||||
|     println!("Vault ENC key: {:x?}", vault_enc_key); |  | ||||||
|     println!("Vault MAC key: {:x?}", vault_mac_key); |  | ||||||
|  |  | ||||||
|     Ok(()) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[tokio::main] | #[tokio::main] | ||||||
| async fn main() -> anyhow::Result<()> { | async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||||
|     let formattter = tracing_subscriber::fmt::Layer::new() |     let formattter = tracing_subscriber::fmt::Layer::new() | ||||||
|         .with_thread_names(true) |         .with_thread_names(true) | ||||||
|         .with_span_events(FmtSpan::FULL); |         .with_span_events(FmtSpan::FULL); | ||||||
| @@ -165,21 +40,17 @@ async fn main() -> anyhow::Result<()> { | |||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     let config: PunlockConfiguration = config.try_into()?; |     let config: PunlockConfiguration = config.try_into()?; | ||||||
|  |  | ||||||
|     config |     config | ||||||
|         .write_to_disk(USER_CONFIG_FILE_PATH.as_path()) |         .write_to_disk(USER_CONFIG_FILE_PATH.as_path()) | ||||||
|         .await?; |         .await?; | ||||||
|  |  | ||||||
|     let credentials = BitwardenClientCredentials::new(USER_CREDENTIALS_FILE_PATH.as_path()).await; |     let bitwarden = Bitwarden::new(config.email) | ||||||
|     credentials |         .authenticate(config.domain) | ||||||
|         .write_to_disk(USER_CREDENTIALS_FILE_PATH.as_path()) |  | ||||||
|         .await?; |         .await?; | ||||||
|  |     let store = UnmountedSecretStore::new(bitwarden) | ||||||
|     let bitwarden = |         .into_platform_store() | ||||||
|         BitwardenHttpClient::new(&config.email, &credentials, config.domain.clone()).await?; |         .await?; | ||||||
|  |     store.write_secrets(&config.entries).await?; | ||||||
|     testdecrypt(&config, &bitwarden)?; |  | ||||||
|  |  | ||||||
|     // let store = SecretStore::new(bitwarden).await?; |  | ||||||
|     // store.write_secrets(&config.entries).await?; |  | ||||||
|     Ok(()) |     Ok(()) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -7,31 +7,16 @@ use regex::Regex; | |||||||
| lazy_static! { | lazy_static! { | ||||||
|     pub static ref LATEST_CONFIGURATION_VERSION: &'static str = "1.0.0"; |     pub static ref LATEST_CONFIGURATION_VERSION: &'static str = "1.0.0"; | ||||||
|     pub static ref CONFIG_FILE_NAME: &'static str = "config.toml"; |     pub static ref CONFIG_FILE_NAME: &'static str = "config.toml"; | ||||||
|     pub static ref CREDENTIALS_FILE_NAME: &'static str = "credentials.toml"; |  | ||||||
|     pub static ref PROJECT_DIRS: ProjectDirs = |     pub static ref PROJECT_DIRS: ProjectDirs = | ||||||
|         ProjectDirs::from("dev", "kruhlmann", "punlock").unwrap(); |         ProjectDirs::from("dev", "kruhlmann", "punlock").unwrap(); | ||||||
|     pub static ref USER_CONFIG_FILE_PATH: PathBuf = |     pub static ref USER_CONFIG_FILE_PATH: PathBuf = | ||||||
|         PROJECT_DIRS.config_dir().join(CONFIG_FILE_NAME.to_string()); |         PROJECT_DIRS.config_dir().join(CONFIG_FILE_NAME.to_string()); | ||||||
|     pub static ref USER_CREDENTIALS_FILE_PATH: PathBuf = PROJECT_DIRS |  | ||||||
|         .config_dir() |  | ||||||
|         .join(CREDENTIALS_FILE_NAME.to_string()); |  | ||||||
|     pub static ref HOME_DIRECTORY: PathBuf = dirs::home_dir().unwrap(); |     pub static ref HOME_DIRECTORY: PathBuf = dirs::home_dir().unwrap(); | ||||||
|     pub static ref SYSTEM_CONFIG_PATH_CANDIDATES: Vec<PathBuf> = [ |     #[cfg(target_os = "linux")] | ||||||
|         PathBuf::from(CONFIG_FILE_NAME.to_string()), |     pub static ref RUNTIME_DIRECTORY: PathBuf = dirs::runtime_dir().unwrap(); | ||||||
|  |     pub static ref SYSTEM_CONFIG_PATH_CANDIDATES: Vec<PathBuf> = [PathBuf::from(CONFIG_FILE_NAME.to_string()), | ||||||
|         USER_CONFIG_FILE_PATH.to_path_buf(), |         USER_CONFIG_FILE_PATH.to_path_buf(), | ||||||
|         PathBuf::from("/etc/punlock/").join("CONFIG_FILE_NAME") |         PathBuf::from("/etc/punlock/").join("CONFIG_FILE_NAME")] | ||||||
|     ] |  | ||||||
|     .to_vec(); |     .to_vec(); | ||||||
|     pub static ref EMAIL_REGEX: Regex = Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap(); |     pub static ref EMAIL_REGEX: Regex = Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap(); | ||||||
|     pub static ref DEFAULT_BITWARDEN_DOMAIN: &'static str = "vault.bitwarden.com"; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[cfg(unix)] |  | ||||||
| lazy_static! { |  | ||||||
|     pub static ref RUNTIME_DIRECTORY: PathBuf = dirs::runtime_dir().unwrap(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[cfg(not(unix))] |  | ||||||
| lazy_static! { |  | ||||||
|     pub static ref RUNTIME_DIRECTORY: PathBuf = std::env::temp_dir(); |  | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										309
									
								
								src/store.rs
									
									
									
									
									
								
							
							
						
						
									
										309
									
								
								src/store.rs
									
									
									
									
									
								
							| @@ -1,71 +1,169 @@ | |||||||
| use std::{ | use std::{ | ||||||
|     path::{Path, PathBuf}, |     os::unix::fs::PermissionsExt, | ||||||
|  |     path::PathBuf, | ||||||
|  |     process::{Command, Stdio}, | ||||||
|     sync::Arc, |     sync::Arc, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| use aes_gcm::{aead::Aead, aes::Aes256, Aes256Gcm, KeyInit, Nonce}; | use futures::{stream::FuturesUnordered, StreamExt, TryFutureExt}; | ||||||
| use anyhow::Context; |  | ||||||
| use argon2::{Argon2, Params}; |  | ||||||
| use base64::{engine::general_purpose, Engine}; |  | ||||||
| use futures::{stream::FuturesUnordered, StreamExt}; |  | ||||||
| use hkdf::Hkdf; |  | ||||||
| use hmac::{Hmac, Mac}; |  | ||||||
| use pbkdf2::pbkdf2_hmac; |  | ||||||
| use sha2::Sha256; |  | ||||||
| use tokio::io::AsyncWriteExt; | use tokio::io::AsyncWriteExt; | ||||||
|  |  | ||||||
| use crate::{ | use crate::{ | ||||||
|     bw::{bitwarden::EncryptedBitwardenItem, BitwardenApi, BitwardenToken}, |     bitwarden::Bitwarden, | ||||||
|     data::PunlockConfigurationEntry, |     config::PunlockConfigurationEntry, | ||||||
|     statics::{HOME_DIRECTORY, RUNTIME_DIRECTORY}, |     statics::{self, HOME_DIRECTORY}, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| pub struct SecretStore { | pub struct UnmountedSecretStore { | ||||||
|     path: Arc<Path>, |     bitwarden: Bitwarden<String>, | ||||||
|     bitwarden: Arc<Box<dyn BitwardenApi>>, |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // struct DecryptedString(String); | impl UnmountedSecretStore { | ||||||
|  |     pub fn new(bitwarden: Bitwarden<String>) -> Self { | ||||||
|  |         Self { bitwarden } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
| // impl DecryptedString { | impl UnmountedSecretStore { | ||||||
| //     pub fn from_encrypted(encrypted: &str, credentials: &BitwardenToken) -> anyhow::Result<Self> { |     pub async fn into_platform_store(self) -> anyhow::Result<UnixSecretStore> { | ||||||
| //         let cipher = Aes256Gcm::new(&credentials.key); |         cfg_if::cfg_if! { | ||||||
| //         let blob = base64::engine::general_purpose::STANDARD |             if #[cfg(target_os = "linux")] { | ||||||
| //             .decode(encrypted) |                 let root_path = statics::RUNTIME_DIRECTORY.join("punlock"); | ||||||
| //             .context("failed to Base64‐decode encrypted data")?; |                 let store = UnixSecretStore::new(self.bitwarden, root_path).teardown().await?.setup().await?; | ||||||
| //         if blob.len() < 12 + 16 { |                 Ok(store) | ||||||
| //             anyhow::bail!("encrypted data too short"); |             } else if #[cfg(target_os = "macos")] { | ||||||
| //         } |                 // mount_ramdisk_macos(mount_point)?; | ||||||
| //         let (nonce_bytes, ciphertext_and_tag) = blob.split_at(12); |                 panic!("todo"); | ||||||
| //         let nonce = Nonce::from_slice(nonce_bytes); |             } else { | ||||||
| //         let plaintext = cipher |                 panic!("todo"); | ||||||
| //             .decrypt(nonce, ciphertext_and_tag) |                 // debug!("On Windows or unsupported OS: using plain dir at {}", mount_point.display()); | ||||||
| //             .map_err(|error| anyhow::anyhow!("decryption failed: {error}"))?; |             } | ||||||
| //         let s = String::from_utf8(plaintext).context("decrypted bytes not valid utf-8")?; |  | ||||||
|  |  | ||||||
| //         Ok(Self(s)) |  | ||||||
| //     } |  | ||||||
| // } |  | ||||||
|  |  | ||||||
| impl SecretStore { |  | ||||||
|     pub async fn new(bitwarden: impl BitwardenApi + 'static) -> anyhow::Result<SecretStore> { |  | ||||||
|         let this = Self { |  | ||||||
|             path: RUNTIME_DIRECTORY.join(env!("CARGO_PKG_NAME")).into(), |  | ||||||
|             bitwarden: Arc::new(Box::new(bitwarden)), |  | ||||||
|         } |         } | ||||||
|         .teardown() |     } | ||||||
|         .await? | } | ||||||
|         .setup() |  | ||||||
|         .await?; | pub struct UnixSecretStore { | ||||||
|         Ok(this) |     bitwarden: Arc<Bitwarden<String>>, | ||||||
|  |     root_path: Arc<PathBuf>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl UnixSecretStore { | ||||||
|  |     pub fn new(bitwarden: Bitwarden<String>, root_path: PathBuf) -> Self { | ||||||
|  |         Self { | ||||||
|  |             bitwarden: Arc::new(bitwarden), | ||||||
|  |             root_path: Arc::new(root_path), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn write_secrets(&self, entries: &[PunlockConfigurationEntry]) -> anyhow::Result<()> { | ||||||
|  |         let mut tasks = FuturesUnordered::new(); | ||||||
|  |         Command::new("bw") | ||||||
|  |             .args(["sync", "--session", &self.bitwarden.session]) | ||||||
|  |             .stdout(Stdio::piped()) | ||||||
|  |             .stderr(Stdio::piped()) | ||||||
|  |             .spawn() | ||||||
|  |             .inspect_err(|error| tracing::error!(?error, "spawn get"))?; | ||||||
|  |  | ||||||
|  |         for entry in entries.iter() { | ||||||
|  |             let root = self.root_path.clone(); | ||||||
|  |             let bw = self.bitwarden.clone(); | ||||||
|  |  | ||||||
|  |             tasks.push( | ||||||
|  |                 async move { | ||||||
|  |                     let secret = bw.fetch(entry).await.inspect_err(|error| tracing::error!(?error, ?entry, "item not found"))?; | ||||||
|  |                     let path = root.join(&entry.path); | ||||||
|  |  | ||||||
|  |                     tokio::fs::create_dir_all(path.parent().unwrap_or(&path)).await?; | ||||||
|  |                     { | ||||||
|  |                         let mut file = tokio::fs::File::create(&path).await?; | ||||||
|  |                         file.write_all(secret.as_bytes()).await?; | ||||||
|  |                         if !secret.ends_with('\n') { | ||||||
|  |                             file.write_all(b"\n").await?; | ||||||
|  |                         } | ||||||
|  |                         file.flush().await?; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     let mut perms = tokio::fs::metadata(&path).await?.permissions(); | ||||||
|  |                     perms.set_readonly(true); | ||||||
|  |  | ||||||
|  |                     if !entry.public { | ||||||
|  |                         #[cfg(unix)] | ||||||
|  |                         perms.set_mode(0o400); | ||||||
|  |                     } | ||||||
|  |                     tokio::fs::set_permissions(&path, perms) | ||||||
|  |                         .await | ||||||
|  |                         .inspect(|_| tracing::debug!(?path, "set readonly")) | ||||||
|  |                         .inspect_err(|error| tracing::error!(?error, ?path, "remove runtime dir"))?; | ||||||
|  |  | ||||||
|  |                     if let Some(ref links) = entry.links { | ||||||
|  |                         for link in links { | ||||||
|  |                             let link_path: PathBuf = if PathBuf::from(link).is_absolute() { | ||||||
|  |                                 PathBuf::from(link) | ||||||
|  |                             } else { | ||||||
|  |                                 HOME_DIRECTORY.join(link) | ||||||
|  |                             }; | ||||||
|  |                             tokio::fs::create_dir_all(link_path.parent().unwrap_or(&link_path)) | ||||||
|  |                                 .await?; | ||||||
|  |                             match tokio::fs::symlink_metadata(&link_path).await { | ||||||
|  |                                 Ok(meta) if meta.file_type().is_symlink() => { | ||||||
|  |                                     let current = tokio::fs::read_link(&link_path).await?; | ||||||
|  |                                     if current == path { | ||||||
|  |                                         tracing::debug!(?link_path, "skipping existing symlink"); | ||||||
|  |                                         continue; | ||||||
|  |                                     } | ||||||
|  |                                     tokio::fs::remove_file(&link_path).await?; | ||||||
|  |                                 } | ||||||
|  |                                 Ok(_) => tokio::fs::remove_file(&link_path).await?, | ||||||
|  |                                 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} | ||||||
|  |                                 Err(e) => return Err(e.into()), | ||||||
|  |                             } | ||||||
|  |  | ||||||
|  |                             let src = path.clone(); | ||||||
|  |                             let dst = link_path.clone(); | ||||||
|  |                             tokio::task::spawn_blocking(move || -> std::io::Result<()> { | ||||||
|  |                                 #[cfg(unix)] | ||||||
|  |                                 std::os::unix::fs::symlink(&src, &dst)?; | ||||||
|  |                                 #[cfg(windows)] | ||||||
|  |                                 std::os::windows::fs::symlink_file(&src, &dst)?; | ||||||
|  |                                 Ok(()) | ||||||
|  |                             }) | ||||||
|  |                             .inspect_err(|error| tracing::error!(src = ?path, dst = ?link_path, ?error, "symlink failed")) | ||||||
|  |                             .await | ||||||
|  |                             .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))??; | ||||||
|  |  | ||||||
|  |                             tracing::info!(src =? path, destination = ?link_path, "created/updated symlink"); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     Ok::<_, anyhow::Error>((entry.id.clone(), entry.path.clone())) | ||||||
|  |                 } | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let mut count = 0; | ||||||
|  |         let mut success = 0; | ||||||
|  |         while let Some(res) = tasks.next().await { | ||||||
|  |             count += 1; | ||||||
|  |             match res { | ||||||
|  |                 Ok((id, path)) => { | ||||||
|  |                     success += 1; | ||||||
|  |                     tracing::info!(?id, ?path, "secret written") | ||||||
|  |                 } | ||||||
|  |                 Err(error) => tracing::error!(?error, "failed to write secret"), | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         tracing::info!("wrote {success}/{count} secrets"); | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async fn teardown(self) -> anyhow::Result<Self> { |     async fn teardown(self) -> anyhow::Result<Self> { | ||||||
|         if self.path.exists() { |         if self.root_path.exists() { | ||||||
|             tokio::fs::remove_dir_all(&*self.path) |             tokio::fs::remove_dir_all(&*self.root_path) | ||||||
|                 .await |                 .await | ||||||
|                 .inspect_err( |                 .inspect_err( | ||||||
|                     |error| tracing::error!(?error, path = ?self.path, "remove runtime dir"), |                     |error| tracing::error!(?error, path = ?self.root_path, "remove runtime dir"), | ||||||
|                 ) |                 ) | ||||||
|                 .ok(); |                 .ok(); | ||||||
|         } |         } | ||||||
| @@ -73,112 +171,11 @@ impl SecretStore { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     async fn setup(self) -> anyhow::Result<Self> { |     async fn setup(self) -> anyhow::Result<Self> { | ||||||
|         tokio::fs::create_dir_all(&*self.path).await.inspect_err( |         tokio::fs::create_dir_all(&*self.root_path) | ||||||
|             |error| tracing::error!(?error, path = ?self.path, "create runtime dir"), |             .await | ||||||
|         )?; |             .inspect_err( | ||||||
|  |                 |error| tracing::error!(?error, path = ?self.root_path, "create runtime dir"), | ||||||
|  |             )?; | ||||||
|         Ok(self) |         Ok(self) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn write_secrets(&self, entries: &[PunlockConfigurationEntry]) -> anyhow::Result<()> { |  | ||||||
|         // let mut tasks = FuturesUnordered::new(); |  | ||||||
|         // let token = self.bitwarden.get_token(); |  | ||||||
|  |  | ||||||
|         // for entry in entries { |  | ||||||
|         //     let root = self.path.clone(); |  | ||||||
|  |  | ||||||
|         //     tasks.push(async move { |  | ||||||
|         //         let item = self.bitwarden.get_item(&entry.id).await.inspect_err(|error| tracing::error!(id = ?entry.id, ?error, "get item"))?; |  | ||||||
|         //         let EncryptedBitwardenItem(cipher) = jmespath::compile(&entry.query) |  | ||||||
|         //             .inspect_err(|error| { |  | ||||||
|         //                 tracing::error!(?error, query = ?entry.query, "input/expression mismatch") |  | ||||||
|         //             })? |  | ||||||
|         //             .search(&item) |  | ||||||
|         //             .inspect_err(|error| tracing::error!(?error, query = ?entry.query, "query failed to apply"))? |  | ||||||
|         //             .as_string() |  | ||||||
|         //             .map(|s| s.to_owned()) |  | ||||||
|         //             .ok_or(anyhow::anyhow!("invalid item"))? |  | ||||||
|         //             .into(); |  | ||||||
|         //         anyhow::bail!("hi"); |  | ||||||
|         // let DecryptedString(cipher) = DecryptedString::from_encrypted(&cipher, token)?; |  | ||||||
|         // let source = root.join(&entry.path); |  | ||||||
|         // tokio::fs::create_dir_all(source.parent().unwrap_or(&source)).await?; |  | ||||||
|         // { |  | ||||||
|         //     let mut file = tokio::fs::File::create(&source).await?; |  | ||||||
|         //     file.write_all(cipher.as_bytes()).await?; |  | ||||||
|         //     if !cipher.ends_with('\n') { |  | ||||||
|         //         file.write_all(b"\n").await?; |  | ||||||
|         //     } |  | ||||||
|         //     file.flush().await?; |  | ||||||
|         // } |  | ||||||
|  |  | ||||||
|         // let mut perms = tokio::fs::metadata(&source).await?.permissions(); |  | ||||||
|         // perms.set_readonly(true); |  | ||||||
|         // #[cfg(unix)] |  | ||||||
|         // if !entry.public { |  | ||||||
|         //     std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o400); |  | ||||||
|         // } |  | ||||||
|         // tokio::fs::set_permissions(&source, perms) |  | ||||||
|         //     .await |  | ||||||
|         //     .inspect(|_| tracing::debug!(?source, "set perms")) |  | ||||||
|         //     .inspect_err(|error| tracing::error!(?error, ?source, "set perms"))?; |  | ||||||
|  |  | ||||||
|         // if let Some(links) = &entry.links { |  | ||||||
|         //     for link in links { |  | ||||||
|         //         let destination: PathBuf = if PathBuf::from(link).is_absolute() { |  | ||||||
|         //             PathBuf::from(link) |  | ||||||
|         //         } else { |  | ||||||
|         //             HOME_DIRECTORY.join(link) |  | ||||||
|         //         }; |  | ||||||
|  |  | ||||||
|         //         tokio::fs::create_dir_all(destination.parent().unwrap_or(&destination)) |  | ||||||
|         //             .await?; |  | ||||||
|  |  | ||||||
|         //         match tokio::fs::symlink_metadata(&destination).await { |  | ||||||
|         //             Ok(md) if md.file_type().is_symlink() => { |  | ||||||
|         //                 let cur = tokio::fs::read_link(&destination).await?; |  | ||||||
|         //                 if cur == source { |  | ||||||
|         //                     tracing::debug!(?source, ?destination, "skip symlink"); |  | ||||||
|         //                     continue; |  | ||||||
|         //                 } |  | ||||||
|         //                 tokio::fs::remove_file(&destination).await?; |  | ||||||
|         //             } |  | ||||||
|         //             Ok(_) => tokio::fs::remove_file(&destination).await?, |  | ||||||
|         //             Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} |  | ||||||
|         //             Err(e) => return Err(e.into()), |  | ||||||
|         //         } |  | ||||||
|  |  | ||||||
|         //         #[cfg(unix)] |  | ||||||
|         //         std::os::unix::fs::symlink(&source, &destination)?; |  | ||||||
|         //         #[cfg(windows)] |  | ||||||
|         //         if std::fs::metadata(&source)?.is_dir() { |  | ||||||
|         //             std::os::windows::fs::symlink_dir(&source, &destination)?; |  | ||||||
|         //         } else { |  | ||||||
|         //             std::os::windows::fs::symlink_file(&source, &destination)?; |  | ||||||
|         //         } |  | ||||||
|  |  | ||||||
|         //         tracing::info!(?source, ?destination, "created/updated symlink"); |  | ||||||
|         //     } |  | ||||||
|         // } |  | ||||||
|  |  | ||||||
|         // Ok::<_, anyhow::Error>((entry.id.clone(), entry.path.clone())) |  | ||||||
|         // }); |  | ||||||
|         // } |  | ||||||
|  |  | ||||||
|         // tracing::error!(len = tasks.len(), "tasks"); |  | ||||||
|  |  | ||||||
|         // let mut total = 0; |  | ||||||
|         // let mut ok = 0; |  | ||||||
|         // while let Some(r) = tasks.next().await { |  | ||||||
|         //     total += 1; |  | ||||||
|         //     match r { |  | ||||||
|         //         Ok((id, p)) => { |  | ||||||
|         //             ok += 1; |  | ||||||
|         //             tracing::info!(?id, path = ?p, "write"); |  | ||||||
|         //         } |  | ||||||
|         //         Err(error) => tracing::error!(?error, "write"), |  | ||||||
|         //     } |  | ||||||
|         // } |  | ||||||
|         // tracing::info!(ok, total, "secrets successfully written"); |  | ||||||
|         Ok(()) |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user