Skip to content

Ten more site claims pinned to the code, and the work list retaken - #367

Merged
Apolloccrypt merged 2 commits into
mainfrom
chore/site-claims-batch-2
Sep 2, 2026
Merged

Ten more site claims pinned to the code, and the work list retaken#367
Apolloccrypt merged 2 commits into
mainfrom
chore/site-claims-batch-2

Conversation

@Apolloccrypt

Copy link
Copy Markdown
Owner

docs/site-claims.md was written against the site as it stood after #323. The
rewrites since (#328 to #366) renamed the free tier, gave ParaSend its own page
and rebuilt the homepage, /pricing, /about, /security, /trust, /docs, /help and
/download, so most of that inventory pointed at text that no longer exists.

Two things here. The work list is retaken over the 40 public pages that exist
now, with the suite that pins each claim named per row. And the next ten
heaviest uncovered claims are pinned in tests/site-claims.test.mjs as rows 13
to 22, each one read out of the code or the configuration rather than off
another page.

Six of the ten were claims the code does not carry. Those are corrected on the
page, not pinned as they stood.

What changed, per claim

# Page Line Source in the code Old text New text
13 help/gmail-extension 318 relay/lib/tiers.js file_mb, relay/relay.js MAX_BLOB (5242880) "Files up to 5 GB are supported." "Files up to 5 MB are supported. That is the per-file ceiling on every plan (file_mb in relay/lib/tiers.js, bounded again by the relay's MAX_BLOB); a larger file is refused with a 413."
13 help/iot-integration 333 same "Files up to 5 GB are supported." "Files up to 5 MB are supported."
14 help/iot-integration 333 deploy/nginx-selfhost.conf (zone=api rate=60r/m, keyed on $binary_remote_addr; zone=inbound rate=5r/m), relay/lib/tiers.js outbound_per_hour "The API accepts up to 60 uploads per minute per API key." "Request rate limits are per IP address, not per API key: the nginx configuration in this repository puts general API traffic in a 60 requests a minute zone and uploads in a stricter one (nginx-selfhost.conf). The ceiling that is per key applies to retrieval, not upload: 50 downloads an hour on Community and 500 an hour on Pro (outbound_per_hour in relay/lib/tiers.js), answered with a 429."
15 help/session-issues 308-310 admin/server.js checkLoginRateLimit (5 per 15 min) and the /api/user/login Redis counters (5 per IP, 10 per email, 900 s). Nothing in admin/server.js or relay/relay.js locks an account header ""Account locked" after multiple failed attempts"; "Accounts are temporarily locked after 10 consecutive failed login attempts within 15 minutes. The lock lifts automatically after 30 minutes. If you cannot wait, email privacy@paramant.app ... to request an early unlock." header ""Too many login attempts""; "There is no account lock. Login attempts are rate limited instead: more than 5 attempts from one IP address, or more than 10 for one email address, inside 15 minutes are answered with a 429. The counter clears by itself once the fifteen minutes are up, so there is nothing to unlock and nobody can lock you out by guessing at your address."
15 help/lost-authenticator 316-317 same "My team account is locked out and it's urgent." / "We prioritize locked team accounts." "My whole team has lost access and it's urgent." / "We prioritize a team that cannot reach its account at all."
15 dpa 280 same "per-IP rate limiting (5 attempts/min)" "per-IP rate limiting (5 attempts per 15 minutes)"
16 security 405 admin/server.js setUserCookie: HttpOnly; Secure; SameSite=Lax; Max-Age=3600, ADR R018 "a 256-bit session token is issued as an httpOnly Secure SameSite=Strict cookie." "a 256-bit session token is issued as an httpOnly Secure SameSite=Lax cookie. Lax, not Strict, so an invited signer arriving from a link in their mail client is not bounced back to the login screen; a cross-site POST still carries no cookie."
17 security 393 relay/lib/totp.js: default algorithm list ['sha256', 'sha1'] "TOTP verification uses SHA-256 HMAC, matching the cryptographic posture of the transport layer." "TOTP verification accepts SHA-256 and SHA-1 HMAC, so every standard authenticator app works; SHA-1 is the RFC 6238 default and several common apps offer nothing else."
18 security 410 relay/lib/totp.js: store.set(slotKey, '1', { NX: true, EX: 90 }).catch(() => 'OK') "Each code can be used exactly once across the system, enforced atomically in Redis." "Each code is marked used the first time it is accepted, with an atomic single-use key in Redis. If Redis cannot be reached the check is skipped rather than failing the login, so replay protection is only as available as Redis is."
19 security 335 relay/relay.js: argon2Lib.hash(password, ...) into pw_hash, verified on retrieval; optional try/require, 501 when absent table row "Argon2id / Password-based blob encryption / RFC 9106" "Argon2id / Password gate on a transfer (hash, optional module) / RFC 9106"
19 docs 848 same "Argon2id / Password-protected blob derive / RFC 9106" same as above
20 security 362 docs/security-audit-2026-04.md finding 5 (accepted trade-off); frontend/js/parashare.page.js sends total_chunks in the clear "... padded to a fixed 5 MB block for DPI masking. An observer on the network cannot infer file size, type, or content." "... A single block reveals no file size, type or content. A transfer that needs more than one block sends its chunk count in the clear, so the number of blocks is visible and the size can be placed within an order of magnitude (audit finding 5, accepted)."
20 security 376 same "Identify ParaShare transfer size: authenticated transfers look identical (fixed 5 MB padding)" "Read an exact ParaShare file size: every block is a fixed 5 MB, and only the block count is visible"
20 docs 447, 859, 972, 1018 same "Fixed 5 MB padding defeats traffic analysis."; "An observer on the network cannot determine the size, type, or content of the transferred file."; "traffic analysis cannot distinguish heartbeats from payloads"; "All transfers ... appear as identical 5 MiB blobs on the network." each now states the fixed block and then the bound: a multi-block transfer sends total_chunks in the clear, which places the size within an order of magnitude, per finding 5
21 index 291 relay/lib/tiers.js outbound_per_hour (50 / 500); the only per-IP rate is ANON_RATE_PER_HOUR on the deprecated /v2/anon-inbound, which reads no plan "up to 50 registered devices, no IP rate limit." "up to 50 registered devices, and up to 500 retrievals an hour through the API instead of 50."
22 (none) - the page tree itself - no text change: /parasend's "No third-party requests ... No fonts, CDNs, analytics or pixels" and /pricing's "No tracking." were true and unpinned. Row 22 walks every public page and fails on any script, link, img, iframe, source, video, audio, embed, object or url() pointing off paramant.app

Where the code did not carry the claim

Six of the ten were false or overstated, and are weakened or corrected rather
than pinned as written:

  • 5 GB on two help pages was a thousand times MAX_BLOB. Corrected to 5 MB.
  • "60 uploads per minute per API key" was an nginx zone keyed on
    $binary_remote_addr, so per IP, and it was the general API zone rather than
    the stricter upload one. Rewritten to name both, and to point at the ceiling
    that really is per key.
  • The account lockout has no implementation. Pin the site's ten heaviest claims to the code that makes them true #327 struck it from /security;
    it survived word for word on /help/session-issues, with an email address for
    an "early unlock" nobody can grant. Replaced with the rate limits that exist.
    /dpa quoted the same limiter with a one-minute window instead of fifteen.
  • SameSite=Strict is wrong in the direction that flatters us: the cookie is
    Lax, deliberately, and the reason is now on the page.
  • "exactly once across the system" is not what a guard that falls back to
    accepting the code gives you. The fail-open is now stated.
  • Argon2id hashes a transfer password and gates access to a stored blob. It
    does not encrypt the blob and it does not derive its key, and it is an
    optional module. Both crypto tables now say so.
  • "An observer cannot infer file size, type, or content" is contradicted by
    finding 5 of the audit report this site publishes. Bounded to what the padding
    actually does.
  • "no IP rate limit" as a paid benefit is the /v2/anon-inbound fossil again
    (the same one row 11 hunts): no per-IP rate reads a plan. Replaced with the
    hourly retrieval ceiling, which is the thing a paid plan raises.

Sabotage

Every pin was broken from both sides and the run confirmed to go red, then
restored. Twenty two sabotages, all caught:

  • page side: 5 GB back on the Gmail page; "60 uploads per minute per API key"
    back; the lockout paragraph back; SameSite=Strict back; SHA-256 alone back;
    the absolute single-use sentence back; Argon2id as blob encryption back; the
    absolute padding sentence back; "no IP rate limit" back; a Google Fonts
    stylesheet added to /pricing; the promise removed from /parasend.
  • source side: file_mb 5 to 50; MAX_BLOB to 10 MB; the api zone to 120r/m;
    the per-IP login cap 5 to 8; the cookie switched to Strict; sha1 dropped
    from the algorithm list; the .catch fail-open removed; the optional argon2
    require made mandatory; total_chunks removed from both ParaShare call sites;
    finding 5 marked fixed instead of accepted; pro outbound_per_hour 500 to 900.

Tests

  • node --test tests/site-claims.test.mjs: 22 pass, 0 fail (was 12).
  • node --test $(grep -L "from 'playwright'" tests/*.mjs): 150 pass, 2 skipped,
    1 fail. The failure is tests/heartbeat-lib.test.mjs, which needs
    @noble/post-quantum; it fails identically on origin/main in a checkout
    without npm ci and is unrelated to this branch.
  • relay/test/pricing-page.test.js: 47 checks passed.
  • relay unit suite: 174 pass, 1 fail (test/parasign-store.test.js, unmet
    precondition redis, same on origin/main here).
  • admin unit suite: 40 pass, 0 fail.
  • bash tests/static-sanity.sh: PASS, all 11 checks clear.
  • The Playwright suites (first-screen, pricing-fold, sign-full) were not
    run: no browser binary in this environment. Every edit here sits well below
    the fold on the pages those suites measure, and /pricing is untouched.

Head elements are not touched. New test blocks keep their declarations in
function scope, and the test-scope guard reports 111 suites clean.

docs/site-claims.md was written against the site as it stood after #323. The
rewrites in #328 to #366 moved most of it, so the inventory is retaken over the
40 public pages that exist now and the coverage column names the suite that
actually pins each claim.

Ten claims are pinned in tests/site-claims.test.mjs, six of which the code did
not carry:

- two help pages offered "files up to 5 GB"; MAX_BLOB is 5 MB
- /help/iot-integration sold an nginx per-IP zone as 60 uploads a minute per key
- the account lockout struck from /security in #327 survived on /help
- /security described the session cookie as SameSite=Strict; it is Lax
- /security named SHA-256 where totp.js dual-verifies SHA-256 and SHA-1
- /security promised single-use TOTP codes over a replay guard that fails open
- Argon2id was listed as blob encryption; it hashes a transfer password
- the padding claim contradicted finding 5 of the audit the site publishes
- the homepage sold "no IP rate limit"; no per-IP rate reads a plan
- nothing held /parasend's "no third-party requests" in place

Each pin is sabotage-tested from both sides: the page and the source.
…an hide from

Five things the review caught on the first commit.

The replacement text on /help/session-issues introduced a new untruth. It said
nobody can lock you out by guessing at your address. The per-address counter in
the /api/user/login handler is keyed on the address alone, incremented before
the code is checked and never cleared on a successful login, so eleven requests
spread over three addresses hold the victim at 429 for the rest of the window.
The sentence now says what the counters do, and row 15 pins it: the three
properties are read out of the handler, and while they hold no page may deny
the effect. The clause rode along unpinned, which is the failure mode this file
exists to prevent.

Row 22 scanned tags only. A bare @import without url() and a fetch() in an
inline script both reach off-origin without one, so those are checked now,
along with XMLHttpRequest, WebSocket, EventSource, Worker and importScripts.
The page set gains /ontvang and /parashare: PRIVATE keeps them out of the
sitemap, but a share link opens them without an account, and audit finding 17
was a font stylesheet on exactly that kind of page.

Row 20 now pins frontend/docs/security-audit-2026-04.md byte-identical to the
copy under docs/. /security and /trust link the served one; the row is bounded
by a finding in the repo one.

Row 16 cited ADR R018 for the SameSite=Lax decision. The ADR says nothing about
SameSite. The comment above setUserCookie is the source, and the note now says
so in both the test and docs/site-claims.md.

An index comparison in row 15 was hardened: indexOf returns -1 when a counter is
gone, and -1 sorts before every real offset, so a deleted counter read as one
that runs first. Both offsets are now required to exist.
@Apolloccrypt

Copy link
Copy Markdown
Owner Author

Review fixes pushed in e24a4cd.

The new untruth. You are right, and it is the exact failure mode this file
is for: I replaced a false sentence with one that carried its own unpinned
claim. admin/server.js increments paramant:user:ratelimit:email:<address>
before findUserByEmail runs, and no path clears it on a successful login, so
eleven attempts spread over three connections hold the address at 429 for the
rest of the fifteen minutes. The clause is gone. What the page says now:

There is no account lock, so there is nothing an administrator has to unlock.
Login attempts are rate limited instead: more than 5 attempts from one IP
address
, or more than 10 for one email address, inside 15 minutes
are answered with a 429. Both counters run before your code is checked and a
successful login does not reset them, so attempts someone else makes on your
address count against you too and can put you on that 429 for the rest of the
window. Each counter expires on its own once the fifteen minutes are up.

Row 15 reads the three properties out of the handler (keyed on the address,
counted before the credential check, never deleted) and requires the caveat
while all three hold. If the counter ever stops being shared, the test flips and
demands the caveat go. Four sabotages, all red: the old clause back, the caveat
silently dropped, a del(emailKey) added on success, and the counter moved
behind the credential check.

While pinning it I found a bug in my own check: indexOf returns -1 when the
counter is gone, and -1 sorts before every real offset, so deleting the counter
outright read as one that runs first. Both offsets are now asserted to exist
before they are compared, and the deletion sabotage is red.

(a) The scan. @import without url() and fetch() in an inline script
are both checked now, and so are XMLHttpRequest.open, WebSocket,
EventSource, Worker, SharedWorker and importScripts. A bare
@import "https://fonts.googleapis.com/..." on /pricing and an inline
fetch("https://analytics.example.com/collect") on the homepage both go red.

(b) /ontvang and /parashare are in the scan. The set is the public list plus
those two, with the reason on the test: PRIVATE keeps them out of the sitemap,
a share link opens them without an account, and finding 17 was a font stylesheet
on that kind of page. A pixel on /ontvang and a CDN worker on /parashare are
both red.

(c) The report copies are pinned byte-identical in row 20, plus the two
links from /security and /trust that send a reader to the served one. Changing
one word in frontend/docs/security-audit-2026-04.md is red.

(d) The source of the SameSite decision is the comment above
setUserCookie (admin/server.js 642-649), not ADR R018:
docs/adrs/R018-parasign-invite-webauthn.md does not mention SameSite at all.
Corrected in the test comment and in docs/site-claims.md row 16.

Tests: tests/site-claims.test.mjs 22/22; root integration suites 150 pass, 2
skipped, 1 fail (heartbeat-lib, needs @noble/post-quantum, identical on
origin/main in a checkout without npm ci); pricing-page.test.js 47 checks;
static-sanity.sh PASS on all 11.

The relay side (the per-address counter as a lockout vector, and the NX guard
falling open) is yours in a separate PR, as agreed. Nothing here changes
behaviour on either.

@Apolloccrypt
Apolloccrypt merged commit b913f9e into main Sep 2, 2026
10 checks passed
Apolloccrypt added a commit that referenced this pull request Sep 2, 2026
… closed

Two findings from the review of #367.

1. /api/user/login incremented paramant:user:ratelimit:email:<email> before it
called findUserByEmail, refused at eleven inside fifteen minutes, and never
deleted the key on a success. The address is request input, so eleven posts over
three IP addresses put the owner of that address on 429 for the full window with
no way to clear it. relay.js carried the same shape one layer down:
userMfaAttemptOk counted attempts against a caller-supplied user_id.

The per-IP refusal stays as it was, because an IP is the caller's own resource.
The per-account counters now count failures only, are cleared by a successful
sign-in, and impose cost rather than denial past the threshold: a proof-of-work
at the admin edge (the challenge signup and password reset already use), a
capped delay at the relay, which has no client to run one.

2. relay/lib/totp.js swallowed a replay-store error with .catch(() => 'OK'), so
a Redis failure accepted a code the single-use key could not mark as spent. On
the path that mints admin sessions that is a replay window inside the 30 second
slot, and the availability it bought was imaginary: the session store is the
same Redis. It fails closed now, with an error the call sites answer as 503
rather than 401, and the admin login passes that through instead of reporting a
wrong code.

Docs, and the pages that described the old behaviour, follow the code:
SECURITY.md carries both decisions and what was given up, docs/api.md gets the
two limits and the new status codes, and /security no longer advertises a
per-email limit that is not one.
Apolloccrypt added a commit that referenced this pull request Sep 2, 2026
…bed is gone

#367 landed while this branch was open and pinned the lockout it found, on
purpose: /help/session-issues was made to carry the caveat that "attempts
someone else makes on your address count against you too" for exactly as long
as the handler kept an address-keyed counter that ran before authentication and
survived a success. That is no longer the handler, so the caveat is no longer
true and the row's own else-branch says to drop it.

Row 15 now reads the three properties instead of requiring the old shape to
exist, so the page follows whichever way they fall. Row 6 and row 18 move to the
numbers and the fail-closed behaviour this branch implements. The page states
the per-IP limit as the only refusal and the ten failures as the point where an
attempt starts costing work.
@Apolloccrypt
Apolloccrypt deleted the chore/site-claims-batch-2 branch September 5, 2026 18:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant