R2 accounts are safe: email by password and code, recovery only to it, sessions that end
Build / Build (push) Successful in 26s
Build / Build (push) Successful in 26s
Completion program R2 (docs/ROADMAP.md, 2026-10-17).
- An email address is set only with the password and a 6-digit code sent to it: /clientapi/user/email, /email/verify,
/email/remove and GET /clientapi/user/account (EmailVerification: an hour, five tries, the code's hash only;
EmailVerificationJob through the new IMailer). The request answers one sentence whether or not the address is free,
and nothing goes to an address another login verified. The old POST /clientapi/user/update, which changed the
address with neither and said when one was taken, answers 422.
- Recovery writes only to a verified address; migration _019 keeps the addresses given before verification existed.
- A changed password ends every session and every persona token, and answers a new JWT for the device that changed it.
- Sessions: every JWT carries its session's id ("si", kept by sniff/again). GET /clientapi/user/logout, which did
nothing, ends that session alone (RootUser.EndedSessions, until its tokens would expire); POST
/clientapi/user/logout/everywhere ends them all.
- AppConfiguration:HashingOptions was never bound, so passwords were hashed at the default 9789 iterations: it is
bound now, at 210,000, and a hash made at other iterations is made again at the next sign-in (matched on the old
hash, so two sign-ins never undo each other).
- Recovery and verification codes expire through TTL indexes; EmailVerification is never in a backup.
- AppConfigurationService reads appsettings as they are now, not the copy older builds saved in Mongo on first boot.
- ViewUserSettings and RootUserSettings carry SavedAt, so a client signing in keeps its own theme until one saved.
- Pasture: peers/mailpit.sh (Mailpit 1.31.4 as mailpit.test) receives PrivaPub's mail; recovery links open
https://decepub.test. scenarios/accounts.sh, in sweep batch A, checks all of the above (28 checks).
Verified: dotnet build PrivaPub.sln -c Release; the pasture with gts and mailpit: accounts 28/28 and gts, 91 passed,
0 failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
ff5e635dda
commit
cff75e3a47
27 files changed
+636
-117
No files matched your search
@@ -43,6 +43,11 @@ decepub.test {
|
||||
reverse_proxy pasture-decepub:80
|
||||
}
|
||||
|
||||
mailpit.test {
|
||||
tls internal
|
||||
reverse_proxy pasture-mailpit:8025
|
||||
}
|
||||
|
||||
hollo.test {
|
||||
tls internal
|
||||
reverse_proxy pasture-hollo:3000
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
"AppConfiguration": {
|
||||
"SupportedLanguages": [ "en" ],
|
||||
"BackendBaseAddress": "https://privapub.test",
|
||||
"FrontendBaseAddress": "https://privapub.test",
|
||||
"HashingOptions": { "Iterations": 10101 },
|
||||
"FrontendBaseAddress": "https://decepub.test",
|
||||
"EmailConfiguration": { "SmtpServer": "pasture-mailpit", "SmtpPort": 1025, "UseSSL": false, "SmtpUsername": "privapub@privapub.test", "SmtpPassword": "" },
|
||||
"HashingOptions": { "Iterations": 210000 },
|
||||
"Jwt": {
|
||||
"Key": "pasture-only-key-not-a-secret-pasture-only-key-not-a-secret-0123456789",
|
||||
"Issuer": "http://privapub.test",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Mailpit 1.31.4 as mailpit.test: the pasture's mail server. PrivaPub sends to it on pasture-mailpit:1025 (plain SMTP, any
|
||||
# login accepted, appsettings.Pasture.json's EmailConfiguration), and the scenarios read what arrived through its API
|
||||
# (https://mailpit.test:6443/api/v1/...). Nothing leaves the pasture.
|
||||
MAILPIT_IMAGE=${MAILPIT_IMAGE:-docker.io/axllent/mailpit:v1.31.4}
|
||||
|
||||
mailpit_up() {
|
||||
podman run -d --replace --name pasture-mailpit --label pasture=1 --network $net \
|
||||
-e MP_SMTP_AUTH_ACCEPT_ANY=1 -e MP_SMTP_AUTH_ALLOW_INSECURE=1 -e MP_MAX_MESSAGES=5000 \
|
||||
$MAILPIT_IMAGE >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
site mailpit.test -s -o /dev/null -w '%{http_code}' https://mailpit.test:6443/api/v1/info 2>/dev/null | grep -q 200 && break
|
||||
sleep 1
|
||||
done
|
||||
echo "mailpit: https://mailpit.test:6443"
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# Accounts (completion program R2): an email address is set only with the password and a code sent to it; recovery writes
|
||||
# only to a verified address, and its link works once; a password change and "sign out everywhere" end every session,
|
||||
# while a logout ends only its own device; a hash made at fewer iterations is made again at sign-in; recovery codes expire
|
||||
# through a TTL index. Needs the mailpit peer.
|
||||
MP=https://mailpit.test:6443
|
||||
mp() { site mailpit.test -s "$@"; }
|
||||
# mp_text <address>: the newest message sent to the address, its text, or nothing
|
||||
mp_text() {
|
||||
local id
|
||||
id=$(mp "$MP/api/v1/search?query=to:$1&limit=1" | j "print(d['messages'][0]['ID'] if d and d.get('messages') else '')")
|
||||
[ -n "$id" ] && mp "$MP/api/v1/message/$id" | j "print(d['Text'])"
|
||||
}
|
||||
mp_count() { mp "$MP/api/v1/search?query=to:$1" | j "print(d['messages_count'] if d else 0)"; }
|
||||
api() { local jwt=$1 method=$2 path=$3; shift 3; curl -s -X "$method" -H "Authorization: Bearer $jwt" -H 'Content-Type: application/json' "$P$path" "$@"; }
|
||||
code_of() { local jwt=$1 method=$2 path=$3; shift 3; curl -s -o /dev/null -w '%{http_code}' -X "$method" -H "Authorization: Bearer $jwt" -H 'Content-Type: application/json' "$P$path" "$@"; }
|
||||
login() { curl -s -X POST "$P/clientapi/user/login" -H 'Content-Type: application/json' -d "{\"userName\":\"$1\",\"password\":\"$2\"}" | j "print(d['token'])"; }
|
||||
|
||||
echo "accounts"
|
||||
run=$(date +%s)
|
||||
user="acct$run"; pw='Accounts-Pass-1!'; pw2='Accounts-Pass-2!'; mail="$user@example.test"
|
||||
curl -s -o /dev/null -X POST "$P/clientapi/user/signup" -H 'Content-Type: application/json' -d "{\"userName\":\"$user\",\"password\":\"$pw\"}"
|
||||
A=$(login "$user" "$pw")
|
||||
[ -n "$A" ] && ok "a login for the scenario" || { ko "no login for the scenario"; return 1; }
|
||||
|
||||
echo " an email address, with the password and a code"
|
||||
[ "$(code_of "$A" POST /clientapi/user/update -d "{\"email\":\"$mail\"}")" = 422 ] \
|
||||
&& ok "the old route changes no address" || ko "the old route still changes the address"
|
||||
[ "$(code_of "$A" POST /clientapi/user/email -d "{\"email\":\"$mail\",\"password\":\"wrong-Pass-9\"}")" = 403 ] \
|
||||
&& ok "a wrong password asks for no address" || ko "a wrong password was taken"
|
||||
sentence=$(api "$A" POST /clientapi/user/email -d "{\"email\":\"$mail\",\"password\":\"$pw\"}")
|
||||
[ "$(api "$A" GET /clientapi/user/account | j "print(d['pendingEmail'])")" = "$mail" ] \
|
||||
&& ok "the address waits for its code" || ko "the address is not waiting for its code"
|
||||
until_true 20 '[ -n "$(mp_text "$mail")" ]' && ok "the code was emailed" || ko "no code arrived"
|
||||
code=$(mp_text "$mail" | grep -oE '\b[0-9]{6}\b' | head -1)
|
||||
[ "$(code_of "$A" POST /clientapi/user/email/verify -d '{"code":"000000"}')" = 400 ] \
|
||||
&& ok "a wrong code is refused" || ko "a wrong code was taken"
|
||||
[ "$(api "$A" POST /clientapi/user/email/verify -d "{\"code\":\"$code\"}" | j "print(d['isEmailValidated'] and d['email'])")" = "$mail" ] \
|
||||
&& ok "the code verifies the address" || ko "the code did not verify the address"
|
||||
other="acct${run}b"
|
||||
curl -s -o /dev/null -X POST "$P/clientapi/user/signup" -H 'Content-Type: application/json' -d "{\"userName\":\"$other\",\"password\":\"$pw\"}"
|
||||
O=$(login "$other" "$pw")
|
||||
[ "$(api "$O" POST /clientapi/user/email -d "{\"email\":\"$mail\",\"password\":\"$pw\"}")" = "$sentence" ] \
|
||||
&& ok "an address in use gets the same answer" || ko "an address in use is told apart"
|
||||
sleep 6
|
||||
[ "$(mp_count "$mail")" = 1 ] && ok "no code goes to an address another login verified" || ko "a code went to an address in use"
|
||||
|
||||
echo " recovery"
|
||||
curl -s -o /dev/null -X POST "$P/clientapi/user/recover/password" -H 'Content-Type: application/json' -d "{\"email\":\"$mail\"}"
|
||||
until_true 20 '[ "$(mp_count "$mail")" = 2 ]' && ok "the recovery link was emailed" || ko "no recovery link arrived"
|
||||
rc=$(mp_text "$mail" | grep -oE 'rc=[0-9a-f]{64}' | head -1 | cut -d= -f2)
|
||||
grep -q 'https://decepub.test/password-recovery?rc=' <<<"$(mp_text "$mail")" && ok "the link opens decePub" || ko "the link does not open decePub"
|
||||
[ "$(curl -s -X POST "$P/clientapi/user/recover/valid" -H 'Content-Type: application/json' -d "\"$rc\"")" = true ] \
|
||||
&& ok "the recovery code is valid" || ko "the recovery code is not valid"
|
||||
B=$(login "$user" "$pw")
|
||||
curl -s -o /dev/null -X POST "$P/clientapi/user/recover/update/password" -H 'Content-Type: application/json' \
|
||||
-d "{\"newPassword\":\"$pw2\",\"repeatedPassword\":\"$pw2\",\"recoveryCode\":\"$rc\"}"
|
||||
[ "$(curl -s -X POST "$P/clientapi/user/recover/valid" -H 'Content-Type: application/json' -d "\"$rc\"")" = false ] \
|
||||
&& ok "the recovery code works once" || ko "the recovery code works twice"
|
||||
[ "$(code_of "$B" GET /clientapi/user/account)" = 401 ] && ok "recovery ended every session" || ko "a session outlived the recovery"
|
||||
[ -n "$(login "$user" "$pw2")" ] && ok "the new password signs in" || ko "the new password does not sign in"
|
||||
unverified="acct${run}c@example.test"
|
||||
api "$O" POST /clientapi/user/email -d "{\"email\":\"$unverified\",\"password\":\"$pw\"}" >/dev/null
|
||||
until_true 20 '[ "$(mp_count "$unverified")" = 1 ]'
|
||||
curl -s -o /dev/null -X POST "$P/clientapi/user/recover/password" -H 'Content-Type: application/json' -d "{\"email\":\"$unverified\"}"
|
||||
curl -s -o /dev/null -X POST "$P/clientapi/user/recover/password" -H 'Content-Type: application/json' -d "{\"userName\":\"$other\"}"
|
||||
sleep 6
|
||||
[ "$(mp_count "$unverified")" = 1 ] && ok "no recovery goes to an unverified address" || ko "a recovery went to an unverified address"
|
||||
|
||||
echo " sessions"
|
||||
C=$(login "$user" "$pw2"); D=$(login "$user" "$pw2")
|
||||
[ "$(code_of "$C" GET /clientapi/user/logout)" = 200 ] && ok "a device signs out" || ko "signing out failed"
|
||||
[ "$(code_of "$C" GET /clientapi/user/account)" = 401 ] && ok "its token is refused" || ko "its token still works"
|
||||
[ "$(code_of "$D" GET /clientapi/user/account)" = 200 ] && ok "another device stays signed in" || ko "another device was signed out"
|
||||
renewed=$(api "$D" GET /clientapi/user/sniff/again | j "print(d['token'])")
|
||||
api "$D" GET /clientapi/user/logout >/dev/null
|
||||
[ "$(code_of "$renewed" GET /clientapi/user/account)" = 401 ] && ok "a renewed token belongs to the session it renewed" || ko "a renewed token outlived its session's logout"
|
||||
E=$(login "$user" "$pw2"); F=$(login "$user" "$pw2")
|
||||
changed=$(api "$E" POST /clientapi/user/update/password \
|
||||
-d "{\"oldPassword\":\"$pw2\",\"newPassword\":\"$pw\",\"repeatedPassword\":\"$pw\"}" | j "print(d['token'])")
|
||||
[ "$(code_of "$F" GET /clientapi/user/account)" = 401 ] && ok "a password change ends the other sessions" || ko "another session outlived a password change"
|
||||
[ "$(code_of "$changed" GET /clientapi/user/account)" = 200 ] && ok "the device that changed it goes on" || ko "the device that changed it was signed out"
|
||||
G=$(login "$user" "$pw")
|
||||
[ "$(code_of "$G" POST /clientapi/user/logout/everywhere)" = 200 ] && ok "signing out everywhere" || ko "signing out everywhere failed"
|
||||
[ "$(code_of "$changed" GET /clientapi/user/account)" = 401 ] && [ "$(code_of "$G" GET /clientapi/user/account)" = 401 ] \
|
||||
&& ok "every session ended" || ko "a session outlived signing out everywhere"
|
||||
|
||||
echo " hashes"
|
||||
old_hash=$(python3 -c "
|
||||
import base64,hashlib,os
|
||||
salt=os.urandom(16)
|
||||
key=hashlib.pbkdf2_hmac('sha512', b'$pw', salt, 10101, 32)
|
||||
print(base64.b64encode(key).decode()+'~'+base64.b64encode(salt).decode()+'~10101')")
|
||||
podman exec pasture-mongo mongosh --quiet PrivaPub --eval "db.RootUser.updateOne({UserName: '$user'}, {\$set: {HashedPassword: '$old_hash'}})" >/dev/null
|
||||
[ -n "$(login "$user" "$pw")" ] && ok "an old hash still signs in" || ko "an old hash no longer signs in"
|
||||
iterations=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db.RootUser.findOne({UserName: '$user'}).HashedPassword.split('~')[2])")
|
||||
[ "$iterations" != 10101 ] && ok "it was made again at $iterations iterations" || ko "the old hash was kept"
|
||||
ttl=$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db.EmailRecovery.getIndexes().some(i => i.expireAfterSeconds === 0) && db.EmailVerification.getIndexes().some(i => i.expireAfterSeconds === 0))")
|
||||
[ "$ttl" = true ] && ok "recovery and verification codes expire by TTL" || ko "no TTL on the codes"
|
||||
@@ -6,8 +6,8 @@
|
||||
# PrivaPub runs with SecureMode on, as in production, unless PRIVAPUB_ENV says otherwise; DOTNET and PASTURE_PORT reach
|
||||
# run.sh as they are. Each batch's output is kept in out/sweep-<batch>.log.
|
||||
# usage: tools/pasture/sweep.sh [A|B|C|D ...] (default: every batch, in that order)
|
||||
# A gts mastodon misskey sharkey akkoma, with relay and aoderelay; scenarios: those five, threads followsync pins
|
||||
# moves relay (relay's covers both relays)
|
||||
# A gts mastodon misskey sharkey akkoma, with relay, aoderelay and mailpit; scenarios: those five, threads followsync
|
||||
# pins moves relay (relay's covers both relays) accounts (mailpit's)
|
||||
# B lemmy lemmy19 piefed mbin nodebb
|
||||
# C peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale
|
||||
# D friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla
|
||||
@@ -19,7 +19,7 @@ export PRIVAPUB_ENV=${PRIVAPUB_ENV:-Federation__SecureMode=true}
|
||||
|
||||
batch_peers() {
|
||||
case "$1" in
|
||||
A) echo "gts mastodon misskey sharkey akkoma relay aoderelay" ;;
|
||||
A) echo "gts mastodon misskey sharkey akkoma relay aoderelay mailpit" ;;
|
||||
B) echo "lemmy lemmy19 piefed mbin nodebb" ;;
|
||||
C) echo "peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale" ;;
|
||||
D) echo "friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla" ;;
|
||||
@@ -27,7 +27,7 @@ batch_peers() {
|
||||
}
|
||||
batch_scenarios() {
|
||||
case "$1" in
|
||||
A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay" ;;
|
||||
A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay accounts" ;;
|
||||
*) batch_peers "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user