Put a message on your team's notches.
n2n lets Nooks talk to each other: a small encrypted group chat, and banners that scroll across everyone's notch at once. No accounts and no ctrlhq server. This page is the whole protocol.

A topic is a key
No accounts, no phone numbers, no ctrlhq server. A group is a random 256-bit key, and whoever holds it is in.
The relay sees ciphertext
Messages pass through the public ntfy.sh relay, sealed with ChaCha20-Poly1305. It sees a random topic name and opaque bytes.
Every sender signs
Each Mac has its own Ed25519 key. A member can read the group, but can't write as someone else.
Nothing lasts long
The relay forgets messages after about 12 hours. History lives only on the Macs in the group.
How it works
The path of one message
Everything interesting happens on the two Macs. The relay in the middle is a dumb pipe: it takes a POST to a topic and streams it to whoever is listening on that topic.
Your Mac
- 1Write the payload as JSON
- 2Sign it with your Ed25519 key
- 3Seal it with the topic's key
- 4POST it to ntfy.sh/n2n-…
ntfy.sh relay
- 1Sees a random topic name
- 2Stores opaque bytes for about 12 hours
- 3Streams them to every listener
Their Macs
- 1Open the seal with the topic's key
- 2Check the signature and the topic
- 3Show it in the chat, or on the notch
Keys
Three keys, all in the Keychain
The topic key is 256 random bits, made when someone starts a topic. It never leaves the group. Two things are derived from it:
topic name = "n2n-" + hex( HMAC-SHA256(key, "nook-n2n topic")[0..16] ) seal key = HKDF-SHA256(key, info: "nook-n2n seal", 32 bytes)
The relay topic name gives nothing away about the group, and it's stable, so every member lands on the same topic without asking anyone.
Your signing key (Ed25519) is your identity in every topic. Your agreement key (X25519) is what a new topic key gets sealed to. Both are made on first use and stored in the Keychain as this-device-only, so they never sync to iCloud or leave the Mac.
Wire format
What actually goes over the wire
The relay message body is one line of base64, built in three layers:
payload = JSON { v, id, topic, kind, from, name, text, ts, ka, keys?, drop?, re? }
signed = JSON { "p": payload, "s": Ed25519.sign(payload) }
body = base64( ChaChaPoly.seal(signed, sealKey) ) // nonce ‖ ciphertext ‖ tagA receiver opens the seal, checks the signature against from, and checks that topic is the topic it came in on. If any step fails, the message is dropped without a word.
| Field | Type | Meaning |
|---|---|---|
| v | int | Protocol version, currently 1. |
| id | string | A UUID. Receivers drop IDs they've already seen, so replays and reconnects never duplicate. |
| topic | string | The topic it was sealed for. A message copied into another topic is rejected. |
| kind | string | What it is (see below). |
| from | bytes | The sender's Ed25519 public key: their identity in every group. |
| name | string | Display name, up to 40 characters. |
| text | string | Up to 4,000 characters for chat, 140 for a notch message, one emoji for a reaction. |
| ts | number | Unix time the sender wrote it. |
| ka | bytes? | The sender's X25519 public key, so a new topic key can be sealed to them. |
| keys | bytes? | rekey only: the new topic key, wrapped for each member who stays. |
| drop | string[]? | rekey only: the members being removed. |
| re | string? | react only: the ID of the message being reacted to. |
| data | bytes? | room: the task list or a change to it. part: this piece of the whole. |
| whole, part, parts | string?, int?, int? | part only: the ID of the whole message, this piece's index, and how many pieces there are. |
Message kinds:
- chatA line in the group's chat.
- notchScrolls across every member's notch as a dot-matrix banner, and lands in the chat too.
- reactAn emoji on another message. One per member per message; an empty text takes it back.
- joinYou opened an invite. Everyone already there answers with a here.
- hereNever shown. Tells a newcomer who's in the group, and shares your X25519 key.
- leaveYou left. Your Mac also deletes the topic key.
- rekeyA new topic key for everyone who stays. This is how members are removed.
- partOne piece of a message too big for the relay. Never shown until all its pieces are in.
- roomWorkrooms: a change to the task list, or with text "state", all of it.
- syncWorkrooms: asks the room for its task list, after joining or being away.
Transport is plain ntfy. Sending is POST https://ntfy.sh/<topic> with X-Firebase: no, so nothing goes through a push service. Listening is one long-lived GET /<topic1,topic2,…>/json?since=… stream for every topic at once. It reconnects with back-off from 2 to 60 seconds and asks only for what it missed.
Long messages
ntfy caps a message at 4 KB and turns anything bigger into an attachment. So when a sealed body would pass 3,900 bytes, the message is zlib-compressed and cut into part messages of 1,200 bytes each. Each part is signed and sealed on its own, and carries the whole message's ID, its index and the total count:
inner = zlib( JSON { kind, text, data } )
part n = payload { kind: "part", whole: <message id>, part: n, parts: N, data: inner[n·1200 ..< (n+1)·1200] }The receiver holds the pieces until all N have arrived, in any order, then decompresses them and handles the result as if it had come in one piece. A message can have up to 48 parts, about 55 KB compressed. Pieces that never complete are dropped after an hour.
Invites
Joining is holding the key
An invite is a small .n2n file, so you can pass it by AirDrop, Messages, Mail or a USB stick:
{ "v": 1, "kind": "nook-n2n-invite", "name": "Design team", "key": "<32 bytes, base64>", "from": "Yash" }Double-click it and ctrlhq asks before joining. Then it subscribes and sends a join, and everyone there answers with a here, so you learn who's in the group. ctrlhq deletes the invite files it writes after ten minutes. Treat an invite like a password: anyone who has it can join.
Key rotation
Removing someone without re-inviting everyone
Every payload carries the sender's X25519 key, so each Mac knows how to reach every member privately. To remove Alex, your Mac makes a new topic key and seals a copy for each member who stays:
eph = fresh X25519 key pair, used once
per member: kek = HKDF-SHA256( X25519(eph, member.ka),
salt: eph.pub ‖ member.ka, info: "nook-n2n rekey" )
keys = eph.pub ‖ [ member-id[0..8] ‖ ChaChaPoly.seal(newKey, kek) ] …This goes out as a rekey message on the old topic, signed like everything else. Each remaining member finds the entry with their ID, unwraps the new key, and moves to the new topic on their own. Alex is listed in drop and has no entry, so their ctrlhq shows they were removed and stops listening.
Each rekey message holds up to 16 wrapped keys, about 3.5 KB once sealed, so it stays under ntfy's 4 KB cap. Bigger groups get several. Your Mac only switches once the relay has accepted all of them. If it can't reach the relay, the topic keeps its old key and nobody is stranded.
New key without removing anyone works the same way. It's the fix for a leaked invite file: everyone you know moves over, and anyone quietly holding the old file is left behind.
Workrooms
A shared task list with no server
A workroom is a topic with a task list: statuses (add your own, each counting as Pending, In progress or Done), assignees, per-person timers and short updates on each task. The chat sits beside it. When the room ends, the list goes read-only and each Mac works out its own summary. It stays private until someone shares it into the chat, where it goes out as a long message.
Nobody owns the list, so every Mac keeps a full copy and they merge. Each editable field carries a stamp: the time, then the writer's ID to break ties. When two copies disagree, the newer stamp wins, so changes can arrive in any order, or more than once, and every Mac ends up with the same list.
task = { id, title, status, assignee, deleted, order, stamps: { field: (time, by) },
time: { member: { total, since?, stamp } }, updates: [ … ] }
merge = per field: keep the newer stamp · time: newer entry per member · updates: unionEach person's tracked time is theirs alone: only their Mac writes it, so it never conflicts. A change goes out as a room message holding only the task that changed. When someone joins, or asks with sync after being away, members wait a random moment and the first one to answer sends the whole room. Everyone else sees that answer and stays quiet. When you're done, Delete room removes the tasks, the chat and the key from your Mac.
In the app
Where you'll use it
- The n2n tab. Topics, chat, members, invites and key rotation.
- Notch messages. Switch the composer to Notch and your words scroll across everyone's notch in dot-matrix.
- Workrooms. Tasks with statuses and timers beside the chat, a Pending / In progress / Done count with hours, and a summary at the end.
- Reactions. Hover a message for 👍 ❤️ 😂 😮 🎉 👀, one per person.
- The power tool. Press ⌘⇧Space in any app and type
/design ship it. ↩ sends it to the chat; pick the second row to put it on everyone's notch.
Threat model
What it protects, and what it doesn't
Protected
- What anyone writes, from the relay and anyone watching it
- Who's in a group, and their names
- Members writing as each other
- Messages replayed into another topic
- Removed members reading anything new
Not protected
- The relay sees timing, sizes and IP addresses
- Anyone with an invite file can join until you make a new key
- No forward secrecy: a leaked topic key opens what the relay still holds (up to 12 hours)
- A removed member keeps what they already received
- Messages sent while you were offline for more than 12 hours
All of it is built on Apple's CryptoKit: ChaCha20-Poly1305, Ed25519, X25519, HKDF-SHA256 and HMAC-SHA256. There's no homemade cryptography.
Questions
Is n2n end-to-end encrypted?
Yes. Messages are sealed on your Mac with a key only the group holds and opened on theirs. The relay carries sealed bytes and never has the key.
Does ctrlhq run a server for this?
No. n2n uses the free public ntfy.sh relay. ctrlhq has no n2n server, no account system and no copy of your messages.
What can the relay see?
A random-looking topic name, when messages are sent, how big they are, and the IP addresses of the Macs sending and listening. It can't see names, members, or anything anyone wrote.
What happens if an invite file leaks?
Whoever has it can join, the same as anyone you invited. Choose New key: everyone you know moves over by themselves, and the old invite file stops working.
Can a removed member still read old messages?
They keep what their Mac already received. They can't read anything sent after the new key, because it's never sealed to them.
What if my Mac was off for a day?
The relay only holds messages for about 12 hours, so you miss what was sent before that. If you missed a rekey too, ask someone for a new invite.
Why is it experimental?
The format may still change, and ntfy.sh is a free public service with its own rate limits. It works, but don't build anything critical on it yet.
Try n2n with a friend.
Turn it on in ctrlhq's n2n tab, start a topic and AirDrop the invite. It's part of ctrlhq: $19 once, with a 14-day money-back guarantee.
ctrlhq