Compare commits

..

1 Commits

Author SHA1 Message Date
d79c1935a7 Migrate 2025-10-20 12:55:35 +02:00
20 changed files with 2373 additions and 363 deletions

1678
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,21 +8,33 @@ name = "punlock"
path = "src/lib.rs"
[dependencies]
aes-gcm = "0.10.3"
anyhow = "1.0.98"
caps = "0.5.5"
cfg-if = "1.0.0"
argon2 = "0.5.3"
async-trait = "0.1.88"
base64 = "0.22.1"
clap = { version = "4.5.37", features = ["derive"] }
dialog = "0.3.0"
directories = "6.0.0"
dirs = "6.0.0"
futures = "0.3.31"
hex = "0.4.3"
hkdf = "0.12.4"
hmac = "0.12.1"
jmespath = "0.3.0"
lazy_static = "1.5.0"
pbkdf2 = "0.12.2"
regex = "1.11.1"
reqwest = { version = "0.12.15", features = ["json"] }
rpassword = "7.4.0"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
sha2 = "0.10.9"
tokio = { version = "1.44.2", features = ["full"] }
toml = "0.8.20"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
users = "0.11.0"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
urlencode = "1.0.1"
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"

View File

@@ -2,7 +2,6 @@
pkgs.mkShell {
buildInputs = [
pkgs.cargo-llvm-cov
pkgs.openssl
pkgs.pkg-config
pkgs.rust-analyzer

View File

@@ -1,124 +0,0 @@
use std::process::Stdio;
use tokio::process::Command;
use crate::{config::PunlockConfigurationEntry, email::Email};
pub struct Bitwarden<S> {
email: Email,
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 => 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(())
}
}

29
src/bw/api.rs Normal file
View File

@@ -0,0 +1,29 @@
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;
}

70
src/bw/api_client.rs Normal file
View File

@@ -0,0 +1,70 @@
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(&params)
.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
}
}

21
src/bw/bitwarden.rs Normal file
View File

@@ -0,0 +1,21 @@
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>;
}

126
src/bw/bitwarden_cli.rs Normal file
View File

@@ -0,0 +1,126 @@
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)
}
}

11
src/bw/mod.rs Normal file
View File

@@ -0,0 +1,11 @@
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;

18
src/bw/url.rs Normal file
View File

@@ -0,0 +1,18 @@
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)
}
}

View File

@@ -1,15 +1,16 @@
use std::convert::{TryFrom, TryInto};
use std::path::Path;
use serde::{Deserialize, Serialize};
use tokio::{fs::File, io::AsyncWriteExt};
use crate::{
email::Email,
statics::{LATEST_CONFIGURATION_VERSION, SYSTEM_CONFIG_PATH_CANDIDATES},
use crate::statics::{
DEFAULT_BITWARDEN_DOMAIN, LATEST_CONFIGURATION_VERSION, SYSTEM_CONFIG_PATH_CANDIDATES,
};
#[derive(Deserialize, Serialize, Debug)]
use super::Domain;
use super::Email;
#[derive(serde::Deserialize, serde::Serialize, Debug)]
pub struct PunlockConfigurationEntry {
pub id: String,
pub query: String,
@@ -19,12 +20,13 @@ pub struct PunlockConfigurationEntry {
pub public: bool,
}
#[derive(Deserialize)]
#[derive(serde::Deserialize)]
pub struct PartialPunlockConfiguration {
pub domain: Option<String>,
pub version: Option<String>,
pub email: Option<String>,
pub entries: Option<Vec<PunlockConfigurationEntry>>,
pub cache_token: Option<bool>,
}
impl TryFrom<&Path> for PartialPunlockConfiguration {
@@ -60,11 +62,12 @@ impl PartialPunlockConfiguration {
}
}
#[derive(Serialize)]
#[derive(serde::Serialize)]
pub struct PunlockConfiguration {
pub cache_token: bool,
pub version: String,
pub email: Email,
pub domain: Option<String>,
pub domain: Domain,
pub entries: Vec<PunlockConfigurationEntry>,
}
@@ -73,7 +76,11 @@ impl TryFrom<PartialPunlockConfiguration> for PunlockConfiguration {
fn try_from(value: PartialPunlockConfiguration) -> anyhow::Result<Self> {
Ok(Self {
domain: value.domain,
cache_token: value.cache_token.unwrap_or(false),
domain: value
.domain
.unwrap_or(DEFAULT_BITWARDEN_DOMAIN.to_string())
.into(),
version: value
.version
.unwrap_or(LATEST_CONFIGURATION_VERSION.to_string()),

55
src/data/credentials.rs Normal file
View File

@@ -0,0 +1,55 @@
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 }
}
}

17
src/data/domain.rs Normal file
View File

@@ -0,0 +1,17 @@
#[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)
}
}

View File

@@ -5,7 +5,7 @@ use serde::Serialize;
use crate::statics::EMAIL_REGEX;
#[derive(Serialize)]
#[derive(Serialize, Clone, Debug)]
pub struct Email(String);
impl Email {

12
src/data/mod.rs Normal file
View File

@@ -0,0 +1,12 @@
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;

36
src/data/password.rs Normal file
View File

@@ -0,0 +1,36 @@
#[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())
}
}

View File

@@ -1,5 +1,5 @@
pub mod bitwarden;
pub mod config;
pub mod email;
#![feature(stmt_expr_attributes)]
pub mod bw;
pub mod data;
pub mod statics;
pub mod store;

View File

