Skip to content

fix(relay): enforce real signing key on inbound tickets (C-001 critical) - #22

Open
David Mireles (louzt) wants to merge 2 commits into
mainfrom
fix/C-001-relay-subject-validation
Open

fix(relay): enforce real signing key on inbound tickets (C-001 critical)#22
David Mireles (louzt) wants to merge 2 commits into
mainfrom
fix/C-001-relay-subject-validation

Conversation

@louzt

@louzt louzt commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

fix(relay): enforce real signing key on inbound tickets (C-001 critical)

Summary

The QUIC relay listener was passing a zeroed ed25519_dalek::SigningKey::from_bytes(&[0u8; 32]) as the expected_subject to server_handshake. Because the dummy key's verifying key is all zeros, any claims.subject value would pass the subject-binding check — bypassing the entire subject-binding guarantee of the signed-ticket format. Only the issuer signature was being enforced.

This change also fixes a pre-existing production bug exposed by the new integration test: the listener was calling conn.open_bi() to accept the handshake stream instead of conn.accept_bi(), causing the server handshake to hang forever on recv.read_exact (Quinn bi-streams are paired but stream IDs are independently allocated per peer).

API change

RelayConfig::new(...) now takes a 5th argument signing_key: Arc<ed25519_dalek::SigningKey> and Relay::signing_key() -> &SigningKey is the new accessor used as the canonical expected_subject. RelayConfig::with_generated_key(...) is a builder for tests/dev loops that mints a fresh key.

Migration:

// Before
let cfg = RelayConfig::new(addr, trust, limiter);
let relay = Arc::new(Relay::new(cfg));

// After — explicit key
let key = Arc::new(generate_signing_key());
let cfg = RelayConfig::new(addr, trust, limiter, key);
let relay = Arc::new(Relay::new(cfg));

// After — test/dev shortcut (random key)
let cfg = RelayConfig::with_generated_key(addr, trust, limiter);
let relay = Arc::new(Relay::new(cfg));

Changes

File Purpose
src/relay/mod.rs RelayConfig.signing_key + new accessor + with_generated_key builder
src/relay/listener.rs open_bi()accept_bi() (pre-existing production bug); use Relay::signing_key() as expected_subject (C-001 fix); feed ConnectionLog into RelayStats (B-003)
src/session.rs Replace let _ = ... silent send-fail drops with .inspect_err(...).ok() (B-002)
src/rate_limit.rs Doc comment with f64 floating-point caveats (B-006)
tests/c001_subject_validation.rs NEW — 2 integration tests (negative + positive control)
tests/relay_listener_e2e.rs Use with_generated_key() after signature change

Verification

cargo test --tests68 passed, 0 failed, 0 warnings:

  • 60 unit tests (lib) — including the new relay::tests::relay_exposes_signing_key
  • 2 NEW integration tests in tests/c001_subject_validation.rs:
    • ticket_with_unrelated_subject_is_rejected_with_subject_mismatch
    • ticket_with_matching_subject_is_accepted
  • 1 tests/integration_trust_sync.rs
  • 4 tests/quic_e2e.rs (including trusted_issuer_is_accepted for the multi-issuer model)
  • 1 tests/relay_listener_e2e.rs (signature change propagation)

The negative test asserts EITHER a HandshakeResponse::Err(SubjectMismatch) arrives on the wire OR the connection is closed with reason b"handshake failed" — both are valid rejections of the C-001 attack.

Sanitization checklist

  • No secrets, tokens, or operator-identifying paths in the diff
  • No force-pushes; commits authored under the session's GPG signing key
  • No unrelated refactors
  • Public API change documented (this PR body + memory entry)
  • New tests cover the regression end-to-end
  • Pre-existing production bug (open_bi hang) fixed in the same commit
  • Conventional commit prefix (fix(relay))
  • Branch ownership recorded via F80.14 lzt-branch-claim

Carried-over gaps (NOT in this PR)

These surfaced during the audit but are out of scope for C-001:

  • Issuer verification wiring: src/relay/listener.rs:142-148 hard-codes &relay_verifying_key as issuer_verifying_key. The single-issuer model works; a multi-issuer trust-store requires extending server_handshake to accept a resolver fn (|NodeId| -> Option<VerifyingKey>).
  • QUIC close error code 0 with b"handshake failed": src/relay/listener.rs:155-157 passes 0u8 as close code, which Quinn interprets as graceful. Clients can't distinguish clean disconnect from handshake rejection by code alone. Reserve a non-zero code (e.g. 0x4747).

Tracked in memory snappipe-c001-critical-bypass-2026-07-10.

Related

  • Issue: C-001 (critical auth bypass)
  • Hardening items: B-002 (send-fail logging), B-003 (stats connection), B-006 (rate-limit f64 doc)
  • Memory: ~/.claude/projects/-home-lou/memory/snappipe-c001-critical-bypass-2026-07-10.md

Summary by CodeRabbit

  • Security

    • Relay connections now verify that ticket identities match the configured relay identity, rejecting mismatched tickets.
    • Handshake failures are reported more reliably while avoiding connection crashes when clients disconnect unexpectedly.
  • Monitoring

    • Relay statistics now include outcomes from successfully processed application streams.
  • Documentation

    • Added operational guidance and a connectivity diagram for SnapPipe-gated QUIC deployment and fallback behavior.
  • Testing

    • Expanded coverage for valid and invalid ticket subjects and end-to-end relay behavior.

Adds §Layer 0: SnapPipe-gated QUIC to OPERATIONAL-DEPLOYMENT.md.
Cross-links to the 5-tier gist case study and to src/relay/listener.rs.
Documents the ordered operations: 5-tier fallback → SnapPipe relay
accept loop → server_handshake on stream 0 → streams 1..N forwarded.
No code changes.
- src/relay/listener.rs:122 server now calls conn.accept_bi() on the
  client's bidi stream. Previously the listener called conn.open_bi()
  which opened a separate stream with no peer; recv.read_exact() then
  blocked forever on the handshake, silently dropping every connection.
  This was a pre-existing production bug unmasked by the new
  c001_subject_validation integration test below.

- src/relay/mod.rs Relay::signing_key() exposes the relay's identity
  key, and RelayConfig.signing_key: Arc<SigningKey> is now a required
  field. src/relay/listener.rs:138 passes relay.signing_key().verifying_key()
  as server_handshake's expected_subject — a zeroed
  ed25519_dalek::SigningKey::from_bytes(&[0; 32]) had previously been
  used there, which made the subject-binding check (which compares
  claims.subject to expected_subject's NodeId-derived id) pass against
  any subject, bypassing the entire signed-ticket subject guarantee.
  The issuer signature was the only thing actually enforced.

- src/session.rs::server_handshake: the four sites that previously did
  `let _ = futures_block_on_send(...)` now do
  `.inspect_err(|e| eprintln!("...")).ok()` so failed handshakes log
  via stderr instead of vanishing. Closes B-002.

- src/relay/listener.rs handle_connection now accepts the shared
  RelayStats and feeds handle_connection's returned ConnectionLog into
  guard.record(&log). Without this the stats snapshot was always 0.
  Closes B-003.

- src/rate_limit.rs doc comment with f64 floating-point caveats
  (multiply-then-divide, subnormal edge cases). Closes B-006.

- tests/c001_subject_validation.rs NEW (218 lines, integration). Two
  tests against a real quinn::Endpoint on loopback:
    * ticket_with_unrelated_subject_is_rejected_with_subject_mismatch —
      expects HandshakeResponse::Err(SubjectMismatch) OR a connection
      close with reason `b"handshake failed"` (the production listener
      closes the conn after handshake error; the close can race ahead
      of the response body).
    * ticket_with_matching_subject_is_accepted — positive control, the
      canonical single-issuer happy path.
  Both register relay_key with TrustStore::add() so the handshake
  reaches the subject check rather than failing on IssuerNotTrusted.

- tests/relay_listener_e2e.rs uses RelayConfig::with_generated_key() in
  place of ::new(...) (signature change propagation).

- 68 tests pass, 0 failures, 0 warnings. Carried-over gaps documented
  in /home/lou/.claude/projects/-home-lou/memory/snappipe-c001-critical-bypass-2026-07-10.md:
    * server_handshake issuer_verifying_key is hardcoded to relay.vk
      (single-issuer model works, multi-issuer needs a resolver fn).
    * listener conn.close() passes error_code 0 while reason says
      `b"handshake failed"` — Quinn treats 0 as graceful, codifying
      a non-zero code is a separate hardening item.

Refs: PR slicing batch for SnapPipe, ultracode 2026-07-10.
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The relay gains signing-key identity, client-accepted Stream 0 handshakes, subject validation, connection statistics recording, improved handshake error logging, integration coverage, and deployment documentation.

Changes

Relay handshake validation

Layer / File(s) Summary
Relay signing-key identity
src/relay/mod.rs, src/rate_limit.rs
RelayConfig stores and exposes a signing key, constructors and tests use the updated API, and token-bucket floating-point behavior is documented.
Listener handshake and statistics
src/relay/listener.rs, tests/relay_listener_e2e.rs
The listener accepts client Stream 0, validates the ticket subject against the relay key, and records completed stream logs in shared statistics.
Handshake error reporting
src/session.rs
Failed handshake error replies are logged while original session errors are preserved, with coverage for send-failure races.
Subject-validation coverage
tests/c001_subject_validation.rs
Integration tests cover rejected unrelated subjects, accepted matching subjects, connection closure, and handshake summaries.
Deployment flow documentation
docs/OPERATIONAL-DEPLOYMENT.md
The deployment guide documents SnapPipe-gated QUIC as Layer 0, including handshake sequencing, fallback tiers, and closure behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant QUICClient
  participant run_listener
  participant server_handshake
  participant RelayStats
  QUICClient->>run_listener: Accept Stream 0
  run_listener->>server_handshake: Validate ticket subject
  server_handshake-->>QUICClient: Return handshake response
  QUICClient->>run_listener: Open application streams
  run_listener->>RelayStats: Record ConnectionLog
Loading

Possibly related PRs

  • LOUST-PRO/SnapPipe#1: Introduced the ticket issuer and subject claims used by listener validation.
  • LOUST-PRO/SnapPipe#2: Established the relay architecture refined here with signing-key identity and subject validation.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: enforcing the relay’s real signing key for inbound ticket validation.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/C-001-relay-subject-validation

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.

@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: 3

Caution

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

⚠️ Outside diff range comments (1)
src/relay/listener.rs (1)

172-178: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Break out of the accept loop on ConnectionError — once the peer disconnects, conn.accept_bi() stops yielding new streams, so continue just spins this spawned task in a tight log/retry loop until cancellation. break here lets the task exit cleanly.

🤖 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 `@src/relay/listener.rs` around lines 172 - 178, The accept loop in the
listener’s bidirectional stream handling spins after the peer disconnects
because all accept_bi() errors use continue. Update the Err branch to break when
the error is a ConnectionError, while preserving the existing logging and retry
behavior for other errors.
🤖 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 `@docs/OPERATIONAL-DEPLOYMENT.md`:
- Around line 173-176: Update the `run_listener` description in the operational
deployment documentation to state that the relay accepts incoming QUIC
connections and then validates the SnapPipe ticket on Stream 0, removing the
claim that peers complete the handshake beforehand.
- Around line 197-203: Update the handshake description in the deployment guide
to explicitly include claims.subject validation against the relay signing key as
a required gate, and add subject mismatch to the listed handshake failure cases
alongside untrusted issuer, replay, and expired tickets.

In `@tests/c001_subject_validation.rs`:
- Around line 78-82: Fix the cargo fmt check failures in
tests/c001_subject_validation.rs by running cargo fmt --all; ensure the
quinn::Endpoint::server calls in the setup blocks, the timeout(...).await match
expression, and the wait_for_close_reason function signature use rustfmt’s
expected line wrapping and indentation.

---

Outside diff comments:
In `@src/relay/listener.rs`:
- Around line 172-178: The accept loop in the listener’s bidirectional stream
handling spins after the peer disconnects because all accept_bi() errors use
continue. Update the Err branch to break when the error is a ConnectionError,
while preserving the existing logging and retry behavior for other errors.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c18949a8-ab98-47f7-9fdd-29b7108e0611

📥 Commits

Reviewing files that changed from the base of the PR and between ad3df11 and 0978a30.

📒 Files selected for processing (7)
  • docs/OPERATIONAL-DEPLOYMENT.md
  • src/rate_limit.rs
  • src/relay/listener.rs
  • src/relay/mod.rs
  • src/session.rs
  • tests/c001_subject_validation.rs
  • tests/relay_listener_e2e.rs

Comment on lines +173 to +176
The SnapPipe relay (`run_listener` in [`src/relay/listener.rs`](../src/relay/listener.rs))
is the **Layer 0** in the 5-tier fallback stack: it runs on the VPS and
accepts incoming QUIC connections from peers that have already completed
a SnapPipe ticket handshake on stream 0.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the documented handshake order.

The relay accepts the QUIC connection first and then performs the Stream 0 ticket handshake; peers have not already completed it before run_listener accepts them. Please describe this as “accepts connections and validates the ticket on Stream 0” to avoid misleading deployment instructions.

🤖 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 `@docs/OPERATIONAL-DEPLOYMENT.md` around lines 173 - 176, Update the
`run_listener` description in the operational deployment documentation to state
that the relay accepts incoming QUIC connections and then validates the SnapPipe
ticket on Stream 0, removing the claim that peers complete the handshake
beforehand.

Comment on lines +197 to +203
1. `ssh-proxy` races Tier 1 (QUIC) → Tier 2 (Hysteria2) → Tier 3 (gost) → Tier 4 (tls-direct) → Tier 5 (direct-ssh).
2. On the VPS side, `run_listener` accepts the QUIC connection.
3. Stream 0 performs `server_handshake` (ticket validation + trust check).
4. If the handshake succeeds, streams 1…N are forwarded to the application.
5. If the handshake fails (untrusted issuer, replay, expired ticket), the
connection is closed — the 5-tier chain degrades gracefully without
leaking sessions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document subject validation as a handshake gate.

The new contract also rejects tickets whose claims.subject does not match the relay signing key, but the documented handshake step only mentions ticket validation and trust checks, and the failure examples omit subject mismatch. Include this case explicitly so the deployment guide reflects the behavior covered by tests/c001_subject_validation.rs and implemented in src/session.rs.

🧰 Tools
🪛 LanguageTool

[grammar] ~197-~197: Ensure spelling is correct
Context: ...1 (QUIC) → Tier 2 (Hysteria2) → Tier 3 (gost) → Tier 4 (tls-direct) → Tier 5 (direct...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🤖 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 `@docs/OPERATIONAL-DEPLOYMENT.md` around lines 197 - 203, Update the handshake
description in the deployment guide to explicitly include claims.subject
validation against the relay signing key as a required gate, and add subject
mismatch to the listed handshake failure cases alongside untrusted issuer,
replay, and expired tickets.

Comment on lines +78 to +82
);
let mut server_cfg = server_cfg;
server_cfg.transport_config(transport);
let server_ep =
quinn::Endpoint::server(server_cfg, dev_bind()).expect("server endpoint");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Fix cargo fmt failures blocking CI.

The pipeline reports cargo fmt --all -- --check failures at multiple locations in this file. Run cargo fmt --all to auto-fix all of them:

  • Lines 81-82: quinn::Endpoint::server(...) call should be collapsed to a single line (fits within line limit).
  • Lines 150-155: timeout(...).await match expression needs reflowed indentation/brace layout.
  • Lines 231-233: wait_for_close_reason function signature should be condensed onto one line.
  • Lines 260-261: Same quinn::Endpoint::server(...) wrapping issue as lines 81-82.

Also applies to: 147-155, 228-233, 257-261

🧰 Tools
🪛 GitHub Actions: CI / 0_test.txt

[error] 78-78: cargo fmt --all -- --check failed (diff indicates formatting changes needed for match/timeout block formatting and indentation).

🪛 GitHub Actions: CI / test

[error] 78-78: cargo fmt --check failed due to formatting of quinn::Endpoint::server(...) call (collapsed to single line).

🤖 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 `@tests/c001_subject_validation.rs` around lines 78 - 82, Fix the cargo fmt
check failures in tests/c001_subject_validation.rs by running cargo fmt --all;
ensure the quinn::Endpoint::server calls in the setup blocks, the
timeout(...).await match expression, and the wait_for_close_reason function
signature use rustfmt’s expected line wrapping and indentation.

Source: Pipeline failures

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.

1 participant