summaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-01 11:47:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-01 11:47:39 +0200
commit760ac421b1944cd69a80e3a92127a1a966f15938 (patch)
treec894b655d4f21698ebb91c0865ddf3e04985586d /docs
parent752b160c7c5e671e0db8f402a52fac27bb85ab06 (diff)
downloadmeshbay-760ac421b1944cd69a80e3a92127a1a966f15938.tar.gz
fix: downloads are marked and keep their extension; Explorer files are refused
The desktop app writes the Mark-of-the-Web on each file it saves on Windows, as a browser does. Bidirectional controls are reserved characters in a saved name (portable-name.js and paths.sanitize_for_download, and again in the main process), so a name cannot display one extension and carry another. The node refuses uploads of files Windows Explorer acts on by itself: desktop.ini, .lnk, .url, .scf, .library-ms, .searchConnector-ms (F-19). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
-rw-r--r--docs/MESHBAY_DESIGN.md47
1 files changed, 36 insertions, 11 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 1f2e43d..a43e35d 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -1621,7 +1621,11 @@ reason to show progress.
Five protections, and they are the substance:
-- a **filename allowlist**;
+- a **filename allowlist**, and **no file Windows Explorer acts on by itself** —
+ `desktop.ini`, `.lnk`, `.url`, `.scf`, `.library-ms`, `.searchConnector-ms`. Shown in
+ a folder the operator browses, any of them can make Explorer contact a server
+ of the uploader's choosing with the operator's Windows credentials; refused on
+ every node, since a Linux node's folder may be shared to Windows;
- **no overwrite** — a colliding name gets a free one. The check is `Path.exists()`,
and `stat()` is itself case-insensitive on NTFS and exFAT, so this already holds
there. A name an upload in flight will take counts as taken, since its file
@@ -2324,15 +2328,29 @@ The rules that make this safe:
CONFLICT DO UPDATE … WHERE … RETURNING` — so a concurrent burst gets no more
attempts than the limit. A request that checked no passphrase gives its attempt
back.
-- **Every path that checks the passphrase counts on the same row**: sign-in,
- passphrase change, changing the e-mail address on file and account deletion. A
- right passphrase clears it; failures older than the window age out.
+- **A browser the account signed in from has a row of its own.** A successful
+ sign-in from a browser that presented no token is answered with one (`known_browser`,
+ a random value the hub keeps only hashed, at most twenty per account, the least
+ recently used going first); a later sign-in presenting it is counted on its own
+ row, which nobody else can spend. The username's row, which anyone can spend,
+ then locks only browsers the account has never used. The token is not a
+ credential — the passphrase is still checked, at the same rate — and a token
+ for another account counts on the name's row like no token at all (**M1**). It
+ survives sign-out by design, and a passphrase reset or the account's erasure
+ forgets every one.
+- **A passphrase re-checked inside an open session counts on the session's
+ account row** — passphrase change, changing the e-mail address on file, account
+ deletion, registering a device, asking for the bundle pepper. A stranger
+ failing at sign-in does not stop the owner doing any of them, and failures there
+ lock nothing at sign-in. A right passphrase clears the row it was checked on;
+ failures older than the window age out.
- **A lockout refuses passphrase sign-in and nothing else.** Open sessions, token
- renewal and device sign-in continue, and a reset code sent to the address on
- file clears it — so a stranger who locks a public username costs its owner at
- most a new sign-in (**AV26**). A session learns its own lockout from
- `/v1/users/me`, because a passphrase change re-wraps every node's bundle before
- the hub accepts the new passphrase and must not start when the hub would refuse.
+ renewal and device sign-in continue, a known browser signs in on its own row,
+ and a reset code sent to the address on file clears it — so a stranger who
+ locks a public username costs its owner at most a sign-in from a new browser
+ (**AV26**). A session learns its own row's lockout from `/v1/users/me`, because
+ a passphrase change re-wraps every node's bundle before the hub accepts the new
+ passphrase and must not start when the hub would refuse.
---
@@ -2433,6 +2451,11 @@ What running it establishes, and what each fact costs:
- **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**.
+- **A downloaded file carries the Mark-of-the-Web**, as a browser's download
+ does: on Windows the application writes `Zone.Identifier` (ZoneId 3) beside each
+ file it saves, so SmartScreen and Protected View apply when it is opened. The
+ application writes its files itself, so nothing else would mark them; FAT and
+ exFAT have no such stream.
- **Installation places files, never secrets.** No key generation in a package's
post-install step or an installer custom action — a golden image would give every
machine the same key.
@@ -3218,7 +3241,9 @@ requirements, not compatibility notes.
the name its disk gave the file and never rewrites it — that is the string that
opens it — so a single file, a zip's entries and the zip's own name are passed
through `portable-name.js` at the moment of saving (reserved characters become
-`_`, a trailing dot or space goes, a reserved stem gains `_`), and the transfer's
+`_` — the bidirectional controls among them, since `invoice\u202efdp.exe` displays
+as `invoiceexe.pdf` — a trailing dot or space goes, a reserved stem gains `_`), and
+the transfer's
row names the original when it changed. The rule is `paths.sanitize_for_download`,
and the two are held byte-identical by a parity test. Two different names can
still become one — a zip keeps both entries under it.
@@ -3561,7 +3586,7 @@ had already been asked.
| **AV23** | **An upload's owner is recorded when the upload ends and applied when the entry is created**, which are different moments (§5.4). Written against the index at the end of the upload it matched nothing, every time, and left every uploaded file owned by nobody — so no member could delete what they had sent |
| **AV24** | **A node registered for no group is refused signaling, not exempted from it** (§7.2). The membership check was written as "if the node claims any group", so it skipped itself — membership, group status and the public-group gate together — for the node AV1 made commonplace: the unconfigured one, which is also the one least able to absorb the work |
| **AV25** | **Which nodes host a group is answered to its members** (§7.3). Only the public case checked, so a private group told any authenticated account that knew its id which machines hosted it — and an ex-member knows that id for ever |
-| **AV26** | **A sign-in lockout refuses passphrase sign-in and nothing else** (§7.7). It is keyed by username, usernames are public, and so anyone can spend somebody else's attempts. Open sessions, renewal and device sign-in are untouched and a reset code ends it, which bounds what a stranger buys to one forced sign-in. The lockout is a DoS primitive by construction; this is the ceiling on it |
+| **AV26** | **A sign-in lockout refuses passphrase sign-in and nothing else** (§7.7). Its username row is public, so anyone can spend it; a browser the account has signed in from counts on a row of its own, and passphrase checks inside a session on the account's. Open sessions, renewal and device sign-in are untouched and a reset code ends it, which bounds what a stranger buys to a sign-in from a browser the account never used |
| **AV27** | **A free-text third-party search is bounded per member and per node** (§6.5). `tmdb_search_req` spends the *operator's* credential, which TMDB rates and the whole group's automatic matching depends on, so one member holding a search box degrades the library for everyone. Per member and not per connection — three tabs is one person — and kept in the group context so a reconnect does not reset it. The refusal is an error, because an empty result list is what "no such film" looks like |
| **AV29** | **An invitation link is bounded on both halves and its mail on the sender** (§3.4, §7.3). Twenty outstanding per group on the node (bearer codes) and on the hub (tickets); and because a link mail reaches an address the hub has no relationship with, at the request of anyone who owns a group, it is counted **per sending account per day** (`mail.invite_link_daily_cap`, 10), under the recipient and instance bounds and outside the recovery reserve (`invite_link` is not a recovery purpose) |
| **AV28** | **How many node keys one account may announce is bounded** (§7.2). Each is a row plus an IP-log row under a one-year retention, so an account in a loop writes a year of storage on the operator's disk having paid only for signatures. Proof of possession (**M8**) settles whose key it is and not how many. Counted only where a row is added: re-announcing a key already held keeps working at the ceiling, or a node that reached it could never refresh its address again |