@@ -1,13 +1,22 @@
use std::convert::TryInto;
use aes_gcm::aes::cipher::BlockDecrypt;
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 clap::Parser;
use punlock::{
bitwarden::Bitwarden,
config::{PartialPunlockConfiguration, PunlockConfiguration},
statics::USER_CONFIG_FILE_PATH,
store::UnmountedSecretStore,
};
use hmac::Mac;
use punlock::bw::{BitwardenApi, BitwardenHttpClient};
use punlock::data::config::PartialPunlockConfiguration;
use punlock::data::{BitwardenClientCredentials, Password, PunlockConfiguration};
use punlock::statics::USER_CREDENTIALS_FILE_PATH;
use punlock::{statics::USER_CONFIG_FILE_PATH, store::SecretStore};
use tracing_subscriber::{
fmt::format::FmtSpan, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter,
};
@@ -19,8 +28,124 @@ struct Cli {
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]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
async fn main() -> anyhow::Result<()> {
let formattter = tracing_subscriber::fmt::Layer::new()
.with_thread_names(true)
.with_span_events(FmtSpan::FULL);
@@ -40,17 +165,21 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
};
let config: PunlockConfiguration = config.try_into()?;
config
.write_to_disk(USER_CONFIG_FILE_PATH.as_path())
.await?;
let bitwarden = Bitwarden::new(config.email)
.authenticate(config.domain)
let credentials = BitwardenClientCredentials::new(USER_CREDENTIALS_FILE_PATH.as_path()).await;
credentials
.write_to_disk(USER_CREDENTIALS_FILE_PATH.as_path())
.await?;
let store = UnmountedSecretStore::new(bitwarden)
.into_platform_store()
.await?;
store.write_secrets(&config.entries).await?;
let bitwarden =
BitwardenHttpClient::new(&config.email, &credentials, config.domain.clone()).await?;
testdecrypt(&config, &bitwarden)?;
// let store = SecretStore::new(bitwarden).await?;
// store.write_secrets(&config.entries).await?;
Ok(())
}

View File

@@ -7,16 +7,31 @@ use regex::Regex;
lazy_static! {
pub static ref LATEST_CONFIGURATION_VERSION: &'static str = "1.0.0";
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 =
ProjectDirs::from("dev", "kruhlmann", "punlock").unwrap();
pub static ref USER_CONFIG_FILE_PATH: PathBuf =
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();
#[cfg(target_os = "linux")]
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()),
pub static ref SYSTEM_CONFIG_PATH_CANDIDATES: Vec<PathBuf> = [
PathBuf::from(CONFIG_FILE_NAME.to_string()),
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();
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();
}

View File

@@ -1,157 +1,71 @@
use std::{os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc};
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use futures::{StreamExt, stream::FuturesUnordered};
use aes_gcm::{aead::Aead, aes::Aes256, Aes256Gcm, KeyInit, Nonce};
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 crate::{
bitwarden::Bitwarden,
config::PunlockConfigurationEntry,
statics::{self, HOME_DIRECTORY},
bw::{bitwarden::EncryptedBitwardenItem, BitwardenApi, BitwardenToken},
data::PunlockConfigurationEntry,
statics::{HOME_DIRECTORY, RUNTIME_DIRECTORY},
};
pub struct UnmountedSecretStore {
bitwarden: Bitwarden<String>,
pub struct SecretStore {
path: Arc<Path>,
bitwarden: Arc<Box<dyn BitwardenApi>>,
}
impl UnmountedSecretStore {
pub fn new(bitwarden: Bitwarden<String>) -> Self {
Self { bitwarden }
}
}
// struct DecryptedString(String);
impl UnmountedSecretStore {
pub async fn into_platform_store(self) -> anyhow::Result<UnixSecretStore> {
cfg_if::cfg_if! {
if #[cfg(target_os = "linux")] {
let root_path = statics::RUNTIME_DIRECTORY.join("punlock");
let store = UnixSecretStore::new(self.bitwarden, root_path).teardown().await?.setup().await?;
Ok(store)
} else if #[cfg(target_os = "macos")] {
// mount_ramdisk_macos(mount_point)?;
panic!("todo");
} else {
panic!("todo");
// debug!("On Windows or unsupported OS: using plain dir at {}", mount_point.display());
}
// impl DecryptedString {
// pub fn from_encrypted(encrypted: &str, credentials: &BitwardenToken) -> anyhow::Result<Self> {
// let cipher = Aes256Gcm::new(&credentials.key);
// let blob = base64::engine::general_purpose::STANDARD
// .decode(encrypted)
// .context("failed to Base64decode encrypted data")?;
// if blob.len() < 12 + 16 {
// anyhow::bail!("encrypted data too short");
// }
// let (nonce_bytes, ciphertext_and_tag) = blob.split_at(12);
// let nonce = Nonce::from_slice(nonce_bytes);
// let plaintext = cipher
// .decrypt(nonce, ciphertext_and_tag)
// .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)),
}
}
}
pub struct UnixSecretStore {
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();
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(())
})
.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(())
.teardown()
.await?
.setup()
.await?;
Ok(this)
}
async fn teardown(self) -> anyhow::Result<Self> {
if self.root_path.exists() {
tokio::fs::remove_dir_all(&*self.root_path)
if self.path.exists() {
tokio::fs::remove_dir_all(&*self.path)
.await
.inspect_err(
|error| tracing::error!(?error, path = ?self.root_path, "remove runtime dir"),
|error| tracing::error!(?error, path = ?self.path, "remove runtime dir"),
)
.ok();
}
@@ -159,11 +73,112 @@ impl UnixSecretStore {
}
async fn setup(self) -> anyhow::Result<Self> {
tokio::fs::create_dir_all(&*self.root_path)
.await
.inspect_err(
|error| tracing::error!(?error, path = ?self.root_path, "create runtime dir"),
)?;
tokio::fs::create_dir_all(&*self.path).await.inspect_err(
|error| tracing::error!(?error, path = ?self.path, "create runtime dir"),
)?;
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(())
}
}