Posted on ::

I just spent last weekend building hush, a truly peer-to-peer, end-to-end encrypted secret sharing tool. Magic Wormhole already exists. Croc exists. So why build another one?

My goal was to both learn and provide a CLI tool that we could use at my current job, where we have a split-tunnel VPN, to share secrets across different offices.

What Hush Does

Before diving in, hush supports very basic functionality:

  • Share secrets: Send text securely to a peer with a simple code (like "7-Ld89GsHO-gGHps31")
  • Transfer files: Send files up to 10MB with end-to-end encryption
  • Encrypted chat: Real-time P2P chat with no server in the middle

All of this works across local networks and VPNs, with zero trust in any relay server (because there isn't one). Didn't want to deal with the corporate processes to spin up a STUN server.

Why build what already exists?

Early in the project, I had a conversation about learning strategies with a coworker. There is focused learning and exploratory learning. For this project, I wanted to go broad and cover some of the areas I had absolutely no idea how they actually worked. Of course I knew how to use many of these things, but there is a big difference between knowing how to use them and knowing how they actually work. Things like networking, cryptography, and security for me were always a gray box.

And the best way for me to learn more about them is to build something real. Not a tutorial project. Not a toy example. Something I'd actually use to share secrets with coworkers across our VPN.

The basic networking knowledge

I knew how to use TCP, UDP, and P2P. But I didn't understand them until I tried to build with them.

UDP for discovery, TCP for data. Quite straightforward but worth mentioning briefly. When you want to find peers on a network, you broadcast a message using UDP to everyone who's listening. It's like shouting "Anyone there?" into a room. UDP doesn't care if anyone gets your message. The good old fire and forget.

Once you've found someone, you could keep communicating over UDP, but you probably want TCP. TCP is reliable, ordered, and actually confirms the other person got your message through an ACK response.

TCP is a stream, not a messaging protocol. This one I didn't know... I thought I could just write() some data on one end and read() it on the other. Nope. TCP gives you a continuous stream of bytes. One read() might give you half of what you sent, or data from two different write() calls smooshed together.

This is why message framing exists. You prefix every message with its size (4 bytes for a uint32), then read exactly that many bytes. Simple, but not obvious until you encounter the problem.

VPN networking. I needed this to work across my work VPN. Every VPN can be configured differently, and in my case we have a split-tunnel VPN (only some of the traffic gets routed through it).

In order to figure out what was allowed and what wasn't in our company VPN, I just performed a few pings to the internal IP from a coworker in a different office. Which worked! Yay! TCP was viable, but UDP was not, meaning I cannot auto-discover people outside my local network. There are multiple workarounds for this. One is a STUN/TURN server, but I didn't want a server. So I settled for a --ip flag to bypass discovery and connect directly via TCP to an internal VPN IP address. Not as elegant as automatic discovery, but it works. This IP can be shared over the same out-of-band secure channel as the code, and the CLI app copies it to the clipboard for convenience.

The cryptography learning

I'm not a cryptographer (who actually is?). I knew I shouldn't (and couldn't) roll my own crypto. But I wanted to understand it a bit better.

PAKE: Password-Authenticated Key Exchange. This was the biggest cryptographic learning of the project, and it's not common knowledge like TCP/UDP.

I didn't know how two computers could establish a shared encryption key over an insecure network. You could use a normal shared password, but that didn't feel safe at all. Just encrypting with that password would be too weak. An attacker who intercepts the encrypted data could brute-force through millions of passwords offline until they find one that works.

PAKE protocols (like SPAKE2, which hush uses) solve this elegantly:

  1. Both parties start with the weak password (the hush code like "7-Ld89GsHO-gGHps31")
  2. The password is shared out-of-band - meaning through a different channel than the encrypted data itself (like Slack, Signal, in person, etc.)
  3. They exchange messages over the network (even if an attacker sees them, no problem!)
  4. Through some clever math, both parties independently derive the same strong encryption key
  5. The password is never sent over the network
  6. An attacker can't brute-force offline - they'd have to try each password guess against the live protocol

The out-of-band sharing is crucial for security: an attacker would need to compromise both the channel where you share the code (like Slack) and the network where the encrypted data flows. The last layer of defense is that the shared password is also one-time use - if both channels have been compromised, then the attacker also has to be quick enough to intercept it.

Here's how the flow works in hush:

  sequenceDiagram
    participant Sender
    participant Network
    participant Receiver

    Note over Sender,Receiver: Both know password "7-Ld89GsHO-gGHps31"

    Sender->>Sender: Generate PAKE initial message
    Sender->>Network: Send initial message
    Network->>Receiver: Forward message

    Receiver->>Receiver: Process message with Update()
    Receiver->>Receiver: Generate response with Bytes()
    Receiver->>Network: Send response
    Network->>Sender: Forward response

    Sender->>Sender: Process response with Update()

    Note over Sender,Receiver: Both now have same strong key!

    Sender->>Network: Send encrypted secret
    Network->>Receiver: Forward encrypted secret
    Receiver->>Receiver: Decrypt with derived key

The beauty of PAKE is that even if someone intercepts every message on the network, they still can't derive the key without knowing the original password. And they can't brute-force it offline - they'd have to actively participate in the protocol for every guess, making it impractical.

I used the schollz/pake library (SPAKE2 implementation) rather than implementing it myself. Because of course...

Security isn't an afterthought

Building with security in mind from the start was very interesting, and definitely not something I'm used to.

File transfer needs security thinking. One of hush's features is sending files up to 10MB. When I first implemented file receiving, I saved files with their original filename. Reasonable, right?

Well, no... An attacker could send a file named ../../.ssh/authorized_keys and potentially overwrite critical system files. This is called a path traversal attack, and it's not theoretical - it's in the OWASP Top 10 for a reason. Even if you sanitize the path, the filename itself could be dangerous - it could override a file in your current directory if you run it from a sensitive folder.

Instead of making an annoying interactive CLI where you can inspect the name and path, I just ignore the original filename entirely. Generate a safe name using a timestamp and the file extension. Something like hush_1699747123.pdf.

Zero-trust architecture. No relay servers. No logs. Ephemeral keys that disappear after the transfer. Even if someone compromises a server (that doesn't exist), they can't access your secrets. This shaped every design decision. You don't need to trust anything, there is nothing to trust.

How quickly Go clicked

This was my first Go project, and I was shocked at how fast I became proficient.

After about half a day of reading documentation and writing code, I was shipping features. Go's simplicity is its superpower. There aren't ten ways to do something - there's usually one obvious way, and it works. I wonder if the simplicity that Go offers will encounter issues when genuinely hard problems need to be solved. One can argue that those do not exist, and if you are a good engineer, everything can be made simple. Anyone who argues that has never encountered a genuinely hard problem.

Takeaway

Did I need to build hush? No. Magic Wormhole exists and is excellent, and Croc is literally from the same person as the PAKE library I used, also in Go and with more and better features than hush.

But I learned quite a bit making hush, way more than I would by reading docs or watching videos (or chatting with everyone's beloved AI). There is no need to come up with a new idea to build something. I would even argue the opposite, which is going to be my next post! Sometimes the best projects aren't the ones that solve new problems. They're the ones that help you understand old solutions.


Table of Contents