Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ 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.
- 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)
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent-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
Expand Down
29 changes: 29 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[arg(long, default_value_t = 7)]
days: u16,
#[arg(long)]
max_access_count: Option<u32>,
#[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 {
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
170 changes: 170 additions & 0 deletions src/send.rs
Original file line number Diff line number Diff line change
@@ -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<u32>,
}

#[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<u32>,
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<u32>,
) -> Result<SendReceipt, Error> {
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<CreatedSend> = 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<Zeroizing<Vec<u8>>, 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)
}
Loading
Loading