Skip to content
gufrancoPublic

About

Send an encrypted message through any chat, using a GitHub username as the address. Five commands, 161 tests, one POSIX sh file, and no runtime dependency beyond age

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

fusho

Send an encrypted message through any chat, using a GitHub username as the address.

English | 日本語 | 简体中文 | 繁體中文 (香港)

ci release license

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-staple

Every 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.

Quick start

Prerequisites

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.

Install

brew install gufranco/tap/fusho

The 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

Verify

$ 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

Usage

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.

Key hosts

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

How it works

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"]
Loading
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.

Signing

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 me

The 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.

Key pinning

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-key

What it does not do

Warning

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

Troubleshooting

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_rsa
no 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/*.pub
My 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_ed25519
I 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.

More options

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

Versioning

fusho follows Semantic Versioning. Every release is tagged and published on the releases page with its changelog.

Alternatives

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.

Portability

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.

Development

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.

License

MIT

About

Send an encrypted message through any chat, using a GitHub username as the address. Five commands, 161 tests, one POSIX sh file, and no runtime dependency beyond age

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages