Skip to content

feat(hub): add X25519 sealed-envelope relay for store-and-forward through taos.my - #2034

Merged
jaylfc merged 6 commits into
jaylfc:devfrom
hognek:feat/hub-relay
Jul 28, 2026
Merged

feat(hub): add X25519 sealed-envelope relay for store-and-forward through taos.my#2034
jaylfc merged 6 commits into
jaylfc:devfrom
hognek:feat/hub-relay

Conversation

@hognek

@hognek hognek commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements cross-user collab A3 (issue #2015): the hub sealed-envelope relay for T1 transport on taos.my.

What's added

  • tinyagentos/hub/relay.py — X25519 envelope sealing module:

    • ECDH + HKDF-SHA256 → ChaCha20-Poly1305 symmetric encryption
    • seal() / unseal() for keypair-based encrypt/decrypt
    • build_envelope() — wraps a sealed payload in a hub-routable outer envelope with recipient field and size enforcement (≤32KB)
    • canonicalize() — canonical JSON serialization for Ed25519 signing (design doc section 2)
  • tinyagentos/routes/account_proxy.py — hub relay forwarding:

    • Two new _ACTIONS entries: hub_relay_drop (POST) and hub_relay_poll (GET)
    • POST /api/account/hub/relay/drop — forwards sealed envelope to taos.my
    • GET /api/account/hub/relay/poll?recipient=... — polls for queued envelopes with recipient validation

Design

The hub never sees plaintext. Nodes seal inner (Ed25519-signed) payloads to the recipient's X25519 public key. The outer envelope only exposes recipient for routing and created_at for TTL expiry. Recipient nodes poll and unseal locally.

Tests

  • tests/test_hub_relay.py — 13 tests (all passing):

    • Round-trip seal→unseal (small/large/unicode payloads)
    • Wrong recipient fails (different keypair)
    • Tampered ciphertext detection
    • Envelope size limit enforcement
    • Canonical serialization (sig-stripping, key-sorting, unicode preservation)
    • Base64 url-safety
  • tests/test_routes_account_proxy.py — 3 new tests added:

    • Drop forwarding with body passthrough
    • Poll forwarding with recipient query param
    • Invalid recipient rejection (path injection prevention)

References

Summary by CodeRabbit

  • New Features

    • Added sealed-envelope hub relay primitives for end-to-end encrypted store-and-forward messaging, including URL-safe base64 encoding, deterministic canonical payloads (with signing field excluded), and max envelope size enforcement.
    • Added account proxy relay endpoints to drop and poll sealed envelopes, with hub-recipient validation and local identity mismatch handling.
  • Tests

    • Added coverage for seal/unseal round-trips, wrong-recipient and tampered-ciphertext failures, UTF-8 byte preservation, URL-safe base64 behavior, envelope size limits, canonicalization (including rejection of NaN/Infinity), and proxy request forwarding/400/403 validation.

@hognek
hognek marked this pull request as ready for review July 19, 2026 09:13
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Hub sealed-envelope primitives provide encrypted, size-limited envelopes and canonical payload bytes. Account-proxy routes validate relay recipients, enforce local identity matching, and forward valid drop and poll requests upstream.

Changes

Hub sealed-envelope relay

Layer / File(s) Summary
Sealed-envelope primitives and validation
tinyagentos/hub/relay.py, tests/test_hub_relay.py
Adds X25519/HKDF key derivation, ChaCha20-Poly1305 encryption, envelope construction, canonicalization, URL-safe base64 helpers, size limits, and end-to-end tests.
Account-proxy relay routes
tinyagentos/routes/account_proxy.py, tests/test_routes_account_proxy.py
Adds recipient validation, local identity checks, and forwarding for relay drop and poll requests, with tests for successful forwarding and rejected inputs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant account_proxy
  participant taos_upstream
  Client->>account_proxy: Submit relay drop or poll request
  account_proxy->>account_proxy: Validate recipient and body
  account_proxy->>taos_upstream: Forward relay action
  taos_upstream-->>account_proxy: Return relay response
  account_proxy-->>Client: Return status and response body
Loading

Suggested reviewers: jaylfc

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.65% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding an X25519 sealed-envelope relay for store-and-forward through taos.my.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gitar-bot

gitar-bot Bot commented Jul 19, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

Comment thread tinyagentos/hub/relay.py
recipient_x25519_pub_hex=recipient_x25519_pub_hex)
outer["recipient"] = recipient
# Enforce max size: the JSON-serialised envelope must be ≤ 32 KB.
raw = json.dumps(outer, sort_keys=True, separators=(",", ":"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Envelope size limit is measured on the raw JSON, not the base64-encoded wire form the docstring promises.

The module docstring (line 23) states the limit is 32 KB (outer envelope base64-encoded), but here you measure len(json.dumps(...).encode("utf-8")). base64 inflates by ~33%, so a 32 KB JSON envelope serializes to ~42 KB on the wire — the documented limit is never actually enforced at the transport layer. Either measure the base64-encoded body the hub will actually receive, or correct the docstring to say the limit applies to the UTF-8 JSON representation.

Also raw.encode("utf-8") is computed twice (here and again inside the ValueError); compute it once into a variable.

# never sees plaintext — it only inspects ``recipient`` for routing.

@router.post("/api/account/hub/relay/drop")
async def hub_relay_drop(request: Request):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: hub_relay_drop forwards the request body (including the in-body recipient) verbatim with no validation tying the recipient to the authenticated session.

Unlike hub_relay_poll (which validates recipient via _valid_rid and rejects bad input before forwarding), this endpoint relays the recipient field from the POST body straight to taos.my. A client authenticated as hub:alice can therefore drop a sealed envelope addressed to hub:bob (or any arbitrary rid). Even though the payload is sealed to Bob's X25519 key so Alice can't read it, this permits recipient-address spam / mailbox flooding and bypasses the routing restriction that the poll side enforces. Consider validating the in-body recipient against _valid_rid and/or asserting it matches the session identity before forwarding.

assert r.status_code == 200
assert "taos_session" not in captured.get("cookie", "")
assert captured.get("cookie", "") == ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The PR description notes these 3 new account-proxy tests "require full app fixture (pre-existing conftest hang on this machine)" and were not actually run. Confirm they execute in CI before merge — the inline monkeypatch/_FakeResp/_patch_upstream fixtures must be available in this file's scope, and a hang or skip here would leave the new relay routes effectively untested in the pipeline.

@kilo-code-bot

kilo-code-bot Bot commented Jul 19, 2026

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (5 files)
  • tinyagentos/hub/relay.py - 0 issues (envelope size-limit doc + double-encode RESOLVED)
  • tinyagentos/routes/account_proxy.py - 0 issues (recipient validation + non-dict body guard RESOLVED)
  • tests/test_hub_relay.py - 0 issues (new, 13 passing tests)
  • tests/test_routes_account_proxy.py - 0 issues (new relay forwarding/validation tests)
  • tests/test_routes_github.py - 0 issues (test mock fix, unrelated to relay feature)

Incremental Check (since previous review commit 81d9f18910)

The incremental delta versus the prior review was a single fix commit ee91db7 ("fix(hub): guard against non-dict JSON body in relay drop"). The current headRefOid matches the previous review commit, so the feature code was already fully reviewed and only the outstanding warnings were addressed in this commit.

Previous findings — verified resolved on changed lines

File Line Previous Status
tinyagentos/hub/relay.py 167 Size limit measured on raw JSON, not base64; raw.encode computed twice RESOLVED — docstring now reads "JSON-serialised envelope"; raw_bytes computed once (lines 167-168)
tinyagentos/routes/account_proxy.py 377 hub_relay_drop forwarded body without recipient validation RESOLVED_valid_hub_recipient(recipient) check present at line 386 before forwarding
tinyagentos/routes/account_proxy.py 381 Non-dict JSON body 500ed instead of 400 RESOLVEDisinstance(payload, dict) guard at line 383; UnicodeDecodeError added to except at line 381; unit test test_hub_relay_drop_rejects_non_dict_body added

All previously active Code Review Findings are resolved. No new issues introduced in the incremental change.

Previous Review Summaries (4 snapshots, latest commit 81d9f18)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 81d9f18)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (5 files)
  • tinyagentos/hub/relay.py - 0 issues (previous size-limit doc + double-encode finding RESOLVED)
  • tinyagentos/routes/account_proxy.py - 0 issues (recipient validation + non-dict body guard both RESOLVED)
  • tests/test_hub_relay.py - 0 issues (new, 13 passing tests)
  • tests/test_routes_account_proxy.py - 0 issues (new relay forwarding/validation tests)
  • tests/test_routes_github.py - 0 issues (test mock fix, unrelated to relay feature)

Previous Findings — Re-verified / Resolved

File Line Previous Status
tinyagentos/hub/relay.py 167 Size limit measured on raw JSON, not base64; raw.encode computed twice RESOLVED — docstring corrected to “JSON-serialised outer envelope, UTF-8 encoded”; raw_bytes computed once
tinyagentos/routes/account_proxy.py 377 hub_relay_drop forwarded body without recipient validation RESOLVED_valid_hub_recipient(recipient) check now present before forwarding
tinyagentos/routes/account_proxy.py 381 Non-dict JSON body 500ed instead of 400 RESOLVEDisinstance(payload, dict) guard added; UnicodeDecodeError added to except clause
tests/test_routes_account_proxy.py 624 New relay tests not executed locally INFO — pre-existing CI-confidence note; not a code defect. All relay tests now structurally present and the PR confirms they pass under Python 3.11.

Previous review (commit 7d45621)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (2 files)
  • tinyagentos/routes/account_proxy.py - 0 issues (previously reported non-dict body 500 and missing recipient validation both RESOLVED in this commit)
  • tests/test_routes_account_proxy.py - 0 issues (new test_hub_relay_drop_rejects_non_dict_body added covering the fix)

Previous Findings — Re-verified / Resolved

File Line Previous Status
tinyagentos/routes/account_proxy.py 383-384 Non-dict JSON body 500ed instead of 400 RESOLVEDisinstance(payload, dict) guard added before .get(); UnicodeDecodeError also added to the except clause
tinyagentos/routes/account_proxy.py 386 hub_relay_drop forwarded without recipient validation RESOLVED_valid_hub_recipient(recipient) check now present before forwarding
tinyagentos/hub/relay.py 167 Envelope size limit measured on raw JSON, not base64 wire form Out of scope for this incremental diff (unchanged file); carried forward for tracking

Previous review (commit 1faa15b)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/routes/account_proxy.py 381 Non-dict JSON body (array/string/scalar) passes json.loads but payload.get(...) raises AttributeError, uncaught by the (json.JSONDecodeError, TypeError) clause, so the request 500s instead of returning 400; mirror the isinstance(payload, dict) guard used in _validate_subdomain_name
tinyagentos/routes/account_proxy.py 377 hub_relay_drop now validates _valid_hub_recipient before forwarding — RE-VERIFIED: previously reported missing validation is fixed
Files Reviewed (4 files)
  • tinyagentos/hub/relay.py - 0 issues (previous size-limit docstring / double-encode WARNING resolved in this commit)
  • tinyagentos/routes/account_proxy.py - 1 new issue (non-dict body 500), 1 re-verified resolved
  • tests/test_routes_account_proxy.py - 0 new (prior SUGGESTION about unrun tests remains; new test_hub_relay_drop_rejects_invalid_recipient added and is structurally identical to passing tests)
  • tests/test_hub_relay.py - 0 issues

Fix these issues in Kilo Cloud

Previous review (commit c47d898)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/hub/relay.py 167 Envelope size limit measured on raw JSON, not base64 wire form promised by docstring; encode computed twice
tinyagentos/routes/account_proxy.py 367 hub_relay_drop forwards in-body recipient verbatim with no session/rid validation (mailbox spam risk)

SUGGESTION

File Line Issue
tests/test_routes_account_proxy.py 624 New account-proxy relay tests reportedly not run (conftest hang); verify they pass in CI
Files Reviewed (4 files)
  • tinyagentos/hub/relay.py - 1 issue
  • tinyagentos/routes/account_proxy.py - 1 issue
  • tests/test_routes_account_proxy.py - 1 issue
  • tests/test_hub_relay.py - 0 issues

Fix these issues in Kilo Cloud


Reviewed by hy3:free · Input: 36.4K · Output: 2.6K · Cached: 159.6K

@jaylfc

jaylfc commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Thanks - this builds nicely on the #2025 peer channel. Fold list before merge (test 3.13 is also failing, likely the unrun tests below):

MUST-FIX (security):

  1. relay.py:167 - the 32KB envelope size limit is measured on the raw JSON, not the base64 wire form the module docstring (line 23) promises. Base64 inflates ~33%, so the effective wire limit is wrong. Measure the encoded form, or fix the docstring to match, and pick one authoritative limit.
  2. account_proxy.py:367 - hub_relay_drop forwards the body (including the in-body recipient) verbatim with no check tying recipient to the authenticated session. This is the same audience-binding class we fixed in feat(contacts): contacts_store + peer_links + signed-envelope peer channel #2025: bind/validate the recipient against the caller identity before relaying, else a caller can drop envelopes to arbitrary recipients.

Then:
3. test_routes_account_proxy.py:624 - the 3 new account-proxy tests were not actually run (conftest hang noted in the PR). Please get them green locally (or in CI) so the relay path is genuinely covered - right now test 3.13 is red.

Once the two security items are folded and CI is green I will re-review and merge.

Comment thread tinyagentos/routes/account_proxy.py Outdated
body = await request.body()
try:
payload = json.loads(body)
recipient = str(payload.get("recipient", ""))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: A JSON body that parses to a non-dict (e.g. a JSON array [1,2,3], a string, or a number) passes json.loads but then payload.get(...) raises AttributeError, which is not caught by the except (json.JSONDecodeError, TypeError) clause — so the request 500s instead of returning 400.

The sibling validator _validate_subdomain_name already guards with if not isinstance(payload, dict). Mirror that here: after parsing, reject non-dict bodies before calling .get.

Suggested change
recipient = str(payload.get("recipient", ""))
if not isinstance(payload, dict):
return JSONResponse({"error": "invalid body"}, status_code=400)
recipient = str(payload.get("recipient", ""))

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@hognek

hognek commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@hognek

hognek commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

All jaylfc MUST-FIX items from the 09:53 review are now addressed:

1. relay.py:167 — docstring/limit mismatch → Fixed in ed89178: docstring updated to "JSON-serialised outer envelope, UTF-8 encoded" to match code behavior. raw.encode("utf-8") computed once, cached.

2. account_proxy.py:377 — missing recipient validation → Fixed in ed89178: _valid_hub_recipient(recipient) guard added to hub_relay_drop. Non-dict body guard added in f122a53 (prevents 500 on arrays/strings/numbers).

3. test routes 3 new tests not run → Fixed in 81d9f18: _build_app_with_app_config now mocks github-app-private-key secret. All 5 account-proxy hub relay tests run and pass locally:

  • 13/13 hub relay (test_hub_relay.py)
  • 5/5 account proxy hub relay (test_routes_account_proxy.py)
  • 1/1 github repo endpoint (test_routes_github.py)

Kilo Code Review: All 4 findings (2 WARNING, 1 SUGGESTION, 1 WARNING-N/A) addressed in these same commits.

Test matrix requires workflow approval for re-run (fork CI policy).

hognek added 4 commits July 27, 2026 09:42
…ough taos.my

Add hub/relay.py module with X25519 ECDH+HKDF→ChaCha20-Poly1305 seal/unseal,
envelope building with size limits (32KB), and canonical-JSON serialization.
Add account proxy forwarding for POST /api/account/hub/relay/drop and
GET /api/account/hub/relay/poll with recipient validation.

Implements cross-user collab A3 (issue jaylfc#2015): the hub sealed-envelope relay
for T1 transport.  Hub never sees plaintext; inner payload is Ed25519-signed
then X25519-sealed.

Tests: 13/13 pass — round-trip, key mismatch, tamper detection, size limit,
unicode, canonical serialization, base64 url-safety. Account proxy route
tests added (3 new) but require full app fixture (pre-existing conftest hang
on this machine; structurally identical to existing passing account-proxy
hub tests).
- Add _valid_hub_recipient() for hub:username format validation
- Validate recipient in hub_relay_drop before forwarding (was bypassed)
- Fix hub_relay_poll to use hub-specific validator (was using generic
  _valid_rid which rejects colons in hub usernames)
- Correct envelope size docstring: base64-encoded → UTF-8 JSON
- Compute raw_bytes once instead of double-encoding in build_envelope
- Add test_hub_relay_drop_rejects_invalid_recipient test

All 4 relay tests pass (Python 3.11).
Add isinstance(payload, dict) check after json.loads() to prevent
AttributeError on non-dict bodies (arrays, strings, numbers).
Also add test_hub_relay_drop_rejects_non_dict_body test.
…pient

MUST-FIX items from jaylfc review on PR jaylfc#2034:

1. relay.py build_envelope: measure base64-encoded wire form (not raw
   JSON) for the 32 KB envelope size limit, matching the docstring.
   Base64 inflates ~33 %, so a raw-JSON-under-limit envelope can exceed
   the transport cap.  Error message now says 'base64-encoded'.

2. account_proxy.py hub_relay_drop: bind in-body recipient to caller's
   local hub identity (resolve_local_identity_id from peer.py) before
   forwarding — same audience-binding pattern as jaylfc#2025 peer channel fix.
   Rejects mismatched recipient with 403 when hub identity is registered.

Tests:
- test_size_limit_measured_on_base64_not_raw_json: 24 KB payload
  exceeds base64-encoded limit while raw JSON is under 32 KB
- test_small_envelope_passes_size_check: sanity check small payload
- test_hub_relay_drop_rejects_recipient_mismatch: 403 on mismatch
- test_hub_relay_drop_accepts_matching_recipient: 200 on match
All 15 relay tests pass.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_routes_account_proxy.py`:
- Around line 704-734: Update test_hub_relay_drop_forwards_envelope to mock
resolve_local_identity_id explicitly, matching the setup used by the later
recipient-mismatch and matching-recipient tests. Configure the mock to return
None so the test consistently exercises the successful forwarding path without
depending on ambient TAOS_DATA_DIR or keystore state.

In `@tinyagentos/hub/relay.py`:
- Around line 192-195: Update canonicalize() to reject NaN and positive or
negative infinity while serializing the payload without the sig field, using
strict JSON serialization or equivalent validation so non-standard numeric
values cannot produce signed bytes. Add coverage in TestCanonicalize for NaN and
infinity inputs.

In `@tinyagentos/routes/account_proxy.py`:
- Around line 405-418: The hub_relay_poll handler must bind the requested
recipient to the caller’s local hub identity, not just validate its format.
Mirror the identity-matching check used by hub_relay_drop before calling
_forward_to, rejecting any well-formed hub:<user> recipient that does not match
the local hub identity.
- Around line 375-402: Update the hub_relay_drop route to offload the blocking
resolve_local_identity_id() call to a worker thread using the file’s existing
asyncio import, adding that import only if absent; preserve the current identity
comparison and 403 response behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ebb08327-b9a8-4180-9c4f-d744fe948c0d

📥 Commits

Reviewing files that changed from the base of the PR and between 7d45621 and 4222739.

📒 Files selected for processing (4)
  • tests/test_hub_relay.py
  • tests/test_routes_account_proxy.py
  • tinyagentos/hub/relay.py
  • tinyagentos/routes/account_proxy.py

Comment thread tests/test_routes_account_proxy.py
Comment thread tinyagentos/hub/relay.py
Comment thread tinyagentos/routes/account_proxy.py
Comment thread tinyagentos/routes/account_proxy.py
@jaylfc

jaylfc commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Held after the deepest review of the batch, because this is crypto. The cryptography itself is correct and safe - merge that part with confidence. The blocker is an authorization control placed on the wrong endpoint, and the tests currently lock in the wrong behavior.

The crypto is sound (verified, not assumed): seal/unseal is a textbook ephemeral-static X25519 sealed box on pyca cryptography - fresh ephemeral X25519 keypair per message (sender forward secrecy, no static-static ECDH), ECDH to HKDF-SHA256 (domain-separated) to ChaCha20-Poly1305 AEAD, fresh os.urandom(12) nonce per message so reuse is structurally impossible. No hand-rolled math, no missing MAC, no custom KDF. Private keys never logged or serialized; unseal is fail-closed. The tamper-rejection test exists and passes (test_tampered_ciphertext_fails flips a byte and asserts None; test_wrong_recipient_fails too). This is the part I was most prepared to reject, and it holds up.

HIGH - the audience binding is inverted (verified). hub_relay_drop (account_proxy.py, the SEND side) does if local_id is not None and recipient != local_id: 403. But on drop, the local node is the SENDER addressing someone else (Alice drops an envelope for hub:bob). Once a node registers a hub identity - the normal state - every legitimate cross-user send returns 403, which breaks the entire feature. The recipient == local_id check is the RECEIVE-side control: peer.py:185 uses exactly that pattern to guard the inbox that receives envelopes addressed to itself. It got copied onto the send side. And test_hub_relay_drop_rejects_recipient_mismatch / _accepts_matching_recipient enshrine the wrong behavior, so CI is green over a feature-breaking bug.

MEDIUM - hub_relay_poll has no recipient binding at all. Any local caller can poll an arbitrary recipient's queue (poll?recipient=hub:victim). Contents stay confidential (sealed), but it leaks queue metadata and, if the hub deletes-on-poll, enables denial-of-delivery. This is exactly where the == local_id check from the HIGH finding belongs.

So the fix is a swap: remove the binding from drop, move it to poll. Latent rather than a live regression (nothing is wired to the relay yet, grep confirms), but it must be fixed before any caller lands or the follow-up wiring is dead on arrival.

LOW - outer envelope fields unauthenticated: seal passes associated_data=None, so recipient/created_at are not AEAD-bound and a MITM can alter them undetected. Low impact (re-labeling just routes ciphertext to someone who cannot decrypt), but pass the ephemeral pub (and ideally recipient) as AAD, and never trust created_at for a security decision.

Fix the drop/poll swap and add a poll-side binding test, then this is ready. The crypto foundation is genuinely good work.

- relay.py: add allow_nan=False to canonicalize() json.dumps
- account_proxy.py: offload resolve_local_identity_id() to thread
- account_proxy.py: bind hub_relay_poll recipient to local identity
- tests: mock resolve_local_identity_id for deterministic relay tests
- tests: add NaN/infinity rejection coverage in TestCanonicalize
@jaylfc

jaylfc commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Deep review done. The cryptography is competent: fresh ephemeral X25519 per message, HKDF-SHA256 with domain separation, ChaCha20-Poly1305 with a per-message nonce, raw ECDH output never used as a key, fails closed, no secrets logged. I found no misuse of the primitives.

Two changes are required before this can merge, and the first is the important one.

1. The recipient binding in hub_relay_drop is the inverse of the correct control, and it breaks the feature.

account_proxy.py:392-399 rejects any envelope whose recipient is not this node's own identity. But drop is the OUTBOUND send: my node drops an envelope addressed to someone else. With this check, a node that has registered a hub identity (the precondition for using the hub at all) can only ever drop to itself, so store-and-forward is dead on arrival.

The commit cites the audience-binding pattern from #2025, but that pattern lives on the INBOUND peer route (routes/peer.py:212-220, :323-331) where envelope["to"] == local_id is right because the envelope is arriving for you. Drop is the opposite direction.

Fix: remove the binding from hub_relay_drop entirely. Keep it in hub_relay_poll, where polling your own queue is the correct direction. The outer envelope has no sender field to bind against, and the sender's authority is the session cookie; only the hub can enforce sender to friend-edge to recipient.

Then invert test_hub_relay_drop_rejects_recipient_mismatch so it asserts that a drop to a DIFFERENT user succeeds. As written the suite is green on the bug: it documents the broken semantic as intended behaviour, and the two forwarding tests monkeypatch resolve_local_identity_id to None, which is precisely the configuration where the binding is switched off.

2. resolve_local_identity_id() is called bare, so the control can silently disable itself.

account_proxy.py:392 and :422 call it with no data_dir. Every sibling passes request.app.state.data_dir (routes/peer.py:212,323, routes/project_invites.py:982). Called bare it falls back to $TAOS_DATA_DIR and then a repo-relative path, so a deployment started with --data-dir and no TAOS_DATA_DIR misses the DB, returns None, and both bindings no-op: the poll endpoint would then query any recipient's queue. A control that disables itself depending on launch flags is worse than no control, because it looks present. Pass request.app.state.data_dir in both places.

Worth doing in the same milestone (hardening, not blockers):

  1. Bind recipient and created_at as AEAD associated data. aead.encrypt(nonce, plaintext, None) passes no AAD, so the routing metadata and TTL are unauthenticated and the hub is semi-trusted by design. Seal with aad = json.dumps({"recipient":..., "created_at":...}, sort_keys=True, separators=(",",":")).encode() and recompute it on unseal, so tampering becomes a decrypt failure.
  2. Mix ephemeral_pub || recipient_pub into the HKDF info to close unknown-key-share, as HPKE and crypto_box_seal do.
  3. Ship unseal_and_verify(envelope, priv, *, sender_ed25519_pub). The docstring promises an Ed25519-signed inner payload but only canonicalize() exists, and X25519 public keys are in the hub directory, so today anyone can seal anything to you. Make the signature-checking path the default one before the first consumer lands.
  4. Measure len(raw_bytes) rather than the base64 form for the 32 KB cap, and restore the "UTF-8 JSON" docstring. The wire body is sent verbatim with no base64, so the current code rejects at about 24 KB what the hub accepts at 32 KB, a silent 25 percent loss of budget.
  5. Add a 413 body cap on hub_relay_drop mirroring routes/peer.py:189.
  6. Converge relay.canonicalize and peer._canonical_json onto one implementation. They differ on allow_nan, which will bite when one signs and the other verifies.

Note also that the server half (#2015, the hub endpoints on taos.my) is a different repo, so every real limit (sender identity, friend-edge check, queue cap, TTL, size cap) is unverifiable from here. The constants in relay.py:39-41 are client-side only. Worth sequencing so this does not sit as a client with no server.

Tests were verified to actually run, no skip guards, and both files pass on the PR head.

…resolve_local_identity_id

Drop is outbound send — a node drops envelopes addressed to any valid
hub recipient. Recipient binding now only applies to hub_relay_poll
(polling your own queue). Also pass request.app.state.data_dir to
resolve_local_identity_id in hub_relay_poll, matching every sibling
route.

Invert test_hub_relay_drop_rejects_recipient_mismatch to
test_hub_relay_drop_allows_different_recipient, asserting 200
for a drop to a different user.

Fixes jaylfc review findings on PR jaylfc#2034.
@hognek

hognek commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Fixes for the two REQUIRED changes:

1. Removed recipient binding from hub_relay_drop (was lines 392-401).
Drop is outbound send — a node drops envelopes addressed to any valid hub recipient. Recipient binding now only applies to hub_relay_poll (polling your own queue). Inverted the test: test_hub_relay_drop_rejects_recipient_mismatchtest_hub_relay_drop_allows_different_recipient, now asserting 200 for a drop to a different user.

2. Pass request.app.state.data_dir to resolve_local_identity_id in hub_relay_poll (line 411). Every sibling route that calls resolve_local_identity_id already passes data_dir (e.g. routes/peer.py:207,308, routes/project_invites.py:982). Also updated the dead monkeypatch lambdas in tests to accept the _data_dir arg so they stay correct.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
tinyagentos/routes/account_proxy.py (2)

411-416: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Fail closed when no local hub identity is available.

resolve_local_identity_id() returns None both for an unregistered identity and lookup failures. Line 412 therefore forwards any valid recipient, allowing polling of another queue precisely when local binding is unavailable. Require a resolved identity and equality before forwarding.

  • tinyagentos/routes/account_proxy.py#L411-L416: return 403 when local_id is None or it differs from recipient.
  • tests/test_routes_account_proxy.py#L739-L760: mock hub:alice and verify polling hub:alice forwards; add a hub:bob case that returns 403 without upstream I/O.
  • tests/test_routes_account_proxy.py#L847-L879: remove or repurpose the obsolete drop-recipient-binding test for the poll authorization cases.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/account_proxy.py` around lines 411 - 416, Update the poll
authorization in tinyagentos/routes/account_proxy.py lines 411-416 to return 403
unless local_id is resolved and equals recipient. In
tests/test_routes_account_proxy.py lines 739-760, mock hub:alice and verify
matching polling forwards, then verify hub:bob returns 403 without upstream I/O.
In tests/test_routes_account_proxy.py lines 847-879, remove or repurpose the
obsolete drop-recipient-binding test for these poll authorization cases.

381-395: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the incoming body before buffer+relay.

hub_relay_drop calls await request.body() and then sends the bytes upstream; the app/server startup uses plain uvicorn.run/uvicorn.Config with no request limit enabled. Other proxy routes already reject oversized Content-Length before reading, so add the same guard here or another request-size cap that also enforces the 32KB envelope spec.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/routes/account_proxy.py` around lines 381 - 395, Add the existing
proxy-route request-size guard to hub_relay_drop before await request.body(),
rejecting Content-Length values above the 32KB envelope limit with the
established error response. Ensure the cap also protects requests lacking
Content-Length or using chunked transfer before buffering and forwarding through
_forward_to.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@tinyagentos/routes/account_proxy.py`:
- Around line 411-416: Update the poll authorization in
tinyagentos/routes/account_proxy.py lines 411-416 to return 403 unless local_id
is resolved and equals recipient. In tests/test_routes_account_proxy.py lines
739-760, mock hub:alice and verify matching polling forwards, then verify
hub:bob returns 403 without upstream I/O. In tests/test_routes_account_proxy.py
lines 847-879, remove or repurpose the obsolete drop-recipient-binding test for
these poll authorization cases.
- Around line 381-395: Add the existing proxy-route request-size guard to
hub_relay_drop before await request.body(), rejecting Content-Length values
above the 32KB envelope limit with the established error response. Ensure the
cap also protects requests lacking Content-Length or using chunked transfer
before buffering and forwarding through _forward_to.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 109bbc07-4ace-48a1-93b7-995903ae2630

📥 Commits

Reviewing files that changed from the base of the PR and between c0ff22c and 2a4b8c8.

📒 Files selected for processing (2)
  • tests/test_routes_account_proxy.py
  • tinyagentos/routes/account_proxy.py

@jaylfc
jaylfc merged commit 5e6dcaa into jaylfc:dev Jul 28, 2026
17 of 18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants