mirror of
https://github.com/Kruhlmann/punlock.git
synced 2026-11-05 09:24:08 +00:00
Compare commits
8
Commits
migrate
...
31b0156f6c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31b0156f6c | ||
|
|
abf205bd93 | ||
|
|
8c6ec3a7f4 | ||
|
|
b093fd6b0e | ||
|
|
08e3b6025a | ||
|
|
4ce91d617f | ||
|
|
993da22357 | ||
|
|
f7543b2e6c |
No files matched your search
@@ -0,0 +1,64 @@
|
||||
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 +1,2 @@
|
||||
/target
|
||||
/.direnv
|
||||
Generated
+81
-59
@@ -225,6 +225,22 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
@@ -325,6 +341,17 @@ dependencies = [
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.31.1"
|
||||
@@ -385,9 +412,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.172"
|
||||
version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
@@ -399,6 +426,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.12"
|
||||
@@ -417,11 +450,11 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata 0.1.10",
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -452,12 +485,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.46.0"
|
||||
version = "0.50.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
|
||||
checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399"
|
||||
dependencies = [
|
||||
"overload",
|
||||
"winapi",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -481,12 +513,6 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "overload"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.3"
|
||||
@@ -548,6 +574,7 @@ dependencies = [
|
||||
"rpassword",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
@@ -564,6 +591,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.11"
|
||||
@@ -579,7 +612,7 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.2.16",
|
||||
"libredox",
|
||||
"thiserror 2.0.12",
|
||||
]
|
||||
@@ -592,17 +625,8 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata 0.4.9",
|
||||
"regex-syntax 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
|
||||
dependencies = [
|
||||
"regex-syntax 0.6.29",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -613,15 +637,9 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax 0.8.5",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.6.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.5"
|
||||
@@ -655,6 +673,19 @@ version = "0.1.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.20"
|
||||
@@ -784,6 +815,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -942,14 +986,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.19"
|
||||
version = "0.3.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
|
||||
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
@@ -1050,28 +1094,6 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
|
||||
+4
-1
@@ -24,5 +24,8 @@ serde_json = "1.0.140"
|
||||
tokio = { version = "1.44.2", features = ["full"] }
|
||||
toml = "0.8.20"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
|
||||
users = "0.11.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.20.0"
|
||||
@@ -0,0 +1,90 @@
|
||||
# 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.
|
||||
+66
-16
@@ -5,8 +5,8 @@ use tokio::process::Command;
|
||||
use crate::{config::PunlockConfigurationEntry, email::Email};
|
||||
|
||||
pub struct Bitwarden<S> {
|
||||
email: Email,
|
||||
session: S,
|
||||
pub email: Email,
|
||||
pub session: S,
|
||||
}
|
||||
|
||||
impl Bitwarden<()> {
|
||||
@@ -26,7 +26,7 @@ impl Bitwarden<()> {
|
||||
.ok();
|
||||
if let Some(ref d) = domain {
|
||||
Command::new("bw")
|
||||
.args(["config", "server", &d])
|
||||
.args(["config", "server", d])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
@@ -77,7 +77,7 @@ impl Bitwarden<()> {
|
||||
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])
|
||||
.args(["get", "item", entry.id.as_ref(), "--session", &self.session])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
@@ -96,18 +96,7 @@ impl Bitwarden<String> {
|
||||
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)
|
||||
extract_secret(&data, &entry.query)
|
||||
}
|
||||
|
||||
pub async fn logout(&self) -> anyhow::Result<()> {
|
||||
@@ -122,3 +111,64 @@ impl Bitwarden<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
+125
-1
@@ -6,12 +6,13 @@ 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: String,
|
||||
pub id: UuidV4,
|
||||
pub query: String,
|
||||
pub path: String,
|
||||
pub links: Option<Vec<String>>,
|
||||
@@ -105,3 +106,126 @@ impl PunlockConfiguration {
|
||||
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());
|
||||
}
|
||||
}
|
||||
+49
-1
@@ -29,8 +29,10 @@ impl Email {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_ref(&self) -> &str {
|
||||
impl AsRef<str> for Email {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
@@ -58,3 +60,49 @@ impl std::fmt::Display for Email {
|
||||
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]");
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod bitwarden;
|
||||
pub mod config;
|
||||
pub mod email;
|
||||
pub mod item_id;
|
||||
pub mod statics;
|
||||
pub mod store;
|
||||
@@ -19,4 +19,8 @@ lazy_static! {
|
||||
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 UUID_V4_REGEX: Regex = Regex::new(
|
||||
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();
|
||||
}
|
||||
+66
-8
@@ -1,6 +1,11 @@
|
||||
use std::{os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc};
|
||||
use std::{
|
||||
os::unix::fs::PermissionsExt,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use futures::{StreamExt, stream::FuturesUnordered};
|
||||
use futures::{stream::FuturesUnordered, StreamExt, TryFutureExt};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::{
|
||||
@@ -52,6 +57,12 @@ impl UnixSecretStore {
|
||||
|
||||
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();
|
||||
@@ -86,11 +97,7 @@ impl UnixSecretStore {
|
||||
|
||||
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)
|
||||
};
|
||||
let link_path = resolve_link_path(link, &HOME_DIRECTORY);
|
||||
tokio::fs::create_dir_all(link_path.parent().unwrap_or(&link_path))
|
||||
.await?;
|
||||
match tokio::fs::symlink_metadata(&link_path).await {
|
||||
@@ -116,8 +123,9 @@ impl UnixSecretStore {
|
||||
std::os::windows::fs::symlink_file(&src, &dst)?;
|
||||
Ok(())
|
||||
})
|
||||
.inspect_err(|error| tracing::error!(src = ?path, dst = ?link_path, ?error, "symlink failed"))
|
||||
.await
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))??;
|
||||
.map_err(std::io::Error::other)??;
|
||||
|
||||
tracing::info!(src =? path, destination = ?link_path, "created/updated symlink");
|
||||
}
|
||||
@@ -167,3 +175,53 @@ impl UnixSecretStore {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_link_path(link: &str, home: &Path) -> PathBuf {
|
||||
let path = PathBuf::from(link);
|
||||
if path.is_absolute() {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn absolute_link_paths_are_left_untouched() {
|
||||
let home = Path::new("/home/testuser");
|
||||
assert_eq!(
|
||||
resolve_link_path("/etc/punlock/key", home),
|
||||
PathBuf::from("/etc/punlock/key")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_is_expanded_to_home() {
|
||||
let home = Path::new("/home/testuser");
|
||||
assert_eq!(
|
||||
resolve_link_path("~", home),
|
||||
PathBuf::from("/home/testuser")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_link_path("~/.ssh/id_rsa", home),
|
||||
PathBuf::from("/home/testuser/.ssh/id_rsa")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_link_paths_are_joined_onto_home() {
|
||||
let home = Path::new("/home/testuser");
|
||||
assert_eq!(
|
||||
resolve_link_path("sub/key", home),
|
||||
PathBuf::from("/home/testuser/sub/key")
|
||||
);
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
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