Send an encrypted message through any chat, using a GitHub username as the address.
English | 日本語 | 简体中文 | 繁體中文 (香港)
Quick start | How it works | Security limits | Troubleshooting
Five commands, 211 tests, 11 POSIX sh modules, and no runtime dependency beyond age.
Note
Threat model. fusho keeps a secret out of chat history: the retention policy, the backup nobody remembers configuring, the colleague who inherits the channel in 2031. It does not protect against a keylogger, a nation-state, or the coworker who pastes the plaintext right under the encrypted block "just in case". Those are covered in what it does not do, and mostly by a much larger budget.
fusho is 封書, Japanese for a sealed letter: addressed to one person, opened only by them.
$ fusho send --no-banner alice 'database password: correct-horse-battery-staple'
-----BEGIN FUSHO MESSAGE-----
v: 1
alg: age
to: alice
key: SHA256:iD5CQysP5Zu+ugBNVoQSDruYFlSFJSJiFQwUXbpdGh4
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNzaC1lZDI1NTE5IGlENUNReSBhWDNu
UjZQd2NCRWRhVGJZbUFVWjZHUUFEeUJIUGNZTFVoMkc5ZmVRR0ZzCg==
-----END FUSHO MESSAGE-----
fusho: copied to the clipboard with pbcopy.Paste all of it into Slack, Telegram, a Jira ticket, or an email. Alice runs one command:
$ pbpaste | fusho read
database password: correct-horse-battery-stapleEvery GitHub account publishes its SSH public keys at github.com/<user>.keys. fusho encrypts to those keys, and the recipient decrypts with the private key already in their ~/.ssh. There is no key exchange, keyserver, or account to create.
Above BEGIN, the block carries a short note telling a first-time recipient what it is and how to open it. The parser ignores everything above BEGIN, so the note can never corrupt a message. Turn it off with --no-banner or FUSHO_BANNER=0.
| Tool | Why | Install |
|---|---|---|
POSIX sh |
The tool itself | Preinstalled everywhere. bash is not required |
curl, ssh-keygen, openssl |
Key fetch, fingerprints, base64 | Preinstalled on macOS and Linux |
age |
The encryption engine | brew install age or apt install age. Installed automatically by the Homebrew formula |
qrencode |
Optional, only for --qr |
brew install qrencode or apt install qrencode |
Important
Without age, fusho falls back to RSA through openssl. That path cannot encrypt to an Ed25519 key and caps a message at 470 bytes. Most GitHub accounts publish Ed25519 keys, so install age.
brew install gufranco/tap/fushoThe formula lives in gufranco/homebrew-tap. Or from source, which also installs the man page and shell completions:
git clone https://github.com/gufranco/fusho.git
cd fusho
make install$ fusho doctor
age ok /opt/homebrew/bin/age, v1.3.2
curl ok /usr/bin/curl
ssh-keygen ok /usr/bin/ssh-keygen
awk ok /usr/bin/awk, bsd 20200816
sed ok /usr/bin/sed, bsd
tr ok /usr/bin/tr, bsd
date ok /bin/date, bsd
openssl ok /usr/bin/openssl, LibreSSL. the RSA fallback pins OAEP to SHA-1 so it interoperates with OpenSSL
identities ok 2 usable key pair(s) under ~/.ssh| Command | Purpose |
|---|---|
fusho |
In a terminal, ask what to do and then for each value. send, read and keys also ask for whatever is missing |
fusho send <user>[@host] [message] |
Encrypt to a user on GitHub, GitLab, Codeberg, SourceHut, or a self-hosted forge. Reads stdin when no message is given |
fusho read [file] |
Decrypt. Reads stdin when piped, otherwise the clipboard |
fusho keys <user> |
List the keys fusho can encrypt to, with fingerprints |
fusho doctor |
Check the local environment and print a remedy for each failure |
fusho version, fusho help |
Print the version and the active engine, or the full usage |
| Flag | Effect |
|---|---|
--to <target> |
Add a recipient. Repeat for several |
--sign <you> |
Sign as a user whose published key you hold |
--require-signature |
On read, refuse a message with no signature |
--accept-new-key |
Accept a recipient whose pinned keys changed |
--expires <when> |
Mark the message stale after 30m, 6h, 7d and so on |
--ignore-expiry |
On read, open a message the sender marked as expired |
--key <prefix> |
Encrypt to one key only, chosen by fingerprint prefix |
--keys-file <path> |
Add recipients from a local public key file |
--identity <path> |
Decrypt with a specific private key |
--identity-command <cmd> |
Read the private key from a command, for keys kept in a secret manager |
--json |
Print machine-readable output |
--qr |
Print the message as a QR code |
--no-banner |
Omit the note above the block |
--no-clipboard |
Do not copy the result to the clipboard |
printf 'the secret' | fusho send alice # stdin keeps it out of the process list
fusho send alice:iD5CQy 'only to that one key' # pin one key by fingerprint prefix
fusho send bob@gitlab 'works across forges' # gitlab, codeberg, sourcehut, self-hosted
fusho send --to alice --to bob@codeberg 'hi' # one message, several people
fusho read # with nothing piped, reads the clipboard--to=alice works too, and -- ends the options. fusho follows the shared command-line contract of its family, described in FAMILY.md.
Tip
Pipe the secret on stdin instead of passing it as an argument. Every user on the machine can see an argument in the process list.
| Host | Recipient syntax | Status |
|---|---|---|
| GitHub | alice |
Default |
| GitLab | alice@gitlab |
Supported |
| Codeberg | alice@codeberg |
Supported |
| SourceHut | alice@sourcehut |
Supported |
| Self-hosted Gitea, Forgejo, GitLab | alice@git.example.com |
Any host serving /<user>.keys |
| Bitbucket | Not supported | It publishes no public key endpoint; use --keys-file |
graph LR
A["fusho send alice"] --> B["GET github.com/alice.keys"]
B --> C{"supported key?"}
C -- "no" --> D["error naming the key types found"]
C -- "yes" --> E{"age installed?"}
E -- "yes" --> F["age, any size"]
E -- "no, RSA key" --> G["openssl RSA-OAEP, size capped"]
E -- "no, Ed25519 key" --> H["error, install age"]
F --> I["armored block, header plus base64"]
G --> I
I --> J["any chat transport"]
J --> K["fusho read"]
K --> L["match fingerprint to a local private key"]
L --> M["decrypt, plaintext to stdout"]
| Engine | Recipient keys | Size limit | Integrity |
|---|---|---|---|
age, used whenever installed |
ssh-ed25519 and ssh-rsa |
None in practice | Authenticated encryption |
openssl fallback |
ssh-rsa only |
470 bytes at 4096-bit, 214 at 2048-bit | Confidentiality only, no integrity check |
The fallback lets a recipient who cannot install anything still receive a short secret, and fusho send warns on stderr when it is used. It refuses an Ed25519 recipient and an oversized message instead of emitting something broken. fusho implements no cryptography of its own; it delegates to age or openssl.
An unsigned message is confidential but anonymous. --sign closes that gap with the SSH key the sender already publishes:
$ fusho send alice --sign you 'this really is from me'
$ fusho read message.txt
fusho: signature verified against a key published by 'you'.
this really is from meThe signature covers the ciphertext and is checked with ssh-keygen -Y verify against the claimed sender's published keys. A tampered body, or a from: swapped to someone else, fails and prints no plaintext.
The first message to someone records their fingerprints in ${XDG_CONFIG_HOME:-~/.config}/fusho/known_recipients. If those keys change later, the send stops:
$ fusho send alice 'secret'
fusho: the keys published by alice changed since you last messaged them.
pinned: SHA256:Hb2u7V4cmzORXDF0QulbVVq83T/B6AfX+64LPZS64n0
current: SHA256:6FV5K276oVndQJv5uxtytvD/KIrimmU/SmWvEgmmdIo
a key rotation looks exactly like an account takeover from here.
check the fingerprint with them out of band, then re-run with --accept-new-keyWarning
An unsigned message proves nothing about who sent it. Anyone can encrypt to a public key and claim to be anyone. Sign with --sign <you>, or refuse unsigned messages with --require-signature.
| Limit | Detail |
|---|---|
| Unauthenticated header | The to: and key: lines are routing metadata. Altering them causes an identity error, never a wrong plaintext, because age authenticates the body |
| Readable by every key on the account | The recipient can read on any device, so the message is only as safe as their weakest published key. Pin one with --key |
| The forge is the trust anchor | Someone who adds a key to the recipient's account can read messages sent afterwards. Key pinning turns that silent takeover into a refused send |
decryption failed, and my private key starts with BEGIN OPENSSH PRIVATE KEY
Only the openssl fallback needs a PEM key. Convert it, keeping the same public key:
ssh-keygen -p -m PEM -f ~/.ssh/id_rsano local private key matches this message
The message was addressed to a key whose private half is not on this machine. fusho prints the fingerprint it wanted; compare it with yours:
ssh-keygen -lf ~/.ssh/*.pubMy keys live in 1Password, Secretive, or another SSH agent
age decrypts only with a key file, and an agent never hands over the private half. Supply the key from your secret manager instead; fusho writes it to a private temporary file and removes it on exit:
pbpaste | fusho read --identity-command "op read 'op://Private/ssh-key/private key'"publishes no key fusho can encrypt to
The account has only ECDSA keys, which neither engine supports. Ask the recipient for an Ed25519 or RSA key.
I have no key pair yet
Create one and add the public half at github.com/settings/keys:
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519I used fusho when it was called osm
Messages that begin with BEGIN OSM MESSAGE still open, signatures made under the old name still verify, and ~/.config/osm moves to ~/.config/fusho the first time you run it. Environment variables now use the FUSHO_ prefix.
| Behaviour | Detail |
|---|---|
| Compression | Payloads are gzipped when that makes them smaller, marked with an enc: header. A 4000-byte repetitive payload becomes a 495-byte message |
| Weak key warning | A recipient key under 3072-bit RSA is flagged on every send. Change the floor with FUSHO_MIN_RSA_BITS |
| Clipboard expiry | The copied message is cleared after 90 seconds, only if the clipboard still holds it. FUSHO_CLIPBOARD_TIMEOUT=0 disables it |
| Custom clipboard | FUSHO_CLIPBOARD_COPY and FUSHO_CLIPBOARD_PASTE take any command, for tmux or SSH sessions |
| Message expiry | --expires 6h records a deadline that fusho read enforces. It is advisory: a message someone can decrypt cannot be un-sent |
| QR output | --qr renders the message for phone-to-laptop transfer, when qrencode is installed |
fusho follows Semantic Versioning. Every release is tagged and published on the releases page with its changelog.
ssh-vault solves the same problem as a compiled binary, if you would rather not run a shell script. There is also an open request to build this into gh.
fusho is POSIX sh. Each row below passed a full encrypt-and-decrypt round trip.
| Shell | Status |
|---|---|
dash, busybox ash, posh |
Supported |
bash 3.2 and 5.x, the system's zsh and Homebrew's, mksh |
Supported |
| System | Status |
|---|---|
| macOS | Tahoe on Apple Silicon and Intel, with the stock /bin/sh, LibreSSL, curl and OpenSSH, and with Homebrew's openssl@3, curl, OpenSSH and gzip |
| Linux, glibc | Debian and Ubuntu |
| Linux, musl | Alpine with bash removed |
| FreeBSD, OpenBSD, NetBSD | Not covered by CI. Their /bin/sh supports every construct fusho uses |
Every run works on the five toolsets in common use: Linux with GNU or uutils coreutils, and macOS with the tools it ships, Homebrew's newer builds of them, or Homebrew's uutils tools. Every tool macOS ships that Homebrew also builds newer, from bash and zsh to openssl, curl, ssh-keygen and gzip, is tested in both builds. CI runs the whole suite on each, and fusho doctor names the flavor and version of every core tool, such as gnu 9.7, uutils 0.12.0 or bsd 20200816, so a bug report says which tools ran.
| Command | Description |
|---|---|
make fmt |
Formats every script with shfmt |
make lint |
shfmt, ShellCheck on every script, test and completion, and mandoc on the man page |
make test |
The bats tests under the system sh |
make test-all |
The same tests under every shell, both builds of bash and zsh included on macOS |
make test-linux |
The same tests in an Ubuntu container |
make coverage |
bashcov line coverage on this Mac and in the Ubuntu container, merged, with the 100% gate CI enforces |
bin/fusho sources the 11 modules in lib/; there is no build step, so what people install is the code in this repository. Tests run against real age and openssl with generated keys, and test/keyserver.py serves the key endpoint locally, so curl sees real HTTP status codes. Once CI passes on a push to main, semantic-release runs: a feat or fix commit publishes a release, updates FUSHO_VERSION in lib/core.sh and the CHANGELOG, and bumps the Homebrew formula.
| Convention | Source |
|---|---|
| Commit format | Conventional Commits |
| Shell linting | shellcheck -o all in make lint, at zero warnings |
| Formatting | shfmt -i 2 |
| Bash floor | 3.2, so macOS /bin/bash is a supported target |
CI runs the suite on Ubuntu and on macOS Tahoe for Apple Silicon and Intel, again under macOS system Bash 3.2, across every POSIX shell in a container, and on Alpine with bash uninstalled. It also encrypts on a macOS runner and decrypts on a Linux runner, comparing checksums.