From a7287e0eb831431c0b681a6b559e3d4c7a919f55 Mon Sep 17 00:00:00 2001 From: turinglabsorg Date: Sat, 5 Sep 2026 14:34:07 +0200 Subject: [PATCH 1/3] feat: share stored secrets through expiring Bitwarden Sends --- AGENTS.md | 1 + README.md | 16 +++++ SKILL.md | 21 +++++++ src/cli.rs | 29 +++++++++ src/lib.rs | 1 + src/send.rs | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++ tests/send.rs | 136 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 374 insertions(+) create mode 100644 src/send.rs create mode 100644 tests/send.rs diff --git a/AGENTS.md b/AGENTS.md index b523968..6fe1ebf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,5 +10,6 @@ Rust CLI + agent skill. Secrets are ingested from Bitwarden (`hush pull` from a - `hush doctor` and `hush bitwarden status` must validate the encrypted `BITWARDEN_SESSION` themselves when no ambient `BW_SESSION` exists; status checks may inject the session only into the scoped `bw status` subprocess and must never print it. - Long files do not go through a chat message. `hush box register robin` publishes `robin@hush.sh`. `hush box seal --to robin@hush.sh --file PATH -u N` encrypts locally and publishes the sealed copy. The message that moves is the id. Each `hush box open --id ID NAME` consumes one use and at 0 the sealed copy is deleted. Keys stay in `~/.hush/box.key`. The directory in `directory/` keeps public keys and sealed copies in Firestore when `FIRESTORE_PROJECT` is set. - Tests stub `bw` with `tests/fixtures/fake-bw.sh` +- Outgoing `hush send --name NAME` may share only a stored secret explicitly authorized by the user. Pipe the encoded payload through stdin, use the ambient or stored Bitwarden session, suppress raw child failure output, and print only a validated HTTPS Send URL and expiry metadata. Never put plaintext in process arguments or temporary files. Text must be hidden and have an explicit finite expiry/deletion date. - Validate with `cargo fmt`, `cargo test --locked`, `cargo clippy --locked -- -D warnings`, and `cargo test --manifest-path directory/Cargo.toml` - GitHub Releases are built by `.github/workflows/release.yml` on `v*` tags (linux-x86_64, macos-x86_64, macos-aarch64, sha256 + cosign) diff --git a/README.md b/README.md index f0e77ae..fa0576f 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,22 @@ hush box open --id ID photo --out ./photo.png `open` writes the vault and, with `--out`, the file. It prints metadata (`name`, `sender`, `bytes`, `uses_left`). It does not print the payload. The message you send is only the id, so the Signal text limit does not apply. The file can be any type. +## Share a secret + +Create an expiring, hidden-text Bitwarden Send from an encrypted vault entry: + +```sh +hush send --name SERVICE_PASSWORD --title "Service access" --days 7 --json +``` + +The command prints only a Send link and expiry metadata. It uses the ambient +Bitwarden session or the encrypted `BITWARDEN_SESSION`, sends the payload through +subprocess stdin, and never places the value in arguments or temporary files. +The sender email is hidden. `--days` accepts 1–31 days and controls both expiration +and deletion; `--max-access-count` optionally limits retrievals. Only nonempty +UTF-8 text up to 1,000 characters is accepted. Raw Bitwarden failure output is +discarded to prevent a failed command from leaking the text or session. + ## Use ```bash diff --git a/SKILL.md b/SKILL.md index 95b28db..d9abdfc 100644 --- a/SKILL.md +++ b/SKILL.md @@ -103,6 +103,27 @@ Use `--master-secret` or `--session-secret` only when the user selected custom names. Account deletion or master-password replacement always requires explicit authorization for the exact account. +## Share a stored secret with a human + +When the user authorizes outgoing credential sharing, create a Bitwarden Send +directly from a stored name. Never decrypt into a file, message body, shell +argument, or direct `bw` command. + +```bash +hush send --name SERVICE_PASSWORD --title "Service access" --days 7 --json +``` + +Only the Send URL and metadata are printed. Text is hidden by default, sender +email is hidden, and both expiry and deletion are set to the requested lifetime +(1–31 days). Add `--max-access-count N` only when an access limit is intended. +The command accepts UTF-8 text up to 1,000 characters. It prefers an ambient +`BW_SESSION`, otherwise it loads the encrypted `BITWARDEN_SESSION` itself. +If the session is locked, rerun `hush bitwarden unlock --email `. + +Send the returned URL through the user's explicitly authorized messaging +channel. Do not send the credential itself. A failed receipt can occur after +remote creation; inspect the sender's Sends before retrying to avoid duplicates. + ## Use a stored secret Always pass `--redact`: child output is piped through a filter that diff --git a/src/cli.rs b/src/cli.rs index 539d7b8..5d1da76 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -55,6 +55,19 @@ enum Cmd { #[arg(long)] json: bool, }, + /// Share a stored secret through an expiring Bitwarden Send; print only its link + Send { + #[arg(long)] + name: String, + #[arg(long)] + title: Option, + #[arg(long, default_value_t = 7)] + days: u16, + #[arg(long)] + max_access_count: Option, + #[arg(long)] + json: bool, + }, /// Inject a secret into a child process environment and exec it. /// Secret-bearing env vars (BW_SESSION, ...) are never inherited. Run { @@ -214,6 +227,22 @@ pub fn run() -> Result<(), Error> { force, json, } => generate(&paths, &name, bytes, force, json), + Cmd::Send { + name, + title, + days, + max_access_count, + json, + } => { + let receipt = + crate::send::send(&paths, &name, title.as_deref(), days, max_access_count)?; + if json { + println!("{}", serde_json::to_string(&receipt)?); + } else { + println!("{}", receipt.url); + } + Ok(()) + } Cmd::Run { name, env, diff --git a/src/lib.rs b/src/lib.rs index 5185d09..77dfc53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod paths; pub mod protocol; pub mod pull; pub mod run; +pub mod send; pub mod shim; pub mod vault; diff --git a/src/send.rs b/src/send.rs new file mode 100644 index 0000000..5585953 --- /dev/null +++ b/src/send.rs @@ -0,0 +1,170 @@ +use std::io::Write; +use std::process::{Command, Stdio}; + +use chrono::{Duration, SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::{bitwarden, vault::Vault, Error, Paths}; + +#[derive(Serialize)] +pub struct SendReceipt { + event: &'static str, + name: String, + pub url: String, + expires_at: String, + max_access_count: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Payload<'a> { + name: &'a str, + r#type: u8, + text: Text<'a>, + deletion_date: &'a str, + expiration_date: &'a str, + max_access_count: Option, + disabled: bool, + hide_email: bool, +} + +#[derive(Serialize)] +struct Text<'a> { + text: &'a str, + hidden: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreatedSend { + access_url: String, +} + +pub fn send( + paths: &Paths, + name: &str, + title: Option<&str>, + days: u16, + max_access_count: Option, +) -> Result { + if !(1..=31).contains(&days) || max_access_count == Some(0) { + return Err(Error::user( + "Send requires --days 1..31 and a positive access limit", + )); + } + let title = title.unwrap_or(name); + if title.trim().is_empty() || title.len() > 200 || title.chars().any(char::is_control) { + return Err(Error::user( + "Send title must contain 1..200 bytes without control characters", + )); + } + let vault = Vault::open(paths)?; + let secret = vault.get(name)?; + let text = std::str::from_utf8(&secret) + .map_err(|_| Error::user("Send supports UTF-8 text secrets only"))?; + if text.is_empty() || text.chars().count() > 1000 || text.contains('\0') { + return Err(Error::user( + "Send supports nonempty text up to 1000 characters without NUL", + )); + } + let session = match std::env::var("BW_SESSION") { + Ok(value) if !value.is_empty() => Zeroizing::new(value.into_bytes()), + _ => vault.get(bitwarden::DEFAULT_SESSION_SECRET)?, + }; + let session = std::str::from_utf8(&session) + .map_err(|_| Error::user("stored Bitwarden session is not valid UTF-8"))?; + if session.is_empty() { + return Err(Error::user( + "Bitwarden session is empty; rerun `hush bitwarden unlock`", + )); + } + let expiry = + (Utc::now() + Duration::days(i64::from(days))).to_rfc3339_opts(SecondsFormat::Secs, true); + let payload = Zeroizing::new(serde_json::to_vec(&Payload { + name: title, + r#type: 0, + text: Text { text, hidden: true }, + deletion_date: &expiry, + expiration_date: &expiry, + max_access_count, + disabled: false, + hide_email: true, + })?); + let encoded = scoped_bw(&["encode"], session, &payload)?; + if encoded.is_empty() + || !encoded + .iter() + .all(|c| c.is_ascii_alphanumeric() || b"+/=\r\n".contains(c)) + { + return Err(Error::user( + "Bitwarden encoding failed; no Send was created", + )); + } + let output = scoped_bw(&["send", "create"], session, &encoded)?; + let response = std::str::from_utf8(&output) + .map_err(|_| { + Error::user("Bitwarden Send returned an invalid receipt; inspect Sends before retrying") + })? + .trim(); + let receipt: Option = serde_json::from_str(response).ok(); + let url = receipt + .as_ref() + .map_or(response, |receipt| receipt.access_url.as_str()); + if !safe_url(url) || url.contains(text) || url.contains(session) { + return Err(Error::user( + "Bitwarden Send returned an invalid receipt; inspect Sends before retrying", + )); + } + Ok(SendReceipt { + event: "send-created", + name: name.to_owned(), + url: url.to_owned(), + expires_at: expiry, + max_access_count, + }) +} + +fn safe_url(url: &str) -> bool { + let Some(rest) = url.strip_prefix("https://") else { + return false; + }; + let Some((authority, path)) = rest.split_once('/') else { + return false; + }; + !authority.is_empty() + && !authority.contains('@') + && path.contains('#') + && url.len() <= 2048 + && url + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b":/?#[]@!$&'()*+,;=._~%-".contains(&c)) +} + +fn scoped_bw(args: &[&str], session: &str, input: &[u8]) -> Result>, Error> { + let mut child = Command::new(bitwarden::require_bw()?) + .args(args) + .env_remove("BW_CLIENTID") + .env_remove("BW_CLIENTSECRET") + .env_remove("BW_PASSWORD") + .env_remove("BW_SERVE") + .env_remove("BW_RESPONSE") + .env_remove("BW_PRETTY") + .env_remove("BW_RAW") + .env_remove("BW_QUIET") + .env_remove("BW_CLEANEXIT") + .env("BW_SESSION", session) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + let write_result = child.stdin.take().unwrap().write_all(input); + let output = child.wait_with_output()?; + let stdout = Zeroizing::new(output.stdout); + if write_result.is_err() || !output.status.success() { + return Err(Error::user( + "Bitwarden Send operation failed; verify the session and inspect Sends before retrying", + )); + } + Ok(stdout) +} diff --git a/tests/send.rs b/tests/send.rs new file mode 100644 index 0000000..842230f --- /dev/null +++ b/tests/send.rs @@ -0,0 +1,136 @@ +use hush::{vault::Vault, Paths}; +use std::{fs, os::unix::fs::PermissionsExt, process::Command}; +use tempfile::TempDir; + +fn exercise(mode: &str, extra: &[&str]) -> (TempDir, std::process::Output) { + let tmp = TempDir::new().unwrap(); + let paths = Paths::new(tmp.path().join("vault")); + assert!(Command::new(env!("CARGO_BIN_EXE_hush")) + .args(["--home", paths.root().to_str().unwrap(), "init"]) + .output() + .unwrap() + .status + .success()); + let vault = Vault::open(&paths).unwrap(); + vault + .put("example", b"synthetic-send-secret", "test", "self") + .unwrap(); + vault + .put("BITWARDEN_SESSION", b"synthetic-session", "test", "self") + .unwrap(); + let fake = tmp.path().join("bw"); + fs::write(&fake, r##"#!/usr/bin/env python3 +import base64, json, os, sys +payload=sys.stdin.buffer.read() +assert os.environ['BW_SESSION'] in ('synthetic-session','ambient-session') +assert 'BW_PASSWORD' not in os.environ +assert 'BW_CLIENTSECRET' not in os.environ +if sys.argv[1:]==['encode']: + print(base64.b64encode(payload).decode()) +elif sys.argv[1:]==['send','create']: + value=json.loads(base64.b64decode(payload)) + assert value['text']=={'text':'synthetic-send-secret','hidden':True} + assert value['type']==0 and value['hideEmail'] and not value['disabled'] + assert value['expirationDate']==value['deletionDate'] + if os.environ['MODE']=='failure': + print('synthetic-send-secret',file=sys.stderr) + print('synthetic-session') + sys.exit(1) + if os.environ['MODE']=='badreceipt': print('https://example.test/#synthetic-send-secret') + else: + with open(os.environ['RECEIPT'], 'w') as f: json.dump({'expiry':value['expirationDate'],'limit':value['maxAccessCount'],'title':value['name']},f) + if os.environ['MODE']=='json': print(json.dumps({'object':'send','accessUrl':'https://send.bitwarden.com/#opaque-id/opaque-key','text':value['text'],'key':'internal-encryption-key'})) + else: print('https://send.bitwarden.com/#opaque-id/opaque-key') +else: sys.exit(2) +"##).unwrap(); + fs::set_permissions(&fake, fs::Permissions::from_mode(0o700)).unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_hush")); + command + .args([ + "--home", + paths.root().to_str().unwrap(), + "send", + "--name", + "example", + "--json", + ]) + .args(extra) + .env_remove("BW_SESSION") + .env("BW_PASSWORD", "unrelated-password") + .env("BW_CLIENTSECRET", "unrelated-client-secret") + .env("HUSH_BW_BIN", fake) + .env("MODE", mode) + .env("RECEIPT", tmp.path().join("receipt.json")); + if mode == "ambient" { + command.env("BW_SESSION", "ambient-session"); + } + let output = command.output().unwrap(); + let output_text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + for secret in [ + "synthetic-send-secret", + "synthetic-session", + "ambient-session", + "unrelated-password", + "unrelated-client-secret", + ] { + assert!(!output_text.contains(secret)); + } + (tmp, output) +} + +#[test] +fn creates_hidden_expiring_send_from_stored_session() { + let (tmp, out) = exercise( + "success", + &[ + "--title", + "Example access", + "--days", + "3", + "--max-access-count", + "8", + ], + ); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let receipt: serde_json::Value = + serde_json::from_slice(&fs::read(tmp.path().join("receipt.json")).unwrap()).unwrap(); + assert_eq!(value["event"], "send-created"); + assert_eq!(value["max_access_count"], 8); + assert_eq!(receipt["limit"], 8); + assert_eq!(receipt["title"], "Example access"); + let expiry = + chrono::DateTime::parse_from_rfc3339(value["expires_at"].as_str().unwrap()).unwrap(); + assert!((expiry.with_timezone(&chrono::Utc) - chrono::Utc::now()).num_seconds() > 258000); +} + +#[test] +fn supports_ambient_session_and_safe_failures() { + let (_, json) = exercise("json", &[]); + assert!(json.status.success()); + assert!(!String::from_utf8_lossy(&json.stdout).contains("internal-encryption-key")); + assert!(exercise("ambient", &[]).1.status.success()); + assert!(!exercise("failure", &[]).1.status.success()); + assert!(!exercise("badreceipt", &[]).1.status.success()); +} + +#[test] +fn rejects_invalid_lifespans_and_access_limits() { + for args in [ + ["--days", "0"], + ["--days", "32"], + ["--max-access-count", "0"], + ] { + let (tmp, out) = exercise("success", &args); + assert!(!out.status.success()); + assert!(!tmp.path().join("receipt.json").exists()); + } +} From ae61d8b5337ae40c5f492f271fc77fd21b5127c3 Mon Sep 17 00:00:00 2001 From: turinglabsorg Date: Sat, 5 Sep 2026 14:35:41 +0200 Subject: [PATCH 2/3] docs: document outgoing Send receipt and retry invariants --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 6fe1ebf..d9ed0bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,5 +11,6 @@ Rust CLI + agent skill. Secrets are ingested from Bitwarden (`hush pull` from a - Long files do not go through a chat message. `hush box register robin` publishes `robin@hush.sh`. `hush box seal --to robin@hush.sh --file PATH -u N` encrypts locally and publishes the sealed copy. The message that moves is the id. Each `hush box open --id ID NAME` consumes one use and at 0 the sealed copy is deleted. Keys stay in `~/.hush/box.key`. The directory in `directory/` keeps public keys and sealed copies in Firestore when `FIRESTORE_PROJECT` is set. - Tests stub `bw` with `tests/fixtures/fake-bw.sh` - Outgoing `hush send --name NAME` may share only a stored secret explicitly authorized by the user. Pipe the encoded payload through stdin, use the ambient or stored Bitwarden session, suppress raw child failure output, and print only a validated HTTPS Send URL and expiry metadata. Never put plaintext in process arguments or temporary files. Text must be hidden and have an explicit finite expiry/deletion date. +- Send accepts `--title`, `--days 1..31` (default 7), optional positive `--max-access-count`, and `--json`; input is nonempty UTF-8 text up to 1,000 characters. Bitwarden versions return either a URL or an object with `accessUrl`; deserialize only that receipt field and discard plaintext-bearing fields. `tests/send.rs` covers both response shapes, stored/ambient sessions, lifespan limits and leaking child failures. An invalid receipt may follow successful remote creation: inspect existing Sends instead of blindly creating duplicates. - Validate with `cargo fmt`, `cargo test --locked`, `cargo clippy --locked -- -D warnings`, and `cargo test --manifest-path directory/Cargo.toml` - GitHub Releases are built by `.github/workflows/release.yml` on `v*` tags (linux-x86_64, macos-x86_64, macos-aarch64, sha256 + cosign) From 985d5e9b2920b692e48031cdc88bf92e033dbad9 Mon Sep 17 00:00:00 2001 From: turinglabsorg Date: Wed, 23 Sep 2026 15:53:23 +0200 Subject: [PATCH 3/3] Release hush 0.6.0 with send. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec0ed89..cd51fb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -743,7 +743,7 @@ dependencies = [ [[package]] name = "hush" -version = "0.5.0" +version = "0.6.0" dependencies = [ "aes-gcm", "age", diff --git a/Cargo.toml b/Cargo.toml index dea1a47..d0736ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hush" -version = "0.5.0" +version = "0.6.0" edition = "2021" license = "MIT" description = "Agent-blind secrets: ingest over Bitwarden, store with age, use by name" diff --git a/README.md b/README.md index fa0576f..f74c0fb 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ With the agent skill and a PATH symlink: curl -fsSL https://raw.githubusercontent.com/turinglabsorg/hush/main/install.sh | sh -s -- --agent-skill --path-link ``` -Pin a version with `--version v0.5.0`. That release includes `hush box`. The installer picks the binary for the machine, so run it on the Mac Pro and on the MacBook separately. Do not copy the binary from one to the other. +Pin a version with `--version v0.6.0`. That release includes `hush box` and `hush send`. The installer picks the binary for the machine, so run it on the Mac Pro and on the MacBook separately. Do not copy the binary from one to the other. ```bash curl -fsSL https://raw.githubusercontent.com/turinglabsorg/hush/main/install.sh | sh -s -- --agent-skill