Expand description
Short-Authentication-String (SAS) verification — Phase G.
Two peers OOB-compare a short derived code to confirm they each hold the matching Ed25519 keys (defense against MITM during initial contact, before fingerprint trust is established).
Protocol shape (each step is a signed RoomMessage on the room’s
gossipsub topic):
- Initiator picks a random 16-byte
tx_id+ an ephemeral X25519 keypair. SendsSasInit { tx_id, ephemeral_x25519_pubkey, target_fp }. - Responder generates their own ephemeral X25519 keypair, computes
ECDH with the initiator’s pubkey, derives the SAS code via
derive_sas_code(shared, tx_id), and replies withSasResponse { tx_id, ephemeral_x25519_pubkey }. The responder sees the code locally and shows it. - The initiator computes ECDH the other direction, derives the same code, shows it.
- Both users compare codes OOB. Each side presses Match → broadcasts
SasConfirm { tx_id, matched: true }. - On receiving the other side’s
matched=true, set the partner’s fingerprint asverified=true(per-room + globalverified_peers).
The signatures on each envelope bind the ephemeral X25519 pubkeys to the sender’s Ed25519 identity. A MITM who substitutes their own ephemeral key into the exchange ends up with a different SAS code than the legitimate peer would compute, so the OOB comparison fails.
§SAS table — a huddle-internal scheme (NOT Matrix wire-compatible)
huddle uses its own 49-emoji subset of the Matrix MSC 2241 list under a
huddle-specific HKDF info string (b"huddle-sas-v1") and maps 6-bit chunks
into 0..49 by rejection sampling (see derive_emoji_indices_rejection).
This does not interoperate with Matrix SAS: canonical MSC 2241 uses a
64-entry table indexed directly by each 6-bit chunk (no modulus, no
rejection sampling) under its own info string, so a Matrix client would
derive entirely different emoji. The derivation produces 7 emoji (42 bits /
6 = 7 chunks) and 3 four-digit decimal groups (39 bits / 13 = 3 chunks, each
offset +1000 so values land in 1000..=9191); the decimal shape matches the
MSC 2241 decimal SAS, but the emoji scheme is huddle↔huddle only. Since both
peers run identical deterministic code, MITM detection is unaffected.
§huddle 2.0: optional post-quantum capability binding
derive_sas_code takes both peers’ ML-KEM encapsulation keys
(our_mlkem_ek, their_mlkem_ek). The binding is gated on the partner’s
key: when we hold a pinned ML-KEM ek for the peer (their_mlkem_ek = Some),
the transcript mixes a b"huddle-sas-pqbind-v1" domain tag plus
SHA-256 of both eks concatenated in byte-sorted order into the HKDF
info — so the pair’s PQ capability becomes part of the out-of-band trust
anchor. Sorting makes the binding symmetric: each peer sees the two keys in
the opposite (our, their) roles, but sorting yields identical info on both
sides, so two honest PQ-capable peers derive the same code. A relay that
strips the ML-KEM pubkey from one side’s announce drives that side’s
their_mlkem_ek to None (classical transcript) while the other still
binds; the two SAS codes then diverge and the OOB comparison catches the
silent classical downgrade. The salt (tx_id) and PRF (HKDF-SHA256) are
unchanged — only the info domain tag + hash are added. When the partner
has no pinned ek (their_mlkem_ek = None — group members, pre-1.3 partners,
or the classical fallback) the derivation is byte-for-byte identical to the
1.x b"huddle-sas-v1" transcript regardless of our own key, so old and new
peers still agree.
Structs§
- SasCode
- SAS code information given to both sides for OOB comparison.
Constants§
- SAS_
EMOJI - huddle’s 49-emoji subset of the Matrix MSC 2241 list, English labels. Indices 0-48; the derivation above rejection-samples 6-bit HKDF chunks into 0..49 (not Matrix’s direct 6-bit indexing — see the module doc).
- TX_
ID_ LEN - Length of the transaction id used as HKDF salt. 16 bytes (128 bits) is plenty of unforgeability; sized to be base64-friendly.
Functions§
- derive_
sas_ code - Derive the 7-emoji + 3-group-decimal SAS code from the X25519
shared secret and the agreed-upon
tx_id. Both peers compute this independently and must end up with the same answer for OOB comparison to succeed. - new_
session - Fresh X25519 ephemeral keypair + random tx_id. The secret stays on the initiator’s machine until the SAS finishes; the pubkey is transmitted in the signed envelope.
- parse_
pubkey - Decode a base64-encoded 32-byte X25519 pubkey received over the wire.