Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ curl -fsSL https://paramant.app/install-pi.sh | bash
```

Or via the browser — no install:
**[Try ParaShare →](https://paramant.app/parashare)** (no account, no key needed)
**[Try the ParaSend web app →](https://paramant.app/parashare)** (no account, no key needed)

**[Create a free account →](https://paramant.app/signup)** (TOTP, no password)

Expand Down Expand Up @@ -131,7 +131,7 @@ Full set: [`docs/adrs/`](docs/adrs/) (R001–R011).
| Chromium browser extension | Source in repo — server-side encryption path during client-side PQ migration ([architecture §08](https://paramant.app/architecture#components)) |
| Outlook Add-in | Source in repo — server-side encryption path during client-side PQ migration ([architecture §08](https://paramant.app/architecture#components)) |

**Zero-knowledge scope:** the relay-cannot-read guarantee applies to transfers from the official SDKs (`paramant-sdk` for Python and JavaScript), the WebApp tools (ParaShare), and the anonymous `/send` flow. The Chromium and Outlook extensions currently take a server-side encryption path while their client-side hybrid crypto is being finished — until that lands, treat extension uploads as relay-side, not zero-knowledge.
**Zero-knowledge scope:** the relay-cannot-read guarantee applies to transfers from the official SDKs (`paramant-sdk` for Python and JavaScript), the ParaSend web app, and the anonymous `/send` flow. The Chromium and Outlook extensions currently take a server-side encryption path while their client-side hybrid crypto is being finished. Until that lands, treat extension uploads as relay-side, not zero-knowledge.

---

Expand Down Expand Up @@ -556,7 +556,7 @@ The relay is **untrusted by design** — it never holds a decryption key.
| Hybrid KEM (browser path) | ML-KEM-768 + ECDH P-256, combined via HKDF-SHA256 |
| Symmetric | AES-256-GCM · NIST SP 800-38D |
| Signatures (relay STH / receipts) | ML-DSA-65 · NIST FIPS 204 |
| Signatures (client, SDK only) | ML-DSA-65 over `ctKem ‖ senderPub ‖ nonce ‖ ct ‖ aad` (Node/Python SDK; browser ParaShare path does not yet sign client-side) |
| Signatures (client, SDK only) | ML-DSA-65 over `ctKem ‖ senderPub ‖ nonce ‖ ct ‖ aad` (Node/Python SDK; the browser web app path does not yet sign client-side) |
| Key derivation | HKDF-SHA256 · RFC 5869 |
| Password blobs | Argon2id · RFC 9106 |
| Crypto runtime | Rust/WASM — browser-side encryption runs in native code |
Expand Down
41 changes: 30 additions & 11 deletions deploy/DEPLOY-3.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,12 +382,28 @@ rsync -rc --no-times /opt/paramant-relay/frontend/ /home/paramant/app/
nginx: main changes three things in `deploy/nginx-paramant-live.conf`
(`/sign` no longer behind `auth_request`, the three `/compliance/*` locations
gone, `/dicom` now `return 404`) and removes the `/compliance` lines from
`deploy/nginx-paramant-public.conf`. The server files carry the 01-09 ParaID
deny that the repo files do not, so **do not copy the repo files over them**.
Apply the three changes by hand and keep the deny:
`deploy/nginx-paramant-public.conf`. **Step 5c has a fourth edit since the two-product-names round**:
the rules page moved from `/pararules` to `/rules`, so every server block that
answers for the site gains

```nginx
location = /pararules { return 301 https://$host/rules; }
```

That one is an addition, not a rewrite, and it is permanent: `/pararules` is
indexed, so the redirect is not a migration step that gets tidied away in a
later round. It is in both repo confs already, and the script writes it into
the server confs itself, anchored on the ParaID deny (`paramant-live.conf`
carries no `server_name`, so the hostname is not usable as an anchor there).
It is idempotent: a block that already has the line is left alone. If you are
doing step 5c by hand instead of through the script, this is the line to add.

The server files carry the 01-09 ParaID deny that the repo files do not, so
**do not copy the repo files over them**. Apply the four changes by hand and
keep the deny:

```bash
nginx -T 2>/dev/null | grep -n 'paraid/issue\|location = /sign\|/compliance\|location = /dicom'
nginx -T 2>/dev/null | grep -n 'paraid/issue\|location = /sign\|/compliance\|location = /dicom\|/pararules'
# edit /etc/nginx/sites-enabled/paramant-public.conf and the live conf accordingly
nginx -t && systemctl reload nginx
```
Expand All @@ -398,8 +414,11 @@ round, not in this one.

### Running step 5 twice

All four nginx changes are idempotent, and the script reads each one as being
in one of three states before it edits anything:
All five nginx changes are idempotent, and the script reads the three rewrites
as being in one of three states before it edits anything. The two additions,
the `/v2/outbound` buffers and the `/pararules` 301, are counted per block
instead: a block that lacks one is a pending edit, a block that has one is left
untouched.

| state | what the conf carries | what happens |
|---|---|---|
Expand Down Expand Up @@ -432,11 +451,11 @@ already succeeded. The removal of the docroot files main deleted (step 5b)
already worked this way: a file that is already gone is counted as *already
absent*, not as a failure.

What is asserted after the edits does not change, and that is where the weight
sits: no `auth_request` on `/sign`, zero `/compliance` locations, `/dicom`
answering `return 404`, the ParaID deny still present, `proxy_buffer_size 32k`
inside **every** `location ~ ^/v2/outbound` block, and `nginx -t` clean before
the reload. A run that changed nothing still tests and reloads nginx, so a hand
What is asserted after the edits is where the weight sits: no `auth_request` on
`/sign`, zero `/compliance` locations, `/dicom` answering `return 404`, the
ParaID deny still present, `proxy_buffer_size 32k` inside **every**
`location ~ ^/v2/outbound` block, the `/pararules` 301 inside **every** site
block, and `nginx -t` clean before the reload. A run that changed nothing still tests and reloads nginx, so a hand
edit made between deploys cannot hide behind "already applied".

## Step 6: smoke tests
Expand Down
75 changes: 70 additions & 5 deletions deploy/deploy-3.1.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1223,7 +1223,7 @@ EOF
fi
fi

step "5c. the three nginx changes, by hand, keeping the ParaID deny"
step "5c. the four nginx changes, by hand, keeping the ParaID deny"
remote "nginx edits" "$TS" "$NGINX_SITES" "$NGINX_BACKUP_DIR" "$NGINX_CONFS" <<'EOF'
set -euo pipefail
TS="$1"; SITES="$2"; NGBK="$3"; CONFS="$4"
Expand Down Expand Up @@ -1255,9 +1255,50 @@ DICOM_RE='location = /dicom[[:space:]]*\{[[:space:]]*try_files /dicom\.html'
SIGN_LOC_RE='location = /sign[[:space:]]*\{'
DICOM404_RE='location = /dicom[[:space:]]*\{[[:space:]]*return 404'
PARAID_RE='paraid/issue'
RULES_RE='location = /pararules'
OUT_RE='^[[:space:]]*location[[:space:]]*~[[:space:]]*\^/v2/outbound[[:space:]]*\{'
BUF_RE='proxy_buffer_size 32k'

# /pararules became /rules in the two-product-names round. The page is indexed,
# so the old path keeps a permanent 301 rather than a migration step that gets
# tidied away in a later round.
#
# The anchor is the ParaID deny. It is in every server block by the 01-09
# server edit the runbook already relies on and FATALs on below, which makes it
# the one line that marks a block as "a block that answers for this site" in
# both confs: paramant-live.conf carries no server_name at all (it is the
# backend paramant-public.conf proxies to), so keying on the hostname would
# have put the redirect in one conf and not the other.
#
# Two passes, like the buffer edit below: pass one learns which blocks already
# carry the redirect, pass two inserts only into the ones that do not. A
# file-wide grep would stop at the first block and leave the rest bare.
RULES_AWK='
FNR==NR {
if ($0 ~ /^server[[:space:]]*\{/) b++
if ($0 ~ /location = \/pararules/) has[b]=1
if ($0 ~ /paraid\/issue/) deny[b]=1
next
}
{
print
if ($0 ~ /^server[[:space:]]*\{/) j++
if ($0 ~ /paraid\/issue/ && deny[j] && !has[j] && !ins[j]) {
print " location = /pararules { return 301 https://$host/rules; }"
ins[j]=1
}
}'

# Count the server blocks that answer for the site (the ParaID deny marks them)
# and how many of those already carry the redirect.
RULES_COUNT_AWK='
/^server[[:space:]]*\{/ { b++ }
/paraid\/issue/ { deny[b]=1 }
/location = \/pararules/ { has[b]=1 }
END { for (i in deny) { t++; if (has[i]) w++ } printf "%d %d\n", t+0, w+0 }'

count_rules() { awk "$RULES_COUNT_AWK" $TARGETS | awk '{t+=$1; w+=$2} END{printf "%d %d\n", t, w}'; }

# Two-pass insert: learn which /v2/outbound blocks already carry the buffer,
# then insert only into the ones that do not.
BUF_AWK='
Expand Down Expand Up @@ -1331,6 +1372,9 @@ echo "before paraid deny = $(count "$PARAID_RE")"
read -r _obt _obw <<< "$(count_blocks)"
echo "before outbound locations = $_obt"
echo "before outbound blocks with buffer = $_obw"
read -r _rbt _rbw <<< "$(count_rules)"
echo "before pararules blocks = $_rbt"
echo "before pararules blocks with redirect = $_rbw"

# Each edit is in one of three states, and only one of them is a stop:
#
Expand Down Expand Up @@ -1383,8 +1427,10 @@ pending=0
for st in "$SIGN_STATE" "$COMP_STATE" "$DICOM_STATE"; do
[ "$st" = todo ] && pending=$((pending + 1))
done
# A /v2/outbound block without the buffer is a fourth thing still to do.
# A /v2/outbound block without the buffer is a fourth thing still to do, and a
# site block without the /pararules redirect a fifth.
pending=$((pending + _obt - _obw))
pending=$((pending + _rbt - _rbw))
echo "before edits pending = $pending"
if [ "$pending" -eq 0 ]; then
echo "before everything already applied = yes"
Expand Down Expand Up @@ -1424,6 +1470,10 @@ for f in $TARGETS; do
awk "$BUF_AWK" "$f" "$f" > "/tmp/nginx-buf.$$"
cat "/tmp/nginx-buf.$$" > "$f"
rm -f "/tmp/nginx-buf.$$"
# 5. the permanent 301 from /pararules to /rules, one per site block.
awk "$RULES_AWK" "$f" "$f" > "/tmp/nginx-rules.$$"
cat "/tmp/nginx-rules.$$" > "$f"
rm -f "/tmp/nginx-rules.$$"
if ! cmp -s "$f" "/tmp/nginx-pre-3.1-$(basename "$f").$TS"; then
echo "edited $(basename "$f")"
edited=$((edited + 1))
Expand All @@ -1441,6 +1491,10 @@ echo "after paraid deny = $(count "$PARAID_RE")"
read -r _oat _oaw <<< "$(count_blocks)"
echo "after outbound locations = $_oat"
echo "after outbound blocks with buffer = $_oaw"
read -r _rat _raw <<< "$(count_rules)"
echo "after pararules blocks = $_rat"
echo "after pararules blocks with redirect = $_raw"
echo "after pararules redirect lines = $(count "$RULES_RE")"

restore() {
for b in "$NGBK"/*.pre-3.1-"$TS"; do
Expand Down Expand Up @@ -1487,8 +1541,9 @@ EOF
# How many files were rewritten depends on what was left to do, so the exact
# count of 2 only holds on a run that found work in both confs. What always
# holds is the END state, and that is asserted hard just below: no
# auth_request on /sign, no /compliance locations, /dicom answering 404, and
# a buffer inside every /v2/outbound block. Those four are the deploy.
# auth_request on /sign, no /compliance locations, /dicom answering 404, a
# buffer inside every /v2/outbound block, and the /pararules 301 inside every
# block that answers for the site. Those five are the deploy.
if [ "$DRY_RUN" -eq 1 ]; then
printf ' SKIP assert (dry-run): both named confs were rewritten, unless every edit was already applied\n'
else
Expand All @@ -1498,7 +1553,7 @@ EOF
[ -n "$pend" ] && [ -n "$edited" ] \
|| die "could not read the nginx edit state from the server"
if [ "$pend" = yes ]; then
ok "nginx: already applied. All three edits and the outbound buffers were in place before this run, so nothing was rewritten (edited files = $edited)"
ok "nginx: already applied. All three edits, the outbound buffers and the /pararules 301 were in place before this run, so nothing was rewritten (edited files = $edited)"
else
[ "$edited" -ge 1 ] \
|| die "$(remote_field 'before edits pending') nginx edit(s) were still pending but no conf was rewritten"
Expand All @@ -1510,6 +1565,7 @@ EOF
expect_count "after dicom try_files" 0 "/dicom no longer serves the page"
expect_min "after dicom 404" 1 "/dicom now returns 404"
expect_min "before outbound locations" 1 "the /v2/outbound location the buffers go on exists"
expect_min "after pararules redirect lines" 1 "/pararules answers a 301 to /rules"
expect 'reloaded nginx' "nginx reloaded"
if [ "$DRY_RUN" -eq 0 ]; then
local ol bf
Expand All @@ -1520,6 +1576,15 @@ EOF
|| die "proxy_buffer_size sits inside $bf of $ol /v2/outbound blocks; the inline receipt header would 502 on the rest"
ok "every /v2/outbound block carries proxy_buffer_size 32k ($bf of $ol blocks)"
fi
if [ "$DRY_RUN" -eq 0 ]; then
local rt rw
rt="$(remote_field 'after pararules blocks')"
rw="$(remote_field 'after pararules blocks with redirect')"
[ -n "$rt" ] && [ -n "$rw" ] || die "could not read the /pararules redirect counts from the server"
[ "$rw" = "$rt" ] \
|| die "the /pararules 301 sits in $rw of $rt site block(s); an indexed link to the old rules page would 404 on the rest"
ok "every site block redirects /pararules to /rules ($rw of $rt blocks)"
fi
if [ "$DRY_RUN" -eq 0 ]; then
local pb pa
pb="$(remote_field 'before paraid deny')"
Expand Down
4 changes: 4 additions & 0 deletions deploy/nginx-paramant-live.conf
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ server {
location = /help { try_files /help/index.html =404; }
location = /security { try_files /security.html =404; }
location = /pentest-report-2026-04-08.txt { return 301 /security; }
# /pararules became /rules in the two-product-names round (snoei 2026-09).
# The old path is indexed, so the 301 is permanent: it is not a migration
# step that gets tidied away later.
location = /pararules { return 301 https://$host/rules; }
location = /license { try_files /license.html =404; }
location /api/captcha/ {
proxy_pass http://127.0.0.1:4200/admin/api/captcha/;
Expand Down
5 changes: 5 additions & 0 deletions deploy/nginx-paramant-public.conf
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ server {
types { application/xml xml; }
}

# /pararules became /rules in the two-product-names round (snoei 2026-09).
# The old path is indexed, so the 301 is permanent: it is not a migration
# step that gets tidied away later.
location = /pararules { return 301 https://$host/rules; }

# Relay API: admin endpoints are never exposed via the public apex.
# /v2/admin/* is auth-gated at the relay (returns 401), but we 404 here
# to avoid leaking the existence of the admin surface on paramant.app.
Expand Down
27 changes: 17 additions & 10 deletions docs/brand/messaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ Fixed wording, from the rules grid on /, word for word:
> Hetzner Germany, Bunny DNS (Slovenia). No US provider in the data path. Email
> goes out via Resend, as /privacy sets out.

The long form, from ParaRule 5 on /pararules, word for word, and it is the
The long form, from rule 5 on /rules, word for word, and it is the
version to use wherever there is room for three sentences:

> Hosted in Germany, owned top to bottom in the EU. No US CLOUD Act reach over
Expand All @@ -169,7 +169,7 @@ Resend sentence.
Pinned by: nothing today. **To add** to `tests/ui-truthfulness.test.mjs`: assert
that `frontend/index.html` still carries "No US provider in the data path" and,
in the same rule, the Resend sentence with its link to /privacy; assert the same
pairing in ParaRule 5 on `frontend/pararules.html`; and assert that no public
pairing in rule 5 on `frontend/rules.html`; and assert that no public
page carries "no US company in the chain", "no US provider in the chain" or
"no US company" without the Resend exception beside it. See the open
contradiction in section 9: /security still carries the unqualified row and has
Expand Down Expand Up @@ -269,14 +269,20 @@ sentence here. Dutch plainness in English words.
We do not have them, so we do not imply them.
- Numbers only where they already appear on the site: prices, 2 signatures a
month, 7 day IP log retention, 99.9% SLA, 48 hour disclosure acknowledgement.
- Claim plus checkpoint, the shape the ParaRules already use: say what it is and
say where it is checked.
- Claim plus checkpoint, the shape the rules on /rules already use: say what it
is and say where it is checked.
- Where something is unfinished, say so in the same voice, without a date. The
Chromium and Outlook extensions currently take a server-side encryption path
and are not zero-knowledge; "In development" means not live and no date.
- Product names on commercial pages: ParaSign and ParaSend. ParaShare is the
name of the in-app sending tool and stays out of hero copy. ParaRules stays as
the name of the rules block only.
- There are two product names, and only two: **ParaSend** and **ParaSign**.
Nothing else is presented as a product, on a commercial page or anywhere else.
ParaShare is not a product name. The URL /parashare stays, because extension
links that were minted years ago point at it and the sdk-js 3.x contract names
it in a `Link: rel="successor-version"` header, but in copy the page is "the
ParaSend web app" or "the sending tool". It never appears as an H1, a kicker
or a product card, and `tests/ui-truthfulness.test.mjs` fails when it does.
The rules page is "Our rules" on /rules. "ParaRules" is retired as a name and
the individual rules are "rule 5", not "ParaRule 5"; /pararules keeps a 301.
- Cryptography names, standard numbers and RAM-only appear below the fold, as
the reason the top half is true. Never in an H1.

Expand Down Expand Up @@ -366,7 +372,8 @@ it is worth a `/parasend` page in a later round, built to the /parasign pattern.
Until then the buyer-facing surface for sending is the homepage product block
and /pricing. Two pages exist today and keep their own jobs.

**/parashare** (the sending tool, behind sign-in, noindex, and it stays that way)
**/parashare** (the ParaSend web app, behind sign-in, noindex, and it stays that
way; the URL keeps its name, the copy does not)

- **Goal:** let a signed-in user send one encrypted file.
- **H1:** unchanged.
Expand Down Expand Up @@ -480,13 +487,13 @@ its own PR and its own test.
qualified (QES)." Both cannot be right. The /about wording is the one this
guide pins, so the FAQ line is the one that has to move, in a separate round.
2. **/security still says "no US company".** Settled on the homepage and on
/pararules, not yet on /security. Those two pages now say no US provider in
/rules, not yet on /security. Those two pages now say no US provider in
the *data path* and name Resend in the same breath, per proof 1. The
Jurisdiction and privacy table on /security still has the unqualified row
"US CLOUD Act: Not applicable: no US infrastructure, no US company", which
reads as no US party anywhere and is broader than /privacy allows. That row
is the one that has to move, in its own PR with its own test, to the data
path wording. Until it does, proof 1 is quoted from / and /pararules only.
path wording. Until it does, proof 1 is quoted from / and /rules only.
Cloudflare is no longer part of this: DNS moved to Bunny and the site is
served straight from Hetzner, so the name is gone from the frontend and from
the out-of-scope list, which now reads "Resend, Hetzner, Bunny, Mollie".
Expand Down
Loading