Send messages, secrets & tokens, and small files effortlessly and seamlessly directly from the terminal via e2e encrypted, ephemeral messaging over a metadata-blind relay. Data is encrypted on the client using your existing SSH ed25519 keys, and kept on the server only for a few minutes. The server cannot read messages, identify participants, or determine who is talking to whom.
brew tap rel-s/stringphone
brew install stringphone# Pair with Bob
stringphone pair bob --github bobs-github-account # or, use the key directly "ssh-ed25519 AAAA..."
alias bob="stringphone bob"
# Alice sends a message
echo "SECRET_TOKEN: x-123..." | bob
# Receive anything from bob
bob > ~/.zshrc.bobs-configstringphone <peer> # auto: send if stdin piped, recv otherwise
stringphone --send <peer> # explicit send (reads stdin)
stringphone --recv <peer> # explicit recv (writes to stdout)
stringphone pair <name> <pubkey> # pair with a public key string
stringphone pair <name> --github <user> # pair via GitHub public keys
stringphone peers # list paired peers
Auto-detection: if stdin is piped, stringphone sends. If stdin is a terminal, it receives. Use --send/--recv to override.
Pairing stores a peer's SSH ed25519 public key locally. You can pair by providing the key directly or by importing from GitHub:
# Direct
stringphone pair alice "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI..."
# From GitHub (uses first ed25519 key found at github.com/<user>.keys)
stringphone pair alice --github alice-github-usernamePeer keys are stored as files in ~/.config/stringphone/peers/<name>.pub.
Config file: ~/.config/stringphone/config.toml
# Relay server URL
server = "https://relay.example.com"
# Path to your SSH ed25519 private key
identity_key = "~/.ssh/id_ed25519"
All fields are optional. Defaults:
| Field | Default | Description |
|---|---|---|
server |
https://relay.string-phone.com |
Relay server URL |
identity_key |
~/.ssh/id_ed25519 |
SSH private key path (~ is expanded) |
verbose |
false |
Enable verbose stderr logging |
Config directory: ~/.config/stringphone/ on all platforms.
You don't have to run the server yourself - there's an online server maintained by me configured as the default.
The server is limited by default to 5 KiB max message size, and a 5m TTL after which messages are deleted.
| Endpoint | Method | Description |
|---|---|---|
POST /mailbox/:id |
POST | Store encrypted message (raw bytes) |
GET /mailbox/:id |
GET | Retrieve message (raw bytes, empty if none) |
GET /health |
GET | Returns {"status":"ok"} |
Mailbox IDs are 64-character hex strings. POST returns 201, 413 (too large), 429 (mailbox full), or 400 (bad ID). GET returns the raw message bytes (empty body if no message). No authentication headers are required — mailbox IDs are unguessable because they include the DH shared secret.
stringphone uses your existing SSH ed25519 keys to establish a shared secret with each peer via X25519 Diffie-Hellman. Messages are encrypted with ChaCha20-Poly1305. The relay server handles only opaque identifiers and ciphertext.
Both parties compute the same shared secret independently:
Alice: shared = X25519(alice_private, bob_public)
Bob: shared = X25519(bob_private, alice_public)
→ Both produce the same 32-byte shared secret
This is a standard ECDH exchange. Neither party transmits their private key.
mailbox_id = BLAKE3(shared_secret || sender_pubkey || recipient_pubkey)
Mailbox IDs are directional: alice→bob ≠ bob→alice.
Because the DH shared secret is included in the hash, an observer who knows both public keys cannot discover the mailbox. This makes sure no third party can overwrite the mailbox contents (even with invalid data).
enc_key = HKDF-SHA256(
ikm = shared_secret,
salt = mailbox_id,
info = "stringphone-v1-msg"
)
The mailbox ID is used as salt, binding the key to the specific sender→recipient direction.
nonce = 12 random bytes
envelope = [8-byte timestamp] [plaintext]
ciphertext = ChaCha20-Poly1305(key=enc_key, nonce=nonce, plaintext=envelope)
ChaCha20-Poly1305 provides authenticated encryption: the ciphertext is confidential and tamper-proof. The 16-byte Poly1305 tag ensures any modification is detected on decryption.
The timestamp allows us to protect against replay attacks. We keep track of the hashes of last seen messages in ~/.config/stringphone/seen; this way we can reject identical message hashes, and reject messages with old timestamps. Rejecting old timestamps allows us to prune the last seen message hashes.
[0x01] [12-byte nonce] [ciphertext + 16-byte tag]
^version
Total overhead: 37 bytes per message (1 version + 12 nonce + 8 timestamp + 16 auth tag).
The relay server sees:
- Mailbox IDs: opaque 32-byte hashes. The server uses them as routing keys.
- Ciphertext blobs: opaque encrypted bytes. The server stores and forwards them.
- Timing: when messages are posted and retrieved.
- Size: ciphertext length (plaintext length + 29 bytes overhead).
- IP addresses: client IPs connecting to the server. Can be hidden using VPNs/Tor/so on.
"only ed25519 keys are supported"
stringphone requires ed25519 SSH keys. RSA and ECDSA keys cannot be used because the protocol relies on X25519 Diffie-Hellman, which requires Curve25519 keys. If you only have an RSA key, generate an ed25519 key alongside it:
ssh-keygen -t ed25519Your existing RSA key continues to work for SSH.
