2026-10-04 20:33:08 +03:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# smolboid pentest: an adversarial pass against our own daemon. Every probe
|
|
|
|
|
# asserts the hardened outcome; a probe that succeeds as an attack fails the
|
|
|
|
|
# suite. Raw sockets via python3 for the HTTP surface, real fumi sends for
|
|
|
|
|
# the mail surface.
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
|
|
|
FUMI="$ROOT/../fumi/target/debug/fumi"
|
|
|
|
|
BUNSHIN_BIN="$ROOT/../bunshin/target/debug/bunshin"
|
|
|
|
|
SMOLBOID="$ROOT/target/debug/smolboid"
|
|
|
|
|
HOST=127.0.0.1
|
|
|
|
|
SMTP_PORT=19631
|
|
|
|
|
HTTP_PORT=18101
|
|
|
|
|
RIG="$(mktemp -d /tmp/smolboid-pentest.XXXXXX)"
|
|
|
|
|
DATA="$RIG/data"
|
|
|
|
|
DAEMON_ADDR="twtxt@$HOST:$SMTP_PORT"
|
|
|
|
|
FEED="$DATA/feeds/tobias.txt"
|
|
|
|
|
BUNSHIN_PID=""
|
|
|
|
|
SMOLBOID_PID=""
|
|
|
|
|
cleanup() {
|
|
|
|
|
[[ -n "$BUNSHIN_PID" ]] && kill "$BUNSHIN_PID" 2>/dev/null || true
|
|
|
|
|
[[ -n "$SMOLBOID_PID" ]] && kill "$SMOLBOID_PID" 2>/dev/null || true
|
|
|
|
|
}
|
|
|
|
|
trap cleanup EXIT
|
|
|
|
|
|
|
|
|
|
step() { printf '\n== %s ==\n' "$*"; }
|
|
|
|
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
|
|
|
|
probe() { # name, expected-status-prefix, raw request bytes (python literal)
|
|
|
|
|
local name=$1 want=$2 payload=$3
|
|
|
|
|
local got
|
|
|
|
|
got=$(python3 - "$HTTP_PORT" "$payload" <<'EOF'
|
|
|
|
|
import socket, sys
|
|
|
|
|
port, payload = int(sys.argv[1]), sys.argv[2].encode().decode("unicode_escape")
|
|
|
|
|
s = socket.create_connection(("127.0.0.1", port), timeout=15)
|
|
|
|
|
s.sendall(payload.encode())
|
|
|
|
|
s.settimeout(15)
|
|
|
|
|
# Accumulate until a full status line lands; a single recv may split.
|
|
|
|
|
data = b""
|
|
|
|
|
while b"\r\n" not in data:
|
|
|
|
|
try:
|
|
|
|
|
chunk = s.recv(4096)
|
|
|
|
|
except socket.timeout:
|
|
|
|
|
break
|
|
|
|
|
if not chunk:
|
|
|
|
|
break
|
|
|
|
|
data += chunk
|
|
|
|
|
if not data:
|
|
|
|
|
data = b"<no response in 15s>"
|
|
|
|
|
print(data.split(b"\r\n")[0].decode(errors="replace"))
|
|
|
|
|
EOF
|
|
|
|
|
)
|
|
|
|
|
if [[ "$got" == "$want"* ]]; then
|
|
|
|
|
echo "ok $name -> $got"
|
|
|
|
|
else
|
|
|
|
|
echo "got: $got"
|
|
|
|
|
fail "probe $name: wanted $want*, got $got"
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
step "build"
|
|
|
|
|
cargo build --manifest-path "$ROOT/../bunshin/Cargo.toml" --quiet
|
|
|
|
|
cargo build --manifest-path "$ROOT/../fumi/Cargo.toml" --quiet
|
|
|
|
|
cargo build --manifest-path "$ROOT/Cargo.toml" --quiet
|
|
|
|
|
|
|
|
|
|
step "bootstrap: bunshin, tobias and a second identity mallory"
|
|
|
|
|
mkdir -p "$DATA"
|
|
|
|
|
"$BUNSHIN_BIN" keygen --key "$RIG/server.key" >"$RIG/keygen.log" 2>&1
|
|
|
|
|
SERVER_KEY="$(grep -oE '[a-z2-7]{52}' "$RIG/keygen.log" | head -1)"
|
|
|
|
|
"$BUNSHIN_BIN" serve --key "$RIG/server.key" --db "$RIG/server.db" \
|
|
|
|
|
--host "$HOST" --port "$SMTP_PORT" >"$RIG/bunshin.log" 2>&1 &
|
|
|
|
|
BUNSHIN_PID=$!
|
|
|
|
|
sleep 1
|
|
|
|
|
author() { "$FUMI" --key "$RIG/author.key" --db "$RIG/author.db" --timeout 10 "$@"; }
|
|
|
|
|
mallory() { "$FUMI" --key "$RIG/mallory.key" --db "$RIG/mallory.db" --timeout 10 "$@"; }
|
|
|
|
|
daemon_fumi() { "$FUMI" --key "$RIG/master.key" --db "$DATA/mail.db" --timeout 10 "$@"; }
|
|
|
|
|
author keygen --force >/dev/null
|
|
|
|
|
author trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
|
|
|
|
author register "tobias@$HOST:$SMTP_PORT" >/dev/null
|
|
|
|
|
"$SMOLBOID" keygen "$RIG/master.key" >/dev/null
|
|
|
|
|
daemon_fumi trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
|
|
|
|
daemon_fumi register "$DAEMON_ADDR" >/dev/null
|
|
|
|
|
|
|
|
|
|
step "admit tobias (never mallory)"
|
|
|
|
|
cat >"$RIG/smolboid.toml" <<EOF
|
|
|
|
|
listen = $HOST:$HTTP_PORT
|
|
|
|
|
base_url = http://$HOST:$HTTP_PORT
|
|
|
|
|
smolmail_host = $HOST
|
|
|
|
|
mailbox = twtxt
|
|
|
|
|
master = $RIG/master.key
|
|
|
|
|
data = $DATA
|
|
|
|
|
poll_secs = 1
|
|
|
|
|
timeout = 10
|
|
|
|
|
cap_bytes = 1048576
|
|
|
|
|
posts_per_day = 50
|
|
|
|
|
reposts_per_day = 50
|
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.
2026-10-14 20:51:21 +03:00
|
|
|
# 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
|
2026-10-04 20:33:08 +03:00
|
|
|
EOF
|
|
|
|
|
"$SMOLBOID" admit --config "$RIG/smolboid.toml" "tobias@$HOST:$SMTP_PORT" >"$RIG/admit.log" 2>&1 \
|
|
|
|
|
|| { cat "$RIG/admit.log"; fail "admit"; }
|
|
|
|
|
author fetch >/dev/null
|
|
|
|
|
"$SMOLBOID" serve --config "$RIG/smolboid.toml" >"$RIG/serve.log" 2>&1 &
|
|
|
|
|
SMOLBOID_PID=$!
|
|
|
|
|
sleep 2
|
|
|
|
|
# A legitimate post so the feed has content to protect.
|
|
|
|
|
author send "$DAEMON_ADDR" --body "real post" >/dev/null
|
|
|
|
|
for ((i = 0; i < 20; i++)); do
|
|
|
|
|
grep -q "real post" "$FEED" 2>/dev/null && break
|
|
|
|
|
sleep 1
|
|
|
|
|
done
|
|
|
|
|
grep -q "real post" "$FEED" || fail "setup: the real post never published"
|
|
|
|
|
|
|
|
|
|
step "http: path traversal and path abuse"
|
|
|
|
|
probe "traversal-etc-passwd" "HTTP/1.1 404" 'GET /twtxt/../../etc/passwd HTTP/1.1\r\n\r\n'
|
|
|
|
|
probe "encoded-traversal" "HTTP/1.1 404" 'GET /twtxt/..%2F..%2Fetc%2Fpasswd HTTP/1.1\r\n\r\n'
|
|
|
|
|
probe "dotdot-no-suffix" "HTTP/1.1 404" 'GET /twtxt/.. HTTP/1.1\r\n\r\n'
|
|
|
|
|
probe "absolute-path" "HTTP/1.1 404" 'GET //etc/passwd HTTP/1.1\r\n\r\n'
|
|
|
|
|
probe "uppercase-username" "HTTP/1.1 404" 'GET /twtxt/TOBIAS.txt HTTP/1.1\r\n\r\n'
|
|
|
|
|
probe "unicode-username" "HTTP/1.1 404" 'GET /twtxt/t\xc3\xb6bias.txt HTTP/1.1\r\n\r\n'
|
|
|
|
|
probe "oversized-username" "HTTP/1.1 404" "GET /twtxt/$(printf 'a%.0s' $(seq 1 200)).txt HTTP/1.1\r\n\r\n"
|
|
|
|
|
probe "null-byte-username" "HTTP/1.1 404" 'GET /twtxt/tob\x00ias.txt HTTP/1.1\r\n\r\n'
|
|
|
|
|
probe "dotfile-username" "HTTP/1.1 404" 'GET /twtxt/.ssh.txt HTTP/1.1\r\n\r\n'
|
|
|
|
|
# The traversal probes must never return file content.
|
|
|
|
|
curl -s "http://$HOST:$HTTP_PORT/twtxt/../../etc/passwd" | grep -q "root:" \
|
|
|
|
|
&& fail "traversal returned /etc/passwd"
|
|
|
|
|
|
|
|
|
|
step "http: request parsing abuse"
|
|
|
|
|
# Any 4xx is a hardened outcome; the server may read it as a method.
|
|
|
|
|
probe "garbage-request" "HTTP/1.1 40" 'not http at all\r\n\r\n'
|
|
|
|
|
probe "smuggled-second-request" "HTTP/1.1 200" 'GET /twtxt/tobias.txt HTTP/1.1\r\nX-Injected: 1\r\n\r\nGET /twtxt/tobias'
|
|
|
|
|
probe "null-in-request" "HTTP/1.1 40" 'GE\x00T /twtxt/tobias.txt HTTP/1.1\r\n\r\n'
|
|
|
|
|
probe "post-method" "HTTP/1.1 405" 'POST /twtxt/tobias.txt HTTP/1.1\r\nContent-Length: 0\r\n\r\n'
|
|
|
|
|
probe "huge-request-line" "HTTP/1.1 40" "GET /$(printf 'a%.0s' $(seq 1 9000)) HTTP/1.1\r\n\r\n"
|
|
|
|
|
probe "extra-headers-tolerated" "HTTP/1.1 200" 'GET /twtxt/tobias.txt HTTP/1.1\r\nHost: x\r\nEvil: 1\r\n\r\n'
|
|
|
|
|
# No header value from the request is ever reflected into the response.
|
|
|
|
|
curl -s -i "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" -H "X-Evil: $(printf 'injected-header')" \
|
|
|
|
|
| grep -qi "injected-header" && fail "request header reflected in response"
|
|
|
|
|
|
2026-10-05 11:12:23 +03:00
|
|
|
step "http: HEAD serves headers without a body"
|
|
|
|
|
python3 - "$HTTP_PORT" <<'PYEOF'
|
|
|
|
|
import socket, sys
|
|
|
|
|
s = socket.create_connection(("127.0.0.1", int(sys.argv[1])), timeout=15)
|
|
|
|
|
s.sendall(b"HEAD /twtxt/tobias.txt HTTP/1.1\r\n\r\n")
|
|
|
|
|
data = b""
|
|
|
|
|
while b"\r\n\r\n" not in data:
|
|
|
|
|
chunk = s.recv(4096)
|
|
|
|
|
if not chunk:
|
|
|
|
|
break
|
|
|
|
|
data += chunk
|
|
|
|
|
head, _, body = data.partition(b"\r\n\r\n")
|
|
|
|
|
assert head.startswith(b"HTTP/1.1 200"), head
|
|
|
|
|
assert b"Content-Length:" in head, head
|
|
|
|
|
assert body == b"", "HEAD must not emit a body"
|
|
|
|
|
print("ok HEAD answers 200 with headers and no body")
|
|
|
|
|
PYEOF
|
|
|
|
|
|
|
|
|
|
step "http: the connection cap sheds floods with 503"
|
|
|
|
|
python3 - "$HTTP_PORT" <<'PYEOF'
|
|
|
|
|
import socket, sys, time
|
|
|
|
|
port = int(sys.argv[1])
|
|
|
|
|
held = []
|
|
|
|
|
for _ in range(64):
|
|
|
|
|
s = socket.create_connection(("127.0.0.1", port), timeout=15)
|
|
|
|
|
s.sendall(b"G") # a partial request, as slowloris sends
|
|
|
|
|
held.append(s)
|
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
extra = socket.create_connection(("127.0.0.1", port), timeout=15)
|
|
|
|
|
extra.sendall(b"GET /twtxt/tobias.txt HTTP/1.1\r\n\r\n")
|
|
|
|
|
data = b""
|
|
|
|
|
while b"\r\n" not in data:
|
|
|
|
|
chunk = extra.recv(512)
|
|
|
|
|
if not chunk:
|
|
|
|
|
break
|
|
|
|
|
data += chunk
|
|
|
|
|
assert data.startswith(b"HTTP/1.1 503"), f"flood absorbed: {data!r}"
|
|
|
|
|
for s in held:
|
|
|
|
|
s.close()
|
|
|
|
|
extra.close()
|
|
|
|
|
print("ok the 65th connection was shed with 503")
|
|
|
|
|
PYEOF
|
|
|
|
|
# The listener must recover once the flood closes.
|
|
|
|
|
for _ in $(seq 1 20); do
|
|
|
|
|
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null && break
|
|
|
|
|
sleep 1
|
|
|
|
|
done
|
|
|
|
|
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null || fail "listener never recovered after the flood"
|
|
|
|
|
|
2026-10-04 20:33:08 +03:00
|
|
|
step "http: slowloris is bounded by the read timeout"
|
|
|
|
|
python3 - "$HTTP_PORT" <<'EOF'
|
|
|
|
|
import socket, sys, time
|
|
|
|
|
port = int(sys.argv[1])
|
|
|
|
|
s = socket.create_connection(("127.0.0.1", port), timeout=30)
|
|
|
|
|
s.sendall(b"GET /twtxt/tobias.txt HTTP/1.1\r\n") # half a request, then stall
|
|
|
|
|
start = time.time()
|
|
|
|
|
s.settimeout(30)
|
|
|
|
|
try:
|
|
|
|
|
data = s.recv(1024)
|
|
|
|
|
except socket.timeout:
|
|
|
|
|
sys.exit("server kept the half-open connection past 30s")
|
|
|
|
|
if time.time() - start > 20:
|
|
|
|
|
sys.exit(f"slowloris held the thread {time.time()-start:.0f}s")
|
|
|
|
|
print(f"ok slowloris closed after {time.time()-start:.0f}s")
|
|
|
|
|
EOF
|
|
|
|
|
# The listener still serves while the stalled connection existed.
|
|
|
|
|
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null || fail "listener wedged after slowloris"
|
|
|
|
|
|
|
|
|
|
step "mail: feed-line injection through post text"
|
|
|
|
|
author send "$DAEMON_ADDR" --body "$(printf 'injected\t2020-01-01T00:00:00Z\tfake')" >/dev/null
|
|
|
|
|
author send "$DAEMON_ADDR" --body "$(printf 'line one\nline two')" >/dev/null
|
|
|
|
|
author send "$DAEMON_ADDR" --body "$(printf 'carriage\rreturn')" >/dev/null
|
|
|
|
|
sleep 4
|
|
|
|
|
[[ "$(grep -c "fake" "$FEED")" == 0 ]] || fail "a TAB-carrying post injected content into the feed"
|
|
|
|
|
[[ "$(grep -c "line two" "$FEED")" == 0 ]] || fail "a multiline body published a second line"
|
|
|
|
|
[[ "$(grep -c "return" "$FEED")" == 0 ]] || fail "a CR-carrying post published"
|
|
|
|
|
[[ "$(grep -cE '^[0-9]{4}-' "$FEED")" == 1 ]] || fail "the feed grew beyond the real post"
|
|
|
|
|
|
2026-10-15 13:59:39 +03:00
|
|
|
step "mail: control-character and hostile payloads are rejected, not published"
|
|
|
|
|
author send "$DAEMON_ADDR" --body "$(printf 'escape\033[31mred')" >/dev/null
|
2026-10-04 20:33:08 +03:00
|
|
|
author send "$DAEMON_ADDR" --body "# smol:mail forged" >/dev/null
|
|
|
|
|
author send "$DAEMON_ADDR" --body "# smol:repost 2020-01-01T00:00:00Z @<x y> #fake" >/dev/null
|
|
|
|
|
sleep 4
|
|
|
|
|
# Poser text may publish as ordinary post text (tab-prefixed, harmless);
|
|
|
|
|
# what must never happen is a line that IS a smol record.
|
2026-10-15 13:59:39 +03:00
|
|
|
[[ "$(grep -c "escape" "$FEED")" == 0 ]] || fail "a post with a control character published"
|
2026-10-04 20:33:08 +03:00
|
|
|
[[ "$(grep -cE '^# smol:mail .*forged' "$FEED")" == 0 ]] || fail "a fake smol record line published"
|
|
|
|
|
[[ "$(grep -cE '^# smol:repost ' "$FEED")" == 0 ]] || fail "text posing as a repost published as a repost"
|
|
|
|
|
"$SMOLBOID" check "$FEED" >"$RIG/check.log" \
|
|
|
|
|
|| { cat "$RIG/check.log"; fail "the feed verifies as forged after the probes"; }
|
|
|
|
|
# The poser texts may publish as posts and verify like posts; two posers
|
|
|
|
|
# sent in the same second can collide (one rejected), so what matters is
|
|
|
|
|
# that everything published verifies and nothing is forged.
|
|
|
|
|
grep -qE "[23] verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|
|
|
|
|
|| { cat "$RIG/check.log"; fail "unexpected feed contents after hostile mail"; }
|
|
|
|
|
|
|
|
|
|
step "mail: repost args cannot inject lines either"
|
|
|
|
|
author send "$DAEMON_ADDR" \
|
|
|
|
|
--header "Twt-Repost: @<tobias http://x> #abc" \
|
|
|
|
|
--body "$(printf 'injected\n')" >/dev/null
|
|
|
|
|
sleep 4
|
|
|
|
|
[[ "$(grep -cE '^# smol:repost ' "$FEED")" == 0 ]] || fail "a noncanonical repost published"
|
|
|
|
|
|
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.
2026-10-14 20:51:21 +03:00
|
|
|
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.
|
2026-10-04 20:33:08 +03:00
|
|
|
mallory keygen --force >/dev/null
|
|
|
|
|
mallory trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
|
|
|
|
mallory register "mallory@$HOST:$SMTP_PORT" >/dev/null
|
|
|
|
|
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"
|
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.
2026-10-14 20:51:21 +03:00
|
|
|
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"
|
2026-10-04 20:33:08 +03:00
|
|
|
|
|
|
|
|
step "mail: forged signing keys verify as invalid, never as verified"
|
|
|
|
|
# Tamper the published feed's own signature line: the record must flip to
|
|
|
|
|
# invalid, proving verification is load-bearing.
|
|
|
|
|
cp "$FEED" "$RIG/tampered.txt"
|
|
|
|
|
python3 - "$RIG/tampered.txt" <<'EOF'
|
|
|
|
|
import sys
|
|
|
|
|
path = sys.argv[1]
|
|
|
|
|
lines = open(path).read().split("\n")
|
|
|
|
|
for i, line in enumerate(lines):
|
|
|
|
|
if line.startswith("# smol:mail "):
|
|
|
|
|
parts = line.split(" ")
|
|
|
|
|
parts[3] = ("a" if parts[3][0] != "a" else "b") + parts[3][1:]
|
|
|
|
|
lines[i] = " ".join(parts)
|
|
|
|
|
break
|
|
|
|
|
open(path, "w").write("\n".join(lines))
|
|
|
|
|
EOF
|
|
|
|
|
if "$SMOLBOID" check "$RIG/tampered.txt" >/dev/null 2>&1; then
|
|
|
|
|
fail "a tampered signature still verified"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
step "mail: reply-flood is capped at 10 per hour"
|
|
|
|
|
for i in $(seq 1 15); do
|
2026-10-15 13:59:39 +03:00
|
|
|
author send "$DAEMON_ADDR" --body "$(printf 'y%.0s' $(seq 1 "$i"))$(printf '\tz')" >/dev/null
|
2026-10-04 20:33:08 +03:00
|
|
|
done
|
|
|
|
|
sleep 5
|
|
|
|
|
author fetch >/dev/null
|
2026-10-15 13:59:39 +03:00
|
|
|
# Fifteen distinct payloads carrying a TAB; at most ten answers, the rest silent.
|
2026-10-04 20:33:08 +03:00
|
|
|
# (Earlier probes may have drawn their own replies for other reasons.)
|
2026-10-15 13:59:39 +03:00
|
|
|
author list --requests 2>/dev/null | grep -c "twtxt: A post has to fit on one line" >"$RIG/replies.count" || true
|
2026-10-04 20:33:08 +03:00
|
|
|
REPLIES=$(grep -oE '^[0-9]+' "$RIG/replies.count" | head -1 || echo 0)
|
|
|
|
|
echo "flood answers: $REPLIES of 15"
|
|
|
|
|
[[ "$REPLIES" -le 10 ]] || fail "reply flood drew $REPLIES answers, over the hourly cap"
|
|
|
|
|
|
|
|
|
|
step "http: still healthy after everything"
|
|
|
|
|
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null || fail "listener unhealthy at the end"
|
|
|
|
|
"$SMOLBOID" check "$FEED" >/dev/null || fail "feed does not verify at the end"
|
|
|
|
|
|
|
|
|
|
step "pentest passed: every probe was repelled"
|
|
|
|
|
cleanup
|
|
|
|
|
trap - EXIT
|