test: cover self-service registration, and fix what it found
Rig legs for the whole author path with no CLI admit anywhere: an unknown sender drawing the info mail, Twt-Register creating the feed, the token arriving so she can publish, Twt-Help, the admin notification, Twt-Block stopping a feed and Twt-Registration closing the door. Pentest probes for the attacks: no Reply-To, a claim on someone else's address, a claim on an address nobody holds, an admin command from a non-admin, and a blocked author coming back. Two real bugs, both found by asserting on what the author receives rather than on what the daemon meant to send. Five catalogue entries were wrapped across lines, and a reason is interpolated into the reply's Subject. A frontmatter value carrying a line break makes the whole block malformed, so smolmail fails closed toward display (SPEC.md sec 5.5) and the reply arrives with no subject at all — every rejection, silently. The same bug class the parent commit fixed sender-side; step 2 reintroduced it from the catalogue, because the Rust prose it replaced used backslash continuations that join without a newline. Reasons are single-line now, with a test over the enum that fails if one ever wraps again. And the flow had a step that could not work. Telling an author to accept this mailbox makes their client attach an Accept token to every later mail, and a published record is verified by rebuilding the exact bytes that were signed — which cannot include a token without publishing it. So every post from an author who followed that advice was refused forever. The info mail now says not to accept the address and where to find replies instead, and that refusal has its own reason, since "resend the bare text" is useless advice when the client adds the field by itself. Three of my own probes were wrong too, kept here as comments so they are not rewritten the same way: grep -q under `set -o pipefail` SIGPIPEs its producer and can report failure for a subject that is plainly present; the refusal reply lands in the main tier precisely because accepting the mailbox is what moves replies there; and blocking an address that was never accepted is a store no-op, so the probe has to register first.
This commit is contained in:
parent
f1084c1056
commit
1171d2157d
6 changed files with 334 additions and 33 deletions
112
tests/pentest.sh
112
tests/pentest.sh
|
|
@ -94,6 +94,11 @@ timeout = 10
|
|||
cap_bytes = 1048576
|
||||
posts_per_day = 50
|
||||
reposts_per_day = 50
|
||||
# Open, so the probes below exercise the path an attacker would actually find.
|
||||
# Silent would make most of them pass for the wrong reason.
|
||||
registration = open
|
||||
admins = tobias@$HOST:$SMTP_PORT
|
||||
admin_notify = off
|
||||
EOF
|
||||
"$SMOLBOID" admit --config "$RIG/smolboid.toml" "tobias@$HOST:$SMTP_PORT" >"$RIG/admit.log" 2>&1 \
|
||||
|| { cat "$RIG/admit.log"; fail "admit"; }
|
||||
|
|
@ -237,7 +242,11 @@ author send "$DAEMON_ADDR" \
|
|||
sleep 4
|
||||
[[ "$(grep -cE '^# smol:repost ' "$FEED")" == 0 ]] || fail "a noncanonical repost published"
|
||||
|
||||
step "mail: unadmitted senders publish nothing"
|
||||
step "mail: an unadmitted sender publishes nothing and gets no token"
|
||||
# Rewritten for self-service registration. The old assertion was that an
|
||||
# unadmitted sender draws no reply at all; under `registration = open` she
|
||||
# draws an info mail. What must still hold is that writing in publishes
|
||||
# nothing, creates no feed, and hands over no accept token.
|
||||
mallory keygen --force >/dev/null
|
||||
mallory trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
||||
mallory register "mallory@$HOST:$SMTP_PORT" >/dev/null
|
||||
|
|
@ -245,6 +254,107 @@ mallory send "$DAEMON_ADDR" --body "mallory was here" >/dev/null
|
|||
sleep 4
|
||||
[[ "$(grep -c "mallory was here" "$FEED")" == 0 ]] || fail "unadmitted mail published"
|
||||
[[ ! -f "$DATA/feeds/mallory.txt" ]] || fail "an unadmitted sender got a feed"
|
||||
mallory fetch >/dev/null 2>&1 || true
|
||||
# The info mail must not carry an Accept token: the token is what moves her
|
||||
# into the main tier, and it is only issued once she actually registers.
|
||||
if mallory list --requests 2>/dev/null | grep -q "how to get a feed"; then
|
||||
mallory read 1 >"$RIG/mallory-info.txt" 2>/dev/null || true
|
||||
grep -qi "^Accept:" "$RIG/mallory-info.txt" \
|
||||
&& fail "the info mail carried an accept token"
|
||||
fi
|
||||
|
||||
step "mail: no signed Reply-To draws no reply at all"
|
||||
# --anonymous omits the Reply-To, so there is no address to verify and nothing
|
||||
# to answer. This is the anti-reflector case: a freshly minted keypair must not
|
||||
# be able to make the daemon send mail anywhere.
|
||||
BEFORE_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l)"
|
||||
mallory send "$DAEMON_ADDR" --anonymous --body "no reply-to here" >/dev/null
|
||||
sleep 4
|
||||
AFTER_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l)"
|
||||
[[ "$BEFORE_SENT" == "$AFTER_SENT" ]] \
|
||||
|| fail "an anonymous sender drew a reply ($BEFORE_SENT -> $AFTER_SENT)"
|
||||
|
||||
step "mail: claiming someone else's address takes nothing and poisons nothing"
|
||||
# The squatting and contact-poisoning probe, and the most important assertion
|
||||
# in this file. Mallory signs a Reply-To naming tobias's address but carrying
|
||||
# her own key. The key matches the signer, so the claim is self-consistent —
|
||||
# which is exactly why the daemon must resolve the address and find tobias's
|
||||
# key there instead.
|
||||
#
|
||||
# Two things must hold: she gets no feed at tobias's slug, and tobias's
|
||||
# contact row must still hold tobias's key. If the daemon replied to the
|
||||
# claimed smol:// URI it would pin her key as verified under his address, and
|
||||
# his own registration would fail on KeyChanged forever after.
|
||||
MALLORY_KEY="$(mallory whoami 2>/dev/null | grep -oE '[a-z2-7]{52}' | head -1)"
|
||||
[[ -n "$MALLORY_KEY" ]] || fail "could not read mallory's key"
|
||||
mallory send "$DAEMON_ADDR" --anonymous \
|
||||
--header "Reply-To: smol://tobias@$HOST:$SMTP_PORT/$MALLORY_KEY" \
|
||||
--header "Twt-Register:" --body "" >/dev/null
|
||||
sleep 5
|
||||
# tobias's feed is his own and still bound to his address.
|
||||
grep -q "# smol:author = tobias@$HOST:$SMTP_PORT" "$FEED" \
|
||||
|| fail "tobias's feed lost its author binding"
|
||||
# And the daemon's contact for tobias must not have become mallory's key.
|
||||
CONTACT="$(daemon_fumi contacts 2>/dev/null | grep "tobias@$HOST:$SMTP_PORT" || true)"
|
||||
if [[ -n "$CONTACT" ]]; then
|
||||
echo "$CONTACT" | grep -q "$MALLORY_KEY" \
|
||||
&& fail "mallory's key got pinned under tobias's address"
|
||||
fi
|
||||
|
||||
step "mail: a claim for an address nobody holds draws nothing"
|
||||
BEFORE_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l)"
|
||||
mallory send "$DAEMON_ADDR" --anonymous \
|
||||
--header "Reply-To: smol://nobody@$HOST:$SMTP_PORT/$MALLORY_KEY" \
|
||||
--header "Twt-Register:" --body "" >/dev/null
|
||||
sleep 5
|
||||
AFTER_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l)"
|
||||
[[ "$BEFORE_SENT" == "$AFTER_SENT" ]] \
|
||||
|| fail "a claim for a nonexistent address drew a reply"
|
||||
[[ ! -f "$DATA/feeds/nobody.txt" ]] || fail "a nonexistent address got a feed"
|
||||
|
||||
step "mail: admin commands from a non-admin do nothing"
|
||||
# And must not hint that admin commands exist: mallory gets the ordinary
|
||||
# unknown-sender answer, never an ack.
|
||||
mallory send "$DAEMON_ADDR" --header "Twt-Admit: mallory@$HOST:$SMTP_PORT" \
|
||||
--body "" >/dev/null
|
||||
sleep 4
|
||||
[[ ! -f "$DATA/feeds/mallory.txt" ]] || fail "Twt-Admit from a non-admin created a feed"
|
||||
mallory fetch >/dev/null 2>&1 || true
|
||||
mallory list --requests 2>/dev/null | grep -qi "admitted" \
|
||||
&& fail "a non-admin was told an admin command ran"
|
||||
|
||||
step "mail: a blocked address stays blocked"
|
||||
# Order matters, and the first attempt at this probe got it wrong. Blocking an
|
||||
# address that was never accepted is a no-op in the store — there is no row to
|
||||
# deactivate — so `accepted_state` stays None and nothing is being tested. The
|
||||
# sequence that matters is the real one: register, get blocked, come back.
|
||||
#
|
||||
# store.block only clears the active flag, so the check has to run before any
|
||||
# network call or Twt-Block is a one-poll inconvenience.
|
||||
mallory send "$DAEMON_ADDR" --header "Twt-Register:" --body "" >/dev/null
|
||||
for ((i = 0; i < 20; i++)); do
|
||||
[[ -f "$DATA/feeds/mallory.txt" ]] && break
|
||||
sleep 1
|
||||
done
|
||||
[[ -f "$DATA/feeds/mallory.txt" ]] || fail "mallory could not register at all"
|
||||
|
||||
author send "$DAEMON_ADDR" --header "Twt-Block: mallory@$HOST:$SMTP_PORT" \
|
||||
--body "" >/dev/null
|
||||
sleep 5
|
||||
|
||||
# Blocked: her posts no longer publish.
|
||||
mallory send "$DAEMON_ADDR" --body "after being blocked" >/dev/null
|
||||
sleep 5
|
||||
grep -q "after being blocked" "$DATA/feeds/mallory.txt" \
|
||||
&& fail "a blocked author still published"
|
||||
|
||||
# And re-registering draws nothing at all, not even a reply.
|
||||
BEFORE_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l || true)"
|
||||
mallory send "$DAEMON_ADDR" --header "Twt-Register:" --body "" >/dev/null
|
||||
sleep 5
|
||||
AFTER_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l || true)"
|
||||
[[ "$BEFORE_SENT" == "$AFTER_SENT" ]] \
|
||||
|| fail "a blocked address drew a reply when re-registering"
|
||||
|
||||
step "mail: forged signing keys verify as invalid, never as verified"
|
||||
# Tamper the published feed's own signature line: the record must flip to
|
||||
|
|
|
|||
142
tests/rig.sh
142
tests/rig.sh
|
|
@ -1,8 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
# smolboid integration rig (PLAN.md step 7): a local bunshin, the fumi CLI as
|
||||
# the author, and the daemon under test — covering post, command, repost,
|
||||
# timestamp collision, size-cap warning, both prune modes and an author
|
||||
# rotation. Real binaries, real Noise sessions, real envelopes.
|
||||
# timestamp collision, size-cap warning, both prune modes, an author rotation,
|
||||
# self-service registration by mail and the admin commands. Real binaries,
|
||||
# real Noise sessions, real envelopes.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
|
@ -84,6 +85,9 @@ data = $DATA
|
|||
poll_secs = 1
|
||||
timeout = 10
|
||||
cap_bytes = 1048576
|
||||
registration = open
|
||||
admins = $AUTHOR_ADDR
|
||||
admin_notify = on
|
||||
EOF
|
||||
"$SMOLBOID" admit --config "$RIG/smolboid.toml" "$AUTHOR_ADDR" >"$RIG/admit.log" 2>&1 \
|
||||
|| { cat "$RIG/admit.log"; fail "admit"; }
|
||||
|
|
@ -187,6 +191,140 @@ OWNERS="$(python3 -c "import json;print(len(json.load(open('$DATA/state.json'))[
|
|||
grep -q "1 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|
||||
|| { cat "$RIG/check.log"; fail "rotation-era records do not verify"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Self-service registration. Nothing below calls `smolboid admit`.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
step "self-service: an unknown sender is told how to register"
|
||||
dfumi() { "$FUMI" --key "$RIG/master.key" --db "$DATA/mail.db" --timeout 10 "$@"; }
|
||||
dana() { "$FUMI" --key "$RIG/dana.key" --db "$RIG/dana.db" --timeout 10 "$@"; }
|
||||
DANA_ADDR="dana@$HOST:$SMTP_PORT"
|
||||
dana keygen --force >/dev/null
|
||||
dana trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
||||
dana register "$DANA_ADDR" >/dev/null
|
||||
|
||||
dana send "$DAEMON_ADDR" --body "is anyone there" >/dev/null
|
||||
# The info mail lands in dana's requests tier: she has not accepted the daemon
|
||||
# yet, so it holds no token of hers.
|
||||
for ((i = 0; i < 25; i++)); do
|
||||
dana fetch >/dev/null 2>&1 || true
|
||||
[[ "$(dana list --requests 2>/dev/null || true)" == *"how to get a feed"* ]] && break
|
||||
sleep 1
|
||||
done
|
||||
[[ "$(dana list --requests 2>/dev/null || true)" == *"how to get a feed"* ]] \
|
||||
|| { tail -20 "$RIG/serve.log"; fail "no info mail for an unknown sender"; }
|
||||
# Nothing was published and no feed exists yet.
|
||||
[[ ! -f "$DATA/feeds/dana.txt" ]] || fail "an unregistered sender got a feed"
|
||||
|
||||
step "self-service: Twt-Register creates the feed and hands over the token"
|
||||
# Deliberately *not* `dana accept "$DAEMON_ADDR"`. Accepting it would make her
|
||||
# client attach an Accept token to every later mail, and a published record
|
||||
# cannot carry one and still verify, so her posts would all be refused. Her
|
||||
# replies arrive in the requests tier instead.
|
||||
dana send "$DAEMON_ADDR" --header "Twt-Register: dee" --body "" >/dev/null
|
||||
wait_for "$DATA/feeds/dana.txt" "# nick = dee" 25
|
||||
curl -fsS "http://$HOST:$HTTP_PORT/twtxt/dana.txt" >"$RIG/dana-feed.txt" \
|
||||
|| fail "dana's feed is not served"
|
||||
grep -q "# nick = dee" "$RIG/dana-feed.txt" || fail "nick not in the served feed"
|
||||
|
||||
# The confirmation carries the daemon's accept token; fetching it is what lets
|
||||
# her publish, and her client learns the token from that mail.
|
||||
for ((i = 0; i < 25; i++)); do
|
||||
dana fetch >/dev/null 2>&1 || true
|
||||
[[ "$(dana list --requests 2>/dev/null || true)" == *"your feed is ready"* ]] && break
|
||||
sleep 1
|
||||
done
|
||||
[[ "$(dana list --requests 2>/dev/null || true)" == *"your feed is ready"* ]] \
|
||||
|| { dana list --requests || true; fail "no registration confirmation"; }
|
||||
|
||||
step "self-service: a registered author can post"
|
||||
dana send "$DAEMON_ADDR" --body "dana registered herself" >/dev/null
|
||||
wait_for "$DATA/feeds/dana.txt" "dana registered herself" 25
|
||||
"$SMOLBOID" check "$DATA/feeds/dana.txt" >"$RIG/dana-check.log" \
|
||||
|| { cat "$RIG/dana-check.log"; fail "dana's records do not verify"; }
|
||||
|
||||
step "self-service: the admin was notified"
|
||||
for ((i = 0; i < 20; i++)); do
|
||||
fumi fetch >/dev/null 2>&1 || true
|
||||
[[ "$(fumi list --requests 2>/dev/null || true)" == *"new feed dana"* ]] && break
|
||||
sleep 1
|
||||
done
|
||||
[[ "$(fumi list --requests 2>/dev/null || true)" == *"new feed dana"* ]] \
|
||||
|| { fumi list --requests || true; fail "no admin notification for the new feed"; }
|
||||
|
||||
step "Twt-Help returns the command reference"
|
||||
dana send "$DAEMON_ADDR" --header "Twt-Help:" --body "" >/dev/null
|
||||
for ((i = 0; i < 25; i++)); do
|
||||
dana fetch >/dev/null 2>&1 || true
|
||||
[[ "$(dana list --requests 2>/dev/null || true)" == *"your commands"* ]] && break
|
||||
sleep 1
|
||||
done
|
||||
[[ "$(dana list --requests 2>/dev/null || true)" == *"your commands"* ]] \
|
||||
|| fail "no help reply"
|
||||
|
||||
step "an accepted mailbox makes posts unpublishable, and says so"
|
||||
# The trap the info mail warns about: once dana accepts this address her client
|
||||
# attaches a token to every mail, and such a record could never be verified.
|
||||
# The daemon must refuse it with that specific reason rather than the generic
|
||||
# "resend the bare text".
|
||||
dana accept "$DAEMON_ADDR" >/dev/null
|
||||
dana send "$DAEMON_ADDR" --body "this carries a token" >/dev/null
|
||||
sleep 4
|
||||
grep -q "this carries a token" "$DATA/feeds/dana.txt" \
|
||||
&& fail "a post carrying an accept token was published"
|
||||
# Checked in both tiers on purpose: accepting the daemon is what moves its
|
||||
# replies from her requests tier into her inbox, so this one arrives in the
|
||||
# main tier precisely because of the step that broke her posting.
|
||||
#
|
||||
# Captured into a variable rather than piped into `grep -q`. Under `set -o
|
||||
# pipefail`, grep -q exits on its first match and SIGPIPEs the producer, so a
|
||||
# pipeline can report failure for a subject that is plainly there.
|
||||
dana_mail() { dana list 2>/dev/null || true; dana list --requests 2>/dev/null || true; }
|
||||
for ((i = 0; i < 20; i++)); do
|
||||
dana fetch >/dev/null 2>&1 || true
|
||||
MAIL="$(dana_mail)"
|
||||
[[ "$MAIL" == *"accept token"* ]] && break
|
||||
sleep 1
|
||||
done
|
||||
MAIL="$(dana_mail)"
|
||||
[[ "$MAIL" == *"accept token"* ]] \
|
||||
|| { echo "$MAIL"; fail "no explanation for the refused post"; }
|
||||
# The whole sentence must survive as a subject: a line break in a frontmatter
|
||||
# value makes the block malformed and strips the subject entirely.
|
||||
[[ "$MAIL" == *"withdraw the acceptance and resend"* ]] \
|
||||
|| { echo "$MAIL"; fail "the reason was truncated in the subject"; }
|
||||
dana block "$DAEMON_ADDR" >/dev/null
|
||||
|
||||
step "admin by mail: Twt-Block stops a feed"
|
||||
fumi send "$DAEMON_ADDR" --header "Twt-Block: $DANA_ADDR" --body "" >/dev/null
|
||||
sleep 3
|
||||
dana send "$DAEMON_ADDR" --body "should not publish" >/dev/null
|
||||
sleep 4
|
||||
grep -q "should not publish" "$DATA/feeds/dana.txt" \
|
||||
&& fail "a blocked author still published"
|
||||
|
||||
step "admin by mail: Twt-Registration closes the door"
|
||||
fumi send "$DAEMON_ADDR" --header "Twt-Registration: closed" --body "" >/dev/null
|
||||
sleep 3
|
||||
MODE="$(python3 -c "import json;print(json.load(open('$DATA/state.json')).get('registration'))")"
|
||||
[[ "$MODE" == "closed" ]] || fail "registration mode is $MODE, expected closed"
|
||||
|
||||
erin() { "$FUMI" --key "$RIG/erin.key" --db "$RIG/erin.db" --timeout 10 "$@"; }
|
||||
ERIN_ADDR="erin@$HOST:$SMTP_PORT"
|
||||
erin keygen --force >/dev/null
|
||||
erin trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
||||
erin register "$ERIN_ADDR" >/dev/null
|
||||
erin send "$DAEMON_ADDR" --header "Twt-Register:" --body "" >/dev/null
|
||||
sleep 4
|
||||
[[ ! -f "$DATA/feeds/erin.txt" ]] || fail "registration is closed but erin got a feed"
|
||||
for ((i = 0; i < 20; i++)); do
|
||||
erin fetch >/dev/null 2>&1 || true
|
||||
[[ "$(erin list --requests 2>/dev/null || true)" == *"registration is closed"* ]] && break
|
||||
sleep 1
|
||||
done
|
||||
[[ "$(erin list --requests 2>/dev/null || true)" == *"registration is closed"* ]] \
|
||||
|| fail "erin was not told registration is closed"
|
||||
|
||||
step "rig passed"
|
||||
echo "feeds: $FEED"
|
||||
echo "logs: $RIG"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue