diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 34 | ||||
| -rw-r--r-- | docs/MESHBAY_NODE_PROTOCOL.md | 14 |
2 files changed, 40 insertions, 8 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index e9b63b5..2966e5e 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -286,7 +286,11 @@ The binding is a **one-time code the new device generates and displays**, hashed together with the new keys: `code_hash = sha256(code ‖ new_pk_ed25519 ‖ new_pk_x25519)`. The approving device asks the node for this account's pending requests **with their stored hashes** and recomputes the hash for each until one -matches. +matches. **An approval answers a pending request**: it names the request's hash, +and the node admits the keys only if that request was filed by those same keys. A +countersignature alone — however it was obtained — admits nothing. Retiring a +device is signed under a prefix of its own, never the admission transcript: a +signature given to retire a key would otherwise admit it. > **The code never reaches the node.** That is what makes a substituted key > impossible rather than merely detectable: a node offering fabricated keys would @@ -546,7 +550,7 @@ declare a group open would be handed its key. An unknown group reads as `invite` **Changing a known passphrase** re-seals every reachable node's identity bundle under the new `M` **before** touching the hub — if the fan-out fails, the account is unchanged. The pepper does not change, and the client, which does not keep it, -asks for it with the open session (`GET /v1/users/me/bundle-pepper`). Only then is +asks for it with the old passphrase (`POST /v1/users/me/bundle-pepper`). Only then is `POST /v1/users/password` called with the old and new `auth_key`. In a browser, nodes that were unreachable are named to the user, with the operator fallback (`member unpin` plus a fresh code) as the way to fix each one. The desktop @@ -616,10 +620,14 @@ The **pepper** is 32 random bytes per account, created by the hub on first use, sealed at rest with the key that seals e-mail addresses and bound to the account (`auth.seal_pepper`). The hub hands it out only where the caller has just proved the passphrase or a device key: in the response of `POST /v1/users/login` and -`POST /v1/users/auth`, and from `GET /v1/users/me/bundle-pepper` for a session -that did (a stored session from before, a passphrase change). **Never** on a token -refresh — that proves possession of a refresh token and nothing else — never to a -node token, never inside a token, never in a log. Erasing the account clears it; +`POST /v1/users/auth`, and from `POST /v1/users/me/bundle-pepper` with the +passphrase (`auth_key`) for a session that lacks the key derived from it (a +restored session, a passphrase change). **Never to a token alone** — a refreshed +one proves possession of a refresh token and nothing else, and one lifted from a +page would put the pepper beside the bundles an operator keeps — never to a node +token, never inside a token, never in a log. For the same reason **registering a +device's hub key takes the passphrase too**: a registered key signs in without +it, and every such sign-in carries the pepper. Erasing the account clears it; a passphrase reset keeps it. What that buys is the reason for all of it: **the tag of a bundle on a node's disk @@ -2387,7 +2395,19 @@ What running it establishes, and what each fact costs: (`transport.js`, `_identityFromKeys` in a browser, `_nativeIdentityHandle` here), so nothing above it knows where the keys are — and no code in the page reads a private key outside that object (`test_identity_seam.py`). Whether a - node keeps a sealed copy is the account's browser access, decided here. + node keeps a sealed copy is the account's browser access, decided here, and the + keyring refuses to seal one while it is off, whatever the page asks. +- **An identity signs a kind, never bytes.** The page names what it is signing — + a join, a device's hello, a device request, an approval, a retirement, a chat + line, an admin operation — and gives the fields; the main process builds the + transcript itself (`transcripts.js`, byte for byte `meshbay_common` and the + page's `transcriptFor`), with the identity's own public keys wherever one is + named, refuses a field naming another node or another account or a moment far + from now, and signs no other kind. A page able to submit bytes would obtain a + signature over anything, reusable outside the operation it claimed to be for. + What a page can still obtain is a signature of a named kind within the session + it runs in — the same reach as the person using the window, which is the scope + accepted for the renderer. - **OS-backed secret storage is real on a desktop and honest without one.** With a keyring it is keyring-backed; headless, the same code reports unavailable and **refuses to store rather than downgrading silently**. diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md index 9efef08..9e51dc9 100644 --- a/docs/MESHBAY_NODE_PROTOCOL.md +++ b/docs/MESHBAY_NODE_PROTOCOL.md @@ -1001,6 +1001,9 @@ D_req = "meshbay:device_req:v1" || LP(node_pk) || LP(user_id) || LP(pk_ed) || D_add = "meshbay:device_add:v1" || LP(node_pk) || LP(user_id) || LP(pk_ed) || LP(pk_x) || LP(nonce_s) || LP(ts) signed by a PINNED device +D_rev = "meshbay:device_revoke:v1" || LP(node_pk) || LP(user_id) || LP(pk_ed) || + LP(nonce_s) || LP(ts) signed by a PINNED device + code_hash = sha256( code "\x1f" pk_ed25519_b64 "\x1f" pk_x25519_b64 ) ``` @@ -1009,6 +1012,11 @@ code_hash = sha256( code "\x1f" pk_ed25519_b64 "\x1f" pk_x25519_b64 ) * `D_add` deliberately **omits the code**: the code is a bearer secret used to find the request, never signed, never echoed. What is signed is the key pair being admitted, so a signature collected for one device cannot admit another. +* `device_add` **must name a pending request** (`code_hash`) filed by the same + `pk_ed25519` and `pk_x25519`; without one, or with one filed by other keys, it is + refused and the request is not spent. A countersignature alone admits nothing. +* `D_rev` has a prefix of its own. Were a retirement signed over `D_add`, a signature + given to retire a key would admit that key wherever it is not pinned yet. * Because both keys go into `code_hash`, a node cannot answer the approver with a substituted key: the approver recomputes the hash from what it typed and what it was given. Nothing here rests on a human comparing digits. @@ -1022,7 +1030,7 @@ code_hash = sha256( code "\x1f" pk_ed25519_b64 "\x1f" pk_x25519_b64 ) N -> C device_list_result {pending, devices: [{pk_ed25519, label, pinned_at, pinned_via, added_by_pk, is_this_one}]} - C -> N device_revoke {pk_ed25519, ts, sig over D_add for the victim's keys} + C -> N device_revoke {pk_ed25519, ts, sig over D_rev for the victim's key} N -> C device_add_ack {revoked: pk_ed25519} ``` @@ -2361,6 +2369,10 @@ device add "meshbay:device_add:v1" LP(node_pk) LP(user_id) LP(pk_ed25519) LP LP(nonce_s) LP(ts) -> Ed25519 by an ALREADY-PINNED device of the same account +device rev "meshbay:device_revoke:v1" LP(node_pk) LP(user_id) LP(pk_ed25519) + LP(nonce_s) LP(ts) + -> Ed25519 by an ALREADY-PINNED device of the same account + device hello "meshbay:device_hello:v1" LP(node_pk) LP(group_id) LP(user_id) LP(pk_ed25519) LP(nonce_s) LP(ts) -> Ed25519 by the device claiming this connection |