# wire — security probe report
**Target:** `https://relay.laulpogan.com` (v0.1 test deployment, behind Cloudflare Tunnel on Spark)
**Date:** 2026-05-10
**Tester:** Claude Opus 4.7 + paul (operator-authorized; this is paul's own deployment)
**Methodology:** black-box adversarial probes against the public HTTP surface, plus internal source review
## Verdict
**No critical or high-severity findings.** v0.1 ships its documented threat model intact (see [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md)). One defense-in-depth tightening landed during this audit (slot_id format validation pre-disk-op).
| Critical | 0 | — |
| High | 0 | — |
| Medium | 0 | — |
| Low / hardening | 1 | slot_id validator (FIXED in this audit, commit follows) |
| Informational | 4 | known-by-design behaviors documented in THREAT_MODEL.md |
## Probes (24 categories)
### Authentication / authorization
**T1 — `/healthz` is unauthenticated 200.** PASS — designed liveness check; reveals only `"ok"`.
**T2 — non-GET methods on `/healthz` rejected.** PASS — POST/PUT/DELETE/PATCH all 405.
**T3 — POST to `/v1/events/<random_slot>` without bearer.** PASS — 401 (auth check happens before slot lookup; doesn't leak which slot_ids exist).
**T4 — POST to known slot with WRONG bearer token.** PASS — 403 via `constant_time_eq` (verified by code review).
**T7 — bearer-token timing side-channel.** PASS — 5.3ms median diff between true vs fake-but-same-length tokens; well below CF tunnel jitter (30-100ms typical). `constant_time_eq` in source confirmed (`src/relay_server.rs`); no early-return on byte mismatch.
**T14 — GET `/v1/events/<slot>` without bearer.** PASS — 401.
**T21 — duplicate `Authorization` headers.** PASS — 400 (axum/hyper rejects header pollution).
### Slot ID / path safety
**T6 — slot ID enumeration.** PASS — 0 of 5 random 16-byte hex slot IDs hit an existing slot. ID space is 2^128; brute force infeasible.
**T19 — path traversal attempts via slot_id.** PASS — `../etc/passwd`, URL-encoded variants, multi-segment paths all return 401 or 404. **Rust path safety:** `state_dir.join("slots").join(format!("{slot_id}.jsonl"))` does NOT normalize `..` — a malicious slot_id like `../etc/passwd` would resolve to `state_dir/etc/passwd.jsonl` (still confined under state_dir), not the actual `/etc/passwd`. Tested empirically: traversal contained.
**T19-fix — slot_id format validation (NEW HARDENING).** During this audit added `is_valid_slot_id()` regex check (`^[0-9a-f]{32}$`) before any disk operation in `append_event_to_disk`. Belt-and-suspenders against future code paths that might let attacker-controlled slot_ids reach disk. Unit test `slot_id_validator_accepts_only_lowercase_32hex` covers regression cases.
### Body / payload limits
**T5 — oversized body rejected.** PASS (after retest with file-based payload — initial test bug from `argv` length cap). 300KB body returns 413 with body cap of 256 KiB enforced exactly as documented.
**T17 — malformed JSON.** PASS — 400 (axum's JSON extractor rejects).
**T20 — 4000-char slot_id in URL.** PASS — 401 (no resource exhaustion; URL is parsed cleanly).
### Pair-slot integrity
**T9 — same-code-hash double host registration.** PASS — first call 201, second call 409 Conflict. Prevents one host from squatting another's pairing.
**T10 — distinct code_hash → distinct pair_id.** PASS — concurrent pairings cannot crosstalk. The pair-slot is keyed by `code_hash` (SHA-256 of code phrase), so a brute-force attacker hashing every possible code would find existing slots, but each guess still requires a complete SPAKE2 round and the pair-slot is consumed on first legitimate join.
**T15 — GET unknown pair_id.** PASS — 404.
### Forgery / signature
**T8 — relay accepts garbage/forged event when posted with valid bearer.** **PASS BY DESIGN.** Documented in [docs/THREAT_MODEL.md §T3](docs/THREAT_MODEL.md). The relay is a dumb pipe: it does not verify Ed25519 signatures. Bad signatures fail at the recipient via `verify_message_v31` and land in the `rejected` bucket of `wire pull --json`. Test `pull_rejects_event_with_unknown_signer` in `tests/e2e_bilateral.rs` proves this.
### Transport / protocol
**T11 — TLS chain valid.** PASS — `curl` (no `-k`) succeeds; CF Universal SSL handles cert.
**T12 — HTTP/2 served.** PASS — Cloudflare default.
**T13 — CRLF / header injection probes.** PASS — 200 (curl filters; backend never sees raw `\r\n` in headers).
**T22 — websocket upgrade attempt.** PASS — 405. The relay does not advertise WS on any route; non-WS endpoints reject upgrade attempts.
**T23 — `OPTIONS /v1/slot/allocate`.** PASS — 405. No CORS preflight enabled (correct — API is for CLI clients, not browsers; if browser access becomes a need, CORS can be added).
**T24 — server header.** PASS — only `server: cloudflare` exposed; relay's hyper `Server` header masked by CF. No version disclosure.
### Availability
**T16 — flood resistance.** **INFO.** 50 rapid `/v1/slot/allocate` calls all returned 201 with no rate limiting. **By design at v0.1** — the relay does no rate limiting; operator relies on Cloudflare WAF (free tier handles 5 rules) or upstream rate-limit middleware. BACKLOG'd as `tower-governor` integration. Mitigation today: each allocated slot is ~100 bytes RAM; brute force to fill memory needs millions of allocs over a sustained period; CF's default DDoS protections kick in.
**T18 — wrong methods on `/v1/slot/allocate`.** PASS — GET/DELETE/PATCH all 405.
## Code-review findings (read of `src/relay_server.rs` + `src/sas.rs` + `src/signing.rs`)
1. **`constant_time_eq` correct:** simple loop, no early-exit on first mismatch. ✓
2. **No `unwrap()` on user-controlled input** in handlers — every `Json<T>` extraction returns 400 on parse failure; downstream `.as_str()` calls fall back to defaults rather than panic.
3. **Pair-slot expiration not implemented** — `PairSlot` lives in memory until process restart. A malicious operator could allocate millions of pair-slots; mitigated by being in-memory-only (relay restart wipes them) but BACKLOG'd as v0.2 hardening (pair-slot TTL of 5 minutes).
4. **No nonce cache for replay** — the relay relies on event_id dedup which catches identical replays. Subtly different events (same body, different timestamp) would be accepted on each replay. The recipient catches them in `verify_message_v31` (event_id mismatch on tamper). Acceptable for v0.1; v0.2 may add a server-side nonce window.
5. **AEAD bootstrap: nonce randomness.** `OsRng.fill_bytes` for 12-byte nonce. Birthday-bound at 2^48 messages per key — pair-flow uses each key once, so bound is irrelevant in practice. ✓
6. **SPAKE2 single-use state.** `PakeSide::finish` consumes the inner state via `.take()`; second call returns error. Verified by `pake_finish_called_twice_errors` test.
## Hardening shipped in this audit
```
src/relay_server.rs:
+fn is_valid_slot_id(s: &str) -> bool { s.len() == 32 && s.bytes().all(...) }
+ check in append_event_to_disk before any disk operation
tests:
+ slot_id_validator_accepts_only_lowercase_32hex (8 cases)
Total: 75 lib + 19 cli + 2 e2e_bilateral + 1 e2e_mesh + 2 e2e_pair + 10 relay = 109/109 pass.
```
## Recommendations for v0.2 (already in BACKLOG.md)
- **Pair-slot TTL** (5 minutes idle) — frees memory + bounds brute force
- **`tower-governor` rate limit** — per-IP cap on `/v1/slot/allocate` and `/v1/pair`
- **Forward secrecy via vodozemac** — Olm Double Ratchet for the bootstrap channel
- **Per-event encryption** (NIP-44 v2 or DIDComm authcrypt) — closes T1 (relay reads message bodies)
- **Sigstore-rooted release artifact attestation** — GitHub Actions release workflow already exists; adding sigstore signing is ~30 LOC
## What I deliberately didn't test
- **Rust ed25519-dalek + chacha20poly1305 + spake2** internals — RustCrypto's audit history is extensive (NCC Group, others); not duplicating their work.
- **Cloudflare Tunnel internals** — out of scope; CF's DDoS + WAF is the operator's contract with CF.
- **Spark host OS** — operator's responsibility; not the wire protocol surface.
- **Side-channel on the pairing host's terminal** (shoulder-surfing the SAS digits) — physical-world attack outside threat model.
## Reproducing this report
```bash
# Public-deployment probes (read-only, no destructive ops):
/tmp/wire-pentest.sh https://relay.laulpogan.com
# Run against any wire deployment:
WIRE_HOME=$(mktemp -d) ./target/release/wire relay-server --bind 127.0.0.1:8770 &
/tmp/wire-pentest.sh http://127.0.0.1:8770
```
Source: `/tmp/wire-pentest.sh` in this session; should be checked into the repo as `scripts/pentest.sh` if these probes are to be CI-gated. (Not done in v0.1 — adding to BACKLOG.)