summaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/chat-sender-keys.md112
1 files changed, 77 insertions, 35 deletions
diff --git a/docs/chat-sender-keys.md b/docs/chat-sender-keys.md
index fefd327..b7fb203 100644
--- a/docs/chat-sender-keys.md
+++ b/docs/chat-sender-keys.md
@@ -355,14 +355,33 @@ device_key(d) = HKDF-SHA256(epoch_key,
```
Every member derives `device_key(d)` for every device from the epoch key, so there
-is nothing to distribute per device and nothing to store. A device encrypts only
-under its own subkey, so **two devices never share an AEAD key** and nonce reuse
-across devices is impossible without any coordination — the property §15.0b wanted
-per-device chains for, obtained by derivation instead of state.
+is nothing to distribute per device and nothing to store. Two *keys* never share a
+subkey, and — the part that carries the weight — **there is no mutable sending
+state at all**, so nothing can be advanced twice. That is the hazard §15.0b wanted
+per-device chains for, removed rather than partitioned.
-Nonces are 96-bit random per message. At one key per device per epoch, the NIST
-SP 800-38D ceiling of 2^32 invocations under a random 96-bit nonce is unreachable
-by a human typing; `groupbox.py` already makes and documents this argument.
+**Corrected 2026-09-07, and the correction matters.** An earlier draft of this
+section said "two devices never share an AEAD key". That is false in the
+deployment that exists, and stating it would have hidden the reason the design is
+safe. Two clients of one account on one node normally hold the **same** identity
+key: a second browser fetches the keypair bundle from the node and recovers the
+existing key rather than minting a new one (`transport.js`,
+`keypair_bundle_fetch`), and so does a fresh Electron install. Device *linking* —
+a distinct key, countersigned — is the exception, not the rule, which is why an
+operator adding a second browser is never asked to pin anything.
+
+So two clients routinely share a device key and therefore this subkey. What makes
+that safe is the nonce, not the derivation: **96 random bits, never a counter.**
+Two independent senders under one key collide only on the birthday bound, which at
+chat volume is unreachable; two independent senders advancing one *counter*
+collide immediately, which is precisely what C1 and §15.0b are about. The design
+degrades correctly into the deployment as it is; a chain-based one would have
+failed in it, silently, on the day someone opened a second tab.
+
+Nonces are 96-bit random per message. The NIST SP 800-38D ceiling of 2^32
+invocations under a random 96-bit nonce is a per-key budget now shared by however
+many clients an account runs at once — still unreachable by people typing;
+`groupbox.py` already makes and documents this argument.
### 5.2 The message
@@ -699,7 +718,7 @@ account is untestable.
| 1.1 | **Payload shape** ✅ | `store.py` gains `format` / `epoch` / `device` / `nonce` / `sig`, all with defaults, plus a unique `(device, nonce)`. Ciphertext travels in its own `ct` field | **The plan said "payload becomes bytes end to end", and that was wrong.** `payload` reaches older clients, and the UI ships inside the desktop package now, so they would have rendered bytes where they expect text. Plaintext keeps exactly the shape it has always had |
| 1.2 | **Message envelope** ✅ | `sender_name` moves inside the sealed payload; own-ness decided from `userId`, with an explicit `own` flag on the optimistic echo; `sender_name` dropped from the hub notification | R11, R12, and §5.8's metadata point |
| 2.1 | **Signatures** ✅ | The client signs over the *ciphertext* with its pinned device key; the node checks the `device` claim against this connection before storing; receivers verify before decrypting | Shipped with 3.2 rather than before it, because one envelope carries both |
-| 2.2 | Tier 2 — group roster | **Not built** — the one deliberate omission. It needs `group_roster_req`/`resp` and, first, `device_add` to persist `(sig, nonce_node, ts)`: today the countersignature is verified and thrown away, and its transcript binds a per-connection nonce, so even a stored signature would be unverifiable by a third party | §6, §13 |
+| 2.2 | **Tier 2 — group roster** ✅ | `device_add` persists `(sig, nonce_node, ts)` beside the pin — it used to verify the countersignature and throw it away, which is what blocked this. `group_roster_req`/`resp`, sealed under a new groupbox purpose and answered to **any member**, relays each device with the evidence that admitted it. The client walks the chain itself (`_verifyRoster`) and keeps its own pins | §6, §13 |
| 3.1 | **Epoch keys** ✅ | `ops.open_chat_epoch` / `ensure_chat_epoch` / `chat_epoch_keys`; wrapped to the node's own X25519 key in `bundles.db`; `chat_keys_req`/`resp` sealed under the new groupbox purpose `chat_keys` | §5.1, §5.4, R15 |
| 3.2 | **Encrypt / decrypt** ✅ | `chatbox.py`, mirrored by `sealChat` / `openChat` / `verifyChatSignature` in `crypto.js` and held byte-identical by six vectors in `test_js_python_parity.py` — including a `\|` inside a group id, which is the separator both the AAD and the HKDF info string use | §5.2 |
| 3.3 | **No switch** ✅ | **The plan's third mistake.** It proposed `chat_encrypted` per group, off by default. Refused by the operator: every node is a test node, so an opt-in flag buys nothing and leaves a plaintext branch reachable. MNP is **2.0**, `MNP_MIN_SUPPORTED` moves with it, a 1.x peer is refused at the handshake, and a test reads the source to assert nothing consults a `chat_encrypted` setting | §5.6, R5 |
@@ -773,35 +792,58 @@ needed by the stage named beside it.
---
-## 13. What was deliberately not built
+## 13. Tier 2, and what it does and does not buy
+
+**Built 2026-09-07**, after this document had recorded it as the one deliberate
+omission. What unblocked it was noticing why it was blocked: not effort, but
+that **the evidence was not being kept.** `_do_device_add` verified the
+countersignature and stored only `added_by_pk` — *which* key approved, never the
+proof — and `device_add_transcript` binds `nonce_node`, the approving
+connection's handshake nonce, so even a stored signature was unverifiable by
+anyone who had not been on that connection. Three columns fixed that.
+
+The property, stated exactly, because the temptation is to round it up:
+
+> Once a member's client has seen an account, **a node that later substitutes a
+> key for it is detected**. Nothing is gained at first sight, where the client
+> has nothing to compare against.
+
+That second sentence is not a caveat to be dropped. It is the same boundary
+`per-node-identity-v1.md` draws and this does not move it: an operator who is
+malicious *from the start*, for a member who has never seen the account, can
+still name whoever they like.
-**Tier 2 — clients pinning `account → device keys` (§6).** Everything else in
-§10 shipped. This did not, and it is worth being exact about what that costs and
-why it stopped here rather than being forgotten.
+**What the node decides: nothing.** It hands over evidence — for each live
+device of each active member of the group, the key, the key that countersigned
+it, the signature, the nonce and the timestamp — and the client walks the chain
+from each account's root outwards. A device the node lists but cannot evidence
+never enters the verified set, so a fabricated key is not laundered in by being
+mentioned. That is why the node is not asked to assert trust: it is the party
+the property holds *against*.
-What is in place: every message is signed by the sending device's key, the node
-refuses a message whose `device` is not the one that proved itself on that
-connection, and every receiver verifies the signature before it decrypts. So
-**no member can forge another member**, which is the property `desktop-client-v1.md`
-§4.8 Tier 1 asks for, and it is the finding (F1) that would have made encrypted
-chat worse than plaintext chat.
+Three decisions worth keeping:
-What is missing: a reader has no independent evidence that a given device key
-belongs to the account the node says it does. Against a **node that turns
-malicious later**, Tier 2 would make substitution detectable rather than merely
-improbable. Today that boundary is where `per-node-identity-v1.md` leaves it —
-the node runs admission, and a member trusts its roster.
+- **A root is a device that names no countersigner**, not one that fails to
+ produce a signature. Treating "no proof" as "root" would have admitted
+ anything a node chose to write, and the tests caught exactly that while this
+ was being built.
+- **First sight pins everything the node says**, not the verified subset.
+ Otherwise a legitimate second device whose countersignature predates this
+ change raises "key changed" on every message — and an alarm that fires on
+ normal events stops being read, which was §4.8's whole reason for budgeting
+ exactly one notice.
+- **A device pinned before 2026-09-07 is unevidenced, and reads as such.** It
+ was countersigned; the proof was not kept. Honest is better than convenient
+ here, and the operator-facing consequence is small because every node is a
+ test node whose devices are trivially re-paired.
-Why it stopped here, and it is not effort: **the evidence Tier 2 needs is not
-being kept.** `_do_device_add` verifies the countersignature and throws it away,
-storing only `added_by_pk` — *which* key approved, never the proof. Worse,
-`device_add_transcript` binds `nonce_node`, a per-connection nonce, so even a
-stored signature is unverifiable by anyone who was not on that connection. Tier 2
-therefore needs `device_add` to persist `(sig, nonce_node, ts)` **before** it is
-useful, and devices pinned before that change carry no evidence at all and would
-be trust-on-first-use only — which has to be visible in the UI rather than
-papered over.
+**The cost, stated because it is a real one:** the roster is member-visible, so
+every member of a group learns how many devices every other member holds and
+what their public keys are. It stays inside the group — the hub is not involved
+— and it is scoped to one group, so a person in two groups on one node is not
+disclosed to the second by being in the first. That is the price of the property
+and it is not avoidable: a member who cannot see the keys cannot check them.
-That is a self-contained change with its own decisions, and folding it into this
-one would have meant shipping a roster message whose contents are, for every
-existing device, "no evidence". It is the next thing to build here. \ No newline at end of file
+**Tier 3 stays deferred**, unchanged and with nothing depending on it: the
+operator signs a roster attestation, which would close first contact. It is
+worth doing only if a deployment appears where the operator is not the machine.