Best first milestone
Receive-only client
Shows a synchronized break whenever an Intermezzo device starts one.
- Advertise a TCP listener
- Accept newline-delimited JSON
- Verify HMAC signatures
- Answer clock requests
- Handle
event/start
Any screen can join the same break schedule. This guide takes you from “I can open a TCP socket” to a compatible client—without assuming Apple frameworks or prior protocol experience.
Bonjour · TCP · JSON · HMAC-SHA256 · no central server
Find peers with Bonjour, exchange one-line JSON over TCP, verify every line with the same shared key, then act at the absolute time in the message.
First, the mental model
There is no Intermezzo server and no account handshake. Nearby devices form a tiny peer-to-peer mesh on the user's own network.
Bonjour advertises where each client is listening.
Peers open ordinary, long-lived TCP connections.
A shared 32-byte key authenticates every message.
Absolute timestamps make every screen act together.
Apple clients also use iCloud when devices are apart, but it is a separate transport. Two clients with the same key on one LAN are a complete Intermezzo system.
Choose your scope
A useful first version only needs to receive breaks. Sending, settings sync, and six-digit pairing can all come afterwards.
Best first milestone
Shows a synchronized break whenever an Intermezzo device starts one.
event/startComplete experience
Can start and postpone breaks, recover after sleep, and synchronize settings.
Generate any stable-per-install string and persist it. Intermezzo uses an uppercase UUID. This is not secret; it only identifies your own messages and clock offset.
Let the user paste the key shown in Intermezzo under Settings → Advanced → Device pairing → Pairing key. Trim whitespace, decode standard base64, and require exactly 32 bytes.
Open TCP on any available port and advertise _intermezzo._tcp in the default local. domain with Bonjour, Avahi, or Android NSD.
Buffer TCP bytes until \n (0x0A). Decode that line as UTF-8 JSON. Skip malformed lines without closing the connection.
Rebuild the canonical string described below, calculate HMAC-SHA256 with the shared key, and compare the tag in constant time.
Reply to timeReq so senders can measure your clock. For event/start, convert the sender's timestamp and schedule the break.
You do not need to browse, dial, sync settings, use iCloud, or implement code pairing to receive synchronized breaks correctly.
The wire reference
The transport is intentionally boring: Bonjour tells you where to connect; TCP carries small JSON objects.
serviceDNS-SD type_intermezzo._tcp in the default local. domain
transportprotocolPlain TCP to the advertised host and port; connections are long-lived
framingencodingOne UTF-8 JSON object followed by one line feed byte, 0x0A
line limitrecommended64 KiB is generous; normal messages are only a few hundred bytes
reconnectbehaviorRedial after about 2 seconds while the peer remains advertised
Every full peer both listens and dials, so A may connect to B while B connects to A. Keep both. Duplicate delivery is part of the protocol.
Only advertise, browse, or keep sockets open while the user has synchronized breaks enabled. TCP is not encrypted; HMAC authenticates the low-sensitivity timing payload but does not hide it.
// Reads may split or combine lines; buffer until \n.
{"kind":"timeReq","t1":1750000000.125,"auth":"…"}\n
{"kind":"event","type":"start","date":1750000000.25,"duration":20,"origin":"ABC","auth":"…"}\n
Every message is one flat object. Only kind is universal; missing and JSON null fields are equivalent.
kindstring · allevent, timeReq, timeResp, settings, session, or schedule
typestringEvent action or session action
datedoubleUnix epoch seconds in the sender's clock
durationdoubleBreak length in seconds; zero for postpone
originstringSender's stable device ID
t1 · t2 · t3doublesClock-handshake timestamps
responderstringClock responder's device ID
settingsobjectName → {"value": string, "modifiedAt": double}
authbase64 stringHMAC-SHA256 tag; required on every sync message
What each message means
A receiver needs events and clock replies. The other kinds make a full peer recover and converge gracefully.
modifiedAt to local time. Per key, strictly newer wins. Unknown or invalid values are ignored without replacing the local stamp.The compatibility-critical part
JSON allows different key orders and number spellings. Intermezzo therefore signs one deterministic string built from the message fields.
Build the canonical string below as UTF-8, calculate HMAC-SHA256 with the raw 32-byte fleet key, then base64-encode the 32-byte result.
kind | type | date | duration | origin | t1 | t2 | t3 | responder
|.auth itself.If your implementation produces this tag, your key handling, number formatting, canonicalization, HMAC, and base64 agree with Intermezzo.
# Key: bytes 0x00 through 0x1f, encoded for display AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= # Unsigned message {"kind":"event","type":"start","date":1750000000.25, "duration":20,"origin":"ABC"} # Canonical UTF-8 input event|start|1750000000.250000|20.000000|ABC|||| # Expected base64 HMAC-SHA256 f6HxT7jGz/vfHyprGJfpwP6+GwgjHVYLN5Esf8qd0gI=
The original kinds—event, timeReq, and timeResp—stop after the frozen nine fields. Every other known kind appends its kind name as field ten. session and schedule stop there.
For settings, append one field per entry sorted by setting name. Render each as name=value@modifiedAt, again with exactly six decimals:
…|responder|settings|chimeTheme=bells@1752170000.250000|workIntervalMinutes=20@1752170003.000000
Unknown kinds must be ignored, never treated as events. A missing, malformed, or incorrect tag means the entire message is dropped.
Make the screens move together
Every date is Unix epoch seconds according to the sender. A small SNTP-style exchange estimates how far that clock is ahead or behind yours.
The requester sends timeReq with t1 = now. The responder records t2 on receipt and replies immediately with the same t1, t2, t3 = now, and its device ID in responder. The requester records t4 when that reply arrives.
offset = ((t2 - t1) + (t3 - t4)) / 2 // peerClock - myClock
localDate = senderDate - offset[origin]
A timeResp must echo the latest outstanding t1 on that connection; otherwise ignore it as stale. Store the result by responder device ID. Use zero until you have a measurement. Full peers initiate on connection and roughly every two minutes.
Getting the shared key
Wire compatibility begins once two devices hold the same 32 bytes. Manual paste is the universal fallback and the sensible first implementation.
Simple and conforming
Accept standard padded base64, tolerate surrounding whitespace, and require exactly 32 decoded bytes.
Polished onboarding
Run a CPace password-authenticated key exchange, then receive the real key over the resulting encrypted channel.
Do not “simplify” pairing by encrypting the fleet key with something derived directly from six digits. That allows instant offline brute force. If CPace is unavailable, keep manual paste.
The host advertises _intermezzo-pair._tcp only while its code is visible. The TXT record contains id and name. Framing is newline-delimited JSON. Pairing messages have no auth field because the shared key does not exist yet.
hellojoiner → hostsid (16 bytes), share (Ya, 32 bytes), ad (device ID), name
responsehost → joinershare (Yb, 32 bytes), ad, name
confirmjoiner → hosttag: base64, 32-byte joiner confirmation value
keyhost → joinerbox: base64 ChaCha20-Poly1305 combined box, 60 bytes total
failhost → joinerreason: diagnostic text; current wrong-code value is badCode
sid and ephemeral scalar ya, derives the code-dependent generator, calculates Ya, and sends hello.response, and derives the intermediate session key. Abort on a low-order point.key; on mismatch it sends fail. Either result burns the code.Use draft-irtf-cfrg-cpace, ciphersuite CPACE-X25519-SHA512. The joiner is always initiator A, the host responder B, and transcript ordering is transcript_ir.
groupX25519RFC 7748; scalar multiplication must abort on low-order points
hashSHA-512Input block size 128
generator DSIUTF-8CPace255
ISK DSIUTF-8CPace255_ISK
mapElligator 2Non-square Z = 2, RFC 9380
CIUTF-8intermezzo-pair-v1
ADa / ADbUTF-8Joiner's / host's device ID
passwordASCIIAll six digits, preserving leading zeroes
ISK = SHA-512(
lv_cat(DSI_ISK, sid, K)
|| lv_cat(Ya, ADa)
|| lv_cat(Yb, ADb)
)
# HKDF-SHA256, empty salt, 32-byte output
confirm_info = "intermezzo.pair.v1.confirm.initiator"
seal_info = "intermezzo.pair.v1.seal"
intermezzo://pair?c=482913&d=<device-id>. Never put the permanent fleet key in the QR.Apple clients also store an unpinned key under intermezzo.sync.key.v1. If two unpinned Apple devices generated different values, the lexicographically smaller base64 text wins. Third-party clients do not need this iCloud behavior.
Before you call it compatible
Test the smallest useful behavior first, then add the full-peer items without changing the core wire format.
Receive-only
timeReq responseFull peer
The usual cause is a different key or canonicalization mismatch—especially locale-dependent decimals, missing empty fields, or signing raw JSON. Then check clock conversion and stale-event rules.
Interop questions, test reports, and new platform ports are welcome at support@tryintermezzo.app.