mirror of
https://github.com/Kruhlmann/punlock.git
synced 2026-11-05 09:24:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79c1935a7 |
No files matched your search
@@ -1,64 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
pull_request:
|
|
||||||
branches: [master]
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Cache Cargo registry
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
target
|
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-
|
|
||||||
- name: Build
|
|
||||||
run: cargo build --release
|
|
||||||
- name: Run tests
|
|
||||||
run: cargo test --release
|
|
||||||
|
|
||||||
clippy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
- name: Cache Cargo registry
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
target
|
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-
|
|
||||||
- name: Run Clippy
|
|
||||||
run: cargo clippy -- -D warnings
|
|
||||||
|
|
||||||
fmt:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: cargo fmt -- --check
|
|
||||||
@@ -1,2 +1 @@
|
|||||||
/target
|
/target
|
||||||
/.direnv
|
|
||||||
Generated
+1574
-78
File diff suppressed because it is too large.
Load diff
+15
-6
@@ -8,24 +8,33 @@ 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"
|
||||||
caps = "0.5.5"
|
argon2 = "0.5.3"
|
||||||
cfg-if = "1.0.0"
|
async-trait = "0.1.88"
|
||||||
|
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.20", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
users = "0.11.0"
|
urlencode = "1.0.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[target.x86_64-pc-windows-gnu]
|
||||||
tempfile = "3.20.0"
|
linker = "x86_64-w64-mingw32-gcc"
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Punlock
|
|
||||||
|
|
||||||
PasswordUNLOCKer - A tool to unlock secrets from [Bitwarden](https://bitwarden.com/) and mount them to the filesystem.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Punlock authenticates with Bitwarden, fetches secrets based on `JMESPath` queries, and writes them to a secure runtime directory. It can also create symlinks to make secrets accessible at convenient locations.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Authenticate with Bitwarden (including self-hosted instances)
|
|
||||||
- Fetch secrets using `JMESPath` queries on Bitwarden items
|
|
||||||
- Store secrets in a secure runtime directory (Linux: `/run/user/<uid>/punlock/`)
|
|
||||||
- Create symlinks to secrets at custom locations
|
|
||||||
- Automatic file permissions (readonly, 400 for private secrets)
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Rust (for building)
|
|
||||||
- Bitwarden CLI (`bw`) installed and available in PATH
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
The binary will be available at `target/release/punlock`.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Create a configuration file at one of these locations:
|
|
||||||
- Current directory: `config.toml`
|
|
||||||
- User config: `~/.config/punlock/config.toml`
|
|
||||||
- System config: `/etc/punlock/config.toml`
|
|
||||||
|
|
||||||
Or specify a custom path with `--config`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
punlock --config /path/to/config.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration Format
|
|
||||||
|
|
||||||
```toml
|
|
||||||
domain = "https://your-bitwarden-instance.com"
|
|
||||||
email = "[email protected]"
|
|
||||||
|
|
||||||
# Secrets to unlock
|
|
||||||
[[entries]]
|
|
||||||
id = "bitwarden-item-uuid"
|
|
||||||
query = "details.fields[[email protected]=='ssh_private_key'].value"
|
|
||||||
path = "ssh/id_rsa"
|
|
||||||
public = false
|
|
||||||
links = ["~/.ssh/id_rsa"]
|
|
||||||
|
|
||||||
[[entries]]
|
|
||||||
id = "another-item-uuid"
|
|
||||||
query = "details.login.password"
|
|
||||||
path = "api/tokens/github"
|
|
||||||
public = true
|
|
||||||
links = ["~/.config/github/token"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration Fields
|
|
||||||
|
|
||||||
- `domain` (optional): Bitwarden server URL for self-hosted instances
|
|
||||||
- `email` (optional): Bitwarden account email
|
|
||||||
- `entries`: Array of secrets to unlock
|
|
||||||
- `id`: Bitwarden item UUID
|
|
||||||
- `query`: `JMESPath` expression to extract the secret from the item
|
|
||||||
- `path`: Relative path in the runtime directory to write the secret
|
|
||||||
- `links` (optional): Array of symlink paths to create (supports `~` for home directory)
|
|
||||||
- `public` (default: `false`): If true, file permissions are 0444; otherwise 0400
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```sh
|
|
||||||
punlock
|
|
||||||
# Or
|
|
||||||
punlock --config /path/to/config.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
On first run, if email is not configured, you'll be prompted to enter it. You'll also need to enter your Bitwarden password.
|
|
||||||
|
|
||||||
## Runtime Directory
|
|
||||||
|
|
||||||
On Linux, secrets are stored in: `/run/user/<uid>/punlock/`
|
|
||||||
|
|
||||||
This is a tmpfs directory that is automatically cleaned on reboot to ensure that secrets do not stick around.
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
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.as_ref(), "--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"))?;
|
|
||||||
|
|
||||||
extract_secret(&data, &entry.query)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn extract_secret(data: &serde_json::Value, query: &str) -> anyhow::Result<String> {
|
|
||||||
let expr = jmespath::compile(query)
|
|
||||||
.inspect_err(|error| tracing::error!(?error, ?query, "input/expression mismatch"))?;
|
|
||||||
let result = expr
|
|
||||||
.search(data)
|
|
||||||
.inspect_err(|error| tracing::error!(?error, ?expr, "query failed to apply"))?;
|
|
||||||
|
|
||||||
match result.as_string() {
|
|
||||||
Some(s) => Ok(s.to_owned()),
|
|
||||||
None => {
|
|
||||||
tracing::error!(?data, ?expr, "find secret");
|
|
||||||
anyhow::bail!("invalid item")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extracts_a_string_value() {
|
|
||||||
let item = json!({ "login": { "username": "u", "password": "s3cr3t" } });
|
|
||||||
let secret = extract_secret(&item, "login.password").expect("should extract password");
|
|
||||||
assert_eq!(secret, "s3cr3t");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extracts_custom_field_via_filter_and_index() {
|
|
||||||
let item = json!({
|
|
||||||
"fields": [
|
|
||||||
{ "name": "other", "value": "ignored" },
|
|
||||||
{ "name": "ssh_private_key", "value": "-----BEGIN KEY-----" }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
let query = "fields[?name=='ssh_private_key'].value | [0]";
|
|
||||||
let secret = extract_secret(&item, query).expect("should extract custom field");
|
|
||||||
assert_eq!(secret, "-----BEGIN KEY-----");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_query_that_yields_a_non_string() {
|
|
||||||
let item = json!({ "fields": [{ "name": "ssh_private_key", "value": "k" }] });
|
|
||||||
// a filter projection yields an array, not a string
|
|
||||||
assert!(extract_secret(&item, "fields[?name=='ssh_private_key'].value").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_missing_key() {
|
|
||||||
let item = json!({ "login": {} });
|
|
||||||
assert!(extract_secret(&item, "login.password").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_invalid_jmespath_expression() {
|
|
||||||
let item = json!({});
|
|
||||||
assert!(extract_secret(&item, "fields[?name==").is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(¶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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
-231
@@ -1,231 +0,0 @@
|
|||||||
use std::convert::{TryFrom, TryInto};
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tokio::{fs::File, io::AsyncWriteExt};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
email::Email,
|
|
||||||
item_id::UuidV4,
|
|
||||||
statics::{LATEST_CONFIGURATION_VERSION, SYSTEM_CONFIG_PATH_CANDIDATES},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug)]
|
|
||||||
pub struct PunlockConfigurationEntry {
|
|
||||||
pub id: UuidV4,
|
|
||||||
pub query: String,
|
|
||||||
pub path: String,
|
|
||||||
pub links: Option<Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub public: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct PartialPunlockConfiguration {
|
|
||||||
pub domain: Option<String>,
|
|
||||||
pub version: Option<String>,
|
|
||||||
pub email: Option<String>,
|
|
||||||
pub entries: Option<Vec<PunlockConfigurationEntry>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<&Path> for PartialPunlockConfiguration {
|
|
||||||
type Error = anyhow::Error;
|
|
||||||
|
|
||||||
fn try_from(path: &Path) -> anyhow::Result<Self> {
|
|
||||||
tracing::debug!(?path, "loading configuration");
|
|
||||||
let content = std::fs::read_to_string(path).inspect_err(|error| {
|
|
||||||
tracing::error!(?error, ?path, "unable to read configuration file");
|
|
||||||
})?;
|
|
||||||
let config: PartialPunlockConfiguration =
|
|
||||||
toml::from_str(&content).inspect_err(|error| {
|
|
||||||
tracing::error!(?error, ?path, "configuration is invalid");
|
|
||||||
})?;
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PartialPunlockConfiguration {
|
|
||||||
pub fn try_from_default_path() -> anyhow::Result<Self> {
|
|
||||||
for path in SYSTEM_CONFIG_PATH_CANDIDATES.iter() {
|
|
||||||
tracing::debug!(?path, "inspecting configuration candidate");
|
|
||||||
if path.exists() {
|
|
||||||
tracing::debug!(?path, "configuration file exists");
|
|
||||||
let cfg: PartialPunlockConfiguration =
|
|
||||||
path.as_path().try_into().inspect_err(|error| {
|
|
||||||
tracing::error!(?error, ?path, "unable to load configuration")
|
|
||||||
})?;
|
|
||||||
return Ok(cfg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
anyhow::bail!("no default configuration found");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct PunlockConfiguration {
|
|
||||||
pub version: String,
|
|
||||||
pub email: Email,
|
|
||||||
pub domain: Option<String>,
|
|
||||||
pub entries: Vec<PunlockConfigurationEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<PartialPunlockConfiguration> for PunlockConfiguration {
|
|
||||||
type Error = anyhow::Error;
|
|
||||||
|
|
||||||
fn try_from(value: PartialPunlockConfiguration) -> anyhow::Result<Self> {
|
|
||||||
Ok(Self {
|
|
||||||
domain: value.domain,
|
|
||||||
version: value
|
|
||||||
.version
|
|
||||||
.unwrap_or(LATEST_CONFIGURATION_VERSION.to_string()),
|
|
||||||
email: if let Some(e) = value.email {
|
|
||||||
e.as_str().try_into().inspect_err(
|
|
||||||
|error| tracing::error!(?error, email = ?e, "invalid email in configuration"),
|
|
||||||
)?
|
|
||||||
} else {
|
|
||||||
Email::from_stdin()
|
|
||||||
},
|
|
||||||
entries: value.entries.unwrap_or_default(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PunlockConfiguration {
|
|
||||||
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 = File::create(path).await.inspect_err(|error| {
|
|
||||||
tracing::error!(?path, ?error, "unable to create configuration file")
|
|
||||||
})?;
|
|
||||||
file.write_all(contents.as_bytes())
|
|
||||||
.await
|
|
||||||
.inspect_err(|error| {
|
|
||||||
tracing::error!(?path, ?error, "unable to write configuration to file")
|
|
||||||
})?;
|
|
||||||
tracing::debug!(?path, "wrote configuration");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const VALID_ID: &str = "11111111-2222-3333-4444-555555555555";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn partial_config_deserializes_full_document() {
|
|
||||||
let toml = r#"
|
|
||||||
domain = "https://bw.example.com"
|
|
||||||
version = "0.1.0"
|
|
||||||
email = "[email protected]"
|
|
||||||
|
|
||||||
[[entries]]
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
query = "details.fields[[email protected]=='ssh_private_key'].value"
|
|
||||||
path = "ssh/id_rsa"
|
|
||||||
public = true
|
|
||||||
links = ["~/.ssh/id_rsa"]
|
|
||||||
"#;
|
|
||||||
let cfg: PartialPunlockConfiguration =
|
|
||||||
toml::from_str(toml).expect("valid configuration should deserialize");
|
|
||||||
|
|
||||||
assert_eq!(cfg.domain.as_deref(), Some("https://bw.example.com"));
|
|
||||||
assert_eq!(cfg.version.as_deref(), Some("0.1.0"));
|
|
||||||
assert_eq!(cfg.email.as_deref(), Some("[email protected]"));
|
|
||||||
|
|
||||||
let entries = cfg.entries.expect("entries should be present");
|
|
||||||
assert_eq!(entries.len(), 1);
|
|
||||||
assert_eq!(entries[0].id.as_ref(), VALID_ID);
|
|
||||||
assert_eq!(entries[0].path, "ssh/id_rsa");
|
|
||||||
assert!(entries[0].public);
|
|
||||||
assert_eq!(entries[0].links, Some(vec!["~/.ssh/id_rsa".to_string()]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn partial_config_applies_field_defaults() {
|
|
||||||
let toml = r#"
|
|
||||||
[[entries]]
|
|
||||||
id = "11111111-2222-3333-4444-555555555555"
|
|
||||||
query = "details.login.password"
|
|
||||||
path = "api/token"
|
|
||||||
"#;
|
|
||||||
let cfg: PartialPunlockConfiguration =
|
|
||||||
toml::from_str(toml).expect("minimal configuration should deserialize");
|
|
||||||
|
|
||||||
assert!(cfg.domain.is_none());
|
|
||||||
assert!(cfg.version.is_none());
|
|
||||||
assert!(cfg.email.is_none());
|
|
||||||
|
|
||||||
let entries = cfg.entries.expect("entries should be present");
|
|
||||||
assert_eq!(entries.len(), 1);
|
|
||||||
assert!(!entries[0].public, "public should default to false");
|
|
||||||
assert!(entries[0].links.is_none(), "links should default to none");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn partial_config_rejects_invalid_toml() {
|
|
||||||
assert!(toml::from_str::<PartialPunlockConfiguration>("this is not = = toml").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn conversion_defaults_version_and_entries() {
|
|
||||||
let partial = PartialPunlockConfiguration {
|
|
||||||
domain: None,
|
|
||||||
version: None,
|
|
||||||
email: Some("[email protected]".to_string()),
|
|
||||||
entries: None,
|
|
||||||
};
|
|
||||||
let cfg: PunlockConfiguration = partial.try_into().expect("valid email should convert");
|
|
||||||
|
|
||||||
assert_eq!(cfg.version, *LATEST_CONFIGURATION_VERSION);
|
|
||||||
assert_eq!(cfg.email.as_ref(), "[email protected]");
|
|
||||||
assert!(cfg.domain.is_none());
|
|
||||||
assert!(cfg.entries.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn conversion_preserves_provided_version_and_entries() {
|
|
||||||
let entry = PunlockConfigurationEntry {
|
|
||||||
id: UuidV4::try_from(VALID_ID).unwrap(),
|
|
||||||
query: "details.login.password".to_string(),
|
|
||||||
path: "api/token".to_string(),
|
|
||||||
links: None,
|
|
||||||
public: false,
|
|
||||||
};
|
|
||||||
let partial = PartialPunlockConfiguration {
|
|
||||||
domain: Some("https://bw.example.com".to_string()),
|
|
||||||
version: Some("0.1.0".to_string()),
|
|
||||||
email: Some("[email protected]".to_string()),
|
|
||||||
entries: Some(vec![entry]),
|
|
||||||
};
|
|
||||||
let cfg: PunlockConfiguration = partial.try_into().expect("valid config should convert");
|
|
||||||
|
|
||||||
assert_eq!(cfg.version, "0.1.0");
|
|
||||||
assert_eq!(cfg.domain.as_deref(), Some("https://bw.example.com"));
|
|
||||||
assert_eq!(cfg.entries.len(), 1);
|
|
||||||
assert_eq!(cfg.entries[0].id.as_ref(), VALID_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn partial_config_rejects_invalid_item_id() {
|
|
||||||
let toml = r#"
|
|
||||||
[[entries]]
|
|
||||||
id = "not-a-uuid"
|
|
||||||
query = "details.login.password"
|
|
||||||
path = "api/token"
|
|
||||||
"#;
|
|
||||||
assert!(toml::from_str::<PartialPunlockConfiguration>(toml).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn conversion_rejects_invalid_email() {
|
|
||||||
let partial = PartialPunlockConfiguration {
|
|
||||||
domain: None,
|
|
||||||
version: Some("0.1.0".to_string()),
|
|
||||||
email: Some("not-an-email".to_string()),
|
|
||||||
entries: Some(vec![]),
|
|
||||||
};
|
|
||||||
assert!(PunlockConfiguration::try_from(partial).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
use std::convert::{TryFrom, TryInto};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use tokio::{fs::File, io::AsyncWriteExt};
|
||||||
|
|
||||||
|
use crate::statics::{
|
||||||
|
DEFAULT_BITWARDEN_DOMAIN, LATEST_CONFIGURATION_VERSION, SYSTEM_CONFIG_PATH_CANDIDATES,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::Domain;
|
||||||
|
use super::Email;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, serde::Serialize, Debug)]
|
||||||
|
pub struct PunlockConfigurationEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub query: String,
|
||||||
|
pub path: String,
|
||||||
|
pub links: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub public: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
|
fn try_from(path: &Path) -> anyhow::Result<Self> {
|
||||||
|
tracing::debug!(?path, "loading configuration");
|
||||||
|
let content = std::fs::read_to_string(path).inspect_err(|error| {
|
||||||
|
tracing::error!(?error, ?path, "unable to read configuration file");
|
||||||
|
})?;
|
||||||
|
let config: PartialPunlockConfiguration =
|
||||||
|
toml::from_str(&content).inspect_err(|error| {
|
||||||
|
tracing::error!(?error, ?path, "configuration is invalid");
|
||||||
|
})?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialPunlockConfiguration {
|
||||||
|
pub fn try_from_default_path() -> anyhow::Result<Self> {
|
||||||
|
for path in SYSTEM_CONFIG_PATH_CANDIDATES.iter() {
|
||||||
|
tracing::debug!(?path, "inspecting configuration candidate");
|
||||||
|
if path.exists() {
|
||||||
|
tracing::debug!(?path, "configuration file exists");
|
||||||
|
let cfg: PartialPunlockConfiguration =
|
||||||
|
path.as_path().try_into().inspect_err(|error| {
|
||||||
|
tracing::error!(?error, ?path, "unable to load configuration")
|
||||||
|
})?;
|
||||||
|
return Ok(cfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anyhow::bail!("no default configuration found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct PunlockConfiguration {
|
||||||
|
pub cache_token: bool,
|
||||||
|
pub version: String,
|
||||||
|
pub email: Email,
|
||||||
|
pub domain: Domain,
|
||||||
|
pub entries: Vec<PunlockConfigurationEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<PartialPunlockConfiguration> for PunlockConfiguration {
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
|
fn try_from(value: PartialPunlockConfiguration) -> anyhow::Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
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()),
|
||||||
|
email: if let Some(e) = value.email {
|
||||||
|
e.as_str().try_into().inspect_err(
|
||||||
|
|error| tracing::error!(?error, email = ?e, "invalid email in configuration"),
|
||||||
|
)?
|
||||||
|
} else {
|
||||||
|
Email::from_stdin()
|
||||||
|
},
|
||||||
|
entries: value.entries.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PunlockConfiguration {
|
||||||
|
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 = File::create(path).await.inspect_err(|error| {
|
||||||
|
tracing::error!(?path, ?error, "unable to create configuration file")
|
||||||
|
})?;
|
||||||
|
file.write_all(contents.as_bytes())
|
||||||
|
.await
|
||||||
|
.inspect_err(|error| {
|
||||||
|
tracing::error!(?path, ?error, "unable to write configuration to file")
|
||||||
|
})?;
|
||||||
|
tracing::debug!(?path, "wrote configuration");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ use serde::Serialize;
|
|||||||
|
|
||||||
use crate::statics::EMAIL_REGEX;
|
use crate::statics::EMAIL_REGEX;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Clone, Debug)]
|
||||||
pub struct Email(String);
|
pub struct Email(String);
|
||||||
|
|
||||||
impl Email {
|
impl Email {
|
||||||
@@ -29,10 +29,8 @@ impl Email {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for Email {
|
pub fn as_ref(&self) -> &str {
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
&self.0
|
&self.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,49 +58,3 @@ impl std::fmt::Display for Email {
|
|||||||
write!(f, "{}", self.0)
|
write!(f, "{}", self.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accepts_valid_emails() {
|
|
||||||
for case in [
|
|
||||||
"[email protected]",
|
|
||||||
"[email protected]",
|
|
||||||
"[email protected]",
|
|
||||||
"[email protected]",
|
|
||||||
] {
|
|
||||||
let email = Email::try_from(case).expect("should accept valid email");
|
|
||||||
assert_eq!(email.as_ref(), case);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_invalid_emails() {
|
|
||||||
for case in [
|
|
||||||
"",
|
|
||||||
"plainaddress",
|
|
||||||
"@missing-local.org",
|
|
||||||
"missing-at-sign.org",
|
|
||||||
"no-tld@domain",
|
|
||||||
"space [email protected]",
|
|
||||||
"user@domain@",
|
|
||||||
"user @domain.com",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
Email::try_from(case).is_err(),
|
|
||||||
"should reject invalid email: {:?}",
|
|
||||||
case
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn display_and_into_string_round_trip() {
|
|
||||||
let email = Email::try_from("[email protected]").unwrap();
|
|
||||||
assert_eq!(email.to_string(), "[email protected]");
|
|
||||||
let as_string: String = email.into();
|
|
||||||
assert_eq!(as_string, "[email protected]");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
-110
@@ -1,110 +0,0 @@
|
|||||||
use std::convert::TryFrom;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::statics::UUID_V4_REGEX;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(try_from = "String")]
|
|
||||||
pub struct UuidV4(String);
|
|
||||||
|
|
||||||
impl UuidV4 {
|
|
||||||
fn is_valid(value: &str) -> bool {
|
|
||||||
UUID_V4_REGEX.is_match(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for UuidV4 {
|
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<String> for UuidV4 {
|
|
||||||
type Error = anyhow::Error;
|
|
||||||
|
|
||||||
fn try_from(value: String) -> anyhow::Result<Self> {
|
|
||||||
if Self::is_valid(&value) {
|
|
||||||
Ok(Self(value))
|
|
||||||
} else {
|
|
||||||
anyhow::bail!("invalid item id {}", value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<&str> for UuidV4 {
|
|
||||||
type Error = anyhow::Error;
|
|
||||||
|
|
||||||
fn try_from(value: &str) -> anyhow::Result<Self> {
|
|
||||||
Self::try_from(value.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<UuidV4> for String {
|
|
||||||
fn from(val: UuidV4) -> Self {
|
|
||||||
val.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for UuidV4 {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(f, "{}", self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accepts_valid_uuids() {
|
|
||||||
for case in [
|
|
||||||
"11111111-2222-3333-4444-555555555555",
|
|
||||||
"2f1e4b6a-9c3d-4a7e-8b2f-1c5d6e7f8a90",
|
|
||||||
"00000000-0000-0000-0000-000000000000",
|
|
||||||
"ABCDEF01-2345-6789-ABCD-EF0123456789",
|
|
||||||
] {
|
|
||||||
let id = UuidV4::try_from(case).expect("should accept valid uuid");
|
|
||||||
assert_eq!(id.as_ref(), case);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_invalid_uuids() {
|
|
||||||
for case in [
|
|
||||||
"",
|
|
||||||
"item-uuid",
|
|
||||||
"11111111222233334444555555555555",
|
|
||||||
"11111111-2222-3333-4444-55555555555",
|
|
||||||
"11111111-2222-3333-4444-5555555555555",
|
|
||||||
"11111111-2222-3333-4444-55555555555g",
|
|
||||||
"11111111 2222 3333 4444 555555555555",
|
|
||||||
"z1111111-2222-3333-4444-555555555555",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
UuidV4::try_from(case).is_err(),
|
|
||||||
"should reject invalid uuid: {:?}",
|
|
||||||
case
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn display_and_into_string_round_trip() {
|
|
||||||
let id = UuidV4::try_from("11111111-2222-3333-4444-555555555555").unwrap();
|
|
||||||
assert_eq!(id.to_string(), "11111111-2222-3333-4444-555555555555");
|
|
||||||
let as_string: String = id.into();
|
|
||||||
assert_eq!(as_string, "11111111-2222-3333-4444-555555555555");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deserializes_valid_uuid_from_string() {
|
|
||||||
let id: UuidV4 = serde_json::from_str("\"11111111-2222-3333-4444-555555555555\"").unwrap();
|
|
||||||
assert_eq!(id.as_ref(), "11111111-2222-3333-4444-555555555555");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deserialization_rejects_invalid_uuid() {
|
|
||||||
assert!(serde_json::from_str::<UuidV4>("\"not-a-uuid\"").is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-4
@@ -1,6 +1,5 @@
|
|||||||
pub mod bitwarden;
|
#![feature(stmt_expr_attributes)]
|
||||||
pub mod config;
|
pub mod bw;
|
||||||
pub mod email;
|
pub mod data;
|
||||||
pub mod item_id;
|
|
||||||
pub mod statics;
|
pub mod statics;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
+144
-15
@@ -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 std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use punlock::{
|
use hmac::Mac;
|
||||||
bitwarden::Bitwarden,
|
use punlock::bw::{BitwardenApi, BitwardenHttpClient};
|
||||||
config::{PartialPunlockConfiguration, PunlockConfiguration},
|
use punlock::data::config::PartialPunlockConfiguration;
|
||||||
statics::USER_CONFIG_FILE_PATH,
|
use punlock::data::{BitwardenClientCredentials, Password, PunlockConfiguration};
|
||||||
store::UnmountedSecretStore,
|
use punlock::statics::USER_CREDENTIALS_FILE_PATH;
|
||||||
};
|
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,
|
||||||
};
|
};
|
||||||
@@ -19,8 +28,124 @@ 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() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
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);
|
||||||
@@ -40,17 +165,21 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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 bitwarden = Bitwarden::new(config.email)
|
let credentials = BitwardenClientCredentials::new(USER_CREDENTIALS_FILE_PATH.as_path()).await;
|
||||||
.authenticate(config.domain)
|
credentials
|
||||||
|
.write_to_disk(USER_CREDENTIALS_FILE_PATH.as_path())
|
||||||
.await?;
|
.await?;
|
||||||
let store = UnmountedSecretStore::new(bitwarden)
|
|
||||||
.into_platform_store()
|
let bitwarden =
|
||||||
.await?;
|
BitwardenHttpClient::new(&config.email, &credentials, config.domain.clone()).await?;
|
||||||
store.write_secrets(&config.entries).await?;
|
|
||||||
|
testdecrypt(&config, &bitwarden)?;
|
||||||
|
|
||||||
|
// let store = SecretStore::new(bitwarden).await?;
|
||||||
|
// store.write_secrets(&config.entries).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
+19
-8
@@ -7,20 +7,31 @@ 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();
|
||||||
#[cfg(target_os = "linux")]
|
pub static ref SYSTEM_CONFIG_PATH_CANDIDATES: Vec<PathBuf> = [
|
||||||
pub static ref RUNTIME_DIRECTORY: PathBuf = dirs::runtime_dir().unwrap();
|
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(),
|
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 UUID_V4_REGEX: Regex = Regex::new(
|
pub static ref DEFAULT_BITWARDEN_DOMAIN: &'static str = "vault.bitwarden.com";
|
||||||
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
}
|
||||||
)
|
|
||||||
.unwrap();
|
#[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();
|
||||||
}
|
}
|
||||||
+146
-189
@@ -1,165 +1,71 @@
|
|||||||
use std::{
|
use std::{
|
||||||
os::unix::fs::PermissionsExt,
|
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::{Command, Stdio},
|
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
use futures::{stream::FuturesUnordered, StreamExt, TryFutureExt};
|
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 tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
bitwarden::Bitwarden,
|
bw::{bitwarden::EncryptedBitwardenItem, BitwardenApi, BitwardenToken},
|
||||||
config::PunlockConfigurationEntry,
|
data::PunlockConfigurationEntry,
|
||||||
statics::{self, HOME_DIRECTORY},
|
statics::{HOME_DIRECTORY, RUNTIME_DIRECTORY},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct UnmountedSecretStore {
|
pub struct SecretStore {
|
||||||
bitwarden: Bitwarden<String>,
|
path: Arc<Path>,
|
||||||
|
bitwarden: Arc<Box<dyn BitwardenApi>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UnmountedSecretStore {
|
// struct DecryptedString(String);
|
||||||
pub fn new(bitwarden: Bitwarden<String>) -> Self {
|
|
||||||
Self { bitwarden }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UnmountedSecretStore {
|
// impl DecryptedString {
|
||||||
pub async fn into_platform_store(self) -> anyhow::Result<UnixSecretStore> {
|
// pub fn from_encrypted(encrypted: &str, credentials: &BitwardenToken) -> anyhow::Result<Self> {
|
||||||
cfg_if::cfg_if! {
|
// let cipher = Aes256Gcm::new(&credentials.key);
|
||||||
if #[cfg(target_os = "linux")] {
|
// let blob = base64::engine::general_purpose::STANDARD
|
||||||
let root_path = statics::RUNTIME_DIRECTORY.join("punlock");
|
// .decode(encrypted)
|
||||||
let store = UnixSecretStore::new(self.bitwarden, root_path).teardown().await?.setup().await?;
|
// .context("failed to Base64‐decode encrypted data")?;
|
||||||
Ok(store)
|
// if blob.len() < 12 + 16 {
|
||||||
} else if #[cfg(target_os = "macos")] {
|
// anyhow::bail!("encrypted data too short");
|
||||||
// mount_ramdisk_macos(mount_point)?;
|
// }
|
||||||
panic!("todo");
|
// let (nonce_bytes, ciphertext_and_tag) = blob.split_at(12);
|
||||||
} else {
|
// let nonce = Nonce::from_slice(nonce_bytes);
|
||||||
panic!("todo");
|
// let plaintext = cipher
|
||||||
// debug!("On Windows or unsupported OS: using plain dir at {}", mount_point.display());
|
// .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")?;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct UnixSecretStore {
|
// Ok(Self(s))
|
||||||
bitwarden: Arc<Bitwarden<String>>,
|
// }
|
||||||
root_path: Arc<PathBuf>,
|
// }
|
||||||
|
|
||||||
|
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()
|
||||||
impl UnixSecretStore {
|
.await?
|
||||||
pub fn new(bitwarden: Bitwarden<String>, root_path: PathBuf) -> Self {
|
.setup()
|
||||||
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 = resolve_link_path(link, &HOME_DIRECTORY);
|
|
||||||
tokio::fs::create_dir_all(link_path.parent().unwrap_or(&link_path))
|
|
||||||
.await?;
|
.await?;
|
||||||
match tokio::fs::symlink_metadata(&link_path).await {
|
Ok(this)
|
||||||
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(std::io::Error::other)??;
|
|
||||||
|
|
||||||
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.root_path.exists() {
|
if self.path.exists() {
|
||||||
tokio::fs::remove_dir_all(&*self.root_path)
|
tokio::fs::remove_dir_all(&*self.path)
|
||||||
.await
|
.await
|
||||||
.inspect_err(
|
.inspect_err(
|
||||||
|error| tracing::error!(?error, path = ?self.root_path, "remove runtime dir"),
|
|error| tracing::error!(?error, path = ?self.path, "remove runtime dir"),
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
@@ -167,61 +73,112 @@ impl UnixSecretStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn setup(self) -> anyhow::Result<Self> {
|
async fn setup(self) -> anyhow::Result<Self> {
|
||||||
tokio::fs::create_dir_all(&*self.root_path)
|
tokio::fs::create_dir_all(&*self.path).await.inspect_err(
|
||||||
.await
|
|error| tracing::error!(?error, path = ?self.path, "create runtime dir"),
|
||||||
.inspect_err(
|
|
||||||
|error| tracing::error!(?error, path = ?self.root_path, "create runtime dir"),
|
|
||||||
)?;
|
)?;
|
||||||
Ok(self)
|
Ok(self)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_link_path(link: &str, home: &Path) -> PathBuf {
|
pub async fn write_secrets(&self, entries: &[PunlockConfigurationEntry]) -> anyhow::Result<()> {
|
||||||
let path = PathBuf::from(link);
|
// let mut tasks = FuturesUnordered::new();
|
||||||
if path.is_absolute() {
|
// let token = self.bitwarden.get_token();
|
||||||
return path;
|
|
||||||
}
|
|
||||||
if link == "~" {
|
|
||||||
return home.to_path_buf();
|
|
||||||
}
|
|
||||||
if let Some(rest) = link.strip_prefix("~/") {
|
|
||||||
return home.join(rest);
|
|
||||||
}
|
|
||||||
home.join(link)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
// for entry in entries {
|
||||||
mod tests {
|
// let root = self.path.clone();
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
// tasks.push(async move {
|
||||||
fn absolute_link_paths_are_left_untouched() {
|
// let item = self.bitwarden.get_item(&entry.id).await.inspect_err(|error| tracing::error!(id = ?entry.id, ?error, "get item"))?;
|
||||||
let home = Path::new("/home/testuser");
|
// let EncryptedBitwardenItem(cipher) = jmespath::compile(&entry.query)
|
||||||
assert_eq!(
|
// .inspect_err(|error| {
|
||||||
resolve_link_path("/etc/punlock/key", home),
|
// tracing::error!(?error, query = ?entry.query, "input/expression mismatch")
|
||||||
PathBuf::from("/etc/punlock/key")
|
// })?
|
||||||
);
|
// .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?;
|
||||||
|
// }
|
||||||
|
|
||||||
#[test]
|
// let mut perms = tokio::fs::metadata(&source).await?.permissions();
|
||||||
fn tilde_is_expanded_to_home() {
|
// perms.set_readonly(true);
|
||||||
let home = Path::new("/home/testuser");
|
// #[cfg(unix)]
|
||||||
assert_eq!(
|
// if !entry.public {
|
||||||
resolve_link_path("~", home),
|
// std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o400);
|
||||||
PathBuf::from("/home/testuser")
|
// }
|
||||||
);
|
// tokio::fs::set_permissions(&source, perms)
|
||||||
assert_eq!(
|
// .await
|
||||||
resolve_link_path("~/.ssh/id_rsa", home),
|
// .inspect(|_| tracing::debug!(?source, "set perms"))
|
||||||
PathBuf::from("/home/testuser/.ssh/id_rsa")
|
// .inspect_err(|error| tracing::error!(?error, ?source, "set perms"))?;
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
// if let Some(links) = &entry.links {
|
||||||
fn relative_link_paths_are_joined_onto_home() {
|
// for link in links {
|
||||||
let home = Path::new("/home/testuser");
|
// let destination: PathBuf = if PathBuf::from(link).is_absolute() {
|
||||||
assert_eq!(
|
// PathBuf::from(link)
|
||||||
resolve_link_path("sub/key", home),
|
// } else {
|
||||||
PathBuf::from("/home/testuser/sub/key")
|
// 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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
-166
@@ -1,166 +0,0 @@
|
|||||||
use std::{
|
|
||||||
convert::TryFrom,
|
|
||||||
ffi::OsString,
|
|
||||||
fs,
|
|
||||||
os::unix::fs::PermissionsExt,
|
|
||||||
sync::{Mutex, MutexGuard},
|
|
||||||
};
|
|
||||||
|
|
||||||
use punlock::{
|
|
||||||
bitwarden::Bitwarden, config::PunlockConfigurationEntry, email::Email, item_id::UuidV4,
|
|
||||||
store::UnixSecretStore,
|
|
||||||
};
|
|
||||||
|
|
||||||
static PATH_LOCK: Mutex<()> = Mutex::new(());
|
|
||||||
|
|
||||||
struct FakeBw {
|
|
||||||
_dir: tempfile::TempDir,
|
|
||||||
_lock: MutexGuard<'static, ()>,
|
|
||||||
old_path: OsString,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FakeBw {
|
|
||||||
fn new(script: &str) -> Self {
|
|
||||||
let lock = PATH_LOCK
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let bw = dir.path().join("bw");
|
|
||||||
fs::write(&bw, format!("#!/usr/bin/env bash\n{script}\n")).expect("write fake bw");
|
|
||||||
fs::set_permissions(&bw, fs::Permissions::from_mode(0o755)).expect("chmod fake bw");
|
|
||||||
|
|
||||||
let old_path = std::env::var_os("PATH").unwrap_or_default();
|
|
||||||
std::env::set_var(
|
|
||||||
"PATH",
|
|
||||||
format!("{}:{}", dir.path().display(), old_path.to_string_lossy()),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
_dir: dir,
|
|
||||||
_lock: lock,
|
|
||||||
old_path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for FakeBw {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
std::env::set_var("PATH", &self.old_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bitwarden() -> Bitwarden<String> {
|
|
||||||
Bitwarden::<String> {
|
|
||||||
email: Email::try_from("[email protected]").expect("valid email"),
|
|
||||||
session: "fake-session".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn entry(id: &str, path: &str, public: bool) -> PunlockConfigurationEntry {
|
|
||||||
PunlockConfigurationEntry {
|
|
||||||
id: UuidV4::try_from(id).expect("valid uuid"),
|
|
||||||
query: "login.password".to_string(),
|
|
||||||
path: path.to_string(),
|
|
||||||
links: None,
|
|
||||||
public,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn fetch_returns_secret_from_fake_bw() {
|
|
||||||
let _fake = FakeBw::new(
|
|
||||||
r#"case "$1" in
|
|
||||||
get) printf '%s' '{"login":{"password":"s3cr3t"}}' ;;
|
|
||||||
esac"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
let secret = bitwarden()
|
|
||||||
.fetch(&entry(
|
|
||||||
"11111111-1111-4111-8111-111111111111",
|
|
||||||
"x/token",
|
|
||||||
false,
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("fetch should succeed");
|
|
||||||
|
|
||||||
assert_eq!(secret, "s3cr3t");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn fetch_errors_when_bw_fails() {
|
|
||||||
let _fake = FakeBw::new(
|
|
||||||
r#"case "$1" in
|
|
||||||
get) echo "item not found" >&2; exit 1 ;;
|
|
||||||
esac"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = bitwarden()
|
|
||||||
.fetch(&entry(
|
|
||||||
"11111111-1111-4111-8111-111111111111",
|
|
||||||
"x/token",
|
|
||||||
false,
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(result.is_err(), "fetch should fail when bw exits non-zero");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn logout_succeeds_against_fake_bw() {
|
|
||||||
let _fake = FakeBw::new("exit 0");
|
|
||||||
bitwarden().logout().await.expect("logout should succeed");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn write_secrets_writes_files_and_skips_failed_items() {
|
|
||||||
let _fake = FakeBw::new(
|
|
||||||
r#"case "$1" in
|
|
||||||
sync) exit 0 ;;
|
|
||||||
get)
|
|
||||||
case "$3" in
|
|
||||||
11111111-1111-4111-8111-111111111111) printf '%s' '{"login":{"password":"alpha"}}' ;;
|
|
||||||
22222222-2222-4222-8222-222222222222) printf '%s' '{"login":{"password":"beta"}}' ;;
|
|
||||||
33333333-3333-4333-8333-333333333333) echo "missing item" >&2; exit 1 ;;
|
|
||||||
*) printf '%s' '{}' ;;
|
|
||||||
esac ;;
|
|
||||||
esac"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
let root = tempfile::tempdir().expect("tempdir");
|
|
||||||
let store = UnixSecretStore::new(bitwarden(), root.path().to_path_buf());
|
|
||||||
|
|
||||||
let entries = vec![
|
|
||||||
entry("11111111-1111-4111-8111-111111111111", "a/token", false),
|
|
||||||
entry("22222222-2222-4222-8222-222222222222", "b/token", true),
|
|
||||||
entry("33333333-3333-4333-8333-333333333333", "c/token", false),
|
|
||||||
];
|
|
||||||
|
|
||||||
store
|
|
||||||
.write_secrets(&entries)
|
|
||||||
.await
|
|
||||||
.expect("write_secrets should not fail on a single bad item");
|
|
||||||
|
|
||||||
let private = root.path().join("a/token");
|
|
||||||
let public = root.path().join("b/token");
|
|
||||||
let missing = root.path().join("c/token");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
fs::read_to_string(&private).expect("private written"),
|
|
||||||
"alpha\n"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
fs::read_to_string(&public).expect("public written"),
|
|
||||||
"beta\n"
|
|
||||||
);
|
|
||||||
assert!(!missing.exists(), "failed item should not be written");
|
|
||||||
|
|
||||||
let private_mode = fs::metadata(&private).expect("meta").permissions().mode();
|
|
||||||
assert_eq!(private_mode & 0o777, 0o400, "private secret should be 0400");
|
|
||||||
|
|
||||||
let public_mode = fs::metadata(&public).expect("meta").permissions().mode();
|
|
||||||
assert_eq!(
|
|
||||||
public_mode & 0o222,
|
|
||||||
0,
|
|
||||||
"public secret should still be read-only (no write bits)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user