Why I built Whisper
You've probably pasted a password in Slack, Teams, or Discord before. And then thought: this is probably not great. And then did it anyway, because everything else feels like too much effort.
That's the workflow problem Whisper exists to fix. But it's not the reason I built it.
The previous attempt
In 2022 I built Sharepassword — Next.js + Node + Chakra UI, with a browser extension. It worked. It was also, technically, the opposite of zero-knowledge: the server held the encryption key. If the database leaked, every secret leaked.
That gap is what pushed me to do it again, properly.
What I actually wanted to learn
I had four things on my list, and Whisper happened to touch all of them:
- The Rust web stack — Axum + Askama + sqlx. Server-rendered HTML, no SPA, no Node.
- A real CLI in Rust — not a hello-world; something I'd actually want to install.
- Distributing a Rust binary on npm —
curl | shis fine, butnpm i -gis universal. - How open source actually works — running a project end-to-end: license, contributing guide, security policy, release hygiene, the community side. I'm planning to do more open source, so I treated this as a deliberate first run at the rest of it.
Bundling them into one project meant the parts had to fit together. The architecture had to keep them honest.
Hexagonal architecture, in actual code
The Cargo workspace splits the way the constraints split:
whisper/
├── services-core/ # pure domain — no async, no I/O
│ ├── entities/
│ ├── values_object/
│ ├── contracts/repositories/ # storage traits
│ ├── services/secret_encryption.rs # encryption trait
│ └── commands/ # use cases, one per file
├── adapters/
│ ├── postgresql-adapter/ # SharedSecretRepository impl
│ └── aes-gcm-crypto/ # SecretEncryption impl
└── applications/
├── axum/server/ # HTTP, Askama templates, routes
├── cli/ # clap, dialoguer, HTTP client
└── discord/ # botThe domain depends on traits, never implementations. Two of them carry most of the weight:
pub trait SecretEncryption {
fn encrypt_secret(&self, secret: &str) -> Result<SecretEncrypted>;
fn decrypt_secret(&self, encrypted_secret: SecretEncrypted) -> Result<String>;
}
pub trait SharedSecretRepository {
fn save(&self, secret: SharedSecret)
-> impl Future<Output = Result<SecretId>> + Send;
fn get_by_id(&self, id: &SecretId)
-> impl Future<Output = Result<Option<SharedSecret>>> + Send;
fn delete_by_id(&self, id: &SecretId)
-> impl Future<Output = Result<()>> + Send;
// ...
}Use cases compose them generically. Notice that the signature doesn't mention Postgres or AES anywhere:
pub async fn handle(
&self,
secret_encryption: &impl SecretEncryption,
shared_secret_repository: &impl SharedSecretRepository,
) -> Result<Option<(String, bool)>, GetSecretByIdError> { ... }The two payoffs that justified the upfront discipline:
- The Axum server doesn't know Postgres exists. Swap to SQLite — it's a new adapter crate. The use cases don't change.
- Domain logic is testable without a database. Mock implementations of
SharedSecretRepositoryare 30 lines. Every use case has unit tests that run in milliseconds.
The CLI
clap with derive macros for parsing, dialoguer for interactive prompts, indicatif for progress and spinners, reqwest for HTTP, tempfile for atomic writes. The subcommand list:
init import push pull rotate
remove status invite join share
get completionsTwo things I won't go back from:
Type-driven UX. clap's derive macros let you encode the entire CLI interface in a struct. Mistypes fail at compile time, not when a user reports a confusing error. Coming from Node CLIs where I'd hand-stitched yargs config — this is a quality-of-life jump.
Single binary, 3.16–3.51 MB stripped depending on the target. Starts in milliseconds, holds no state in a daemon, uninstalls cleanly. Compare to anything that ships its own runtime.
Publishing a Rust binary on npm
This was the part I was least sure about going in. The pattern — popularised by esbuild and adopted by swc, biome, and others — is: one platform-specific package per architecture, plus a thin Node shim that picks the right one at install time.
The base whisper-secrets package declares them as optional dependencies:
"optionalDependencies": {
"@whisper-secrets/linux-x64": "0.2.0",
"@whisper-secrets/linux-arm64": "0.2.0",
"@whisper-secrets/darwin-arm64": "0.2.0",
"@whisper-secrets/win32-x64": "0.2.0"
}Each platform package sets os and cpu fields in its own package.json — npm only installs the one that matches the user's machine. A ~50-line Node script in bin/whisper-secrets resolves the matched package and execFileSyncs its binary.
The release pipeline is one tag-triggered GitHub Actions workflow:
push tag v0.2.0
├── test (fmt + clippy + unit + integration)
├── create draft GitHub release
├── matrix build (4 targets, cross for Linux ARM)
│ each: build → strip → tar.gz + sha256
│ → publish @whisper-secrets/<plat>
└── publish whisper-secrets (base) + finalize releaseTwo recommendations if you're doing this:
OIDC Trusted Publishing. No NPM_TOKEN in the repo, no rotation. The workflow gets id-token: write permission, npm verifies the GitHub identity, you publish. One less long-lived secret to lose.
--provenance. Every release ships SLSA attestations linking the published binary back to the Action run and the source commit. Anyone can verify with npm audit signatures.
Whether either matters for a tool downloaded a few hundred times a month is debatable. The supply-chain hygiene cost about an hour of YAML.
Open source as practice, not just license
The bar for calling something "open source" is low — push to GitHub, add an MIT license, done. The bar for it actually being usable by anyone else is much higher. Since I'm planning to keep doing this, I treated Whisper as a deliberate first run at the rest:
CONTRIBUTING.mdwith local setup, tests, and PR processSECURITY.mdwith a private disclosure path (no public issues for vulnerabilities)- Code of conduct, issue and PR templates, a public Slack community, semver tags, signed releases
Small things in isolation. Together they're the difference between "code on the internet" and "a project someone else can contribute to." The next project starts with the scaffolding already done.
The point
Whisper started as a way to explore the Rust web stack, the Rust CLI story, the Rust-on-npm distribution story, and how to actually run an open-source project. It quickly became a solution to a recurring workflow problem — the "ugh I guess I'll just paste this in Slack" one. Both things are true. The first is why I finished it; the second is why I still use it.
Source: github.com/quentinved/Whisper. Try it: whisper.quentinvedrenne.com. CLI: whisper-secrets on npm.