diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index eefd4ad..c834964 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -9,25 +9,137 @@ on: env: CARGO_TERM_COLOR: always +# Third-party actions are pinned to immutable commit SHAs (with the human-readable +# tag in a trailing comment) so a re-tagged release cannot silently change CI. + jobs: - build-and-test: + fmt: + name: Formatting runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Setup stable toolchain + run: | + rustup toolchain install stable --profile minimal --component rustfmt + rustup default stable + - name: Check formatting + run: cargo fmt --all -- --check + lint: + name: Clippy + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup stable toolchain run: | rustup toolchain install stable --profile minimal --component clippy rustup default stable rustup target add wasm32-unknown-unknown - - name: Lint - run: cargo clippy --workspace --all-features -- -D warnings - - name: Workspace tests - run: cargo test --workspace --verbose - - name: Wasm check - run: cargo check -p foctet-http --features workers --target wasm32-unknown-unknown + - name: Lint (all features) + run: cargo clippy --workspace --all-features --locked -- -D warnings + - name: Wasm check (workers) + run: cargo check -p foctet-http --features workers --target wasm32-unknown-unknown --locked + - name: Wasm check (foctet-wasm) + run: cargo check -p foctet-wasm --target wasm32-unknown-unknown --locked + - name: Wasm check (browser WebSocket transport) + run: cargo check -p foctet-transport --no-default-features --features transport-websock --target wasm32-unknown-unknown --locked + - name: Wasm check (browser WebTransport datagram adapter) + run: cargo check -p foctet-transport --no-default-features --features transport-webtrans-browser --target wasm32-unknown-unknown --locked + + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Setup stable toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + - name: Workspace tests (all features, locked) + run: cargo test --workspace --all-features --locked --verbose - name: Feature combinations (foctet-core) run: | - cargo test -p foctet-core --no-default-features - cargo test -p foctet-core --no-default-features --features runtime-futures - cargo test -p foctet-core --no-default-features --features runtime-tokio + cargo test -p foctet-core --no-default-features --locked + cargo test -p foctet-core --no-default-features --features runtime-futures --locked + cargo test -p foctet-core --no-default-features --features runtime-tokio --locked + + wasm-browser-test: + name: WASM browser tests (headless Chrome) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Setup stable toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + rustup target add wasm32-unknown-unknown + - name: Install wasm-pack + run: cargo install wasm-pack --locked + - name: Run in-browser tests (headless Chrome) + # Use the runner's preinstalled chromedriver, which matches the + # preinstalled Chrome; wasm-pack's auto-downloaded driver can mismatch. + run: | + export CHROMEDRIVER="$CHROMEWEBDRIVER/chromedriver" + wasm-pack test --headless --chrome foctet-wasm + + miri: + name: Miri (foctet-core protocol logic) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Setup nightly toolchain with Miri + run: | + rustup toolchain install nightly --profile minimal --component miri + rustup default nightly + - name: Run Miri on the parser / state-machine / crypto-framing modules + # The full suite (handshakes, Ed25519) is impractically slow under Miri; + # this filtered set covers the modules where memory-safety subtleties + # live (replay bitmap shifting, TLV/control/frame parsing, sequence + # allocation, AEAD framing) and finishes in a couple of minutes. + run: cargo miri test -p foctet-core --no-default-features --locked -- replay:: payload:: limits:: control:: sequence:: crypto:: frame:: + + interop-verify: + name: Independent vector verification (Node) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + # Verifies the canonical test vectors with an implementation that shares + # no code with the Rust workspace (@noble crypto + a from-spec decoder), + # catching systematic bugs the Rust-derived artifacts would reproduce. + - name: Verify vectors independently + working-directory: interop + run: | + npm ci + npm test + + msrv: + name: MSRV (1.88) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Setup MSRV toolchain + run: | + rustup toolchain install 1.88.0 --profile minimal + rustup default 1.88.0 + - name: Check workspace builds on MSRV + run: cargo check --workspace --all-features --locked + + security-audit: + name: Advisory scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + # Vulnerability/advisory scan via the official RustSec action (prebuilt + # cargo-audit, no compile-from-source). + - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + cargo-deny: + name: License & source policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: EmbarkStudios/cargo-deny-action@d6d27f1b02f0c07f9db6ffc34e7871bba5ab5816 # v2.0.9 + with: + command: check diff --git a/.gitignore b/.gitignore index 5eddb91..8324dd4 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,9 @@ Cargo.lock # macOS *.DS_Store + +# WASM / npm build artifacts +foctet-wasm/pkg/ +foctet-wasm/pkg-node/ +foctet-wasm/pkg-web/ +node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a8f73d5..e9cd78d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,562 @@ All notable changes to this project are documented in this file. -## [Unreleased] +## [0.3.0] - 2026-07-05 ### Added +- **Centralized protocol limits expanded (P1, §2.4).** `ProtocolLimits` now + also carries `max_plaintext_len` (enforced on every async/sync send path + before encryption), `max_buffered_tx_bytes` (bounds the async framed + transport's outbound queue; exceeding it fails with the new + `CoreError::OutboundBufferLimitExceeded` instead of growing memory + unboundedly — a rejected send consumes no sequence number), and + `handshake_timeout` (the transport builders' `DEFAULT_HANDSHAKE_TIMEOUT` now + aliases `foctet_core::DEFAULT_HANDSHAKE_TIMEOUT`). Control-plane input is + hard-bounded by the new `MAX_CONTROL_MESSAGE_LEN` (rejected in + `ControlMessage::decode` before inspection), and the distinct-inbound-stream + bound via `max_replay_windows` is documented on the struct. + +- **Connection-level handshake rate limiting (P1, §2.4).** + `foctet_transport::HandshakeRateLimiter` — a shareable token bucket + (sustained rate + burst) consulted before any handshake work; fails fast + with the new `CoreError::HandshakeRateLimited`. Integrated convenience: + `TokioTransportBuilder::establish_responder_with_auth_timeout_and_limiter`. + Drop-cancellation semantics of all `establish_*` futures are now documented + (`rate_limit` module docs). + +- **In-session rekey over the WASM session API (P1, §5).** `FoctetSession` + gains `forceRekey()` / `canRekey` / `handleControlMessage()` / + `activeKeyId`: the alternating DH-ratchet rekey now runs over the wasm + message *and* datagram modes (rekey control messages travel over the + reliable channel; the framing endpoint adopts the rotated key and retains + previous generations, so in-flight / reordered old-key frames still open). + `Session::can_rekey()` accessor added in core. Tested natively and in + headless Chrome. + +- **Observability hooks without secrets (P2, §7).** New + `foctet_core::observe` module: a `SessionObserver` trait receives + `SessionEvent`s (`HandshakeCompleted`, `RekeyInitiated`, `RekeyApplied`, + `ControlRejected`) carrying only public metadata — never key bytes or + plaintext (`Session::with_observer` / `set_observer`). Replay-protection + rejections are surfaced as counters (`ReplayProtector::rejections`, + `replay_rejections()` on `FoctetFramed`, `SyncIo`, `MessageEndpoint`, + `DatagramEndpoint`) for replay/flooding monitoring. + +- **Independent (non-Rust) verification of the canonical vectors (P1/P2, + §6/§7).** `interop/verify_vectors.mjs` re-implements the Draft v0 key + schedule, frame AEAD (full XChaCha20-Poly1305 open with header-as-AAD, plus + tamper negative controls), handshake transcript bindings, Ed25519 identity + verification, and the DH-ratchet rekey step on the `@noble` + libraries — zero shared code with the Rust workspace — and checks every + committed vector. Runs in CI (`interop-verify` job), replacing the + header-only `minimal_decoder` as the independent check. + +- **Browser WebTransport datagram adapter (P1, §3.3).** + `foctet_transport::webtrans_browser::BrowserWebTransportDatagrams` + (`transport-webtrans-browser` feature, wasm32) implements + `DatagramTransport` over a `WebTransport.datagrams` duplex handed in from + JS, duck-typed via `js-sys` reflection (avoids web-sys's unstable-APIs cfg; + clamps to the browser's `maxDatagramSize`). Verified in headless Chrome + against in-page WHATWG streams end to end through + `SecureDatagramChannel` (roundtrip both directions + oversize fail-closed); + wasm build gated in CI. + +- **Miri in CI (P2, §6).** New `miri` job runs the parser / state-machine / + crypto-framing test modules of `foctet-core` (replay bitmap shifting, + TLV/control/frame parsing, sequence allocation, AEAD framing) under Miri on + nightly. + +- **Out-of-order rekey negative test (P1, §2.6).** A `Rekey` naming the *next* + expected `old_key_id` (skipping ahead, internally consistent binding) is + rejected, session state is unchanged, and the genuine in-order rekey still + applies (`session.rs::rekey_delivered_ahead_of_order_is_rejected_and_state_is_unchanged`). + +- **Documentation hardening (P1/P2).** SPEC: version stamp + (`foctet-spec/0.3-draft`, decoupled from crate versions), RFC 2119 + conformance language, normative §5.1.1 datagram MTU/path-change/ + fragmentation policy (no fragmentation, fail-closed, ≤1200-byte raw-UDP + guidance), and the handshake outline promoted to normative (binding hash + definitions in place of the "(draft)" markers). `ContextBinding::with_authority` + now documents the required authority normalization steps; axum module docs + give recommended body limits and streaming backpressure tuning; the + WebSocket module documents its mux/backpressure contract; SECURITY.md + finalizes the vulnerability-report channels, response SLA + (ack ≤ 7 d / triage ≤ 14 d / fix or advisory ≤ 90 d), and scope. + +- **Two-process transport examples + real-environment runbook (`tests.md`).** + `quinn_split` and `websock_split` gained a `--role server|client|loopback` + (plus `--addr`, `--tls-cert`/`--tls-key`, and a client `--wrong-identity` flag + for the identity-mismatch negative test), so the QUIC and WebSocket adapters + can be exercised across two processes / two hosts instead of only an in-process + loopback. The axum body-echo client gained `--replay` (re-sends the identical + sealed request to demonstrate the HTTP 409 replay rejection). `tests.md` is a + step-by-step runbook with exact commands, expected output, and negative tests + for every real-environment area; the verified flows (QUIC, WebSocket, axum + replay, WASM browser) are marked as such. + +### Fixed + +- **Silent duplicate delivery from `AsyncSecureChannel::send_data` (P0).** The + async send future re-encrypted and re-sent the same plaintext under the next + sequence number every time the underlying transport flush returned + `Poll::Pending`, so any backend whose flush can suspend (e.g. the multiplexed + WebSocket adapter, which waits for a flush acknowledgement) delivered every + payload two or more times. Because each duplicate carried a fresh valid + sequence number it also passed replay protection. Backends whose flush + completes immediately (in-memory duplex, quinn, muxtls, WebTransport) never + exhibited it, which is why the in-process test suite stayed green. The send + future now latches after enqueueing so the payload is encrypted exactly once. + Caught by the new real-backend conformance suite; regression-tested with a + suspending-flush mock (`secure_channel.rs::send_data_is_not_duplicated_when_flush_suspends`). + +- **WASM session abort on `Instant::now()` (P1, §5).** Creating or rekeying a + `Session` called `std::time::Instant::now()`, which aborts the module on + `wasm32-unknown-unknown` (no monotonic clock). This crashed the WASM + `FoctetSession` handshake at runtime even though native tests passed. The + session now uses an internal monotonic-clock abstraction: native targets keep + `std::time::Instant`; on `wasm32` the *age-based* rekey threshold is disabled + (frame-count and byte-count thresholds still apply). Caught by the new + in-browser harness; verified in Node and a real browser engine. + +### Added + +- **Threat model + operational policies documentation (P2, §7).** New + `docs/THREAT_MODEL.md` (system/adversary model; defenses and residual risks + for MITM, replay, reordering/truncation, rollback/downgrade, endpoint/relay/ + storage compromise, DoS, metadata leakage, key loss, and the WASM boundary; + explicit non-goals) and `docs/POLICIES.md` (wire/profile/crate versioning + and the Draft-v0 compatibility rules, the normative no-negotiation rule for + v0, key lifecycle/rotation/backup guidance per key kind, and + incident-response playbooks). SPEC §0/§3/§5.1 and SECURITY.md refreshed to + match the shipped surface (Durable Object store, rekey-over-datagram, + raw-UDP anti-amplification, WASM session, headless-browser CI) and to link + the new documents. + +- **Headless-browser test suite in CI (P1, §5).** New + `foctet-wasm/tests/browser.rs` runs `wasm-bindgen-test` tests inside a real + headless Chrome: body-envelope roundtrip, context-binding enforcement, the + full authenticated `FoctetSession` handshake with bidirectional messages and + replay rejection, and a datagram-mode roundtrip with wrong-mode rejection. + Run locally with `wasm-pack test --headless --chrome foctet-wasm`; the new + `wasm-browser-test` CI job runs it on every push/PR with the runner's + version-matched Chrome + chromedriver. This closes the "browser-runner + integration test in CI" gap (the browser harness page remains for manual + runs and Rust→JS interop). + +- **Fuzzing in CI with a seeded corpus + time budget (P1, §6).** New + `.github/workflows/fuzz.yml` runs all seven fuzz targets weekly (and on + manual dispatch with an adjustable budget) under a per-target libFuzzer time + budget, uploading crash artifacts on failure. Seed inputs live in + `fuzz/seeds//` — valid frames, envelopes, archives, and control + messages generated by the new `gen_fuzz_seeds` example with the same fixed + keys the targets hard-code, so AEAD-open/key-unwrap paths are exercised from + the first execution. + +- **Real-backend byte-stream conformance tests (P1, §3.1).** The shared + conformance suite (`foctet-transport/tests/conformance.rs`) now also runs over + a real loopback connection for every advertised byte-stream backend — QUIC + bidirectional streams (quinn), WebTransport bidirectional streams, muxtls, and + multiplexed WebSocket (websock-mux) — each brought up with a self-signed + localhost certificate and the native Foctet handshake. This closes the "run + the shared suite against every advertised byte-stream backend" gap and is what + exposed the duplicate-send bug above. + +- **In-browser WASM runtime harness (P1, §5).** + `foctet-wasm/examples/browser/index.html` exercises the SDK in a real browser + engine: body-envelope roundtrip, context binding, Rust→JS wire compatibility + (the same `interop_vector.json` fixture as the Node test), and a full in-page + `FoctetSession` authenticated handshake + message exchange. Serve via + `npm run browser` (builds `pkg-web/` and starts a static server). Documented in + `tests.md` as the manual browser real-environment test pending a headless CI + runner. + +### Deprecated + +- **Stateless full-HTTP-request seal/open APIs (P0, §1.3).** The body-only + helpers that protect a whole HTTP *request* without replay defense or + HTTP-context binding are now `#[deprecated]` (since 0.3.0) in favor of the + context-bound path. A captured request sealed with these is replayable by + design. Affected: `HttpSealer::seal_request`, `HttpOpener::open_request`, + `raw::{seal,open}_http_request[_with_limits]`, `AxumOpener::open_request` and + `open_axum_request_body[_with_limits]`, and `WorkersOpener::open_request` with + `open_worker_request[_with_limits]`. Migrate to + `seal_request_with_context` / `open_request_with_context` (or the Axum/Workers + `*_with_context` adapters) backed by a `ReplayStore`. The lower-level + `seal_body` / `open_body` primitives are unchanged for callers that supply + their own context and anti-replay. + +### Added + +- **More fuzz targets + a transport support matrix (P1, §6/§3.1).** New fuzz + targets cover the untrusted-input parsers beyond `frame`/`archive`: + `control_message`, `handshake` (the state machine fed arbitrary control + messages), `body_envelope`, `stream_body` (streaming header + incremental + decoder), and `datagram_message` (datagram/message frame open). The README gains + a **Transport Support Matrix** documenting each adapter's shape, API, feature + flag, native/browser availability, and what verifies it. +- **Unified `SecureChannel` shape trait + conformance suite (P1, §3.1/§3.2).** + The three transport shapes now share one application contract: the new + `foctet_transport::SecureChannel` trait (`send_payload`/`recv_payload`) is + implemented by the byte-stream channels (`TokioTransportChannel`, + `FuturesTransportChannel`), `SecureMessageChannel`, and + `SecureDatagramChannel`, so generic code runs over any shape. A + `ByteStreamTransport` marker names the byte-stream shape alongside + `MessageTransport` / `DatagramTransport`. A shared conformance suite + (`tests/conformance.rs`) runs the same checks (bidirectional round trip, + ordering, larger payload) against all three shapes, keeping them behaviourally + consistent. +- **Turn-key streaming-body framework wiring (P1, §4).** `foctet_core::StreamFrameDecoder` + (+ `StreamItem`) reassembles a streaming body's self-delimiting frames from + arbitrarily split byte chunks, so the stream works over any byte transport. On + top of it, `foctet_http::HttpRequestStreamReader` is a framework-agnostic, + push-based reader that validates the protected context (freshness + single-use + replay) when the header arrives and yields decrypted plaintext chunks, with + `finish()` rejecting a truncated/cancelled upload (new `HttpError::StreamIncomplete`). + `foctet_http::axum::open_request_stream` drives it directly from an axum request + body (a callback per plaintext chunk, no whole-body buffering); Cloudflare + Workers use the same reader over a `ReadableStream`. +- **Canonical DH-ratchet rekey test vector (P1, §2.3).** `test-vectors/rekey-v0.json` + captures one deterministic ratchet step (`derive_ratchet_root` then + `dh_ratchet_step`), generated by `gen_vectors` and regression-checked by + `test_vectors::rekey_ratchet_vector_matches`, so the in-session rekey key + schedule (HKDF labels and wiring) cannot change without an explicit, reviewed + vector update — a fixed compatibility reference for future changes. +- **Browser (Rust/wasm) WebSocket message transport (P1, §3.4).** + `WebsockMessageTransport` is now generic over the `websock` crate's + cross-platform `WebSocketConnection` trait, so the same adapter drives a + `SecureMessageChannel` over both the native (`websock-tungstenite`) connection + and the **browser** (`websock-wasm`) `WebSocket` — a Rust/wasm front-end needs + no JavaScript glue. The `transport-websock` feature was split into the + cross-platform `transport-websock` (raw message transport, wasm-compatible) and + the native-only `transport-websock-mux` (multiplexed byte-stream helpers, Tokio + runtime); `foctet-transport` now compiles for `wasm32-unknown-unknown` under + `transport-websock`, and CI gates that build. **Breaking:** the multiplexed + `*_secure_channel*` helpers now require the `transport-websock-mux` feature. +- **Rekey over datagrams (P1, §3.3).** `SecureDatagramChannel::rekey_from_session` + (and the message channel's equivalent) adopts a session's rotated DH-ratchet + key after the rekey completes over a reliable control channel — the QUIC-style + separation where key updates ride a reliable stream and data rides datagrams. + Because each key has a `key_id` and the datagram endpoint retains previous + keys, an old-key datagram that arrives reordered or delayed *after* a rekey + still decrypts (verified by a cross-rekey reordering test). The datagram module + documents the flow. +- **Streaming (chunked) HTTP bodies (P1, §4).** New `foctet_core::body_stream` + (`StreamSealer` / `StreamOpener`) seals a body as an ordered sequence of + per-chunk-authenticated frames instead of one buffered envelope: one + ECIES-wrapped content key per stream, a unique `prefix||index` nonce per chunk, + and AAD binding the stream header, chunk index, a flags byte, and the caller + context. Exactly one authenticated `FINAL` chunk provides truncation/extension + resistance (`is_finished()` must be `true` to accept the stream), sequential + indices reject reorder/gap/duplicate, and an aborted stream simply never + finalizes (safe cancellation). `foctet_http::stream::{HttpStreamSealer, + HttpStreamOpener}` wraps it with the HTTP protected context plus freshness and + single-use replay enforcement (the message id is consumed once per stream). +- **Channel binding in the WASM `AuthConfig` (P1, §2.1/§5).** `foctet-wasm`'s + `AuthConfig` gains `boundToChannel(channelBinding)` (no Foctet identity; MITM + resistance from an authenticated outer channel) and `withChannelBinding(..)` + (additive to any config), so browser/JS `FoctetSession`s get the same + transcript channel binding as native sessions. +- **Opt-in anti-amplification for the raw-UDP datagram adapter (P1, §3.3).** + `UdpDatagramTransport::with_anti_amplification(factor)` (with + `DEFAULT_AMPLIFICATION_FACTOR` = 3, matching QUIC) refuses to send once the + cumulative bytes sent would exceed `factor ×` the bytes received from an + unvalidated peer (returning `io::ErrorKind::WouldBlock`), so a spoofed source + address cannot turn the endpoint into a reflector/amplifier. + `mark_peer_validated()` lifts the limit once the peer proves it can receive + (e.g. when the Foctet handshake over the path completes). Off by default, so + existing behavior is unchanged; counters are atomic and shared across clones. +- **Pluggable handshake signer for hardware-backed identities (P1, §2.5).** New + `HandshakeSigner` trait (`public_key()` + `sign()`, `Send + Sync`) is the seam + for non-extractable long-term identity keys — implement it for an HSM, cloud + KMS, TPM, or OS keystore so the Ed25519 private key never enters process + memory. `IdentityKeyPair` implements it for the in-process case; + `SessionAuthConfig::with_local_signer(..)` accepts any signer, and + `with_local_identity(..)` is unchanged. **Breaking:** `SessionAuthConfig` now + stores `Option>` and no longer derives `Eq`/`PartialEq` + (its `Debug` shows only the signer's public key); `HandshakeAuth::sign` takes + `&dyn HandshakeSigner` (an `&IdentityKeyPair` argument still coerces); + `SessionAuthConfig::local_identity()` is replaced by `local_signer()` / + `local_identity_public_key()`. +- **Typed authenticated-peer result (P1, §2.1).** `Session::authenticated_peer()` + returns an `Option` naming the peer's verified Ed25519 + identity public key — the typed counterpart to the `peer_authenticated()` + boolean. `AuthenticatedPeer` exposes `identity_public_key()` and a + constant-time `matches(&PeerIdentity)`. A handshake authenticated only by a + `ChannelBinding` (no Foctet identity) returns `None`, since no peer identity + was proven. +- **Channel binding to an authenticated outer channel (P1, §2.1).** New + `ChannelBinding` + `SessionAuthConfig::bound_to_channel(..)` / + `with_channel_binding(..)` (`foctet-core`). The binding value (e.g. a TLS + exporter per RFC 5705) is folded into the handshake transcript hash on both + sides, so a man-in-the-middle that terminates the outer channel and relays the + Foctet handshake computes a different transcript and fails closed — letting an + authenticated outer channel substitute for a Foctet Ed25519 identity. + `bound_to_channel` is the typed, production-named alternative to + `unauthenticated_for_testing`. The transcript is byte-identical to before when + no binding is set (an extra length-prefixed, domain-separated hash input only; + no change to key derivation, the AEAD, or signatures), and the binding flows + through the transport builders automatically. +- **Framed Foctet session over WebAssembly (P1, §5).** `foctet-wasm` now exposes + `FoctetSession` — the full authenticated handshake plus ordered, + replay-protected per-message `sealMessage`/`openMessage` — not just the + one-shot body envelope. WebAssembly performs the cryptography and handshake + state machine while the JS side owns the transport (a browser `WebSocket`, + `WebTransport` stream, or datagram channel), exchanging `Uint8Array` blobs. + Adds `IdentityKeyPair` (Ed25519), `AuthConfig` (pinned-peer `authenticated` or + explicit `unauthenticatedForTesting`), and `DecodedMessage`. The handshake + fails closed against an unexpected peer. Inner logic is native-tested; the + `wasm32-unknown-unknown` build is verified. In-session rekey is not yet carried + over this message API. + - A session can run in **message mode** (`newInitiator`/`newResponder`, + `sealMessage`/`openMessage` — reliable WebSocket / WebTransport stream) or + **datagram mode** (`newDatagramInitiator`/`newDatagramResponder`, + `sealDatagram`/`openDatagram` — MTU-bounded WebTransport datagrams via + `DatagramEndpoint`, configurable `maxDatagramSize`). A session is locked to + one mode so the two framings can never share a `(key_id, stream_id)` nonce + space. +- **Raw-WebSocket message transport (P1, §3.4).** `WebsockMessageTransport` + (`foctet-transport`, `transport-websock`) implements `MessageTransport` over a + `websock` crate connection, carrying exactly one Foctet frame per **binary** + WebSocket message — the first concrete `MessageTransport` backend beyond the + in-memory test double. Pair it with `SecureMessageChannel`. Verified by a real + plain-WebSocket loopback roundtrip test. +- **Runtime-agnostic handshake timeout (P1, §2.4).** + `FuturesTransportBuilder::establish_initiator_with_auth_and_timeout` / + `establish_responder_with_auth_and_timeout` bound the native handshake by a + caller-supplied timer *future* (e.g. `tokio::time::sleep`, an async-io timer, a + browser timer), racing it against the handshake with a `std`-only `poll_fn` and + failing with `CoreError::HandshakeTimeout`. This gives the futures path parity + with the existing Tokio `Duration`-based timeout. + ### Changed + +- **Rekey is now a forward-secret DH ratchet (P1, §2.3).** + In-session rekey no longer re-derives keys from the one handshake shared secret + (which gave no post-compromise security). Each rekey performs a Diffie-Hellman + ratchet step: the rekeying side generates a fresh ephemeral X25519 key, mixes + `X25519(new_ephemeral, peer_ratchet_public)` into a root-key chain + (`derive_ratchet_root` / `dh_ratchet_step`), and the handshake shared secret is + discarded after seeding the root. Rekeys **alternate** between the peers + (enforced by a turn flag: out-of-turn `force_rekey` returns the new + `CoreError::RekeyNotPermitted`; threshold-driven rekey defers instead of + failing), so the root chain cannot fork and both peers' keys rotate — giving + forward secrecy and, across an alternating rekey, post-compromise security in + both directions. **Breaking:** the `Rekey` control message carries + `ratchet_public` instead of `rekey_salt`; `derive_rekey_traffic_keys` is removed + in favor of `derive_ratchet_root` + `dh_ratchet_step`. +- **`TrafficKeys` is now non-`Clone`; share keys via `KeyHandle` (P1, §2.5).** + Traffic-key secret bytes now exist in exactly one place and are zeroized when + it drops, instead of being copied into every owner. Shared ownership goes + through the new `KeyHandle` (`Arc`): it derefs to `TrafficKeys`, + clones cheaply (refcount only), and delegates `Debug`/`Eq` to the redacted, + constant-time `TrafficKeys` impls. **Breaking:** `Session::active_keys()` / + `active_and_previous_keys()` / `key_ring()` now return `KeyHandle`(s), and the + endpoint constructors (`FoctetFramed::new` / `SyncIo::new` / `from_tokio` / + `from_futures`, `MessageEndpoint::{new,with_config}`, + `DatagramEndpoint::{new,with_config}`) and `install_active_keys` take a + `KeyHandle`. Wrap a freshly derived key set with `KeyHandle::new(..)` (or + `.into()`). The transport `SecureMessageChannel`/`SecureDatagramChannel` and + quinn adapters' `install_active_keys` take `KeyHandle` to match. +- **CI release-hardening gates (P1, §6).** The Rust workflow now enforces + `cargo fmt --all -- --check`, runs `cargo test --workspace --all-features + --locked`, adds an MSRV job (`rust-version = "1.88"`, declared on every + published crate) and a `cargo-deny` license/source/advisory job (`deny.toml`), + and pins all third-party actions to immutable commit SHAs. Every cargo + invocation now passes `--locked` for reproducible builds. +- **Documentation accuracy (P1, §1.3).** `README.md` and `SECURITY.md` were + corrected to match the shipped surface — datagram (QUIC + raw-UDP), the + `foctet-wasm` body-envelope SDK, and the HTTP protected-context + durable + (Redis) replay layer are now described as implemented, with the remaining gaps + (browser-WebTransport datagram, Cloudflare KV/Durable Object store, npm + publish) stated explicitly. + +### Security + +- **Patch dependency advisories (§6).** Bumped `quinn-proto` → 0.11.15 + (RUSTSEC-2026-0037, endpoint DoS), `rkyv` → 0.8.16 (RUSTSEC-2026-0122, + use-after-free in `*::clear`), and `rustls-webpki` → 0.103.13 + (RUSTSEC-2026-0049 / -0098 / -0099 / -0104, CRL/name-constraint flaws). The + dev-only, unmaintained `rustls-pemfile` advisory (RUSTSEC-2025-0134, no safe + upgrade) is explicitly tracked in `deny.toml`. + +- **Centralize fail-closed outbound sequence allocation (P0 follow-up).** The + blocking stream, async framed stream, datagram, and discrete-message paths now + share one internal sequence allocator, so `SequenceExhausted` behavior cannot + drift between transport shapes. Session-state restoration is explicitly + unsupported until a design can preserve every outbound counter atomically. + +- **Fix nonce reuse on synchronous sequence exhaustion (P0).** `SyncIo` now fails + closed with `SequenceExhausted` instead of wrapping the sequence counter, so it + can no longer reuse an XChaCha20-Poly1305 `(key_id, stream_id, seq)` nonce. This + matches the async `FoctetFramed` policy. +- **Commit replay-window state only after AEAD authentication (P1).** All receive + paths (`SyncIo::recv`, `SyncIo::recv_application_with_session`, and + `FoctetFramed`'s decoder) now authenticate the ciphertext before recording the + sequence number, preventing a forged high-sequence frame from desynchronizing or + DoS-ing the receiver. +- **Authenticated-by-default native handshake (P1).** A default `SessionAuthConfig` + now fails closed; an unauthenticated handshake requires an explicit + `SessionAuthConfig::unauthenticated_for_testing()` / + `allow_unauthenticated(true)` opt-in. +- **Bounded replay-window map (P2).** `ReplayProtector` caps the number of distinct + `(key_id, stream_id)` windows (`DEFAULT_MAX_REPLAY_WINDOWS`), returning + `ReplayCapacityExceeded` rather than growing unbounded. +- **Fail closed on outbound plaintext length overflow (P1).** `encrypt_frame` + now rejects plaintext whose ciphertext length (plaintext + AEAD tag) would + overflow the frame header's `u32 ct_len` field, instead of silently + truncating it. Shared by every sync (`SyncIo`) and async (`FoctetFramed`) + send path via a new internal `checked_ciphertext_len` helper. +- **Handshake read timeout for the Tokio transport path (P1).** + `TokioTransportBuilder::establish_initiator_with_timeout` / + `establish_responder_with_timeout` (plus `_with_auth_and_timeout` and + `_with_default_timeout` convenience variants, `DEFAULT_HANDSHAKE_TIMEOUT`) + bound how long the native handshake can block on a stalled or hostile peer, + failing with the new `CoreError::HandshakeTimeout` instead of hanging + forever. The `quinn`/`websock`/`webtrans`/`muxtls` adapters all build on + `TokioTransportBuilder`, so they can opt in by switching call sites. +- **`cargo-audit` advisory scan in CI; fixed the vulnerabilities it found.** + Bumped `quinn-proto` (0.11.13 → 0.11.14, fixes a high-severity Quinn DoS, + RUSTSEC-2026-0037), `rustls-webpki` (0.103.9 → 0.103.13, fixes several + certificate-validation advisories), `rand` (0.9.2 → 0.9.4), and `rkyv` + (0.8.15 → 0.8.16) in `Cargo.lock` — all compatible patch/minor bumps, no API + changes. Added a `security-audit` CI job (`.github/workflows/rust.yml`, + via `rustsec/audit-check`) that fails the build on any future vulnerability + finding; it scans advisories only, not licenses (that's `cargo-deny`, + still open below). +- **Negative protocol test coverage: control/data flag confusion and rekey + collisions (P1).** New regression tests proving the `IS_CONTROL` header + flag (not payload shape) is authoritative for control-vs-data dispatch, and + that the rekey state machine rejects a stale `old_key_id`, a replayed + `Rekey` message, a forged transcript binding, and a handshake message + replayed onto an already-active session. +- **Optional selected-header binding for HTTP protected contexts (request + side).** `ContextBinding::with_bound_headers` authenticates the presence + and exact value bytes of named headers (e.g. a tenant ID) into the same + AEAD associated data as method/path/query, so an on-path party swapping a + bound header — without touching the ciphertext, route, or carrier headers — + fails authentication instead of silently reattributing the request. Purely + additive: empty by default, byte-identical associated data to before when + unused. Response-side header binding is not covered yet. +- **Ready-made Axum extractor for protected, replay-checked requests.** + `ProtectedHttpState` trait + `ProtectedRequest` (`foctet-http/src/axum.rs`) + let a handler take a decrypted, context-authenticated, single-use-checked + `http::Request>` directly as a parameter via Axum's `FromRequest`, + instead of calling the opener and replay store manually in every handler. + `AxumError` now implements `IntoResponse`, mapping to a status code without + ever echoing the source error's detail in the response body. +- **Raw-UDP datagram adapter.** `UdpDatagramTransport` + (`foctet-transport/src/udp.rs`, `runtime-tokio` feature) implements the + generic `DatagramTransport` trait over a connected `tokio::net::UdpSocket`, + so `SecureDatagramChannel` works over plain UDP the same way it already + does over QUIC datagrams. Verified with a real-socket roundtrip test. + +### Added + +- **Message transport shape: secure discrete-message channels (P1, §3.2).** + Completes the transport-shape matrix (byte stream + datagram + message). New + `foctet_core::message::MessageEndpoint` (`MessageConfig`, `DecodedMessage`, + `DEFAULT_MAX_MESSAGE_SIZE` = 16 MiB) seals exactly one Foctet frame per + *reliable, ordered, message-bounded* unit — the right shape for **raw + WebSocket messages**, where each message is a discrete frame rather than a + byte in an opaque stream. Unlike the datagram endpoint it is not MTU-bounded; + unlike the byte-stream framing it preserves message boundaries with no length + prefix or reassembly. Replay state is committed only after AEAD + authentication, and per-`(key_id, stream_id)` sequence allocation fails closed + on exhaustion. `foctet_transport` adds the generic `MessageTransport` trait + (`!Send`-friendly for browser bindings) and `SecureMessageChannel` that + layers `MessageEndpoint` over any message backend, mirroring + `DatagramTransport` / `SecureDatagramChannel`. Covered by core codec tests and + an in-memory secure-channel roundtrip/replay/key-rotation test. +- **Centralized `ProtocolLimits` for the stream transports (P1, §2.4).** A new + `foctet_core::limits::ProtocolLimits` gathers the DoS-relevant stream bounds + (max inbound ciphertext length, retained previous keys, replay-window size, + and the distinct-replay-window cap) that `FoctetFramed` and `SyncIo` had + hardcoded as scattered magic numbers. Both expose `with_limits(...)` and a + `limits()` accessor; the existing `with_max_ciphertext_len` / + `with_max_retained_keys` setters now route through it. This also makes the + replay-window size and window cap configurable on the stream paths for the + first time (previously fixed at the defaults). Defaults are unchanged + (`DEFAULT_MAX_CIPHERTEXT_LEN` = 16 MiB, `DEFAULT_MAX_RETAINED_KEYS` = 2, + `DEFAULT_REPLAY_WINDOW`, `DEFAULT_MAX_REPLAY_WINDOWS`), so behavior is + identical unless explicitly overridden. Datagram (`DatagramConfig`) and body + (`BodyEnvelopeLimits`) keep their shape-specific limit types. +- **Generic datagram transport abstraction.** `foctet_transport::datagram`: + a backend-agnostic `DatagramTransport` trait and `SecureDatagramChannel` + that layers the core datagram endpoint over any datagram backend. + `quinn::Connection` implements `DatagramTransport` (verified over a real + connection), so the same secure-datagram code path works for future + WebTransport/UDP backends. + +### Security + +- **Secret-material hygiene for key types (P1, §2.5).** Long-term and traffic + secrets no longer leak through `Debug` or non-constant-time comparison: + - `TrafficKeys`, `EphemeralKeyPair`, `IdentityKeyPair` + (`foctet-core`) and `HttpOpenOptions` (`foctet-http`) now have hand-written + `Debug` impls that render secret bytes as `` instead of the raw + array, so a stray `{:?}` in application logs can no longer disclose a + traffic key, ephemeral scalar, identity secret, or recipient secret key. + - `TrafficKeys` and `IdentityKeyPair` equality is now **constant-time** over + the secret bytes (via `subtle::ConstantTimeEq`), replacing the derived + `PartialEq`/`Eq` that short-circuited on the first differing byte. + - `HttpOpenOptions` stores the recipient secret in a `Zeroizing` wrapper so it + is wiped on drop, and no longer derives `PartialEq`/`Eq`. +- **Durable HTTP replay store interface.** `foctet-http` gains an + `AsyncReplayStore` trait (intentionally `!Send`-friendly for Cloudflare + Workers) with a blanket impl over the sync `ReplayStore`, async opener paths + (`HttpOpener::open_request_with_async_store`, `AxumOpener` variant), and a + Redis-backed `RedisReplayStore` (`redis` feature) using an atomic `SET NX PX` + for multi-instance deployments. +- **WASM / TypeScript SDK (`foctet-wasm`).** New crate exposing a small, + versioned `wasm-bindgen` API over the body envelope (`sealBody` / `openBody`, + `sealBodyWithContext` / `openBodyWithContext`, `KeyPair`) with generated + TypeScript declarations and `Uint8Array` values. Builds for Node, browser, and + bundler targets via `wasm-pack`. A Node interop test opens Rust-produced + envelopes (`tests/interop_vector.json`), proving cross-language wire + compatibility. +- **Datagram API.** `foctet_core::datagram` (`DatagramEndpoint`, `DatagramConfig`, + `DecodedDatagram`): one complete bounded frame per datagram, configurable max + datagram size, per-`(key_id, stream_id)` fail-closed sequence allocation, + authenticate-before-replay, and loss/reorder tolerance. A QUIC datagram adapter + `foctet_transport::quinn::QuinnDatagramChannel` ships with a real-connection + roundtrip test. +- **`foctet-http`: HTTP protected-context + anti-replay.** New versioned context + schema (`ProtectedContext`, `ContextCarrier`, `ContextBinding`, + `foctet-http-ctx-v1`) that binds method/path/query/status/message-id/timestamp/ + expiry into the body-envelope AEAD, plus a `ReplayStore` trait with atomic + check-and-insert and an `InMemoryReplayStore`. New high-level APIs + `HttpSealer::seal_request_with_context` / `HttpOpener::open_request_with_context` + (and response variants), and an Axum adapter + (`AxumOpener::open_request_with_context`, `AxumSealer::seal_response_with_context`). + A captured envelope can no longer be replayed or moved onto a different route. +- **Cloudflare Workers context-binding parity.** `WorkersOpener::open_request_with_context` + / `open_request_with_async_store` and `WorkersSealer::seal_response_with_context` + bring the `worker::Request` / `worker::Response` adapters up to the same + protected-context + anti-replay coverage as the Axum adapter (method, path, + query, and headers are read from the real Worker request before the body is + authenticated). +- `seal_body_with_context` / `open_body_with_context` / + `open_body_for_key_id_with_context`: bind an application-supplied context into the + body-envelope AEAD as associated data (foundation for HTTP context binding / + anti-replay). Empty context is byte-identical to the previous output. +- `SessionAuthConfig::unauthenticated_for_testing` / `allow_unauthenticated` / + `allows_unauthenticated`. +- `ReplayProtector::with_max_windows` / `tracked_windows`; + `DEFAULT_MAX_REPLAY_WINDOWS`; `CoreError::ReplayCapacityExceeded`. +- `SECURITY.md` documenting the security posture, threat model, reporting process, + supported versions, and known limitations. + +### Changed + +- README and SPEC corrected to reflect the implemented surface: stream-oriented + only; UDP/datagram and TypeScript/WASM SDK are not implemented; in-session rekey + is symmetric traffic-key rotation, not post-compromise security. +- **Breaking:** raw secret-key extraction is now explicitly named and zeroizing. + `IdentityKeyPair::secret_key_bytes() -> [u8; 32]` is renamed to + `expose_secret_key_bytes() -> Zeroizing<[u8; 32]>`, and + `HttpOpenOptions::recipient_secret_key() -> [u8; 32]` to + `expose_recipient_secret_key() -> Zeroizing<[u8; 32]>`. The `expose_` prefix + makes secret extraction greppable, and the `Zeroizing` return type wipes the + caller's copy on drop. Callers that need the raw array can dereference + (`*opts.expose_recipient_secret_key()`). diff --git a/Cargo.toml b/Cargo.toml index 1f37010..e2016d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,12 +6,14 @@ members = [ "foctet-http", "foctet-archive", "foctet-transport", + "foctet-wasm", "fuzz" ] [workspace.package] -version = "0.2.0" +version = "0.3.0" edition = "2024" +rust-version = "1.88" authors = ["shellrow "] license = "MIT" repository = "https://github.com/foctal/foctet" @@ -26,10 +28,11 @@ hkdf = "0.12" rand_core = "0.6" rkyv = { version = "0.8", features = ["bytecheck"] } sha2 = "0.10" +subtle = "2.6" thiserror = "2.0" x25519-dalek = { version = "2.0", features = ["static_secrets", "getrandom", "zeroize"] } zeroize = "1.8" -foctet-core = { path = "foctet-core", version = "0.2.0" } -foctet-http = { path = "foctet-http", version = "0.2.0" } -foctet-archive = { path = "foctet-archive", version = "0.2.0" } -foctet-transport = { path = "foctet-transport", version = "0.2.0" } +foctet-core = { path = "foctet-core", version = "0.3.0" } +foctet-http = { path = "foctet-http", version = "0.3.0" } +foctet-archive = { path = "foctet-archive", version = "0.3.0" } +foctet-transport = { path = "foctet-transport", version = "0.3.0" } diff --git a/README.md b/README.md index 56b096a..65cb9f8 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,18 @@ Transport-agnostic end-to-end encryption layer for secure data transfer. +> **Status: experimental (Draft v0) — not production-ready.** Foctet provides +> authenticated encrypted framing, HTTP body envelopes, encrypted archives, and +> a WASM/TypeScript SDK. The wire format is still unstable. See +> [`SECURITY.md`](SECURITY.md) before deploying. + ## Crates - `foctet-core`: Framing, crypto, handshake/rekey state, replay protection. - `foctet-http`: Thin HTTP adapter for `application/foctet` body envelopes. - `foctet-archive`: Encrypted single-file and split archives with recipient key wrapping. - `foctet-transport`: Layered transport integration helpers. +- `foctet-wasm`: WebAssembly / TypeScript bindings for the body envelope. - `foctet`: Top-level re-export crate. ## Stability @@ -26,6 +32,12 @@ Transport-agnostic end-to-end encryption layer for secure data transfer. See [`docs/recommended-deployments.md`](docs/recommended-deployments.md) for the recommended production composition patterns across transport E2EE, HTTP body envelopes, and archive/file delivery. +Security documentation: + +- [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) — what Foctet defends against, residual risks, and explicit non-goals. +- [`docs/POLICIES.md`](docs/POLICIES.md) — versioning/compatibility/deprecation, key lifecycle and rotation, incident response. +- [`SECURITY.md`](SECURITY.md) — current security posture, known limitations, vulnerability reporting. + ## Examples - Repository examples: [examples][examples-url] @@ -33,10 +45,35 @@ See [`docs/recommended-deployments.md`](docs/recommended-deployments.md) for the ## What Foctet Covers -- Transport-agnostic encrypted framing for byte streams and split send/recv transports. -- Encrypted body envelopes for HTTP integrations such as `axum` and Cloudflare Workers. -- Encrypted archive formats for files and split-file delivery. -- Transport helpers for `quinn`, `webtrans`, `websock`, and `muxtls`. +Implemented and tested today: + +- **Byte-stream channels** over generic split I/O plus transport helpers for + QUIC, WebTransport, WebSocket mux, and muxTLS. +- **Datagram channels** for QUIC datagrams, raw UDP, and browser WebTransport. +- **Message channels** for reliable, ordered message transports such as raw + WebSocket. +- **HTTP body envelopes** with protected-context request binding and replay + defense. +- **Encrypted archives** for single-file and split-file delivery. +- **WASM/TypeScript bindings** for body envelopes and framed sessions. + +Current gaps: + +- npm publishing for the WASM SDK +- framework-specific streaming response helpers +- final v1 wire/API compatibility commitment + +## Transport Support + +Foctet exposes three transport shapes: + +- **byte stream**: `TokioTransportBuilder` / `FuturesTransportBuilder`, plus + feature-gated adapters such as `quinn`, `webtrans`, `websock`, and `muxtls` +- **message**: `MessageTransport` + `SecureMessageChannel` +- **datagram**: `DatagramTransport` + `SecureDatagramChannel` + +The browser-facing surface is `foctet-wasm` (`FoctetSession`) and the +wasm32-only `BrowserWebTransportDatagrams` adapter. ## Quick Start @@ -59,35 +96,35 @@ let channel = builder .await?; ``` -If you already derived or exchanged Foctet session state out of band, you can still inject an active `Session` directly. - -For encrypted files and reproducible test fixtures, `foctet-archive` exposes archive builders for both normal and deterministic generation: - -```rust,ignore -use foctet_archive::{ - ArchiveBuildSecrets, ArchiveOptions, create_archive_from_bytes_with_secrets, -}; - -let secrets = ArchiveBuildSecrets { - archive_id: [0x91; 16], - file_id: [0x92; 16], - dek: [0x93; 32], - wrap_ephemeral_secret_keys: vec![[0x94; 32]], -}; - -let (archive_bytes, meta) = create_archive_from_bytes_with_secrets( - payload, - &[recipient_public_key], - ArchiveOptions::default(), - &secrets, -)?; -``` +If you already derived or exchanged Foctet session state out of band, you can +still inject an active `Session` directly. -Use the `*_with_secrets` archive APIs only for reproducible vectors and deterministic tests. Production archive creation should use the default random builders. +For archives, prefer the default randomized builders in `foctet-archive`. +Reserve the deterministic `*_with_secrets` APIs for reproducible vectors and +tests. ## Security Notes -- Foctet fails closed on sequence/key identifier exhaustion and rejects invalid all-zero X25519 shared secrets. -- Native handshake authentication supports optional Ed25519 transcript signatures with pinned peer identity verification. -- For production deployments, prefer authenticated handshakes with pinned peer keys, or bind Foctet to an already-authenticated outer channel. -- Deterministic archive secrets intentionally disable build-time randomness. Reusing them across real payloads leaks equality and key-reuse signals, so reserve them for fixtures and interoperability tests. +See [`SECURITY.md`](SECURITY.md) for the full posture, threat model, and +reporting process. + +- Both the async (`FoctetFramed`) and synchronous (`SyncIo`) paths **fail + closed on sequence/key-id exhaustion**. +- **Do not persist and restore live session state.** No persistence format exists + yet; after a restart, establish a fresh session rather than reusing traffic keys + with reset or uncertain outbound sequence state. +- **Replay state is committed only after AEAD authentication**, so a forged frame + cannot desynchronize or DoS the receiver; the replay-window map is bounded. +- The native handshake is **authenticated by default**: an unauthenticated handshake + requires an explicit `SessionAuthConfig::unauthenticated_for_testing()` opt-in, + intended only for tests or for use inside an already-authenticated outer channel. + Prefer authenticated handshakes with pinned peer keys for production. +- For HTTP, prefer the **protected-context APIs** (`HttpSealer::seal_request_with_context` + / `HttpOpener::open_request_with_context`, plus the `axum` / Workers adapters): + they bind request metadata (method/path/query/message-id/timestamp/expiry) into + the AEAD and enforce single use through a `ReplayStore`. The low-level + stateless request helpers remain replayable by design and are not suitable + for production HTTP. +- Deterministic archive secrets intentionally disable build-time randomness. Reusing + them across real payloads leaks equality and key-reuse signals, so reserve them for + fixtures and interoperability tests. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..892de1e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,180 @@ +# Security Policy + +## Status: experimental — not production-ready + +Foctet is an **experimental Draft v0** implementation of authenticated encrypted +framing, a one-shot `application/foctet` body envelope, and encrypted archive +formats. The project is under active security and interoperability development. + +It is **not yet a stable, general-purpose production E2EE SDK**. Several +surfaces (datagram/UDP, WASM body envelope, HTTP protected context) are +implemented but remain partial or evolving — see "Known +limitations" below. Do not describe it as a complete E2EE library for arbitrary +TCP/UDP/QUIC/WebSocket/WebTransport payloads until the gates in +"Roadmap to a production claim" below are met. + +The full threat model (per-threat defenses, residual risks, metadata leakage, +key-loss policy) is documented in `docs/THREAT_MODEL.md`; versioning, +key-lifecycle, and incident-response policies are in `docs/POLICIES.md`. + +## Reporting a vulnerability + +Please report suspected vulnerabilities privately. Do **not** open a public issue +or pull request for security problems. + +**Contact (in order of preference):** + +1. GitHub's **private vulnerability reporting** ("Security" → "Report a + vulnerability") on this repository — the canonical channel; it keeps the + report, discussion, and advisory in one place. +2. If you cannot use GitHub, email the maintainer at the address listed on the + repository/crates.io profile, with `[foctet security]` in the subject. + +Include a description, the affected crate(s) and version(s), impact as you +understand it, and a reproduction if possible. + +**Response targets (best-effort; this is a volunteer-maintained project):** + +- **Acknowledgement** within **7 days** of the report. +- **Triage and severity assessment** (confirmed / not a vulnerability / + needs more info) within **14 days**. +- **Fix or public advisory** within **90 days** for confirmed issues, sooner + for critical ones; if a fix needs longer we will say so and agree on a + disclosure date with you. + +Coordinated disclosure is preferred: please allow the fix to ship before public +discussion. Credit is given in the advisory unless you ask otherwise. There is +currently no bug bounty. + +**In scope:** the `foctet-*` crates in this repository, the wire format and key +schedule as specified in `SPEC.md`, the WASM/JS boundary, and the committed CI +supply-chain configuration. **Out of scope:** vulnerabilities in third-party +dependencies (report upstream; we will pick up the fix), and issues requiring a +compromised endpoint (see `docs/THREAT_MODEL.md` for the trust boundary). + +## Supported versions + +While the project is in the `0.x` Draft v0 line, only the latest published `0.x` +release receives security fixes. There is no long-term-support branch yet. A +supported-version policy will accompany the first `v1` release. + +## What is protected today + +- **Confidentiality / integrity / authenticity** of framed payloads and body + envelopes via X25519 + HKDF-SHA-256 + XChaCha20-Poly1305, with the wire header + authenticated as AEAD associated data. +- **All-zero X25519 shared secrets are rejected.** +- **Fail-closed sequence and key-id exhaustion** on both the async (`FoctetFramed`) + and synchronous (`SyncIo`) paths — a frame is never emitted with a reused + `(key_id, stream_id, seq)` nonce. +- **No session-state restoration.** Foctet does not provide a session-persistence + format. After a crash or restart, applications must establish a fresh session; + restoring traffic keys with reset or uncertain outbound sequence state can reuse + a nonce and is unsafe. +- **Replay protection** via per-`(key_id, stream_id)` sliding windows, committed + **only after AEAD authentication** so a forged frame cannot desynchronize or + DoS the receiver. The number of tracked windows is bounded + (`DEFAULT_MAX_REPLAY_WINDOWS`) to prevent unbounded memory growth. +- **Authenticated-by-default native handshake.** A default `SessionAuthConfig` + fails closed: an unauthenticated handshake requires an explicit + `SessionAuthConfig::unauthenticated_for_testing()` / + `allow_unauthenticated(true)` opt-in, intended only for tests or for use inside + an already-authenticated outer channel (e.g. mutually authenticated TLS). + Identity authentication uses Ed25519 transcript signatures with pinned peer + identities. +- **Optional context binding** for body envelopes (`seal_body_with_context` / + `open_body_with_context`): an application-supplied context (for HTTP: method, + authority, path, timestamp, message ID, …) is folded into the AEAD associated + data so a captured envelope cannot be replayed onto a different request. + +## Known limitations (do not rely on these yet) + +These are tracked work items; treat each as **unsupported** until implemented, +documented, and thoroughly tested: + +1. **HTTP anti-replay (near-complete, not yet hard-enforced).** `foctet-http` + ships a versioned protected-context schema (`ProtectedContext`, `x-foctet-*` + carrier headers), a bounded `ReplayStore` with atomic check-and-insert + (`InMemoryReplayStore`), and context-bound APIs + (`seal_request_with_context` / `open_request_with_context`, plus Axum and + Workers adapters) that bind method/path/query/message-id/timestamp/expiry + into the AEAD and enforce single use. Multi-instance / serverless + deployments have an `AsyncReplayStore` trait (`!Send`-friendly for + Cloudflare Workers) with a Redis backend (`RedisReplayStore`, atomic + `SET NX PX`) **and** a Cloudflare **Durable Object** adapter + (`DurableObjectReplayStore`). The stateless full-request family is + `#[deprecated]` in favor of the context-bound path; hard removal/gating is + deferred to the API freeze so downstream callers get a deprecation cycle. + The low-level `seal_body` / `open_body` primitives remain stateless by + design — production HTTP code must use the `*_with_context` APIs backed by + a shared, durable store. Still open: authority-normalization guidance. +2. **Forward-secret DH ratchet rekey.** In-session + rekey now performs a Diffie-Hellman ratchet step: each rekey mixes a fresh + ephemeral X25519 output into a root-key chain, and rekeys **alternate** + between the two peers (enforced by a turn flag, so the root chain cannot fork + and both peers' ratchet keys rotate). This gives forward secrecy and, across + an alternating rekey, post-compromise security in both directions. + Operational note: under strictly one-directional traffic the alternation can + stall after one step (the quiet side never takes its turn); rekey + periodically from both ends for continued ratcheting. +3. **Datagram support (near-complete).** A dedicated datagram API + (`foctet_core::datagram::DatagramEndpoint`: one bounded frame per datagram, + size cap, authenticate-before-replay, loss/reorder tolerant) ships with a + QUIC datagram adapter (`foctet_transport::quinn::QuinnDatagramChannel`) and + a raw-UDP adapter over a connected socket + (`foctet_transport::udp::UdpDatagramTransport`) with an **opt-in + anti-amplification limiter** (`with_anti_amplification`; peer + discovery/pinning and MTU discovery remain the caller's responsibility). + **Rekey-over-datagram** is supported: the DH-ratchet rekey rides a reliable + control channel and `SecureDatagramChannel::rekey_from_session` adopts the + rotated keys, with retained previous keys so reordered old-key datagrams + still decrypt. A browser-WebTransport datagram adapter now ships as + `foctet_transport::webtrans_browser::BrowserWebTransportDatagrams` + (`transport-webtrans-browser`, wasm32) and is exercised in headless Chrome + against in-page WHATWG streams. Still pending: a live end-to-end browser + test against a real HTTP/3 WebTransport server, plus more deployment + guidance around path-MTU changes and conservative datagram sizing. +4. **WASM/TypeScript SDK (partial).** The `foctet-wasm` crate ships a + `wasm-bindgen` API for the body envelope (seal/open, context-bound variants, + `KeyPair`) **and** a framed `FoctetSession` (authenticated handshake, + ordered `sealMessage`/`openMessage`, datagram `sealDatagram`/`openDatagram`, + and in-session DH-ratchet rekey via `forceRekey` / `handleControlMessage`), + with generated `.d.ts`, Node/browser/bundler builds, a Node interop test that + opens Rust-produced envelopes, an in-browser runtime harness + (`foctet-wasm/examples/browser/index.html`), and a **headless-Chrome test + suite in CI** (`foctet-wasm/tests/browser.rs`). **WASM clock limitation:** + `wasm32-unknown-unknown` has no monotonic clock, so the *age-based* rekey + threshold is disabled there; the frame-count and byte-count thresholds still + apply, and a long-lived WASM session should still drive rekey explicitly when + needed. Still pending: a published npm package and host-backed + (non-extractable) key handling (documented as unavailable on current + platforms). +5. **Streaming HTTP bodies (near-complete).** A chunked streaming mode exists + (`foctet_core::body_stream`, plus `foctet_http`'s `HttpStreamSealer` / + `HttpStreamOpener`): per-chunk AEAD with unique nonces, an authenticated + final-chunk marker (truncation/extension resistance), ordering checks, and + the same protected-context + replay binding as the one-shot path. Turn-key + request wiring exists (`StreamFrameDecoder`, the framework-agnostic + `HttpRequestStreamReader`, and the axum helper `open_request_stream`, no + whole-body buffering). Still open: a response-body streaming helper and + backpressure *tuning* guidance. +6. **Wire format is unstable** (`0.x`, Draft v0). Even though vectors and + interoperability fixtures are checked in CI, breaking wire changes may still + occur until the v1 compatibility commitment begins. + +## Roadmap to a production / `v1` claim + +Before using "production-ready" or "v1 stable" wording, all of the following must +hold: + +- All P0/P1 findings fixed and regression-tested (see the project review). +- Documentation, examples, and operational guidance aligned with the shipped + surface. +- Authenticated peer identity or explicit authenticated-channel binding is + mandatory for production constructors. +- HTTP has an authenticated protected context and replay defense, or is + explicitly excluded from the production promise. +- The advertised transport matrix has real implementations and conformance tests. +- WASM/TypeScript are either truly shipped and tested or excluded from the claim. +- Dependency advisory/license checks, fuzzing, reproducible builds, CI coverage, + and a vulnerability-response process are active. diff --git a/SPEC.md b/SPEC.md index d17b1bf..ccd2f40 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,13 +4,17 @@ Foctet Protocol Specification (Draft v0) 0\. Status ---------- -* **Status**: Draft v0 (work-in-progress) +* **Status**: Draft v0 (work-in-progress). **Not production-ready.** See `SECURITY.md` for the current security posture and known limitations, and `docs/THREAT_MODEL.md` for the full threat model. +* **Spec version**: `foctet-spec/0.3-draft`. The specification is versioned independently of the crate versions: crates may release without spec changes, and this stamp only changes when normative content changes. Any wire-level change MUST bump this stamp and move `test-vectors/` in the same commit (see the compatibility policy below). +* **Conformance language**: The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are to be interpreted as in RFC 2119/8174. Unless a section is explicitly marked *informative*, requirements stated with these key words are normative for Draft v0 implementations; an implementation that violates a MUST is not a conforming Foctet implementation even if it interoperates with this codebase. +* **Implementation status**: This specification describes the target protocol. As of this revision, the implemented surface is: **stream-oriented** framing (TCP / QUIC bi-streams / WebTransport bi-streams / multiplexed WebSocket / muxtls, all covered by a real-connection conformance suite), the **message shape** (raw WebSocket, native + browser), the **datagram API** (`foctet_core::datagram`) with QUIC and raw-UDP adapters (opt-in anti-amplification) and rekey-over-datagram via a reliable control channel, the one-shot body envelope and archive formats, streaming HTTP bodies with per-chunk AEAD, an HTTP protected-context + anti-replay layer (in-memory / Redis / Cloudflare Durable Object stores), the DH-ratchet rekey, and a **WASM/TypeScript SDK** (`foctet-wasm`) covering the body envelope **and** the framed session (message + datagram modes, including in-session rekey), tested in headless Chrome in CI, plus a browser-WebTransport datagram adapter (`foctet_transport::webtrans_browser`). The canonical vectors are additionally verified by an independent non-Rust implementation (`interop/verify_vectors.mjs`, in CI). Still pending items are marked "(pending)" in place; the largest are a published npm package and an end-to-end Workers/`wrangler` test. * **Scope**: Defines Foctet **Core** (framing, E2EE payload protection, key schedule), **Secure Archive** (encrypted storage format), and references the `application/foctet` one-shot body envelope specification (`docs/http-body-format.md`). * **Deployment guidance**: Recommended production composition patterns are summarized in `docs/recommended-deployments.md`. * **Non-goals**: Transport reliability, congestion control, NAT traversal, application semantics. Those are delegated to underlying transports and higher layers. -* **Compatibility Policy (Draft v0)**: +* **Compatibility Policy (Draft v0)** — full policy in `docs/POLICIES.md`: * The current release line is `0.x` and may include breaking changes while v0 is still draft. * Any wire-level change MUST update `SPEC.md` and corresponding files under `test-vectors/` in the same change. + * There is deliberately **no in-band version/cipher negotiation in v0**; unknown versions/profiles are rejected (no downgrade surface). Negotiation rules for future versions are specified in `docs/POLICIES.md` §1.3. * A stable compatibility commitment is deferred to v1. * * * @@ -21,7 +25,7 @@ Foctet Protocol Specification (Draft v0) ### 1.1 Primary Goals * **E2EE / Zero-Knowledge**: Intermediaries (relays, storage providers) MUST NOT be able to decrypt payloads. -* **Transport-agnostic**: Works over QUIC, WebTransport, TLS-TCP, WSS, plain TCP/UDP, or any byte stream / datagram. +* **Transport-agnostic**: Works over QUIC, WebTransport, TLS-TCP, WSS, plain TCP, or any byte stream, and over datagram transports via a dedicated datagram API (QUIC, raw-UDP, and browser-WebTransport datagram adapters shipped — see §5.1). * **Thin core, strong invariants**: Minimal primitives with strict security guarantees. * **Archiveable**: Encrypted data MUST be representable as a file (or multiple files) for offline distribution and later reassembly. @@ -51,6 +55,10 @@ Normative keywords: **MUST**, **SHOULD**, **MAY**. 3\. Threat Model ---------------- +This section is a summary. The **normative, complete threat model** — including +per-threat defenses, residual risks, metadata-leakage inventory, DoS bounds, +key-loss policy, and the WASM boundary — is `docs/THREAT_MODEL.md`. + ### 3.1 Adversary Capabilities * Can observe, drop, delay, reorder, replay, and inject packets/frames. @@ -62,7 +70,8 @@ Normative keywords: **MUST**, **SHOULD**, **MAY**. * **Confidentiality**: Payload plaintext not revealed to relays/storage. * **Integrity & Authenticity**: Endpoints detect tampering/injection. * **Replay protection**: Endpoints detect replayed frames within a session. -* **Forward secrecy**: Session compromise does not reveal past sessions (and ideally limits within-session exposure via rekey). +* **Forward secrecy (between sessions)**: A fresh ephemeral X25519 handshake per session means compromise of one session's keys does not reveal other sessions' traffic. +* **Within-session rekey is a forward-secret DH ratchet.** Each rekey performs a Diffie-Hellman ratchet step (a fresh ephemeral X25519 output mixed into a root-key chain; see §7.1.2), and rekeys alternate between the peers so both ratchet keys rotate. This provides forward secrecy and, across an alternating rekey, post-compromise security. Under strictly one-directional traffic the alternation can stall after one step, so rekey periodically from both ends. * **Key separation**: Distinct keys for directions and purposes (data vs control). ### 3.3 Misuse Cases (Implementation Risks) @@ -75,6 +84,13 @@ Implementations MUST document and defend against at least: * disabling replay checks in production paths * using unbounded allocations from attacker-controlled lengths +Session persistence is not currently specified or supported. An implementation +MUST NOT restore a session, traffic key, or outbound sequence allocator with a +reset or uncertain sequence value under the same traffic key. After a crash or +restart, it MUST establish a fresh session unless a future, versioned +persistence design can atomically preserve every outbound sequence allocator +and its key-generation state across durable storage. + * * * 4\. Architecture Overview @@ -106,11 +122,18 @@ Relays forward frames without decryption and SHOULD NOT require any Foctet aware Foctet Core can run over: -* **Byte stream** transports (TCP, TLS-TCP, WSS): requires Foctet framing delimiter/length prefix. -* **Datagram** transports (UDP, QUIC datagram): each datagram MUST contain one or more complete frames. +* **Byte stream** transports (TCP, TLS-TCP, WSS, QUIC/WebTransport bidirectional streams): requires Foctet framing delimiter/length prefix. +* **Datagram** transports (UDP, QUIC datagram, WebTransport datagram): each datagram MUST contain exactly one complete, bounded frame; the maximum datagram size MUST be configured at or below the transport MTU; replay state MUST be committed only after AEAD authentication; and anti-amplification limits MUST be applied at the transport layer. This uses a dedicated datagram API (`foctet_core::datagram::DatagramEndpoint`) that is separate from the byte-stream API and MUST NOT be approximated by reusing the stream API. QUIC (`foctet_transport::quinn::QuinnDatagramChannel`), raw-UDP (`foctet_transport::udp::UdpDatagramTransport`, opt-in anti-amplification), and browser-WebTransport (`foctet_transport::webtrans_browser::BrowserWebTransportDatagrams`, wasm32, over `WebTransport.datagrams`) adapters are implemented. See §5.1.1 for the normative MTU/fragmentation policy. Transport MUST provide a method to send/receive bytes. Reliability is not required but affects upper-layer behavior. +#### 5.1.1 Datagram MTU, path changes, and fragmentation (normative) + +* **Foctet does not fragment.** A payload whose sealed frame would exceed the configured maximum datagram size MUST be rejected fail-closed (`FrameTooLarge`) before any bytes are sent. Implementations MUST NOT split one application payload across multiple datagrams at the Foctet layer: fragments would be independently lost/reordered, and reassembly state would create a pre-authentication resource-exhaustion surface. Applications that need larger payloads MUST use a byte-stream or message shape instead, or segment **above** Foctet so that every segment is an independent, self-contained payload. +* **Configured size, not probed size.** The maximum datagram size is configuration (`DatagramConfig::max_datagram_size`, default `DEFAULT_MAX_DATAGRAM_SIZE`), clamped to the transport's reported limit where one exists (QUIC's `max_datagram_size`, WebTransport's `maxDatagramSize`). Foctet performs no path-MTU discovery of its own. +* **Path changes.** If the underlying path MTU drops below the configured size mid-session (mobility, tunnel changes), transports that enforce their own limit (QUIC, WebTransport) will surface send failures; the raw-UDP adapter cannot detect this, so deployments over raw UDP SHOULD choose a conservative size that survives expected paths (the common guidance is ≤ 1200 bytes of datagram, matching QUIC's pre-validation default) rather than an interface-MTU-derived value. On persistent send failures after a suspected path change, callers SHOULD lower the configured size (new endpoint/config) or re-establish the session; silently truncating or fragmenting is not permitted. +* **Oversize received datagrams** MUST be rejected without allocation proportional to the claimed length (bounded by `DatagramConfig` limits), as with the stream shape's `max_ciphertext_len`. + ### 5.2 Core Concepts * Every application message is encoded into one or more **Frames**. @@ -149,7 +172,7 @@ Immediately followed by: **AAD** MUST include the entire header from `magic` through `ct_len`. `magic` MUST be present in Draft v0. -### 6.3 Flags (draft) +### 6.3 Flags (normative) * bit0: `HAS_ROUTING` (routing info present at higher layer / relay envelope) * bit1: `IS_CONTROL` (control frame vs application data) @@ -195,7 +218,7 @@ Operational constraints for uniqueness: * Reusing the same key material with wrapped `key_id` values is NOT allowed. * Implementations SHOULD treat `(direction, stream_id, key_id, seq)` as a write-once space and fail closed on state rollback. -#### 7.1.2 Rekey +#### 7.1.2 Rekey (DH ratchet) Endpoints SHOULD rekey on: @@ -203,7 +226,24 @@ Endpoints SHOULD rekey on: * frame-count threshold, OR * data-volume threshold -Rekey produces a new `key_id` and new traffic keys via HKDF with context binding. +Rekey performs one **Diffie-Hellman ratchet step**. The rekeying side generates a +fresh ephemeral X25519 key pair, computes `dh = X25519(new_ephemeral_private, +peer_current_ratchet_public)`, and advances the root chain and traffic keys via +`HKDF(salt = root, ikm = dh)` (see §7.1.3). It sends the new ratchet public key in +the `Rekey` control message (replacing the former random rekey salt). The +receiver computes the same `dh` with its current ratchet private key against the +new public, advancing identically. + +Rekeys **alternate** between the two peers: after a side initiates a rekey it MUST +NOT initiate another until it has received one from the peer (`old_key_id` and the +turn flag enforce this). This prevents the root chain from forking and ensures +both peers' ratchet keys rotate, providing forward secrecy and post-compromise +security across an alternating rekey. The initiator takes the first turn. The +receiver MUST reject a `Rekey` whose `old_key_id` is not its active key, whose +`new_key_id` is not `old_key_id + 1`, or whose transcript binding does not match. + +> Status: this ratchet construction is implemented and part of the current Draft +> v0 behavior. #### 7.1.3 Profile 0x01 Algorithm Invariants @@ -214,7 +254,12 @@ For Draft v0 profile `0x01`, implementations MUST satisfy all of the following: * Shared secret output is 32 bytes. * **HKDF-SHA256 derivation** * Initial traffic keys use labels `foctet c2s` and `foctet s2c`. - * Rekey traffic keys use labels `foctet rekey c2s || key_id` and `foctet rekey s2c || key_id`. + * The ratchet root is seeded from the handshake shared secret with + `HKDF(salt = session_salt, ikm = shared_secret)` and label + `foctet ratchet init`. + * Each rekey advances the ratchet with `HKDF(salt = root, ikm = dh)`: the new + root uses label `foctet ratchet root`, and the new traffic keys use labels + `foctet ratchet c2s || key_id` and `foctet ratchet s2c || key_id`. * `key_c2s` and `key_s2c` MUST be derived independently and MUST NOT share output buffers. * **AEAD usage** * Cipher is XChaCha20-Poly1305 with 24-byte nonce and 16-byte authentication tag. @@ -242,16 +287,17 @@ Foctet supports two modes: Draft v0 defines **Native**. -### 8.2 Native Handshake Outline (draft) +### 8.2 Native Handshake (normative) -* Each side generates ephemeral X25519 key pair. -* Exchange ephemeral public keys in control frames. -* Derive shared secret `ss = X25519(eph_priv, peer_eph_pub)`. +* Each side generates an ephemeral X25519 key pair. +* Ephemeral public keys are exchanged in control frames (§8.2.1 for the authentication trailer; the exact control-message wire layouts are fixed by `test-vectors/handshake-v0.json` and verified by the independent decoder in `interop/`). +* Derive shared secret `ss = X25519(eph_priv, peer_eph_pub)`. An all-zero `ss` MUST be rejected. * Derive traffic keys: * `prk = HKDF-Extract(salt=session_salt, IKM=ss)` * `key_c2s = HKDF-Expand(prk, info="foctet c2s", L=keylen)` * `key_s2c = HKDF-Expand(prk, info="foctet s2c", L=keylen)` -* Optional: bind to static identity keys (Ed25519) by signing transcript. +* Transcript bindings MUST be verified before any key material is used: `client_transcript_binding = SHA-256("foctet hs client" || client_eph_public || session_salt [|| channel-binding mix])` and `server_transcript_binding = SHA-256("foctet hs server" || client_eph_public || server_eph_public || session_salt [|| channel-binding mix])`, where the optional channel-binding mix is `"foctet channel-binding" || len(binding) as u64-be || binding` and is included only when a non-empty channel binding is configured (both sides MUST agree). +* Identity binding (Ed25519 transcript signatures, §8.2.1–8.2.2) is REQUIRED by default; running without it demands an explicit opt-in or a typed channel binding (§8.3). ### 8.2.1 Handshake Authentication Payload @@ -448,7 +494,9 @@ Operational guidance: * `1 GiB` outbound plaintext, OR * `10 minutes` elapsed * Replay window SHOULD default to `4096` and MAY be increased for high-reordering networks. -* Implementations SHOULD persist or monotonic-track sender sequence state when process restarts are possible. +* Until a versioned session-persistence format is specified, implementations + MUST establish a fresh session after a process restart. They MUST NOT reuse + a traffic key with reset or uncertain sender sequence state. ### 12.4 Side-channel & Implementation Safety diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..7c7d719 --- /dev/null +++ b/deny.toml @@ -0,0 +1,58 @@ +# cargo-deny configuration — license, advisory, source, and ban policy. +# Run locally with: cargo deny check +# See https://embarkstudios.github.io/cargo-deny/ + +[graph] +# Check all targets the workspace can build for. +all-features = true + +[advisories] +version = 2 +# Fail the build on any security vulnerability. +yanked = "deny" +# Known accepted advisories (review and prune periodically): +ignore = [ + # rustls-pemfile is unmaintained (no safe upgrade) but only reachable through + # transport examples/tests (dev surface), not the published library API. + "RUSTSEC-2025-0134", +] + +[licenses] +version = 2 +# Permissive licenses allowed for redistribution. SPDX "OR" expressions are +# satisfied by any one allowed term, so copyleft alternatives offered via OR +# do not need to be listed here. +allow = [ + "MIT", + "MIT-0", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Zlib", + "Unicode-3.0", + "Unlicense", + "BSL-1.0", + "CC0-1.0", + "CDLA-Permissive-2.0", + "NCSA", +] +confidence-threshold = 0.9 + +[licenses.private] +# Skip workspace crates marked `publish = false` (e.g. fuzz harness). +ignore = true + +[bans] +# Warn on duplicate versions of the same crate; deny known-bad patterns as needed. +multiple-versions = "warn" +wildcards = "deny" +# Internal path dependencies (e.g. the fuzz harness) legitimately use `*`. +allow-wildcard-paths = true + +[sources] +# Only allow crates from the official registry by default. +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/docs/POLICIES.md b/docs/POLICIES.md new file mode 100644 index 0000000..44f2a0a --- /dev/null +++ b/docs/POLICIES.md @@ -0,0 +1,158 @@ +# Foctet Policies: Compatibility, Keys, and Incident Response + +**Document version:** 1.0 (2026-07-02) · applies to the Draft v0 wire format / +the `0.x` release line. + +This document collects the operational policies referenced by `SPEC.md`, +`SECURITY.md`, and `docs/THREAT_MODEL.md`: how versions and compatibility are +managed, how keys should live and die, and what happens when something goes +wrong. + +--- + +## 1. Versioning and compatibility + +### 1.1 What is versioned + +Three things version independently: + +- **Wire format** — the frame layout, body-envelope format, archive format, + and handshake/control messages. Currently **Draft v0** (`version` byte `0x00` + in the frame header; envelope/archive magics carry their own version bytes). +- **Crypto profile** — the algorithm suite, named by the `profile_id` header + byte. v0 defines exactly one mandatory profile: + `0x01 = X25519 + HKDF-SHA-256 + XChaCha20-Poly1305`. +- **Crates / npm package** — SemVer on the Rust crates (`0.x` line) and the + (not yet published) npm package. + +### 1.2 Draft v0 policy (current) + +- The `0.x` line **may include breaking changes**, both API and wire. Breaking + wire changes MUST update `SPEC.md` and the canonical vectors under + `test-vectors/` in the same change; CI regression tests pin the vectors. +- Within `0.x`, API deprecations get at least **one minor release** of + `#[deprecated]` warning before removal (e.g. the stateless HTTP + `seal_request`/`open_request` family, deprecated since 0.3.0, will be + removed or gated no earlier than the API-freeze release). +- Only the **latest published `0.x` release** receives fixes; there are no + backport branches during draft. + +### 1.3 Version negotiation + +There is deliberately **no in-band version or cipher negotiation in v0**: +both peers must speak Draft v0 / profile `0x01`, and a frame with an unknown +version or profile is rejected. This removes downgrade surface while the +format is unstable. + +When a v1 (or a second profile) exists, the following rules apply: + +- Version/profile selection MUST be folded into the handshake transcript so a + MITM cannot strip or alter the offered set undetected (downgrade + resistance). +- An endpoint MUST NOT silently fall back to an older wire version; fallback, + if offered at all, must be an explicit application decision. +- New profiles are additive: `profile_id` values are never reused, and + removing a profile is a breaking (major) change. + +### 1.4 v1 commitment (future) + +Declaring v1 requires the release gates described in the project security and +compatibility documentation (spec complete and matching code+vectors, stable +compatibility policy, operational readiness, etc.). From v1 on: + +- the wire format is stable within a major version; frames, envelopes, and + archives produced by any v1.x implementation are readable by any other; +- a supported-versions table replaces the "latest 0.x only" rule, with a + minimum security-fix window announced at release; +- deprecations follow SemVer: deprecate in a minor, remove no earlier than + the next major. + +## 2. Key lifecycle + +Foctet uses four kinds of keys. Per-kind guidance: + +| Key | Lives | Rotation | Notes | +| --- | --- | --- | --- | +| Ephemeral X25519 (handshake/ratchet) | one handshake / one ratchet step | automatic | never persisted; zeroized on drop | +| Traffic keys (per-direction AEAD) | one `key_id` generation within a session | automatic via rekey thresholds (frames / bytes / age) or `force_rekey` | non-`Clone`, single-owner, zeroized; never persist | +| Recipient X25519 (body envelopes / archives) | long-lived, application-managed | application policy (see below) | `key_id` field routes to the right key | +| Ed25519 identity (handshake auth) | long-lived, application-managed | application policy (see below) | prefer `HandshakeSigner` over raw bytes | + +### 2.1 Session (traffic) keys + +- Rekey thresholds (`RekeyThresholds`) default to bounded frames/bytes/age; + tune them to your traffic profile. Every rekey is a DH-ratchet step and + rekeys alternate between peers — under one-directional traffic, drive + `force_rekey` periodically from both ends so the ratchet keeps healing. +- On WASM the age threshold is inactive (no monotonic clock): drive rekey by + count thresholds or explicitly. +- **Never persist session state.** There is no session-resumption format; a + restored outbound sequence counter can reuse a nonce. After a crash, + handshake again. + +### 2.2 Long-lived recipient and identity keys + +- **Generation:** from the platform CSPRNG (the library uses `getrandom`). +- **Storage:** identity signing should go through the `HandshakeSigner` trait + backed by an HSM / cloud KMS / OS keystore where available, so the secret + never enters process memory. Raw-byte keys (`IdentityKeyPair`, + envelope recipient secrets) should live in a secrets manager, be loaded via + the `expose_`-prefixed APIs (returning `Zeroizing` buffers), and never be + logged — `Debug` output is redacted, but application logging of raw buffers + defeats that. +- **Rotation:** rotate on a schedule appropriate to exposure (e.g. yearly for + offline-stored keys, more often for keys on internet-facing hosts) and + immediately on suspected compromise. Envelope/archive recipients are + identified by `key_id`, so rotation is: publish the new public key under a + new `key_id`, keep the old secret only as long as data sealed to it must + remain readable, then destroy it. +- **Multiple recipients as backup:** archives and envelopes can wrap the DEK + to several recipients. If data recovery is a requirement, wrap to an + application-controlled backup key stored offline — Foctet itself has no + escrow or recovery path (`THREAT_MODEL.md` §3.9). +- **Identity distribution and revocation** are the application's + responsibility: Foctet pins the exact public key you configure. Keep an + application-level mapping of "who currently holds which identity key" and + treat unpinning/replacing a key as a security-relevant, audited action. + +## 3. Incident response + +### 3.1 For a vulnerability in Foctet itself + +Follow `SECURITY.md`: private reporting (GitHub private vulnerability +reporting or maintainer email), acknowledgement target 7 days, coordinated +disclosure. Fixes land in the latest release line; wire-affecting fixes come +with updated vectors and a CHANGELOG **Security** entry, and (post-v1) a +RustSec advisory for the affected crates. + +### 3.2 For a key compromise in a deployment + +Suggested playbook, by key type: + +1. **Traffic key / single session** — close the session; a new handshake + derives unrelated keys. Past traffic before the compromised generation + stays protected (forward secrecy); if the exposure window is unknown, + assume everything under that session's current and later generations until + re-handshake. +2. **Identity (Ed25519) key** — stop using it immediately (new handshakes with + it are impersonatable); distribute and pin the replacement out of band; + audit for handshakes authenticated by the old key during the exposure + window. Past recorded traffic is *not* retroactively decryptable from an + identity key alone. +3. **Recipient (X25519) key for envelopes/archives** — everything ever sealed + to that key must be considered readable by the attacker. Rotate the + `key_id`, re-seal still-sensitive data to the new key, and destroy the old + secret once re-sealing is complete. +4. **Replay-store compromise** (Redis/DO) — the store holds message IDs, not + keys or plaintext; the impact is replay-protection loss. Restore an atomic + store before continuing to accept context-bound requests, and treat + requests accepted during the outage as potentially replayed. + +### 3.3 Operational monitoring + +Failures that warrant alerting in a deployment: sustained AEAD authentication +failures (active tampering or key mismatch), replay-store rejections above +baseline (replay attempt), handshake timeouts/auth failures spikes (probing), +and `SequenceExhausted`/`ReplayCapacityExceeded` errors (limits tuned too low +or abuse). Foctet surfaces these as typed errors; wiring them to metrics is +application-side (observability hooks are tracked in `TODO.md` §7). diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 0000000..499c339 --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,289 @@ +# Foctet Threat Model + +**Document version:** 1.0 (2026-07-02) · applies to the Draft v0 wire format / +the `0.x` release line. + +This document describes what Foctet defends against, what it explicitly does +not, and which residual risks an integrating application must handle itself. +It complements `SPEC.md` (wire format, §3 summarizes the adversary), +`SECURITY.md` (current posture, reporting), and +`docs/recommended-deployments.md` (composition guidance). Where this document +and the code disagree, that is a bug — please report it. + +--- + +## 1. System model + +A Foctet deployment has up to four kinds of parties: + +- **Endpoints** — the two peers that run the Foctet handshake (or install + shared traffic keys) and hold the plaintext. Native apps, servers, browsers + (WASM), Workers. +- **Relays** — nodes that forward Foctet frames without holding traffic keys + (TCP/WS relays, TURN-like forwarders, message brokers). +- **Storage** — anything that holds sealed bytes at rest: object stores (R2, + S3), databases, file systems, CDN caches holding sealed HTTP bodies or + archives. +- **Infrastructure** — the transport path itself (networks, load balancers, + TLS terminators) and platform services (Redis / Durable Objects used as + replay stores). + +Foctet's core promise: **only endpoints see plaintext.** Relays, storage, and +infrastructure are untrusted for confidentiality and integrity (at most +honest-but-curious, potentially malicious). + +## 2. Adversary capabilities + +We assume an active network adversary who can: + +- observe, drop, delay, reorder, duplicate, and inject any packet, frame, + datagram, HTTP request, or stored blob; +- operate any relay or storage node, including serving modified or replayed + content; +- open connections to any endpoint and speak the protocol (including partial + or malformed handshakes); +- spoof source addresses on connectionless transports (UDP); +- but **cannot** break the underlying cryptography (X25519, Ed25519, + HKDF-SHA-256, XChaCha20-Poly1305) and does not hold an endpoint's secret + keys unless a scenario below says otherwise. + +Out of scope entirely: compromise of the OS/hardware while the process runs +(a root-level attacker reads memory regardless), malicious dependencies in the +consumer's build, and correctness of the platform's CSPRNG (`getrandom`). + +## 3. Threats and defenses + +### 3.1 Active man-in-the-middle (MITM) + +**Threat.** An attacker on the path substitutes their own X25519 ephemeral in +the handshake, splitting one session into two. + +**Defense.** The native handshake is **authenticated by default and fails +closed**: a default `SessionAuthConfig` refuses to complete without peer +authentication. Two production mechanisms exist: + +- **Ed25519 identity authentication** — each side signs the handshake + transcript hash (which covers both ephemerals and the session salt) with a + long-term identity key; the peer verifies against a **pinned** identity + (`PeerIdentity`). The verified key is exposed as + `Session::authenticated_peer()`. Signing can be delegated to an HSM/KMS via + the `HandshakeSigner` trait so the identity secret never enters process + memory. +- **Channel binding** — `SessionAuthConfig::bound_to_channel(..)` folds an + outer-channel value (e.g. a TLS exporter) into the transcript hash, so the + handshake only completes inside that specific outer channel. Suitable when + the outer channel already authenticates the peer (mutual TLS). + +Running unauthenticated requires the deliberately alarming +`unauthenticated_for_testing()` opt-in. + +**Residual risk.** Identity distribution/pinning is the application's problem: +Foctet verifies "the peer holds the key you pinned", not "the key belongs to +Alice". A wrong or attacker-supplied pinned key defeats authentication. +Trust-on-first-use, directories, and revocation are out of scope for v0 +(see §3.9 on key loss). + +### 3.2 Replay + +**Threat.** The attacker re-delivers a previously valid ciphertext: a frame in +a session, a datagram, a sealed HTTP request, a whole streaming body. + +**Defenses, per shape:** + +- **Stream/message/datagram sessions** — every frame carries + `(key_id, stream_id, seq)`; receivers keep a sliding replay window per + `(key_id, stream_id)` and reject duplicates. Replay state is committed + **only after AEAD authentication**, so a forged high sequence number cannot + desynchronize the window (this ordering is regression-tested). The number of + tracked windows is capped (`max_replay_windows`) to bound memory. +- **HTTP one-shot and streaming bodies** — the `*_with_context` path binds a + `ProtectedContext` (method, path, query, direction, timestamp, expiry, a + random 16-byte message ID) into the AEAD as associated data, and consumes + the message ID **exactly once** through an atomic `ReplayStore` + (`check_and_insert`). Multi-instance deployments must use a shared, durable + store — Redis (`SET NX PX`) or the Cloudflare Durable Object adapter; a + per-instance in-memory store cannot see replays that arrive at another + instance. Freshness (timestamp/expiry, clock skew) bounds how long an entry + must be retained. +- **Archives / stored blobs** — replay of a stored object is *by definition* + the read path; single-use semantics for stored data are an application + concern (e.g. bind the archive to a purpose via its encrypted metadata). + +**Residual risk.** The deprecated stateless HTTP family +(`seal_request`/`open_request` without context) is replayable by design and +kept only for migration; production code must use the context-bound path. TTL +choice matters: a replay-store entry must outlive the freshness window. + +### 3.3 Reordering, truncation, and stream splicing + +**Threat.** The attacker reorders frames, truncates a stream early, or splices +ciphertext from one context into another. + +**Defense.** Byte-stream and message sessions enforce ordering via the replay +window; the streaming body format requires strictly sequential chunk indices, +authenticates chunk position (`index` in the AAD), and requires exactly one +authenticated FINAL chunk — a truncated, extended, reordered, or duplicated +stream fails to verify (`is_finished()` must be true before the plaintext is +trusted). Frames are bound to their `(direction, key_id, stream_id, seq)` via +nonce + AAD, so cross-context splicing fails authentication. Datagram mode +tolerates loss and reordering by design (per-datagram independence) — an +application needing ordering on datagrams must layer it. + +### 3.4 Rollback / downgrade + +**Threats and defenses:** + +- **Version/profile downgrade** — the profile ID rides in the authenticated + header (AAD); v0 ships exactly one mandatory profile + (X25519+HKDF-SHA-256+XChaCha20-Poly1305), so there is no weaker suite to + negotiate down to. Future profiles must fold negotiation into the + transcript (see `docs/POLICIES.md`). +- **Key rollback** — a `Rekey` control message must name the **current** + active `old_key_id` and `new_key_id = old + 1`; stale, replayed, or jumped + rekeys are rejected, and the rekey transcript binding covers the new + ratchet public key. An attacker cannot force traffic back onto an old key: + retained previous keys are decrypt-only and bounded + (`max_retained_keys`). +- **Sequence rollback (self-inflicted)** — restoring session state with reset + counters would reuse nonces. Foctet ships **no session-persistence format** + and the spec forbids restoring outbound sequence state; after a crash, + establish a fresh session. + +### 3.5 Endpoint compromise (key exposure over time) + +**Threat.** An attacker obtains an endpoint's keys at some point in time. + +**Properties:** + +- **Between sessions** — each session runs a fresh ephemeral X25519 handshake: + compromise of one session's traffic keys does not decrypt other sessions + (forward secrecy at session granularity). +- **Within a session** — rekey is a **DH ratchet**: each rekey mixes a fresh + ephemeral DH output into a root chain, and rekeys alternate between peers. + Keys before the compromise stay safe (forward secrecy); after a compromise, + security heals once both peers have taken a ratchet turn + (post-compromise security). Under one-directional traffic the alternation + stalls (the quiet side never takes its turn); rekey periodically from both + ends. +- **Long-term identity compromise** — an attacker holding the Ed25519 identity + key can impersonate the endpoint in *new* handshakes (it cannot decrypt + past traffic — the identity key only signs). Revocation/rotation of + identities is the application's responsibility (see `docs/POLICIES.md`); + using a `HandshakeSigner` backed by an HSM/KMS reduces exfiltration risk. + +**In-process hygiene** (reduces exposure window, not a boundary): traffic-key +bytes live in exactly one place (`TrafficKeys` is non-`Clone`, shared via +`KeyHandle`), are zeroized on drop, redacted in `Debug` output, and compared +in constant time; secret-returning APIs are `expose_`-prefixed and return +`Zeroizing` buffers. On WASM, zeroization cannot be guaranteed across the JS +boundary (JS engines copy freely) and keys are extractable bytes — treat a +compromised page/extension context as a compromised endpoint. + +### 3.6 Relay and storage compromise + +**Threat.** A malicious relay or storage provider reads, modifies, reorders, +or selectively drops what passes through it. + +**Defense.** Relays and storage never hold traffic keys; payloads are AEAD- +protected end to end, headers are authenticated as AAD, and archive metadata +(file names, content types) is encrypted — a relay sees only routing-level +framing (see §3.8 for what leaks). Modification anywhere fails authentication +at the endpoint. Split archives authenticate each part and bind parts to the +manifest, so a storage provider cannot swap or truncate parts undetected. + +**Residual risk.** Availability: a malicious relay can always drop or delay +traffic — Foctet detects, it does not prevent. Traffic analysis: see §3.8. + +### 3.7 Denial of service and resource exhaustion + +**Threat.** An attacker feeds crafted input to exhaust memory/CPU, or uses an +endpoint as an amplifier. + +**Defenses:** + +- All attacker-controlled lengths are validated against explicit limits before + allocation (`ProtocolLimits`: max ciphertext length; `BodyEnvelopeLimits`; + `DatagramConfig::max_datagram_size`; archive limits). Replay-window count is + capped. Handshake reads are bounded by timeouts on both the Tokio path + (`DEFAULT_HANDSHAKE_TIMEOUT`) and the runtime-agnostic futures path. +- **Authenticate-before-commit** everywhere: forged input cannot mutate + replay/session state, so an off-path attacker cannot poison a session. +- **UDP anti-amplification** — the raw-UDP adapter has an opt-in QUIC-style + limiter (`with_anti_amplification`, default factor 3): until the peer is + validated, an endpoint will not send more than `factor ×` the bytes it has + received, defeating spoofed-source reflection. QUIC/WebTransport enforce + this at the transport layer already. +- Parsers for every attacker-facing format are fuzzed continuously (7 targets, + seeded, time-budgeted in CI). + +**Residual risk.** Foctet bounds per-session and per-message work; it does not +rate-limit connection/handshake *attempts* — deploy standard perimeter +controls (SYN/handshake rate limits, concurrency caps). This is tracked as an +open item (TODO §2.4). + +### 3.8 Metadata leakage (traffic analysis) + +**What an observer sees, by design:** + +- Frame headers ride in plaintext (authenticated, not encrypted): version, + flags (control vs data), profile, `key_id`, `stream_id`, `seq`, and exact + ciphertext length. Handshake control messages (ephemeral publics, salt) are + plaintext. +- Sealed HTTP requests expose normal HTTP metadata (method, path, headers, + timing) plus the `x-foctet-*` carrier headers; the *protected context* is + authenticated, not hidden. +- Archives expose a minimal plaintext header (magic, version, sizes); names + and content metadata are encrypted. +- Timing, frequency, direction, and sizes of traffic are visible everywhere. + +**Non-defenses.** Foctet does not pad, batch, or otherwise shape traffic, and +does not hide who talks to whom. Applications needing resistance to traffic +analysis must add padding/cover traffic or route over an anonymity network. +An optional relay-facing outer envelope (SPEC §9.2) can wrap frames when even +Foctet's own header must be hidden from a specific hop. + +### 3.9 Key loss (availability of data) + +**Threat.** The holder of the only decryption key loses it. + +**Position.** Foctet is strictly end-to-end: there is **no key escrow and no +recovery path**. Losing the recipient secret for a body envelope or archive +makes the data permanently unreadable; losing an identity key means +re-establishing trust out of band. Archives support **multiple recipients** +(the DEK is wrapped per recipient), which is the supported mitigation: wrap to +a backup/escrow recipient key *that the application controls* if recovery is a +requirement. Key backup, rotation cadence, and compromise response are +specified in `docs/POLICIES.md`. + +### 3.10 Cross-language / WASM boundary + +**Threats specific to the JS/WASM SDK:** + +- Keys and plaintext cross the JS boundary as `Uint8Array`s; JS engines may + copy them arbitrarily — zeroization guarantees stop at the boundary. +- No non-extractable key storage: WebCrypto has no portable non-extractable + X25519/Ed25519 type, so a compromised page context (XSS, malicious + extension) can exfiltrate keys. Treat the browsing context as the endpoint's + trust boundary and apply standard web hardening (CSP, no untrusted scripts). +- The wasm runtime has no monotonic clock: age-based rekey is disabled there + (frame/byte-count thresholds still apply); long-lived WASM sessions should + drive rekey explicitly. + +Wire compatibility between Rust and JS is pinned by interop fixtures (Node +opens Rust-produced envelopes) and in-browser tests in CI. + +## 4. Explicit non-goals + +- Anonymity, unlinkability, or traffic-analysis resistance (§3.8). +- Availability against an on-path adversary (drop/delay always possible). +- Multi-device identity, group messaging semantics, or key directories. +- Deniability (Ed25519 transcript signatures are non-repudiable to anyone + holding the transcript). +- Protection of a compromised endpoint's own plaintext. + +## 5. Assurance status + +Defenses above are implemented and regression-tested (unit, property, +conformance across real transports, fuzzing in CI, cross-language interop, +in-browser runtime tests). Report suspected gaps via the process in +`SECURITY.md`. diff --git a/docs/examples.md b/docs/examples.md index 9189190..59d2cca 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -8,7 +8,7 @@ This guide collects the recommended Foctet examples by deployment style. | --- | --- | --- | | Authenticated end-to-end stream over a single connection | `foctet/examples/secure_channel_tokio.rs` | Smallest authenticated native-handshake example with pinned identities | | Runtime-agnostic transport integration over a real stream transport | `foctet-transport/examples/quinn_split.rs` | Shows the recommended `foctet-transport` builder flow with authenticated per-stream sessions | -| HTTP body encryption with a Rust server | `foctet-http/examples/axum_body_echo_server.rs` and `foctet-http/examples/axum_body_echo_client.rs` | Demonstrates the high-level `HttpSealer` / `HttpOpener` path | +| HTTP body encryption with a Rust server | `foctet-http/examples/axum_body_echo_server.rs` and `foctet-http/examples/axum_body_echo_client.rs` | Demonstrates the production-recommended protected-context path (`*_with_context`) with replay defense | | HTTP body encryption with Cloudflare Workers | `foctet-http/examples/workers-echo` plus `foctet-http/examples/workers_echo_client.rs` | Shows Workers integration while preserving body-only scope | | Archive/file encryption and split archive roundtrip | `foctet/examples/file_archive_roundtrip.rs` | Shows single-file and split archive creation plus restore | | Deterministic interoperability fixtures | `foctet/examples/gen_vectors.rs` | Regenerates the repository test vectors | @@ -33,12 +33,30 @@ cargo run -p foctet --example secure_channel_sync ### Transport-builder integrations -- `foctet-transport/examples/quinn_split.rs` -- `foctet-transport/examples/webtrans_split.rs` -- `foctet-transport/examples/websock_split.rs` -- `foctet-transport/examples/muxtls_split.rs` +- `foctet-transport/examples/quinn_split.rs` (`--features transport-quinn`) +- `foctet-transport/examples/webtrans_split.rs` (`--features transport-webtrans`) +- `foctet-transport/examples/websock_split.rs` (`--features transport-websock-mux`) +- `foctet-transport/examples/muxtls_split.rs` (`--features transport-muxtls`) +- `foctet-transport/examples/udp_datagram_split.rs` (`--features runtime-tokio`) — + raw-UDP datagrams with the handshake over a TCP control channel + anti-amplification +- `foctet-transport/examples/websock_message_server.rs` (`--features "runtime-tokio transport-websock"`) — + native raw-WebSocket message responder; counterpart for the browser WASM SDK + (`foctet-wasm/examples/browser/websocket.html`) +- `foctet-transport/examples/webtrans_datagram_split.rs` (`--features "runtime-tokio transport-webtrans"`) — + native WebTransport responder (handshake over a stream, data over datagrams); + counterpart for the browser page (`foctet-wasm/examples/browser/webtransport.html`) + +These examples all use authenticated Foctet handshakes and `SessionAuthConfig`. They +are the best reference when integrating Foctet with existing stream transports. Each +needs its transport feature enabled, e.g.: -These examples all use authenticated Foctet handshakes and `SessionAuthConfig`. They are the best reference when integrating Foctet with existing stream transports. +```bash +cargo run -p foctet-transport --example websock_split --features transport-websock-mux +``` + +> The `transport-websock` feature alone enables the cross-platform raw-WebSocket +> *message* transport (`WebsockMessageTransport`, native + browser). The multiplexed +> byte-stream channel used by `websock_split` requires `transport-websock-mux`. ## HTTP Body Envelope Examples @@ -56,6 +74,12 @@ Run the client in another terminal: cargo run -p foctet-http --example axum_body_echo_client --features axum ``` +Both sides use the protected-context API (`seal_request_with_context` / +`open_request_with_context`): the request metadata (method/path/query/message-id/ +timestamp/expiry) is bound into the AEAD and the server enforces single use through +an `InMemoryReplayStore`, so a captured request cannot be replayed (a replay returns +HTTP 409). Swap in a `RedisReplayStore` for multi-instance deployments. + ### Cloudflare Workers Run local Worker development: @@ -72,7 +96,11 @@ Run the Rust client in another terminal: cargo run -p foctet-http --example workers_echo_client ``` -These examples protect HTTP body bytes only. Keep the outer channel authenticated and keep the advisory `x-foctet-scope: body-only` header unless you have a compatibility reason not to. +These examples encrypt the HTTP body bytes and, on the protected-context path, +also authenticate the request metadata bound into the AEAD +(method/path/query/message-id/timestamp/expiry). Keep the outer channel +authenticated and keep the advisory `x-foctet-scope: body-only` header unless +you have a compatibility reason not to. ## Archive and File Examples diff --git a/docs/key-rotation.md b/docs/key-rotation.md new file mode 100644 index 0000000..307de74 --- /dev/null +++ b/docs/key-rotation.md @@ -0,0 +1,99 @@ +# HTTP / Workers Recipient-Key Rotation + +This guide covers rotating the **recipient key** that senders seal `foctet-http` +body envelopes to — the server key for request protection, or the client key for +response protection. It applies equally to the axum and Cloudflare Workers +adapters, which both build on `HttpOpener`. + +Rotation of a live Foctet **session** key (`foctet-transport`) is a separate +mechanism (the DH ratchet / rekey control frames) and is not covered here. + +## Model + +A body envelope is sealed to one recipient public key and carries a sender-chosen +`key_id` (kid) identifying which recipient key was used. The kid is authenticated +(bound into the AEAD), so it cannot be altered in transit. + +The recipient opens with an ordered **keyring** of secret keys +(`HttpOpenOptions`). Opening tries each key in order and succeeds on the first +that authenticates. This lets a recipient accept both the current and a previous +key during an overlap window: + +```rust +// Accept the current key (v2) and the retiring key (v1). +let opener = HttpOpener::new( + HttpOpenOptions::new(server_secret_v2).with_recipient_key(server_secret_v1), +); +``` + +Trial decryption is safe: + +- Every keyring entry is one of the recipient's own secret keys, and each attempt + is against context-bound, authenticated ciphertext, so a non-matching key + simply fails to open — there is no decryption oracle. +- Authentication runs **before** the replay store is consulted, so a failing key + attempt never consumes a replay slot. A request sealed to a key the recipient + no longer holds is rejected identically on every retry. + +Place the key that serves the most traffic first to minimize wasted attempts. + +## Rotation procedure + +1. **Generate** a new recipient keypair and assign it a fresh kid + (for example `server-v2`). Store the secret with `wrangler secret put` + (Workers) or your secret manager — never hardcode it. +2. **Add** the new secret to the recipient keyring alongside the current one and + deploy. The recipient now accepts both keys. Nothing sender-side has changed + yet, so all traffic still opens. +3. **Publish** the new public key + kid to senders and begin the overlap window. + Senders migrate from the old kid to the new one at their own pace. +4. **Wait** at least the length of the longest sender-side key cache or protected + context TTL (`DEFAULT_CONTEXT_TTL_SECS` by default). Retiring the old key + before every in-flight sender has migrated causes spurious `401`s. +5. **Retire** the old key: remove it from the keyring and redeploy. Requests + still sealed to the old key now fail authentication and are answered `401`. +6. **Destroy** the retired secret once you are confident no rollback is needed. + +**Rollback:** if the new key is bad, keep the old key in the keyring and tell +senders to revert to the old kid. Because the recipient still holds both, no +redeploy is required to accept old-kid traffic again. + +## Failure handling + +With `WorkersError::status_code()` (Workers) and `AxumError::into_response` +(axum), failures map to status codes without leaking error detail: + +| Condition | Status | Cause | +| --- | --- | --- | +| Sealed to a key not in the keyring (retired/unknown) | `401` | `OpenFailed` | +| Tampered body or bound header | `401` | `OpenFailed` | +| Expired protected context | `401` | `ContextExpired` | +| Replayed request | `409` | `Replayed` | +| Malformed context headers | `400` | `MissingContext` / `InvalidContext` | +| Replay-store backend error | `500` | `ReplayStore` | + +Only genuine server-side faults return `500`; a client sealing to a retired key +is a `401`, not a server error. + +## Monitoring + +- Track the `401` rate during and after an overlap window. A rising `401` rate + after retiring a key means senders had not finished migrating — roll the old + key back into the keyring. +- On Workers, watch live traffic with `npx wrangler tail` while rolling out each + step. +- Keep the overlap window open until the `401` rate at the *old* kid drops to + zero before retiring. + +## Verifying rotation + +The `foctet-http` test suite covers the rotation logic +(`cargo test -p foctet-http`): + +- `key_rotation_overlap_accepts_current_and_previous_key` +- `key_rotation_rejects_key_after_it_is_retired` +- `key_rotation_trial_decryption_does_not_consume_replay_slot` + +The `workers-echo` example drives the same scenarios against a real Worker; see +its [README](../foctet-http/examples/workers-echo/README.md) for the +`SERVER_KEY_VERSION` matrix (`v1` / `v2` accepted, `retired` → `401`). diff --git a/docs/recommended-deployments.md b/docs/recommended-deployments.md index 7405b4d..63efd16 100644 --- a/docs/recommended-deployments.md +++ b/docs/recommended-deployments.md @@ -7,7 +7,7 @@ This guide summarizes the recommended production composition patterns for Foctet | Use case | Recommended Foctet layer | What Foctet protects | What must be protected elsewhere | | --- | --- | --- | --- | | Interactive end-to-end transport between peers | `foctet-transport` with authenticated native handshake | Stream payloads, frame integrity, replay window, rekey lifecycle | Peer discovery, transport availability, routing metadata | -| HTTP request/response body encryption | `foctet-http` or `foctet_core::body` | HTTP body bytes only | Method, URL, query, status code, outer headers, server authentication | +| HTTP request/response body encryption | `foctet-http` or `foctet_core::body` | HTTP body bytes; with protected-context APIs, selected request metadata is also authenticated and replay-protected | Method, URL, query, status code, outer headers, server authentication | | File transfer, offline export, or storage handoff | `foctet-archive` | File contents, encrypted metadata, recipient-scoped DEK wrapping | File naming outside the archive, storage ACLs, distribution channel authenticity | ## Pattern 1: Authenticated Transport E2EE @@ -45,8 +45,16 @@ Use this when you need encrypted payload bodies over HTTP APIs but do not need f - Recommended outer channel: HTTPS or another authenticated session that already authenticates the peer. - Recommended setup: - seal request or response bodies with `application/foctet` + - for production HTTP requests, prefer `foctet-http`'s protected-context + path (`seal_request_with_context` / `open_request_with_context`) with a + shared replay store - keep `x-foctet-scope: body-only` - authenticate the outer HTTP channel separately + - rotate recipient keys with an `HttpOpener` keyring and an overlap window + (see [Key rotation](key-rotation.md)) + - for zero-knowledge storage where the server holds no key, seal at rest with + `foctet_core::storage` (see + [Zero-knowledge storage backends](zero-knowledge-workers-storage.md)) ### When to choose it @@ -56,13 +64,18 @@ Use this when you need encrypted payload bodies over HTTP APIs but do not need f ### Important boundary -Foctet HTTP does not hide or authenticate: +Foctet HTTP does not hide: - request method - URL path or query - response status code - outer HTTP headers unless your application copies them into the encrypted body +With the protected-context APIs, Foctet **does authenticate** selected request +metadata (method, authority, path, query, message ID, timestamp, expiry) and +enforces single-use replay protection, but that metadata remains visible to the +outer HTTP stack. + If you need full-message confidentiality, use transport E2EE instead of relying on HTTP body envelopes alone. ## Pattern 3: Archive and File Distribution diff --git a/docs/zero-knowledge-workers-storage.md b/docs/zero-knowledge-workers-storage.md new file mode 100644 index 0000000..5fdb78c --- /dev/null +++ b/docs/zero-knowledge-workers-storage.md @@ -0,0 +1,226 @@ +# Zero-Knowledge Storage Backends + +How to use foctet so a backend stores and moves user data it **cannot read** — +the model for a password manager or any end-to-end-encrypted app. The examples +focus on Cloudflare Workers, but the same storage model applies to D1, Turso / +libSQL, PostgreSQL, MySQL / MariaDB, R2, KV, object stores, and ordinary Rust +servers such as axum or Actix Web. + +The core rule is independent of the database: the backend stores opaque +ciphertext bytes and never receives the user's plaintext or decryption key. + +## Two encryption patterns + +| Pattern | Who decrypts | foctet API | Use for | +| --- | --- | --- | --- | +| **Blind storage** (zero-knowledge) | client only | `foctet_core::storage` (`seal_storage_record` / `open_storage_record`) | vault items, notes, attachments — anything the server just stores | +| **Encrypted transport** | the Worker | `foctet_http::workers` (`WorkersOpener` / `WorkersSealer`) | requests the server must act on (sync metadata, sharing control) | + +A zero-knowledge app is mostly blind storage, with encrypted transport only for +the few operations the server legitimately processes. + +## Blind storage + +The client seals a value to its own key and hands the opaque bytes to the +Worker, which stores them verbatim. Each value binds a record descriptor — +namespace, id, version — into the AEAD: + +```rust +use foctet_core::{StorageRecord, seal_storage_record, open_storage_record}; + +let record = StorageRecord::new(b"vault-items", b"login-github", version); +let blob = seal_storage_record(plaintext, account_public_key, b"account-v1", record)?; +// ... store `blob` in KV / D1 / Turso / PostgreSQL / MySQL / R2 / any backend, +// keyed by plaintext-free metadata such as owner id, record id, and version ... +let plaintext = open_storage_record(&blob, account_secret_key, record)?; +``` + +The descriptor is authenticated but not stored in the envelope, so the reader +must supply the same `StorageRecord` to open it. This gives: + +- **Confidentiality:** the server holds only ciphertext; it has no key. +- **Substitution resistance:** a backend cannot answer a read for record A with + record B's ciphertext — the namespace/id in the reader's descriptor would not + match, and the open fails. +- **Rollback resistance:** bind the version the client *expects* (tracked + client-side or via an authenticated version pointer); serving a stale + ciphertext then fails to open. + +See the runnable [`workers-kv-vault`](../foctet-http/examples/workers-kv-vault/README.md) +example — a Worker with **no crypto dependency** that stores blobs in KV. + +## Mapping to Cloudflare primitives + +Blind storage produces `Vec`, so it drops into any Cloudflare store: + +- **KV** — `put_bytes(id, blob)` / `get(id).bytes()`. Best for per-record vault + items and small config. (See the example.) +- **D1** — store the blob in a `BLOB` column; index on plaintext-free columns + only (record id, owner id, version), never on decrypted fields. +- **Turso / libSQL** — store the blob in a `BLOB` column, or encode it as + base64/hex `TEXT` only if the client library or migration path makes binary + values awkward. Cloudflare's TypeScript Worker integration uses + `@libsql/client/web`; worker-rs applications commonly use `libsql-client`. + Both are application-level database choices: foctet only requires that the + stored value remain the exact sealed bytes. +- **R2** — for large attachments, seal with `foctet-archive` + (`create_split_archive_from_bytes`) and store the parts as R2 objects. +- **Durable Objects** — hold per-user coordination state and opaque blobs; a DO + also backs strongly-consistent anti-replay (see + [`DurableObjectReplayStore`](../foctet-http/src/workers.rs)). +- **Hyperdrive to PostgreSQL / MySQL** — Hyperdrive is a connectivity layer, not + an encryption boundary. Store foctet ciphertext in `BYTEA` (PostgreSQL) or + `BLOB` / `VARBINARY` (MySQL / MariaDB), and keep indexes limited to + plaintext-free metadata. +- **Queues** — enqueue sealed bytes for async fan-out. Queues redeliver, so make + consumers idempotent; dedupe by the record/message id with an + `AsyncReplayStore` (e.g. a Durable Object) if a duplicate would cause harm. + +## SQL schema pattern + +For relational databases, the safe shape is boring on purpose: split the +server-visible routing metadata from the sealed user payload. + +```sql +CREATE TABLE vault_records ( + owner_id TEXT NOT NULL, + namespace TEXT NOT NULL, + record_id TEXT NOT NULL, + version INTEGER NOT NULL, + key_id TEXT NOT NULL, + ciphertext BLOB NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + PRIMARY KEY (owner_id, namespace, record_id) +); + +CREATE INDEX vault_records_owner_version + ON vault_records(owner_id, namespace, version); +``` + +The corresponding `StorageRecord` must be derived from the same logical +descriptor that the client expects: + +```rust +let record = StorageRecord::new( + namespace.as_bytes(), + record_id.as_bytes(), + version, +); +``` + +The server may use `owner_id`, `namespace`, `record_id`, and `version` for +routing, authorization checks, pagination, conflict detection, or quota +accounting. It must not derive columns from decrypted fields such as a note +title, URL, task name, email body, attachment filename, or password entry +username unless those fields are intentionally public metadata for that +application. + +When binary columns are unavailable or inconvenient, store `ciphertext` as +base64 `TEXT` and decode it byte-for-byte before returning it to the client. +Do not JSON-serialize the plaintext and call that zero knowledge. + +## Turso / libSQL deployment patterns + +Turso works as a zero-knowledge backend when it is only the persistence layer for +sealed bytes. + +- **Cloudflare Worker, TypeScript:** use Turso's Worker-compatible web client + (`@libsql/client/web`) to insert and fetch the sealed blob. The Worker should + receive ciphertext from the client, store it, and return ciphertext on reads. + It does not need a foctet secret key for blind storage. +- **Cloudflare Worker, worker-rs:** use `libsql-client` or another + wasm-compatible libSQL client. The database adapter should accept and return + `Vec` (or encoded text) without trying to parse foctet envelopes. +- **Native Rust backend:** use the `turso` or `libsql` crate when that is the + right operational fit for an axum / Actix Web / background worker process. + The same zero-knowledge rule applies: store opaque bytes and keep keys on the + end-user client. + +Turso authentication tokens, database URLs, retry policy, migrations, and query +builders are application concerns. They do not belong in foctet unless foctet +itself starts owning a database service, which it intentionally does not. + +## PostgreSQL, MySQL, and MariaDB + +PostgreSQL, MySQL, and MariaDB are also valid blind-storage backends. + +Recommended binary columns: + +| Database | Ciphertext column | Notes | +| --- | --- | --- | +| PostgreSQL | `BYTEA` | Keep server-side indexes on owner/record/version metadata only. | +| MySQL / MariaDB | `BLOB`, `MEDIUMBLOB`, or `VARBINARY` | Choose the size class from the maximum sealed payload size. | +| SQLite / D1 / libSQL / Turso | `BLOB` | `TEXT` with base64 is acceptable only as an encoding workaround. | + +With Cloudflare Workers, Hyperdrive can connect a Worker to PostgreSQL or MySQL, +but Hyperdrive does not change the cryptographic model. With axum, Actix Web, or +another native Rust backend, any DB library (`sqlx`, `diesel`, `tokio-postgres`, +`mysql_async`, `turso`, `libsql`, or a project-specific adapter) can be used as +long as it preserves the sealed bytes exactly. + +## What belongs in foctet vs the application + +foctet should provide the cryptographic storage envelope and the authenticated +descriptor binding. It should not grow database-specific adapters for every +storage engine. + +foctet responsibilities: + +- `StorageRecord` descriptor binding. +- `seal_storage_record` / `open_storage_record`. +- archive formats for large sealed objects. +- HTTP protected-context helpers for requests that the server must process. +- documentation and test vectors for the wire formats. + +Application responsibilities: + +- database schema, migrations, pooling, retries, and credentials; +- Cloudflare bindings, Turso tokens, Hyperdrive configuration, or native DB + connection strings; +- authorization for who may create, read, update, or delete a record id; +- conflict resolution, version allocation, pagination, quotas, and retention; +- deciding which metadata is intentionally visible to the backend. + +## Safe and unsafe patterns + +Safe patterns: + +- Store `ciphertext` plus `owner_id`, `namespace`, `record_id`, and `version`. +- Bind the same namespace/id/version into `StorageRecord`. +- Return ciphertext to the client and let the client decrypt. +- Use encrypted transport separately for server-processed control operations. +- Use `foctet-archive` or split archives for large attachments. + +Unsafe patterns: + +- Decrypt in the Worker or backend and store plaintext in SQL. +- Store searchable decrypted fields next to the ciphertext and still call the + design zero knowledge. +- Let the server choose a different descriptor than the client expects. +- Treat database encryption-at-rest, Turso auth tokens, TLS, Hyperdrive, or + private networking as a replacement for client-side foctet encryption. +- Reuse one ciphertext under a different namespace/id/version and expect it to + open. + +## Sharing and multiple devices + +- **One account, many devices:** derive one account keypair from the master + password; every device holds the same key, so single-recipient + `seal_storage_record` reads everywhere. +- **Sharing with other users:** wrap one payload to several recipient public keys + with `foctet-archive` (`create_archive_from_bytes(payload, &[pub_a, pub_b, …])`), + and store the archive bytes like any other blob. + +## Client key management (out of foctet's scope) + +foctet encrypts to X25519 keys; it does not derive them from a password. The +client is responsible for: + +- deriving the account key from the master password with a memory-hard KDF + (Argon2id) and never uploading it; +- rotating the account key with an overlap window if it changes (see + [Key rotation](key-rotation.md)); +- authenticating the outer transport (TLS) and the user session separately — + blind storage protects data at rest, not who is allowed to read or write a + given record id. diff --git a/foctet-archive/Cargo.toml b/foctet-archive/Cargo.toml index bbec424..da816ac 100644 --- a/foctet-archive/Cargo.toml +++ b/foctet-archive/Cargo.toml @@ -2,6 +2,7 @@ name = "foctet-archive" version.workspace = true edition.workspace = true +rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true diff --git a/foctet-core/Cargo.toml b/foctet-core/Cargo.toml index 11f1c6e..8b42389 100644 --- a/foctet-core/Cargo.toml +++ b/foctet-core/Cargo.toml @@ -2,6 +2,7 @@ name = "foctet-core" version.workspace = true edition.workspace = true +rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true @@ -23,6 +24,7 @@ futures-sink = "0.3" hkdf.workspace = true rand_core.workspace = true sha2.workspace = true +subtle.workspace = true thiserror.workspace = true tokio = { version = "1.48", features = ["io-util"], optional = true } x25519-dalek.workspace = true diff --git a/foctet-core/src/auth.rs b/foctet-core/src/auth.rs index 8b5155e..bfc5ea0 100644 --- a/foctet-core/src/auth.rs +++ b/foctet-core/src/auth.rs @@ -1,29 +1,64 @@ +use std::sync::Arc; + use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use rand_core::OsRng; +use subtle::ConstantTimeEq; use zeroize::Zeroizing; use crate::CoreError; +/// Signs native handshake transcripts with a long-term Ed25519 identity. +/// +/// This is the seam for **hardware-backed or otherwise non-extractable** identity +/// keys: implement it for an HSM, a cloud KMS, a TPM, or an OS keystore so the +/// Ed25519 private key never enters process memory. The software +/// [`IdentityKeyPair`] implements it for the common case. +/// +/// The contract is deliberately narrow — expose the public key and produce a +/// detached Ed25519 signature over `message` — so a signer is never asked to +/// reveal private key bytes. `Send + Sync` is required so a configured +/// [`Session`](crate::Session) stays usable across threads and async tasks. +pub trait HandshakeSigner: Send + Sync { + /// Returns the Ed25519 public identity key (the verifying key). + fn public_key(&self) -> [u8; 32]; + + /// Produces a detached Ed25519 signature over `message`. + fn sign(&self, message: &[u8]) -> [u8; 64]; +} + /// Authentication mode discriminator for native handshake messages. pub const HANDSHAKE_AUTH_NONE: u8 = 0; /// Ed25519-based transcript authentication for native handshake messages. pub const HANDSHAKE_AUTH_ED25519: u8 = 1; /// Local long-term identity key pair used to sign handshake transcripts. -#[derive(Clone, Eq, PartialEq)] +#[derive(Clone)] pub struct IdentityKeyPair { secret_key: Zeroizing<[u8; 32]>, public_key: [u8; 32], } impl core::fmt::Debug for IdentityKeyPair { + /// Prints only the public key; the secret scalar is never formatted. fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("IdentityKeyPair") .field("public_key", &self.public_key) + .field("secret_key", &"") .finish() } } +impl PartialEq for IdentityKeyPair { + /// Compares identity key pairs in constant time over the secret scalar. + fn eq(&self, other: &Self) -> bool { + let secret_eq = self.secret_key.ct_eq(other.secret_key.as_ref()); + let public_eq = self.public_key.ct_eq(&other.public_key); + (secret_eq & public_eq).into() + } +} + +impl Eq for IdentityKeyPair {} + impl IdentityKeyPair { /// Generates a fresh Ed25519 identity key pair. pub fn generate() -> Self { @@ -46,9 +81,16 @@ impl IdentityKeyPair { self.public_key } - /// Returns the Ed25519 secret key bytes. - pub fn secret_key_bytes(&self) -> [u8; 32] { - *self.secret_key + /// Exposes a zeroizing copy of the Ed25519 secret key bytes. + /// + /// This is a deliberate, auditable extraction of long-term secret material + /// (for persistence or serialization). The returned [`Zeroizing`] wrapper + /// wipes its copy on drop, but callers are responsible for not spreading + /// further unprotected copies. Named with an `expose_` prefix so secret + /// extraction is greppable and obvious at the call site. + #[must_use] + pub fn expose_secret_key_bytes(&self) -> Zeroizing<[u8; 32]> { + self.secret_key.clone() } /// Signs handshake transcript bytes. @@ -58,6 +100,50 @@ impl IdentityKeyPair { } } +impl HandshakeSigner for IdentityKeyPair { + fn public_key(&self) -> [u8; 32] { + IdentityKeyPair::public_key(self) + } + + fn sign(&self, message: &[u8]) -> [u8; 64] { + IdentityKeyPair::sign(self, message) + } +} + +/// An outer-channel binding value mixed into the Foctet handshake transcript. +/// +/// When both peers configure the *same* binding (for example a TLS exporter +/// value per RFC 5705, a TLS channel id, or any other value that is unique to +/// the authenticated outer channel), it is folded into the handshake transcript +/// hash. A man-in-the-middle that terminates the outer channel and relays the +/// Foctet handshake necessarily has a *different* binding value, so the two +/// sides compute different transcripts and the handshake fails closed — even +/// when no Foctet Ed25519 identity is used. This lets an authenticated outer +/// channel substitute for Foctet identity authentication. +/// +/// The binding is **not** secret; it is authenticated context, not key +/// material. An empty binding is treated as "no binding" and leaves the +/// transcript byte-identical to a handshake configured without one. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ChannelBinding(Vec); + +impl ChannelBinding { + /// Creates a channel binding from the outer channel's binding bytes. + pub fn new(bytes: impl Into>) -> Self { + Self(bytes.into()) + } + + /// Returns the binding bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + /// Returns whether the binding carries no bytes (treated as "no binding"). + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + /// Peer identity pin used to verify remote handshake authentication. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct PeerIdentity { @@ -72,6 +158,39 @@ impl PeerIdentity { } } +/// A peer whose Ed25519 identity was proven during the handshake. +/// +/// Obtained from [`crate::Session::authenticated_peer`] after a successful +/// handshake in which the remote side presented a valid identity signature (and, +/// when a [`PeerIdentity`] was pinned, matched it). It is the typed counterpart +/// to the [`crate::Session::peer_authenticated`] boolean: it additionally tells +/// you *which* identity authenticated. A handshake whose man-in-the-middle +/// resistance comes only from a [`ChannelBinding`] (no Foctet identity) yields +/// `None`, because no peer *identity* was proven. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AuthenticatedPeer { + identity_public_key: [u8; 32], +} + +impl AuthenticatedPeer { + /// Creates an authenticated-peer record from a verified identity key. + pub fn new(identity_public_key: [u8; 32]) -> Self { + Self { + identity_public_key, + } + } + + /// Returns the verified Ed25519 identity public key of the peer. + pub fn identity_public_key(&self) -> [u8; 32] { + self.identity_public_key + } + + /// Returns whether this peer matches the given pinned [`PeerIdentity`]. + pub fn matches(&self, identity: &PeerIdentity) -> bool { + self.identity_public_key.ct_eq(&identity.public_key).into() + } +} + /// Authentication payload attached to a handshake control message. #[derive(Clone, Debug, Eq, PartialEq)] pub struct HandshakeAuth { @@ -82,11 +201,15 @@ pub struct HandshakeAuth { } impl HandshakeAuth { - /// Creates an authentication payload from a local identity and transcript message. - pub fn sign(identity: &IdentityKeyPair, message: &[u8]) -> Self { + /// Creates an authentication payload from a local signer and transcript message. + /// + /// Accepts any [`HandshakeSigner`] (the software [`IdentityKeyPair`] coerces + /// automatically), so hardware-backed identities work without exposing key + /// bytes. + pub fn sign(signer: &dyn HandshakeSigner, message: &[u8]) -> Self { Self { - identity_public_key: identity.public_key(), - signature: identity.sign(message), + identity_public_key: signer.public_key(), + signature: signer.sign(message), } } @@ -107,22 +230,115 @@ impl HandshakeAuth { } /// Session-level handshake authentication configuration. -#[derive(Clone, Debug, Default, Eq, PartialEq)] +/// +/// # Safe by default +/// +/// A default ([`SessionAuthConfig::new`]) configuration **fails closed**: the +/// native handshake will not complete unless the peer presents a valid +/// authenticated handshake. To run an intentionally unauthenticated handshake — +/// for example inside an already-authenticated outer channel such as mutually +/// authenticated TLS, or in tests — you must explicitly opt in with +/// [`SessionAuthConfig::unauthenticated_for_testing`] (or +/// [`SessionAuthConfig::allow_unauthenticated`]). This makes the active +/// man-in-the-middle exposure of an unauthenticated ephemeral handshake an +/// explicit, auditable choice rather than a silent default. +#[derive(Clone, Default)] pub struct SessionAuthConfig { - local_identity: Option, + local_signer: Option>, peer_identity: Option, require_peer_authentication: bool, + allow_unauthenticated: bool, + channel_binding: Option, +} + +impl core::fmt::Debug for SessionAuthConfig { + /// Shows the local signer only by its public key (never secret material) and + /// omits the trait object's internals. + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("SessionAuthConfig") + .field( + "local_signer_public_key", + &self.local_signer.as_ref().map(|signer| signer.public_key()), + ) + .field("peer_identity", &self.peer_identity) + .field( + "require_peer_authentication", + &self.require_peer_authentication, + ) + .field("allow_unauthenticated", &self.allow_unauthenticated) + .field("channel_binding", &self.channel_binding) + .finish() + } } impl SessionAuthConfig { - /// Creates an empty authentication configuration. + /// Creates an empty, fail-closed authentication configuration. + /// + /// Without a pinned [`PeerIdentity`] or an explicit + /// [`SessionAuthConfig::allow_unauthenticated`] opt-in, the handshake will + /// reject a peer that does not authenticate. pub fn new() -> Self { Self::default() } - /// Attaches a local identity used to sign native handshake messages. + /// Creates a configuration that explicitly permits an unauthenticated + /// handshake. + /// + /// Only use this when peer authentication is guaranteed by an outer channel + /// (e.g. mutually authenticated TLS) or in tests. An unauthenticated Foctet + /// handshake on an untrusted transport is vulnerable to an active + /// man-in-the-middle. + pub fn unauthenticated_for_testing() -> Self { + Self { + allow_unauthenticated: true, + ..Self::default() + } + } + + /// Creates a configuration whose man-in-the-middle resistance comes from an + /// authenticated outer channel rather than a Foctet Ed25519 identity. + /// + /// The `binding` (e.g. a TLS exporter value) is folded into the handshake + /// transcript on both sides, so a relay across a different outer channel + /// fails closed. This is the typed, production-oriented alternative to + /// [`SessionAuthConfig::unauthenticated_for_testing`]: there is no Foctet + /// identity, but the handshake is bound to a channel you already trust. + pub fn bound_to_channel(binding: ChannelBinding) -> Self { + Self { + allow_unauthenticated: true, + channel_binding: Some(binding), + ..Self::default() + } + } + + /// Attaches a software local identity used to sign native handshake messages. + /// + /// Convenience over [`SessionAuthConfig::with_local_signer`] for the common + /// in-process [`IdentityKeyPair`] case. pub fn with_local_identity(mut self, identity: IdentityKeyPair) -> Self { - self.local_identity = Some(identity); + self.local_signer = Some(Arc::new(identity)); + self + } + + /// Attaches a local [`HandshakeSigner`] used to sign native handshake + /// messages. + /// + /// Use this for hardware-backed or otherwise non-extractable identity keys + /// (HSM, cloud KMS, TPM, OS keystore): the private key never enters process + /// memory. For an in-process key, prefer + /// [`SessionAuthConfig::with_local_identity`]. + pub fn with_local_signer(mut self, signer: S) -> Self { + self.local_signer = Some(Arc::new(signer)); + self + } + + /// Binds the handshake transcript to an outer-channel [`ChannelBinding`]. + /// + /// Additive to any identity configuration: both peers must supply the same + /// binding or the handshake fails. An empty binding leaves the transcript + /// byte-identical to a handshake configured without one. + pub fn with_channel_binding(mut self, binding: ChannelBinding) -> Self { + self.channel_binding = Some(binding); self } @@ -138,9 +354,29 @@ impl SessionAuthConfig { self } - /// Returns the local identity, if configured. - pub fn local_identity(&self) -> Option<&IdentityKeyPair> { - self.local_identity.as_ref() + /// Explicitly permits (or forbids) completing an unauthenticated handshake. + /// + /// See [`SessionAuthConfig::unauthenticated_for_testing`] for the safety + /// implications. This is ignored when peer authentication is required or a + /// peer identity is pinned (those always demand authentication). + pub fn allow_unauthenticated(mut self, allow: bool) -> Self { + self.allow_unauthenticated = allow; + self + } + + /// Returns whether an unauthenticated handshake is explicitly permitted. + pub fn allows_unauthenticated(&self) -> bool { + self.allow_unauthenticated + } + + /// Returns the configured local handshake signer, if any. + pub fn local_signer(&self) -> Option<&dyn HandshakeSigner> { + self.local_signer.as_deref() + } + + /// Returns the local identity public key, if a local signer is configured. + pub fn local_identity_public_key(&self) -> Option<[u8; 32]> { + self.local_signer.as_ref().map(|signer| signer.public_key()) } /// Returns the pinned peer identity, if configured. @@ -152,4 +388,50 @@ impl SessionAuthConfig { pub fn requires_peer_authentication(&self) -> bool { self.require_peer_authentication } + + /// Returns the configured outer-channel binding bytes, or an empty slice + /// when none is set. An empty binding leaves the transcript unchanged. + pub fn channel_binding_bytes(&self) -> &[u8] { + match &self.channel_binding { + Some(binding) => binding.as_bytes(), + None => &[], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_debug_redacts_secret_key() { + let identity = IdentityKeyPair::from_secret_key_bytes([0x37; 32]); + let rendered = format!("{identity:?}"); + assert!(rendered.contains("")); + // The secret scalar's array form must never appear. + let leaked = format!("{:?}", [0x37_u8; 32]); + assert!( + !rendered.contains(&leaked), + "secret key leaked into Debug output: {rendered}" + ); + } + + #[test] + fn identity_equality_is_value_based() { + let a = IdentityKeyPair::from_secret_key_bytes([0x11; 32]); + let b = IdentityKeyPair::from_secret_key_bytes([0x11; 32]); + let c = IdentityKeyPair::from_secret_key_bytes([0x22; 32]); + assert_eq!(a, b); + assert_ne!(a, c); + } + + #[test] + fn expose_secret_key_bytes_round_trips() { + let secret = [0x9C; 32]; + let identity = IdentityKeyPair::from_secret_key_bytes(secret); + let exposed = identity.expose_secret_key_bytes(); + assert_eq!(*exposed, secret); + // Rebuilding from the exposed bytes yields the same identity. + assert_eq!(IdentityKeyPair::from_secret_key_bytes(*exposed), identity); + } } diff --git a/foctet-core/src/body.rs b/foctet-core/src/body.rs index 4f80aac..34e96a4 100644 --- a/foctet-core/src/body.rs +++ b/foctet-core/src/body.rs @@ -19,8 +19,8 @@ pub const BODY_PROFILE_V0: u8 = 0x01; pub const X25519_PUBLIC_KEY_LEN: usize = 32; /// XChaCha20-Poly1305 nonce length in bytes. pub const XCHACHA_NONCE_LEN: usize = 24; -const CONTENT_KEY_LEN: usize = 32; -const TAG_LEN: usize = 16; +pub(crate) const CONTENT_KEY_LEN: usize = 32; +pub(crate) const TAG_LEN: usize = 16; const WRAP_INFO_LABEL: &[u8] = b"foctet body wrap v0"; /// Parser and encoder hardening limits for body envelopes. @@ -86,6 +86,14 @@ pub enum BodyEnvelopeError { /// HKDF expansion failed. #[error("hkdf expand failed")] Hkdf, + /// A stream chunk arrived after the final chunk, or sealing/opening + /// continued after the stream was finalized. + #[error("stream already finalized")] + StreamFinished, + /// A stream chunk arrived with an unexpected index (out of order, gap, or + /// duplicate). + #[error("stream chunk out of order")] + ChunkOutOfOrder, } #[derive(Clone, Debug)] @@ -123,6 +131,32 @@ pub fn seal_body_with_limits( recipient_public_key: [u8; 32], recipient_key_id: &[u8], limits: &BodyEnvelopeLimits, +) -> Result, BodyEnvelopeError> { + seal_body_with_context( + plaintext, + recipient_public_key, + recipient_key_id, + &[], + limits, + ) +} + +/// Seals plaintext bytes and additionally binds an application-supplied +/// `context` into the payload AEAD as associated data. +/// +/// The `context` bytes are **not** transmitted in the envelope; the opener must +/// supply byte-identical context or decryption fails. This is the building +/// block for binding an envelope to its surrounding protocol context — for +/// HTTP, a canonical encoding of purpose/direction, method, authority, path, +/// query, timestamp/expiry, and a unique message ID — so a captured envelope +/// cannot be replayed onto a different request or operation. An empty `context` +/// produces byte-identical output to [`seal_body_with_limits`]. +pub fn seal_body_with_context( + plaintext: &[u8], + recipient_public_key: [u8; 32], + recipient_key_id: &[u8], + context: &[u8], + limits: &BodyEnvelopeLimits, ) -> Result, BodyEnvelopeError> { if recipient_key_id.is_empty() { return Err(BodyEnvelopeError::InvalidHeader("empty recipient key id")); @@ -173,6 +207,7 @@ pub fn seal_body_with_limits( return Err(BodyEnvelopeError::LimitExceeded("header_len")); } + let aad = aead_aad(&header, context); let cipher = XChaCha20Poly1305::new_from_slice(&content_key[..]) .map_err(|_| BodyEnvelopeError::EncryptFailed)?; let payload_ciphertext = cipher @@ -180,7 +215,7 @@ pub fn seal_body_with_limits( XNonce::from_slice(&payload_nonce), Payload { msg: plaintext, - aad: &header, + aad: &aad, }, ) .map_err(|_| BodyEnvelopeError::EncryptFailed)?; @@ -213,8 +248,20 @@ pub fn open_body_with_limits( envelope: &[u8], recipient_secret_key: [u8; 32], limits: &BodyEnvelopeLimits, +) -> Result, BodyEnvelopeError> { + open_body_with_context(envelope, recipient_secret_key, &[], limits) +} + +/// Opens a body envelope, requiring the same `context` that was supplied to +/// [`seal_body_with_context`]. Decryption fails if the context does not match. +pub fn open_body_with_context( + envelope: &[u8], + recipient_secret_key: [u8; 32], + context: &[u8], + limits: &BodyEnvelopeLimits, ) -> Result, BodyEnvelopeError> { let parsed = parse_envelope(envelope, limits)?; + let aad = aead_aad(parsed.header_bytes, context); for recipient in &parsed.recipients { let content_key = match unwrap_content_key( @@ -236,7 +283,7 @@ pub fn open_body_with_limits( XNonce::from_slice(&parsed.payload_nonce), Payload { msg: parsed.payload_ciphertext, - aad: parsed.header_bytes, + aad: &aad, }, ) .map_err(|_| BodyEnvelopeError::DecryptFailed)?; @@ -267,8 +314,27 @@ pub fn open_body_for_key_id_with_limits( recipient_secret_key: [u8; 32], recipient_key_id: &[u8], limits: &BodyEnvelopeLimits, +) -> Result, BodyEnvelopeError> { + open_body_for_key_id_with_context( + envelope, + recipient_secret_key, + recipient_key_id, + &[], + limits, + ) +} + +/// Opens an envelope for a specific recipient key identifier, requiring the same +/// `context` supplied to [`seal_body_with_context`]. +pub fn open_body_for_key_id_with_context( + envelope: &[u8], + recipient_secret_key: [u8; 32], + recipient_key_id: &[u8], + context: &[u8], + limits: &BodyEnvelopeLimits, ) -> Result, BodyEnvelopeError> { let parsed = parse_envelope(envelope, limits)?; + let aad = aead_aad(parsed.header_bytes, context); let entry = parsed .recipients @@ -291,12 +357,26 @@ pub fn open_body_for_key_id_with_limits( XNonce::from_slice(&parsed.payload_nonce), Payload { msg: parsed.payload_ciphertext, - aad: parsed.header_bytes, + aad: &aad, }, ) .map_err(|_| BodyEnvelopeError::DecryptFailed) } +/// Builds the payload AEAD associated data from the envelope header and an +/// optional application-supplied context. An empty context yields exactly the +/// header bytes, preserving wire/vector compatibility with context-free +/// envelopes. +fn aead_aad(header: &[u8], context: &[u8]) -> Vec { + if context.is_empty() { + return header.to_vec(); + } + let mut aad = Vec::with_capacity(header.len() + context.len()); + aad.extend_from_slice(header); + aad.extend_from_slice(context); + aad +} + fn parse_envelope<'a>( envelope: &'a [u8], limits: &BodyEnvelopeLimits, @@ -456,7 +536,7 @@ fn parse_envelope<'a>( }) } -fn wrap_content_key( +pub(crate) fn wrap_content_key( content_key: &[u8; CONTENT_KEY_LEN], recipient_public_key: [u8; 32], eph_priv: StaticSecret, @@ -481,7 +561,7 @@ fn wrap_content_key( .map_err(|_| BodyEnvelopeError::KeyUnwrapFailed) } -fn unwrap_content_key( +pub(crate) fn unwrap_content_key( wrapped_key: &[u8], key_id: &[u8], recipient_secret_key: [u8; 32], @@ -664,6 +744,51 @@ mod tests { assert_eq!(out, plain); } + #[test] + fn context_binding_roundtrip_and_mismatch() { + let recipient_priv = StaticSecret::random_from_rng(OsRng); + let recipient_pub = PublicKey::from(&recipient_priv).to_bytes(); + let limits = BodyEnvelopeLimits::default(); + + let plain = b"POST /pay body"; + let ctx_a = b"foctet-http-v0|req|POST|api.example|/pay|ts=1|id=abc"; + let ctx_b = b"foctet-http-v0|req|POST|api.example|/refund|ts=1|id=abc"; + + let envelope = + seal_body_with_context(plain, recipient_pub, b"kid", ctx_a, &limits).expect("seal"); + + // Correct context opens. + let out = open_body_with_context(&envelope, recipient_priv.to_bytes(), ctx_a, &limits) + .expect("open with matching context"); + assert_eq!(out, plain); + + // A different context (e.g. replay onto another route) must fail. + let err = open_body_with_context(&envelope, recipient_priv.to_bytes(), ctx_b, &limits) + .expect_err("mismatched context must fail"); + assert_eq!(err, BodyEnvelopeError::DecryptFailed); + + // Opening without context (legacy path) must also fail for a + // context-bound envelope. + let err = open_body(&envelope, recipient_priv.to_bytes()) + .expect_err("context-bound envelope must not open context-free"); + assert_eq!(err, BodyEnvelopeError::DecryptFailed); + } + + #[test] + fn empty_context_matches_legacy_bytes() { + let recipient_priv = StaticSecret::random_from_rng(OsRng); + let recipient_pub = PublicKey::from(&recipient_priv).to_bytes(); + let limits = BodyEnvelopeLimits::default(); + let plain = b"hello"; + + // An envelope sealed with empty context must open via the legacy + // context-free path (AAD is byte-identical). + let envelope = + seal_body_with_context(plain, recipient_pub, b"kid", &[], &limits).expect("seal"); + let out = open_body(&envelope, recipient_priv.to_bytes()).expect("legacy open"); + assert_eq!(out, plain); + } + #[test] fn open_rejects_invalid_magic() { let recipient_priv = StaticSecret::random_from_rng(OsRng); diff --git a/foctet-core/src/body_stream.rs b/foctet-core/src/body_stream.rs new file mode 100644 index 0000000..f36c920 --- /dev/null +++ b/foctet-core/src/body_stream.rs @@ -0,0 +1,735 @@ +//! Streaming `application/foctet` body: per-chunk AEAD with end-to-end integrity. +//! +//! The one-shot [`crate::body`] envelope must buffer the whole payload. This +//! module instead seals a payload as an ordered sequence of independently +//! authenticated chunks, so a large HTTP body can be encrypted and decrypted as +//! it streams without holding it all in memory. +//! +//! # Construction +//! +//! A single random content key (CEK) is generated for the stream and wrapped for +//! the recipient with the same ECIES construction as the one-shot envelope +//! (ephemeral X25519 → HKDF → AEAD key-wrap). The wrapped key, a random 16-byte +//! per-stream nonce prefix, and the recipient key id travel once in a **stream +//! header** (prologue). Each chunk is then sealed with the CEK under a unique +//! nonce `prefix || chunk_index` and authenticates, as associated data, the full +//! stream header, the chunk index, a flags byte, and the caller-supplied context. +//! +//! # Security properties +//! +//! - **Per-chunk AEAD, unique nonces.** Every chunk uses a distinct +//! `(content key, nonce)` because the 8-byte big-endian chunk index is part of +//! the nonce and the 16-byte prefix is unique per stream. +//! - **Truncation and extension resistance.** Exactly one chunk carries the +//! authenticated `FINAL` flag. A receiver only treats the stream as complete +//! after it opens that chunk ([`StreamOpener::is_finished`]); a dropped tail is +//! detected as an unfinished stream, and any chunk after the final one (or a +//! forged extra chunk, which cannot be decrypted) is rejected. +//! - **Ordering.** Chunk indices are sequential and checked, so reordering, +//! gaps, or duplicates fail closed. +//! - **Context / replay binding.** The `context` bytes are bound into every +//! chunk's AAD, so a captured stream cannot be replayed onto a different +//! request when the context carries a unique message id (see `foctet-http`). +//! +//! # Cancellation +//! +//! An aborted stream simply never delivers a `FINAL` chunk; the receiver detects +//! this via [`StreamOpener::is_finished`] returning `false` and MUST discard the +//! partial plaintext. A truncated and a cancelled stream are indistinguishable, +//! which is the safe outcome. + +use bytes::{Buf, BytesMut}; +use chacha20poly1305::{ + KeyInit, XChaCha20Poly1305, XNonce, + aead::{Aead, Payload}, +}; +use rand_core::{OsRng, RngCore}; +use zeroize::Zeroizing; + +use crate::body::{ + BodyEnvelopeError, BodyEnvelopeLimits, CONTENT_KEY_LEN, TAG_LEN, unwrap_content_key, + wrap_content_key, +}; +use x25519_dalek::{PublicKey, StaticSecret}; + +/// Magic marker for a streaming body header (`FOCTETHS` = Foctet HTTP Stream). +pub const STREAM_MAGIC: [u8; 8] = *b"FOCTETHS"; +/// Streaming body wire version. +pub const STREAM_VERSION_V0: u8 = 0x01; +/// Streaming body cryptographic profile (X25519 + HKDF + XChaCha20-Poly1305). +pub const STREAM_PROFILE_V0: u8 = 0x01; +/// Length of the random per-stream nonce prefix in bytes. +pub const STREAM_NONCE_PREFIX_LEN: usize = 16; +/// Per-chunk frame overhead (index + flags + ct_len fields), excluding the AEAD +/// tag carried inside the ciphertext. +pub const STREAM_CHUNK_OVERHEAD: usize = 8 + 1 + 4; + +/// Flag bit marking the final chunk of a stream. +const FLAG_FINAL: u8 = 1 << 0; + +fn chunk_nonce(prefix: &[u8; STREAM_NONCE_PREFIX_LEN], index: u64) -> [u8; 24] { + let mut nonce = [0u8; 24]; + nonce[..STREAM_NONCE_PREFIX_LEN].copy_from_slice(prefix); + nonce[STREAM_NONCE_PREFIX_LEN..].copy_from_slice(&index.to_be_bytes()); + nonce +} + +/// Builds the per-chunk AEAD associated data: stream header ‖ index ‖ flags ‖ context. +fn chunk_aad(header: &[u8], index: u64, flags: u8, context: &[u8]) -> Vec { + let mut aad = Vec::with_capacity(header.len() + 8 + 1 + context.len()); + aad.extend_from_slice(header); + aad.extend_from_slice(&index.to_be_bytes()); + aad.push(flags); + aad.extend_from_slice(context); + aad +} + +fn encode_stream_header( + nonce_prefix: &[u8; STREAM_NONCE_PREFIX_LEN], + eph_pub: &[u8; 32], + recipient_key_id: &[u8], + wrapped_key: &[u8], +) -> Result, BodyEnvelopeError> { + let key_id_len = u16::try_from(recipient_key_id.len()) + .map_err(|_| BodyEnvelopeError::LimitExceeded("key_id_len"))?; + let wrapped_len = u16::try_from(wrapped_key.len()) + .map_err(|_| BodyEnvelopeError::LimitExceeded("wrapped_key_len"))?; + + let mut out = Vec::with_capacity( + STREAM_MAGIC.len() + + 2 + + STREAM_NONCE_PREFIX_LEN + + 32 + + 2 + + recipient_key_id.len() + + 2 + + wrapped_key.len(), + ); + out.extend_from_slice(&STREAM_MAGIC); + out.push(STREAM_VERSION_V0); + out.push(STREAM_PROFILE_V0); + out.extend_from_slice(nonce_prefix); + out.extend_from_slice(eph_pub); + out.extend_from_slice(&key_id_len.to_be_bytes()); + out.extend_from_slice(recipient_key_id); + out.extend_from_slice(&wrapped_len.to_be_bytes()); + out.extend_from_slice(wrapped_key); + Ok(out) +} + +struct ParsedStreamHeader { + nonce_prefix: [u8; STREAM_NONCE_PREFIX_LEN], + eph_pub: [u8; 32], + key_id: Vec, + wrapped_key: Vec, + header_len: usize, +} + +fn parse_stream_header( + header: &[u8], + limits: &BodyEnvelopeLimits, +) -> Result { + if header.len() > limits.max_header_bytes { + return Err(BodyEnvelopeError::LimitExceeded("header_len")); + } + let mut cur = 0usize; + let take = |buf: &[u8], cur: &mut usize, n: usize| -> Result, BodyEnvelopeError> { + let end = cur.checked_add(n).ok_or(BodyEnvelopeError::Truncated)?; + if end > buf.len() { + return Err(BodyEnvelopeError::Truncated); + } + let out = buf[*cur..end].to_vec(); + *cur = end; + Ok(out) + }; + + if take(header, &mut cur, STREAM_MAGIC.len())? != STREAM_MAGIC { + return Err(BodyEnvelopeError::InvalidHeader("magic")); + } + let version = take(header, &mut cur, 1)?[0]; + if version != STREAM_VERSION_V0 { + return Err(BodyEnvelopeError::UnsupportedVersion(version)); + } + let profile = take(header, &mut cur, 1)?[0]; + if profile != STREAM_PROFILE_V0 { + return Err(BodyEnvelopeError::UnsupportedProfile(profile)); + } + + let mut nonce_prefix = [0u8; STREAM_NONCE_PREFIX_LEN]; + nonce_prefix.copy_from_slice(&take(header, &mut cur, STREAM_NONCE_PREFIX_LEN)?); + let mut eph_pub = [0u8; 32]; + eph_pub.copy_from_slice(&take(header, &mut cur, 32)?); + + let key_id_len = u16::from_be_bytes( + take(header, &mut cur, 2)? + .try_into() + .map_err(|_| BodyEnvelopeError::Truncated)?, + ) as usize; + if key_id_len == 0 || key_id_len > limits.max_key_id_len { + return Err(BodyEnvelopeError::InvalidHeader("key_id_len")); + } + let key_id = take(header, &mut cur, key_id_len)?; + + let wrapped_len = u16::from_be_bytes( + take(header, &mut cur, 2)? + .try_into() + .map_err(|_| BodyEnvelopeError::Truncated)?, + ) as usize; + if wrapped_len != CONTENT_KEY_LEN + TAG_LEN { + return Err(BodyEnvelopeError::InvalidHeader("wrapped_key_len")); + } + let wrapped_key = take(header, &mut cur, wrapped_len)?; + + Ok(ParsedStreamHeader { + nonce_prefix, + eph_pub, + key_id, + wrapped_key, + header_len: cur, + }) +} + +/// Seals a payload as an ordered sequence of authenticated chunks. +/// +/// Create one with [`StreamSealer::new`], send the returned header bytes first, +/// then call [`StreamSealer::seal_chunk`] for each chunk, passing `is_final = +/// true` for the last. +pub struct StreamSealer { + cipher: XChaCha20Poly1305, + nonce_prefix: [u8; STREAM_NONCE_PREFIX_LEN], + header: Vec, + context: Vec, + next_index: u64, + finished: bool, + max_chunk_plaintext: usize, +} + +impl StreamSealer { + /// Creates a sealer for `recipient_public_key` and returns `(sealer, + /// stream_header_bytes)`. Send `stream_header_bytes` before any chunk. + /// + /// `context` is bound into every chunk's AEAD (empty is allowed and is + /// byte-compatible with an empty-context opener). + pub fn new( + recipient_public_key: [u8; 32], + recipient_key_id: &[u8], + context: &[u8], + limits: &BodyEnvelopeLimits, + ) -> Result<(Self, Vec), BodyEnvelopeError> { + if recipient_key_id.is_empty() { + return Err(BodyEnvelopeError::InvalidHeader("empty recipient key id")); + } + if recipient_key_id.len() > limits.max_key_id_len { + return Err(BodyEnvelopeError::LimitExceeded("key_id_len")); + } + + let mut content_key = Zeroizing::new([0u8; CONTENT_KEY_LEN]); + OsRng.fill_bytes(&mut content_key[..]); + let mut nonce_prefix = [0u8; STREAM_NONCE_PREFIX_LEN]; + OsRng.fill_bytes(&mut nonce_prefix); + + let eph_priv = StaticSecret::random_from_rng(OsRng); + let eph_pub = PublicKey::from(&eph_priv).to_bytes(); + let wrapped_key = wrap_content_key( + &content_key, + recipient_public_key, + eph_priv, + eph_pub, + recipient_key_id, + )?; + + let header = encode_stream_header(&nonce_prefix, &eph_pub, recipient_key_id, &wrapped_key)?; + if header.len() > limits.max_header_bytes { + return Err(BodyEnvelopeError::LimitExceeded("header_len")); + } + + let cipher = XChaCha20Poly1305::new_from_slice(&content_key[..]) + .map_err(|_| BodyEnvelopeError::EncryptFailed)?; + + let sealer = Self { + cipher, + nonce_prefix, + header: header.clone(), + context: context.to_vec(), + next_index: 0, + finished: false, + max_chunk_plaintext: limits.max_payload_len.saturating_sub(TAG_LEN), + }; + Ok((sealer, header)) + } + + /// Returns the stream header bytes (the prologue to send before chunks). + pub fn header(&self) -> &[u8] { + &self.header + } + + /// Seals one chunk; pass `is_final = true` for the last chunk of the stream. + /// + /// Fails with [`BodyEnvelopeError::StreamFinished`] if called after the final + /// chunk. + pub fn seal_chunk( + &mut self, + plaintext: &[u8], + is_final: bool, + ) -> Result, BodyEnvelopeError> { + if self.finished { + return Err(BodyEnvelopeError::StreamFinished); + } + if plaintext.len() > self.max_chunk_plaintext { + return Err(BodyEnvelopeError::LimitExceeded("chunk_plaintext")); + } + + let index = self.next_index; + let flags = if is_final { FLAG_FINAL } else { 0 }; + let nonce = chunk_nonce(&self.nonce_prefix, index); + let aad = chunk_aad(&self.header, index, flags, &self.context); + + let ciphertext = self + .cipher + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad: &aad, + }, + ) + .map_err(|_| BodyEnvelopeError::EncryptFailed)?; + + let ct_len = u32::try_from(ciphertext.len()) + .map_err(|_| BodyEnvelopeError::LimitExceeded("chunk_ct_len"))?; + + let mut out = Vec::with_capacity(STREAM_CHUNK_OVERHEAD + ciphertext.len()); + out.extend_from_slice(&index.to_be_bytes()); + out.push(flags); + out.extend_from_slice(&ct_len.to_be_bytes()); + out.extend_from_slice(&ciphertext); + + self.next_index = self.next_index.wrapping_add(1); + if is_final { + self.finished = true; + } + Ok(out) + } + + /// Returns whether the final chunk has been sealed. + pub fn is_finished(&self) -> bool { + self.finished + } +} + +/// One decrypted stream chunk. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DecodedChunk { + /// Decrypted chunk payload bytes. + pub plaintext: Vec, + /// Whether this was the final chunk of the stream. + pub is_final: bool, +} + +/// Opens a stream sealed by [`StreamSealer`], chunk by chunk. +/// +/// Create one from the stream header with [`StreamOpener::new`], then call +/// [`StreamOpener::open_chunk`] for each received chunk. **After the stream you +/// MUST check [`StreamOpener::is_finished`]** — a `false` result means the +/// stream was truncated or cancelled and the assembled plaintext must be +/// discarded. +pub struct StreamOpener { + cipher: XChaCha20Poly1305, + nonce_prefix: [u8; STREAM_NONCE_PREFIX_LEN], + header: Vec, + context: Vec, + expected_index: u64, + finished: bool, + max_chunk_ct: usize, +} + +impl StreamOpener { + /// Parses the stream `header`, unwraps the content key with + /// `recipient_secret_key`, and prepares to open chunks bound to `context`. + pub fn new( + recipient_secret_key: [u8; 32], + header: &[u8], + context: &[u8], + limits: &BodyEnvelopeLimits, + ) -> Result { + let parsed = parse_stream_header(header, limits)?; + let content_key = unwrap_content_key( + &parsed.wrapped_key, + &parsed.key_id, + recipient_secret_key, + parsed.eph_pub, + )?; + let cipher = XChaCha20Poly1305::new_from_slice(&content_key) + .map_err(|_| BodyEnvelopeError::KeyUnwrapFailed)?; + + Ok(Self { + cipher, + nonce_prefix: parsed.nonce_prefix, + header: header[..parsed.header_len].to_vec(), + context: context.to_vec(), + expected_index: 0, + finished: false, + max_chunk_ct: limits.max_payload_len, + }) + } + + /// Opens one received chunk into its plaintext. + /// + /// Fails closed on an out-of-order/duplicate index + /// ([`BodyEnvelopeError::ChunkOutOfOrder`]), on any chunk after the final one + /// ([`BodyEnvelopeError::StreamFinished`]), or on authentication failure. + pub fn open_chunk(&mut self, chunk: &[u8]) -> Result { + if self.finished { + return Err(BodyEnvelopeError::StreamFinished); + } + if chunk.len() < STREAM_CHUNK_OVERHEAD { + return Err(BodyEnvelopeError::Truncated); + } + + let index = u64::from_be_bytes(chunk[0..8].try_into().expect("8 bytes")); + let flags = chunk[8]; + let ct_len = u32::from_be_bytes(chunk[9..13].try_into().expect("4 bytes")) as usize; + let ciphertext = &chunk[STREAM_CHUNK_OVERHEAD..]; + if ciphertext.len() != ct_len { + return Err(BodyEnvelopeError::Truncated); + } + if ct_len > self.max_chunk_ct { + return Err(BodyEnvelopeError::LimitExceeded("chunk_ct_len")); + } + if index != self.expected_index { + return Err(BodyEnvelopeError::ChunkOutOfOrder); + } + // Only the FINAL flag is defined; reject unknown flag bits so they cannot + // be flipped without breaking authentication-equivalent expectations. + if flags & !FLAG_FINAL != 0 { + return Err(BodyEnvelopeError::InvalidHeader("chunk flags")); + } + + let nonce = chunk_nonce(&self.nonce_prefix, index); + let aad = chunk_aad(&self.header, index, flags, &self.context); + let plaintext = self + .cipher + .decrypt( + XNonce::from_slice(&nonce), + Payload { + msg: ciphertext, + aad: &aad, + }, + ) + .map_err(|_| BodyEnvelopeError::DecryptFailed)?; + + self.expected_index = self.expected_index.wrapping_add(1); + let is_final = flags & FLAG_FINAL != 0; + if is_final { + self.finished = true; + } + Ok(DecodedChunk { + plaintext, + is_final, + }) + } + + /// Returns whether the final chunk has been opened. A complete stream MUST + /// end with this returning `true`; otherwise the stream was truncated or + /// cancelled and its plaintext must be discarded. + pub fn is_finished(&self) -> bool { + self.finished + } +} + +/// Fixed-size prefix of a stream header before the variable key-id / wrapped-key +/// fields: magic(8) + version(1) + profile(1) + nonce_prefix + eph_pub(32). +const STREAM_HEADER_FIXED_PREFIX: usize = 8 + 1 + 1 + STREAM_NONCE_PREFIX_LEN + 32; + +/// Returns the full stream-header length once enough bytes are buffered to +/// determine it, `None` if more bytes are needed, or an error if the +/// length-prefix fields are invalid. +fn stream_header_len( + buf: &[u8], + limits: &BodyEnvelopeLimits, +) -> Result, BodyEnvelopeError> { + if buf.len() < STREAM_HEADER_FIXED_PREFIX + 2 { + return Ok(None); + } + let key_id_len = u16::from_be_bytes([ + buf[STREAM_HEADER_FIXED_PREFIX], + buf[STREAM_HEADER_FIXED_PREFIX + 1], + ]) as usize; + if key_id_len == 0 || key_id_len > limits.max_key_id_len { + return Err(BodyEnvelopeError::InvalidHeader("key_id_len")); + } + let wrapped_off = STREAM_HEADER_FIXED_PREFIX + 2 + key_id_len; + if buf.len() < wrapped_off + 2 { + return Ok(None); + } + let wrapped_len = u16::from_be_bytes([buf[wrapped_off], buf[wrapped_off + 1]]) as usize; + if wrapped_len != CONTENT_KEY_LEN + TAG_LEN { + return Err(BodyEnvelopeError::InvalidHeader("wrapped_key_len")); + } + let total = wrapped_off + 2 + wrapped_len; + if total > limits.max_header_bytes { + return Err(BodyEnvelopeError::LimitExceeded("header_len")); + } + // Only report the header as ready once all of its bytes have arrived. + if buf.len() < total { + return Ok(None); + } + Ok(Some(total)) +} + +/// One framed unit produced by a [`StreamFrameDecoder`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum StreamItem { + /// The stream header (prologue), produced once before any chunk. Feed it to + /// [`StreamOpener::new`]. + Header(Vec), + /// One complete chunk frame. Feed it to [`StreamOpener::open_chunk`]. + Chunk(Vec), +} + +/// Reassembles a streaming body's self-delimiting frames from arbitrarily split +/// byte chunks (e.g. HTTP body data frames that do not align to Foctet chunk +/// boundaries). +/// +/// Push received bytes with [`Self::push`], then drain complete frames with +/// [`Self::decode_next`]: it yields exactly one [`StreamItem::Header`] first, +/// then [`StreamItem::Chunk`]s, returning `None` whenever more bytes are needed. +/// This makes the streaming body usable over any byte transport — an axum/hyper +/// request body, a Cloudflare Workers `ReadableStream`, or a raw socket. +pub struct StreamFrameDecoder { + buf: BytesMut, + header_done: bool, + limits: BodyEnvelopeLimits, +} + +impl StreamFrameDecoder { + /// Creates a decoder bounded by `limits` (header size, chunk ciphertext size). + pub fn new(limits: &BodyEnvelopeLimits) -> Self { + Self { + buf: BytesMut::new(), + header_done: false, + limits: limits.clone(), + } + } + + /// Appends received bytes to the internal buffer. + pub fn push(&mut self, bytes: &[u8]) { + self.buf.extend_from_slice(bytes); + } + + /// Drains the next complete frame, or `None` if more bytes are needed. + pub fn decode_next(&mut self) -> Result, BodyEnvelopeError> { + if !self.header_done { + return match stream_header_len(&self.buf, &self.limits)? { + None => Ok(None), + Some(len) => { + let header = self.buf[..len].to_vec(); + self.buf.advance(len); + self.header_done = true; + Ok(Some(StreamItem::Header(header))) + } + }; + } + + if self.buf.len() < STREAM_CHUNK_OVERHEAD { + return Ok(None); + } + // Chunk layout: index(8) ‖ flags(1) ‖ ct_len(4) ‖ ciphertext. + let ct_len = + u32::from_be_bytes([self.buf[9], self.buf[10], self.buf[11], self.buf[12]]) as usize; + if ct_len > self.limits.max_payload_len { + return Err(BodyEnvelopeError::LimitExceeded("chunk_ct_len")); + } + let total = STREAM_CHUNK_OVERHEAD + ct_len; + if self.buf.len() < total { + return Ok(None); + } + let chunk = self.buf[..total].to_vec(); + self.buf.advance(total); + Ok(Some(StreamItem::Chunk(chunk))) + } + + /// Returns whether the header has been decoded yet. + pub fn header_decoded(&self) -> bool { + self.header_done + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rand_core::OsRng; + use x25519_dalek::{PublicKey, StaticSecret}; + + fn recipient() -> ([u8; 32], [u8; 32]) { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret).to_bytes(); + (secret.to_bytes(), public) + } + + fn seal_stream(public: [u8; 32], context: &[u8], chunks: &[&[u8]]) -> (Vec, Vec>) { + let limits = BodyEnvelopeLimits::default(); + let (mut sealer, header) = + StreamSealer::new(public, b"kid", context, &limits).expect("sealer"); + let mut out = Vec::new(); + for (i, c) in chunks.iter().enumerate() { + let is_final = i + 1 == chunks.len(); + out.push(sealer.seal_chunk(c, is_final).expect("seal chunk")); + } + assert!(sealer.is_finished()); + (header, out) + } + + #[test] + fn decoder_reassembles_frames_from_arbitrary_byte_splits() { + let (secret, public) = recipient(); + let context = b"ctx"; + let parts: Vec<&[u8]> = vec![b"alpha", b"beta", b"gamma", b"delta"]; + let (header, chunks) = seal_stream(public, context, &parts); + + // The whole wire stream: header followed by the self-delimiting chunks. + let mut wire = header.clone(); + for c in &chunks { + wire.extend_from_slice(c); + } + + let limits = BodyEnvelopeLimits::default(); + let mut decoder = StreamFrameDecoder::new(&limits); + let mut opener: Option = None; + let mut assembled = Vec::new(); + + // Feed the wire 3 bytes at a time to exercise frames split across pushes. + for piece in wire.chunks(3) { + decoder.push(piece); + while let Some(item) = decoder.decode_next().expect("decode") { + match item { + StreamItem::Header(h) => { + assert_eq!(h, header); + opener = + Some(StreamOpener::new(secret, &h, context, &limits).expect("opener")); + } + StreamItem::Chunk(c) => { + let decoded = opener + .as_mut() + .expect("header before chunks") + .open_chunk(&c) + .expect("open chunk"); + assembled.extend_from_slice(&decoded.plaintext); + } + } + } + } + + assert!(opener.expect("opener built").is_finished()); + assert_eq!(assembled, b"alphabetagammadelta"); + } + + #[test] + fn stream_roundtrip_reassembles_payload() { + let (secret, public) = recipient(); + let context = b"foctet-http-ctx-v1|POST|/upload"; + let parts: Vec<&[u8]> = vec![b"hello ", b"streaming ", b"world"]; + let (header, chunks) = seal_stream(public, context, &parts); + + let limits = BodyEnvelopeLimits::default(); + let mut opener = StreamOpener::new(secret, &header, context, &limits).expect("opener"); + let mut assembled = Vec::new(); + for chunk in &chunks { + let decoded = opener.open_chunk(chunk).expect("open chunk"); + assembled.extend_from_slice(&decoded.plaintext); + } + assert!(opener.is_finished()); + assert_eq!(assembled, b"hello streaming world"); + } + + #[test] + fn truncation_is_detected_as_unfinished() { + let (secret, public) = recipient(); + let parts: Vec<&[u8]> = vec![b"part0", b"part1", b"part2"]; + let (header, chunks) = seal_stream(public, b"", &parts); + + let limits = BodyEnvelopeLimits::default(); + let mut opener = StreamOpener::new(secret, &header, b"", &limits).expect("opener"); + // Deliver all but the final chunk. + for chunk in &chunks[..chunks.len() - 1] { + opener.open_chunk(chunk).expect("open chunk"); + } + // The stream never reached its FINAL chunk: it must be treated as + // incomplete and the partial plaintext discarded. + assert!(!opener.is_finished()); + } + + #[test] + fn extension_after_final_is_rejected() { + let (secret, public) = recipient(); + let parts: Vec<&[u8]> = vec![b"only-chunk"]; + let (header, chunks) = seal_stream(public, b"", &parts); + + let limits = BodyEnvelopeLimits::default(); + let mut opener = StreamOpener::new(secret, &header, b"", &limits).expect("opener"); + opener.open_chunk(&chunks[0]).expect("final chunk"); + assert!(opener.is_finished()); + // A second chunk after the final one must be rejected. + let err = opener + .open_chunk(&chunks[0]) + .expect_err("post-final chunk must be rejected"); + assert!(matches!(err, BodyEnvelopeError::StreamFinished)); + } + + #[test] + fn reordered_chunk_is_rejected() { + let (secret, public) = recipient(); + let parts: Vec<&[u8]> = vec![b"a", b"b", b"c"]; + let (header, chunks) = seal_stream(public, b"", &parts); + + let limits = BodyEnvelopeLimits::default(); + let mut opener = StreamOpener::new(secret, &header, b"", &limits).expect("opener"); + opener.open_chunk(&chunks[0]).expect("chunk 0"); + // Skipping chunk 1 and delivering chunk 2 must fail closed. + let err = opener + .open_chunk(&chunks[2]) + .expect_err("out-of-order chunk must be rejected"); + assert!(matches!(err, BodyEnvelopeError::ChunkOutOfOrder)); + } + + #[test] + fn wrong_context_fails_authentication() { + let (secret, public) = recipient(); + let (header, chunks) = seal_stream(public, b"context-A", &[b"data"]); + + let limits = BodyEnvelopeLimits::default(); + let mut opener = StreamOpener::new(secret, &header, b"context-B", &limits).expect("opener"); + let err = opener + .open_chunk(&chunks[0]) + .expect_err("mismatched context must fail authentication"); + assert!(matches!(err, BodyEnvelopeError::DecryptFailed)); + } + + #[test] + fn tampered_chunk_fails_authentication() { + let (secret, public) = recipient(); + let (header, mut chunks) = seal_stream(public, b"", &[b"sensitive"]); + + // Flip a ciphertext byte. + let last = chunks[0].len() - 1; + chunks[0][last] ^= 0xff; + + let limits = BodyEnvelopeLimits::default(); + let mut opener = StreamOpener::new(secret, &header, b"", &limits).expect("opener"); + let err = opener + .open_chunk(&chunks[0]) + .expect_err("tampered ciphertext must fail"); + assert!(matches!(err, BodyEnvelopeError::DecryptFailed)); + } + + #[test] + fn wrong_recipient_cannot_open() { + let (_secret, public) = recipient(); + let (other_secret, _other_public) = recipient(); + let (header, _chunks) = seal_stream(public, b"", &[b"data"]); + + let limits = BodyEnvelopeLimits::default(); + let result = StreamOpener::new(other_secret, &header, b"", &limits); + assert!(matches!(result, Err(BodyEnvelopeError::KeyUnwrapFailed))); + } +} diff --git a/foctet-core/src/control.rs b/foctet-core/src/control.rs index b9d6c83..fd43580 100644 --- a/foctet-core/src/control.rs +++ b/foctet-core/src/control.rs @@ -6,6 +6,15 @@ use crate::{ const CONTROL_PREFIX: [u8; 4] = *b"FCTL"; const CONTROL_VERSION: u8 = 0; +/// Upper bound on any encoded Draft v0 control message, in bytes. +/// +/// Every control message is fixed-size per kind; the largest is a +/// `ClientHello` carrying Ed25519 identity authentication +/// (6-byte prefix/version/kind + 96-byte hello body + 97-byte auth trailer). +/// [`ControlMessage::decode`] rejects anything longer before inspecting it, +/// so control-plane input is hard-bounded regardless of transport limits. +pub const MAX_CONTROL_MESSAGE_LEN: usize = 6 + 96 + 97; + /// Control message type discriminator for Draft v0 control payloads. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(u8)] @@ -43,14 +52,14 @@ pub enum ControlMessage { /// Optional identity authentication for the handshake transcript. auth: Option, }, - /// Rekey event message. + /// Rekey event message carrying one DH-ratchet step. Rekey { /// Previous key identifier. old_key_id: u8, /// New key identifier. new_key_id: u8, - /// Salt value used for rekey derivation. - rekey_salt: [u8; 32], + /// Sender's fresh ephemeral X25519 public key for this ratchet step. + ratchet_public: [u8; 32], /// Transcript binding hash. transcript_binding: [u8; 32], }, @@ -103,12 +112,12 @@ impl ControlMessage { Self::Rekey { old_key_id, new_key_id, - rekey_salt, + ratchet_public, transcript_binding, } => { out.push(*old_key_id); out.push(*new_key_id); - out.extend_from_slice(rekey_salt); + out.extend_from_slice(ratchet_public); out.extend_from_slice(transcript_binding); } Self::Error { code } => { @@ -121,7 +130,7 @@ impl ControlMessage { /// Decodes control payload from wire bytes. pub fn decode(bytes: &[u8]) -> Result { - if bytes.len() < 6 { + if bytes.len() < 6 || bytes.len() > MAX_CONTROL_MESSAGE_LEN { return Err(CoreError::InvalidControlMessage); } if bytes[0..4] != CONTROL_PREFIX { @@ -174,14 +183,14 @@ impl ControlMessage { } let old_key_id = body[0]; let new_key_id = body[1]; - let mut rekey_salt = [0u8; 32]; - rekey_salt.copy_from_slice(&body[2..34]); + let mut ratchet_public = [0u8; 32]; + ratchet_public.copy_from_slice(&body[2..34]); let mut transcript_binding = [0u8; 32]; transcript_binding.copy_from_slice(&body[34..66]); Ok(Self::Rekey { old_key_id, new_key_id, - rekey_salt, + ratchet_public, transcript_binding, }) } @@ -236,12 +245,53 @@ fn decode_handshake_auth(bytes: &[u8]) -> Result, CoreErro mod tests { use super::*; + #[test] + fn every_control_message_fits_the_documented_bound() { + let auth = Some(HandshakeAuth { + identity_public_key: [1u8; 32], + signature: [2u8; 64], + }); + let messages = [ + ControlMessage::ClientHello { + eph_public: [3u8; 32], + session_salt: [4u8; 32], + transcript_binding: [5u8; 32], + auth: auth.clone(), + }, + ControlMessage::ServerHello { + eph_public: [6u8; 32], + transcript_binding: [7u8; 32], + auth, + }, + ControlMessage::Rekey { + old_key_id: 0, + new_key_id: 1, + ratchet_public: [8u8; 32], + transcript_binding: [9u8; 32], + }, + ControlMessage::Error { code: 42 }, + ]; + for msg in messages { + assert!(msg.encode().len() <= MAX_CONTROL_MESSAGE_LEN); + } + } + + #[test] + fn decode_rejects_input_longer_than_the_bound() { + let mut oversized = ControlMessage::Error { code: 1 }.encode(); + oversized.resize(MAX_CONTROL_MESSAGE_LEN + 1, 0); + assert!(matches!( + ControlMessage::decode(&oversized), + Err(CoreError::InvalidControlMessage) + )); + } + #[test] fn control_roundtrip() { let msg = ControlMessage::Rekey { old_key_id: 1, new_key_id: 2, - rekey_salt: [7u8; 32], + ratchet_public: [7u8; 32], transcript_binding: [9u8; 32], }; let encoded = msg.encode(); diff --git a/foctet-core/src/crypto.rs b/foctet-core/src/crypto.rs index 733fbc3..4df14a2 100644 --- a/foctet-core/src/crypto.rs +++ b/foctet-core/src/crypto.rs @@ -2,9 +2,13 @@ use chacha20poly1305::{ KeyInit, XChaCha20Poly1305, XNonce, aead::{Aead, Payload}, }; +use std::ops::Deref; +use std::sync::Arc; + use hkdf::Hkdf; use rand_core::{OsRng, RngCore}; use sha2::Sha256; +use subtle::ConstantTimeEq; use x25519_dalek::{PublicKey, StaticSecret}; use zeroize::{Zeroize, Zeroizing}; @@ -23,7 +27,21 @@ pub enum Direction { } /// Bidirectional traffic keys bound to a single `key_id`. -#[derive(Clone, Debug, Eq, PartialEq)] +/// +/// # Secret material +/// +/// The `c2s` / `s2c` fields are live XChaCha20-Poly1305 keys. They are +/// **not** printed by the [`Debug`] implementation (which redacts them), are +/// compared in constant time (see the [`PartialEq`] impl), and are zeroized on +/// drop. Reading the raw bytes directly via the public fields is an explicit, +/// auditable exposure — prefer [`TrafficKeys::key_for`], and only copy the +/// bytes out when you immediately wrap the copy (e.g. in +/// [`zeroize::Zeroizing`]). +/// +/// `TrafficKeys` is deliberately **not** `Clone`: the secret key bytes exist in +/// exactly one place and are zeroized when that place is dropped. Share keys +/// through a [`KeyHandle`] (a reference-counted handle) instead of copying the +/// secret bytes into multiple owners. pub struct TrafficKeys { /// Active key identifier carried in frame headers. pub key_id: u8, @@ -43,6 +61,32 @@ impl TrafficKeys { } } +impl core::fmt::Debug for TrafficKeys { + /// Redacts the directional key bytes so they cannot leak into logs. + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("TrafficKeys") + .field("key_id", &self.key_id) + .field("c2s", &"") + .field("s2c", &"") + .finish() + } +} + +impl PartialEq for TrafficKeys { + /// Compares the directional keys in constant time. + /// + /// The `key_id` is a public frame-header byte and is compared normally; the + /// secret key bytes are compared with [`subtle::ConstantTimeEq`] so that + /// equality checks do not leak key material through timing. + fn eq(&self, other: &Self) -> bool { + let c2s_eq = self.c2s.ct_eq(&other.c2s); + let s2c_eq = self.s2c.ct_eq(&other.s2c); + self.key_id == other.key_id && (c2s_eq & s2c_eq).into() + } +} + +impl Eq for TrafficKeys {} + impl Drop for TrafficKeys { fn drop(&mut self) { self.c2s.zeroize(); @@ -50,6 +94,43 @@ impl Drop for TrafficKeys { } } +/// A shared, reference-counted handle to a set of [`TrafficKeys`]. +/// +/// Because [`TrafficKeys`] is not `Clone`, the session key ring, the previous-key +/// retention list, and the various I/O endpoints share one key set through a +/// `KeyHandle` rather than each owning a copy of the secret bytes. Cloning a +/// `KeyHandle` only bumps the reference count; the underlying key bytes are +/// zeroized once the last handle is dropped. +/// +/// A `KeyHandle` dereferences to the inner [`TrafficKeys`], so field access +/// (`handle.key_id`) and methods (`handle.key_for(dir)`) work directly, and it +/// coerces to `&TrafficKeys` at call sites such as [`encrypt_frame`]. Equality +/// and `Debug` delegate to [`TrafficKeys`] (constant-time comparison, redacted +/// secret bytes). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct KeyHandle(Arc); + +impl KeyHandle { + /// Wraps a freshly derived key set in a shared handle. + pub fn new(keys: TrafficKeys) -> Self { + Self(Arc::new(keys)) + } +} + +impl From for KeyHandle { + fn from(keys: TrafficKeys) -> Self { + Self::new(keys) + } +} + +impl Deref for KeyHandle { + type Target = TrafficKeys; + + fn deref(&self) -> &TrafficKeys { + &self.0 + } +} + /// Builds a Draft v0 XChaCha nonce from frame metadata. pub fn make_nonce(key_id: u8, stream_id: u32, seq: u64) -> [u8; 24] { let mut nonce = [0u8; 24]; @@ -75,34 +156,59 @@ pub fn derive_traffic_keys( Ok(TrafficKeys { key_id, c2s, s2c }) } -/// Derives rekeyed traffic keys from shared/session/rekey salt inputs. -pub fn derive_rekey_traffic_keys( - shared_secret: &[u8; 32], +/// Derives the initial DH-ratchet root key from the handshake shared secret. +/// +/// The root key seeds the rekey ratchet (see [`dh_ratchet_step`]); it is mixed +/// with a fresh Diffie-Hellman output at every rekey so that traffic keys gain +/// forward secrecy and post-compromise security across rekeys, rather than all +/// being derivable from the one handshake secret. +pub fn derive_ratchet_root( session_salt: &[u8; 32], - rekey_salt: &[u8; 32], + shared_secret: &[u8; 32], +) -> Result<[u8; 32], CoreError> { + let hk = Hkdf::::new(Some(session_salt), shared_secret); + let mut root = [0u8; 32]; + hk.expand(b"foctet ratchet init", &mut root) + .map_err(|_| CoreError::Hkdf)?; + Ok(root) +} + +/// Performs one DH-ratchet step: mixes a fresh Diffie-Hellman output `dh` into +/// the ratchet `root`, returning the advanced root and the next traffic keys. +/// +/// `(new_root, c2s, s2c)` are independent HKDF-SHA-256 expansions of +/// `HKDF(salt = root, ikm = dh)`. Because `dh` comes from a freshly generated +/// ephemeral key at each rekey, an attacker who learns the current keys cannot +/// derive the keys after the next rekey (post-compromise security), and an +/// attacker who later compromises the long-term state cannot derive past keys +/// (forward secrecy) once the ephemeral private keys are discarded. +pub fn dh_ratchet_step( + root: &[u8; 32], + dh: &[u8; 32], key_id: u8, -) -> Result { - let mut salt = Zeroizing::new([0u8; 64]); - salt[..32].copy_from_slice(session_salt); - salt[32..].copy_from_slice(rekey_salt); - let hk = Hkdf::::new(Some(&salt[..]), shared_secret); +) -> Result<([u8; 32], TrafficKeys), CoreError> { + let hk = Hkdf::::new(Some(root), dh); + let mut new_root = [0u8; 32]; let mut c2s = [0u8; 32]; let mut s2c = [0u8; 32]; - let mut info_c2s = [0u8; 17]; - info_c2s[..16].copy_from_slice(b"foctet rekey c2s"); - info_c2s[16] = key_id; - let mut info_s2c = [0u8; 17]; - info_s2c[..16].copy_from_slice(b"foctet rekey s2c"); - info_s2c[16] = key_id; + hk.expand(b"foctet ratchet root", &mut new_root) + .map_err(|_| CoreError::Hkdf)?; + + let mut info_c2s = [0u8; 19]; + info_c2s[..18].copy_from_slice(b"foctet ratchet c2s"); + info_c2s[18] = key_id; + let mut info_s2c = [0u8; 19]; + info_s2c[..18].copy_from_slice(b"foctet ratchet s2c"); + info_s2c[18] = key_id; hk.expand(&info_c2s, &mut c2s) .map_err(|_| CoreError::Hkdf)?; hk.expand(&info_s2c, &mut s2c) .map_err(|_| CoreError::Hkdf)?; - Ok(TrafficKeys { key_id, c2s, s2c }) + Ok((new_root, TrafficKeys { key_id, c2s, s2c })) } /// Generates a random session salt for key derivation. @@ -113,13 +219,23 @@ pub fn random_session_salt() -> [u8; 32] { } /// Ephemeral X25519 key pair used during native handshake. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct EphemeralKeyPair { private: Zeroizing<[u8; 32]>, /// Public key bytes. pub public: [u8; 32], } +impl core::fmt::Debug for EphemeralKeyPair { + /// Redacts the private scalar so it cannot leak into logs. + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("EphemeralKeyPair") + .field("private", &"") + .field("public", &self.public) + .finish() + } +} + impl EphemeralKeyPair { /// Generates a fresh ephemeral X25519 key pair. pub fn generate() -> Self { @@ -143,6 +259,19 @@ impl EphemeralKeyPair { } } +/// XChaCha20-Poly1305 authentication tag length, in bytes. +const AEAD_TAG_LEN: usize = 16; + +/// Computes the ciphertext length (plaintext + AEAD tag) for a given plaintext +/// length, failing closed instead of silently truncating if it would not fit +/// in the frame header's `u32 ct_len` field. +fn checked_ciphertext_len(plaintext_len: usize) -> Result { + if plaintext_len > (u32::MAX as usize) - AEAD_TAG_LEN { + return Err(CoreError::FrameTooLarge); + } + Ok((plaintext_len + AEAD_TAG_LEN) as u32) +} + /// Encrypts plaintext into a Foctet frame using AEAD profile `0x01`. pub fn encrypt_frame( keys: &TrafficKeys, @@ -152,6 +281,12 @@ pub fn encrypt_frame( seq: u64, plaintext: &[u8], ) -> Result { + // Reject plaintext that would make the ciphertext length (plaintext + AEAD + // tag) overflow the header's `u32 ct_len` field. Without this check the + // cast below would silently truncate, producing a frame whose declared + // length doesn't match its actual ciphertext. + let expected_ct_len = checked_ciphertext_len(plaintext.len())?; + let key = Zeroizing::new(keys.key_for(direction)); let cipher = XChaCha20Poly1305::new_from_slice(&key[..]).map_err(|_| CoreError::InvalidKeyLength)?; @@ -169,7 +304,7 @@ pub fn encrypt_frame( let nonce = XNonce::from_slice(&nonce_raw); let mut aad_header = header.clone(); - aad_header.ct_len = (plaintext.len() + 16) as u32; + aad_header.ct_len = expected_ct_len; let aad = aad_header.encode(); let ciphertext = cipher @@ -273,4 +408,81 @@ mod tests { ); assert_eq!(&nonce[13..], &[0u8; 11]); } + + #[test] + fn checked_ciphertext_len_fits_just_below_overflow() { + let max_plaintext = (u32::MAX as usize) - AEAD_TAG_LEN; + assert_eq!( + checked_ciphertext_len(max_plaintext).expect("fits"), + u32::MAX + ); + } + + #[test] + fn checked_ciphertext_len_fails_closed_on_overflow() { + let max_plaintext = (u32::MAX as usize) - AEAD_TAG_LEN; + let err = checked_ciphertext_len(max_plaintext + 1).expect_err("must not truncate"); + assert!(matches!(err, CoreError::FrameTooLarge)); + } + + #[test] + fn traffic_keys_debug_redacts_key_bytes() { + let keys = TrafficKeys { + key_id: 9, + c2s: [0xAB; 32], + s2c: [0xCD; 32], + }; + let rendered = format!("{keys:?}"); + assert!(rendered.contains("key_id: 9")); + assert!(rendered.contains("")); + // No raw key byte should appear in the debug output. + assert!(!rendered.contains("ab")); + assert!(!rendered.contains("171")); // 0xAB as decimal + assert!(!rendered.contains("205")); // 0xCD as decimal + } + + #[test] + fn traffic_keys_equality_is_value_based() { + let a = TrafficKeys { + key_id: 1, + c2s: [0x01; 32], + s2c: [0x02; 32], + }; + let b = TrafficKeys { + key_id: 1, + c2s: [0x01; 32], + s2c: [0x02; 32], + }; + let c = TrafficKeys { + key_id: 1, + c2s: [0x01; 32], + s2c: [0x03; 32], + }; + let d = TrafficKeys { + key_id: 2, + c2s: [0x01; 32], + s2c: [0x02; 32], + }; + assert_eq!(a, b); + assert_ne!(a, c); + assert_ne!(a, d); + } + + #[test] + fn ephemeral_key_pair_debug_redacts_private_scalar() { + // Use a fixed private scalar so we can assert its rendered array form is + // absent from the Debug output. + let private = Zeroizing::new([0x5A_u8; 32]); + let public = PublicKey::from(&StaticSecret::from(*private)).to_bytes(); + let pair = EphemeralKeyPair { private, public }; + + let rendered = format!("{pair:?}"); + assert!(rendered.contains("")); + // The private scalar's array representation must never appear. + let leaked = format!("{:?}", [0x5A_u8; 32]); + assert!( + !rendered.contains(&leaked), + "private scalar leaked into Debug output: {rendered}" + ); + } } diff --git a/foctet-core/src/datagram.rs b/foctet-core/src/datagram.rs new file mode 100644 index 0000000..ce3eef7 --- /dev/null +++ b/foctet-core/src/datagram.rs @@ -0,0 +1,357 @@ +//! Datagram-oriented Foctet endpoint (one frame per datagram). +//! +//! Stream framing ([`crate::frame::FoctetFramed`]) length-prefixes frames inside +//! a reliable, ordered byte stream. Datagram transports (UDP, QUIC datagrams, +//! WebTransport datagrams) are unreliable, unordered, and message-bounded, so +//! they need a different contract: +//! +//! - **Exactly one complete, bounded frame per datagram.** A datagram with +//! trailing bytes or a truncated frame is rejected; there is no cross-datagram +//! reassembly. +//! - **A configured maximum datagram size** ([`DatagramConfig::max_datagram_size`]) +//! that the caller MUST keep below the transport path MTU. Outbound frames that +//! would exceed it fail closed rather than being emitted. +//! - **Loss and reordering are expected.** The replay window accepts +//! out-of-order sequence numbers within its span and rejects duplicates, so +//! dropped or reordered datagrams do not break the channel. +//! - **Replay state is committed only after AEAD authentication**, so a forged +//! datagram cannot advance the window (matching the stream paths). +//! - **Anti-amplification** (not sending many bytes to an unverified peer) is a +//! transport-layer responsibility and is documented for adapters; this codec +//! does not itself send data. +//! +//! Outbound sequence numbers are tracked per `(key_id, stream_id)` and fail +//! closed on exhaustion, so a `(key_id, stream_id, seq)` nonce is never reused. + +use std::collections::HashMap; + +use crate::{ + CoreError, + crypto::{Direction, KeyHandle, decrypt_frame_with_key, encrypt_frame}, + frame::{FRAME_HEADER_LEN, Frame, FrameHeader}, + replay::{DEFAULT_MAX_REPLAY_WINDOWS, DEFAULT_REPLAY_WINDOW, ReplayProtector}, + sequence::OutboundSequence, +}; + +/// AEAD tag length added to every frame ciphertext. +const TAG_LEN: usize = 16; + +/// Per-frame wire overhead (fixed header + AEAD tag). +pub const DATAGRAM_FRAME_OVERHEAD: usize = FRAME_HEADER_LEN + TAG_LEN; + +/// Conservative default maximum datagram size in bytes. +/// +/// Chosen to fit comfortably within the QUIC minimum datagram allowance +/// (`1232` bytes) with headroom; tune to the actual transport path MTU. +pub const DEFAULT_MAX_DATAGRAM_SIZE: usize = 1200; + +/// Configuration for a [`DatagramEndpoint`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DatagramConfig { + /// Maximum on-wire datagram (single frame) size in bytes. + pub max_datagram_size: usize, + /// Per-`(key_id, stream_id)` replay window span. + pub replay_window: u64, + /// Maximum number of distinct replay windows tracked simultaneously. + pub max_replay_windows: usize, + /// Number of previous keys retained for inbound decryption after rekey. + pub max_retained_keys: usize, +} + +impl Default for DatagramConfig { + fn default() -> Self { + Self { + max_datagram_size: DEFAULT_MAX_DATAGRAM_SIZE, + replay_window: DEFAULT_REPLAY_WINDOW, + max_replay_windows: DEFAULT_MAX_REPLAY_WINDOWS, + max_retained_keys: 2, + } + } +} + +/// One decrypted inbound datagram. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DecodedDatagram { + /// Authenticated frame header. + pub header: FrameHeader, + /// Decrypted payload bytes. + pub plaintext: Vec, +} + +/// Seals and opens individual Foctet datagrams (one frame each). +/// +/// This type performs no I/O; pair it with a datagram transport adapter (for +/// example a QUIC or WebTransport datagram socket) that moves the returned bytes. +#[derive(Clone, Debug)] +pub struct DatagramEndpoint { + keys: Vec, + active_key_id: u8, + max_retained_keys: usize, + inbound_direction: Direction, + outbound_direction: Direction, + next_seq: HashMap<(u8, u32), OutboundSequence>, + replay: ReplayProtector, + max_datagram_size: usize, +} + +impl DatagramEndpoint { + /// Creates a datagram endpoint with default configuration. + pub fn new( + keys: KeyHandle, + inbound_direction: Direction, + outbound_direction: Direction, + ) -> Self { + Self::with_config( + keys, + inbound_direction, + outbound_direction, + DatagramConfig::default(), + ) + } + + /// Creates a datagram endpoint with explicit configuration. + pub fn with_config( + keys: KeyHandle, + inbound_direction: Direction, + outbound_direction: Direction, + config: DatagramConfig, + ) -> Self { + Self { + active_key_id: keys.key_id, + keys: vec![keys], + max_retained_keys: config.max_retained_keys.max(1), + inbound_direction, + outbound_direction, + next_seq: HashMap::new(), + replay: ReplayProtector::new(config.replay_window) + .with_max_windows(config.max_replay_windows), + max_datagram_size: config.max_datagram_size.max(DATAGRAM_FRAME_OVERHEAD + 1), + } + } + + /// Returns the configured maximum datagram size in bytes. + pub fn max_datagram_size(&self) -> usize { + self.max_datagram_size + } + + /// Returns the maximum plaintext bytes that fit in one datagram. + pub fn max_plaintext_len(&self) -> usize { + self.max_datagram_size - DATAGRAM_FRAME_OVERHEAD + } + + /// Returns the active key identifier. + pub fn active_key_id(&self) -> u8 { + self.active_key_id + } + + /// Returns how many inbound frames this endpoint's replay protection has + /// rejected since creation (see + /// [`crate::ReplayProtector::rejections`]); an observability counter that + /// carries no key material. + pub fn replay_rejections(&self) -> u64 { + self.replay.rejections() + } + + /// Returns known key IDs, active first. + pub fn known_key_ids(&self) -> Vec { + self.keys.iter().map(|k| k.key_id).collect() + } + + /// Installs new active keys and retains a bounded set of previous keys. + pub fn install_active_keys(&mut self, keys: KeyHandle) { + self.keys.retain(|k| k.key_id != keys.key_id); + self.keys.insert(0, keys.clone()); + self.active_key_id = keys.key_id; + let keep = self.max_retained_keys + 1; + if self.keys.len() > keep { + self.keys.truncate(keep); + } + } + + fn active_keys(&self) -> Result<&KeyHandle, CoreError> { + self.keys + .iter() + .find(|k| k.key_id == self.active_key_id) + .ok_or(CoreError::MissingSessionSecret) + } + + fn key_for_id(&self, key_id: u8) -> Option<&KeyHandle> { + self.keys.iter().find(|k| k.key_id == key_id) + } + + /// Seals plaintext into a single datagram using the active key. + /// + /// Fails closed with [`CoreError::FrameTooLarge`] when the resulting frame + /// would exceed [`DatagramConfig::max_datagram_size`], and with + /// [`CoreError::SequenceExhausted`] when the per-stream sequence space is + /// exhausted. In both cases no sequence number is consumed. + pub fn seal( + &mut self, + stream_id: u32, + flags: u8, + plaintext: &[u8], + ) -> Result, CoreError> { + let keys = self.active_keys()?.clone(); + let key_id = keys.key_id; + let sequence = self + .next_seq + .get(&(key_id, stream_id)) + .copied() + .unwrap_or_default(); + let seq = sequence.current(); + + let frame = encrypt_frame( + &keys, + self.outbound_direction, + flags, + stream_id, + seq, + plaintext, + )?; + let bytes = frame.to_bytes(); + if bytes.len() > self.max_datagram_size { + return Err(CoreError::FrameTooLarge); + } + + // Reserve the next sequence only after the datagram is known to be + // emittable, so a rejected datagram never consumes a nonce. + let next = sequence.prepared_next()?; + self.next_seq.insert((key_id, stream_id), next); + Ok(bytes) + } + + /// Opens one datagram into its decrypted payload. + /// + /// The datagram MUST contain exactly one complete frame and no trailing + /// bytes. The ciphertext is authenticated before replay state is committed. + pub fn open(&mut self, datagram: &[u8]) -> Result { + if datagram.len() > self.max_datagram_size { + return Err(CoreError::FrameTooLarge); + } + if datagram.len() < FRAME_HEADER_LEN { + return Err(CoreError::InvalidHeaderLength(datagram.len())); + } + + // `Frame::from_bytes` requires the ciphertext length to match the header + // exactly, enforcing one complete frame per datagram with no trailing + // bytes and no truncation. + let frame = Frame::from_bytes(datagram)?; + frame.header.validate_v0()?; + + let keys = self + .key_for_id(frame.header.key_id) + .ok_or(CoreError::UnexpectedKeyId { + expected: self.active_key_id, + actual: frame.header.key_id, + })?; + + let plaintext = decrypt_frame_with_key(keys, self.inbound_direction, &frame)?; + self.replay.check_and_record( + frame.header.key_id, + frame.header.stream_id, + frame.header.seq, + )?; + + Ok(DecodedDatagram { + header: frame.header, + plaintext, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::{EphemeralKeyPair, KeyHandle, derive_traffic_keys, random_session_salt}; + + fn endpoints() -> (DatagramEndpoint, DatagramEndpoint) { + let a = EphemeralKeyPair::generate(); + let b = EphemeralKeyPair::generate(); + let ss = a.shared_secret(b.public).expect("shared secret"); + let salt = random_session_salt(); + let keys = KeyHandle::new(derive_traffic_keys(&ss, &salt, 1).expect("traffic keys")); + // Client seals C2S / opens S2C; server is the mirror. + let client = DatagramEndpoint::new(keys.clone(), Direction::S2C, Direction::C2S); + let server = DatagramEndpoint::new(keys, Direction::C2S, Direction::S2C); + (client, server) + } + + #[test] + fn datagram_roundtrip() { + let (mut client, mut server) = endpoints(); + let dg = client.seal(0, 0, b"hello datagram").expect("seal"); + let opened = server.open(&dg).expect("open"); + assert_eq!(opened.plaintext, b"hello datagram"); + assert_eq!(opened.header.seq, 0); + } + + #[test] + fn tolerates_loss_and_reordering() { + let (mut client, mut server) = endpoints(); + let d0 = client.seal(0, 0, b"zero").expect("d0"); + let d1 = client.seal(0, 0, b"one").expect("d1"); + let d2 = client.seal(0, 0, b"two").expect("d2"); + + // Deliver out of order, drop nothing: 2, 0, 1. + assert_eq!(server.open(&d2).expect("d2").plaintext, b"two"); + assert_eq!(server.open(&d0).expect("d0").plaintext, b"zero"); + assert_eq!(server.open(&d1).expect("d1").plaintext, b"one"); + + // A duplicate is rejected as a replay. + let err = server.open(&d1).expect_err("duplicate rejected"); + assert!(matches!(err, CoreError::Replay)); + } + + #[test] + fn rejects_oversized_outbound_and_keeps_sequence() { + let (mut client, _server) = endpoints(); + let config = DatagramConfig { + max_datagram_size: DATAGRAM_FRAME_OVERHEAD + 4, + ..DatagramConfig::default() + }; + let mut small = DatagramEndpoint::with_config( + client.active_keys().unwrap().clone(), + Direction::S2C, + Direction::C2S, + config, + ); + // 4-byte plaintext fits exactly. + let ok = small.seal(0, 0, b"abcd").expect("fits"); + assert!(ok.len() <= small.max_datagram_size()); + // 5 bytes overflow and must fail closed without consuming a sequence. + let err = small.seal(0, 0, b"abcde").expect_err("too large"); + assert!(matches!(err, CoreError::FrameTooLarge)); + // The next valid datagram still uses seq 1 (only the first succeeded). + let next = small.seal(0, 0, b"efgh").expect("next"); + let frame = Frame::from_bytes(&next).expect("parse"); + assert_eq!(frame.header.seq, 1); + let _ = client.seal(0, 0, b"x"); + } + + #[test] + fn replay_state_committed_only_after_auth() { + let (mut client, mut server) = endpoints(); + // Forge a high-sequence datagram by corrupting an authentic one. + let _warm = client.seal(0, 0, b"warm"); + let mut forged = client.seal(0, 0, b"forged").expect("seal"); + let last = forged.len() - 1; + forged[last] ^= 0xff; + + let err = server.open(&forged).expect_err("forged must fail auth"); + assert!(matches!(err, CoreError::Aead)); + + // The forged datagram must not have advanced the replay window, so the + // genuine low-sequence datagrams are still accepted. + let d0 = client.seal(1, 0, b"genuine").expect("seal new stream"); + assert_eq!(server.open(&d0).expect("genuine").plaintext, b"genuine"); + } + + #[test] + fn rejects_trailing_bytes() { + let (mut client, mut server) = endpoints(); + let mut dg = client.seal(0, 0, b"payload").expect("seal"); + dg.push(0x00); // extra trailing byte -> not exactly one frame + let err = server.open(&dg).expect_err("trailing bytes rejected"); + assert!(matches!(err, CoreError::CiphertextLengthMismatch { .. })); + } +} diff --git a/foctet-core/src/frame.rs b/foctet-core/src/frame.rs index 31d2a8b..93730ac 100644 --- a/foctet-core/src/frame.rs +++ b/foctet-core/src/frame.rs @@ -10,10 +10,12 @@ use futures_sink::Sink; use crate::{ CoreError, control::ControlMessage, - crypto::{Direction, TrafficKeys, decrypt_frame_with_key, encrypt_frame}, + crypto::{Direction, KeyHandle, decrypt_frame_with_key, encrypt_frame}, io::PollIo, + limits::ProtocolLimits, payload::{self, Tlv}, - replay::{DEFAULT_REPLAY_WINDOW, ReplayProtector}, + replay::ReplayProtector, + sequence::OutboundSequence, session::Session, }; @@ -201,15 +203,14 @@ pub struct DecodedFrame { #[derive(Clone, Debug)] pub struct FoctetFramed { io: T, - keys: Vec, + keys: Vec, active_key_id: u8, - max_retained_keys: usize, + limits: ProtocolLimits, inbound_direction: Direction, outbound_direction: Direction, default_stream_id: u32, default_flags: u8, - next_seq: u64, - max_ciphertext_len: usize, + next_seq: OutboundSequence, rx: BytesMut, tx: BytesMut, replay: ReplayProtector, @@ -220,24 +221,24 @@ impl FoctetFramed { /// Creates a framed transport with initial traffic keys. pub fn new( io: T, - keys: TrafficKeys, + keys: KeyHandle, inbound_direction: Direction, outbound_direction: Direction, ) -> Self { + let limits = ProtocolLimits::default(); Self { io, active_key_id: keys.key_id, keys: vec![keys], - max_retained_keys: 2, inbound_direction, outbound_direction, default_stream_id: 0, default_flags: 0, - next_seq: 0, - max_ciphertext_len: 16 * 1024 * 1024, + next_seq: OutboundSequence::default(), rx: BytesMut::with_capacity(8 * 1024), tx: BytesMut::new(), - replay: ReplayProtector::new(DEFAULT_REPLAY_WINDOW), + replay: limits.replay_protector(), + limits, eof: false, } } @@ -254,15 +255,31 @@ impl FoctetFramed { self } + /// Applies a complete set of [`ProtocolLimits`], rebuilding the replay + /// protector from the new replay-window size and window cap. + /// + /// Intended to be called immediately after [`FoctetFramed::new`], before any + /// frames are processed; it resets replay-window state. + pub fn with_limits(mut self, limits: ProtocolLimits) -> Self { + self.replay = limits.replay_protector(); + self.limits = limits; + self + } + + /// Returns the active protocol limits. + pub fn limits(&self) -> ProtocolLimits { + self.limits + } + /// Sets inbound ciphertext size limit. pub fn with_max_ciphertext_len(mut self, max_len: usize) -> Self { - self.max_ciphertext_len = max_len; + self.limits.max_ciphertext_len = max_len; self } /// Sets number of retained previous keys. pub fn with_max_retained_keys(mut self, max: usize) -> Self { - self.max_retained_keys = max.max(1); + self.limits.max_retained_keys = max.max(1); self } @@ -291,25 +308,32 @@ impl FoctetFramed { self.active_key_id } + /// Returns how many inbound frames this transport's replay protection has + /// rejected since creation (see [`crate::ReplayProtector::rejections`]); + /// an observability counter that carries no key material. + pub fn replay_rejections(&self) -> u64 { + self.replay.rejections() + } + /// Installs new active keys and retains previous keys. - pub fn install_active_keys(&mut self, keys: TrafficKeys) { + pub fn install_active_keys(&mut self, keys: KeyHandle) { self.keys.retain(|k| k.key_id != keys.key_id); self.keys.insert(0, keys.clone()); self.active_key_id = keys.key_id; - let keep = self.max_retained_keys + 1; + let keep = self.limits.max_retained_keys + 1; if self.keys.len() > keep { self.keys.truncate(keep); } } - fn active_keys(&self) -> Result<&TrafficKeys, CoreError> { + fn active_keys(&self) -> Result<&KeyHandle, CoreError> { self.keys .iter() .find(|k| k.key_id == self.active_key_id) .ok_or(CoreError::MissingSessionSecret) } - fn key_for_id(&self, key_id: u8) -> Option<&TrafficKeys> { + fn key_for_id(&self, key_id: u8) -> Option<&KeyHandle> { self.keys.iter().find(|k| k.key_id == key_id) } @@ -321,7 +345,7 @@ impl FoctetFramed { .first() .map(|k| k.key_id) .ok_or(CoreError::InvalidSessionState)?; - let keep = self.max_retained_keys + 1; + let keep = self.limits.max_retained_keys + 1; if self.keys.len() > keep { self.keys.truncate(keep); } @@ -342,19 +366,38 @@ impl FoctetFramed { actual: key_id, })? .clone(); + self.enqueue_encrypted(&keys, flags, stream_id, plaintext) + } + + /// Encrypts one frame and appends it to the outbound buffer, enforcing the + /// configured plaintext and buffered-bytes limits. The sequence number is + /// only committed once the frame is actually enqueued, so a rejected send + /// consumes no nonce. + fn enqueue_encrypted( + &mut self, + keys: &KeyHandle, + flags: u8, + stream_id: u32, + plaintext: &[u8], + ) -> Result<(), CoreError> { + if plaintext.len() > self.limits.max_plaintext_len { + return Err(CoreError::FrameTooLarge); + } let frame = encrypt_frame( - &keys, + keys, self.outbound_direction, flags, stream_id, - self.next_seq, + self.next_seq.current(), plaintext, )?; - self.next_seq = self - .next_seq - .checked_add(1) - .ok_or(CoreError::SequenceExhausted)?; - self.tx.extend_from_slice(&frame.to_bytes()); + let bytes = frame.to_bytes(); + if self.tx.len().saturating_add(bytes.len()) > self.limits.max_buffered_tx_bytes { + return Err(CoreError::OutboundBufferLimitExceeded); + } + let next_seq = self.next_seq.prepared_next()?; + self.next_seq.commit(next_seq); + self.tx.extend_from_slice(&bytes); Ok(()) } } @@ -383,20 +426,7 @@ impl FoctetFramed { ) -> Result<(), CoreError> { let this = self.get_mut(); let active = this.active_keys()?.clone(); - let frame = encrypt_frame( - &active, - this.outbound_direction, - flags, - stream_id, - this.next_seq, - plaintext, - )?; - this.next_seq = this - .next_seq - .checked_add(1) - .ok_or(CoreError::SequenceExhausted)?; - this.tx.extend_from_slice(&frame.to_bytes()); - Ok(()) + this.enqueue_encrypted(&active, flags, stream_id, plaintext) } /// Enqueues a control payload frame. @@ -511,7 +541,7 @@ impl FoctetFramed { header.validate_v0()?; let ct_len = header.ct_len as usize; - if ct_len > self.max_ciphertext_len { + if ct_len > self.limits.max_ciphertext_len { return Err(CoreError::FrameTooLarge); } @@ -523,19 +553,23 @@ impl FoctetFramed { let frame_bytes = self.rx.split_to(total); let frame = Frame::from_bytes(&frame_bytes)?; - self.replay.check_and_record( - frame.header.key_id, - frame.header.stream_id, - frame.header.seq, - )?; - let keys = self .key_for_id(frame.header.key_id) .ok_or(CoreError::UnexpectedKeyId { expected: self.active_key_id, actual: frame.header.key_id, })?; + + // Authenticate the ciphertext *before* committing replay-window state so + // an unauthenticated frame carrying an attacker-chosen sequence number + // cannot permanently advance the window and reject later legitimate + // frames (receive-side desynchronization / DoS). let plaintext = decrypt_frame_with_key(keys, self.inbound_direction, &frame)?; + self.replay.check_and_record( + frame.header.key_id, + frame.header.stream_id, + frame.header.seq, + )?; Ok(Some(DecodedFrame { header: frame.header, @@ -610,20 +644,7 @@ impl Sink> for FoctetFramed { fn start_send(self: Pin<&mut Self>, item: Vec) -> Result<(), Self::Error> { let this = self.get_mut(); let active = this.active_keys()?.clone(); - let frame = encrypt_frame( - &active, - this.outbound_direction, - this.default_flags, - this.default_stream_id, - this.next_seq, - &item, - )?; - this.next_seq = this - .next_seq - .checked_add(1) - .ok_or(CoreError::SequenceExhausted)?; - this.tx.extend_from_slice(&frame.to_bytes()); - Ok(()) + this.enqueue_encrypted(&active, this.default_flags, this.default_stream_id, &item) } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { @@ -753,11 +774,17 @@ mod tests { use futures_sink::Sink; use crate::{ - crypto::{Direction, EphemeralKeyPair, derive_traffic_keys, random_session_salt}, + ControlMessage, CoreError, + crypto::{ + Direction, EphemeralKeyPair, KeyHandle, derive_traffic_keys, encrypt_frame, + random_session_salt, + }, io::{PollRead, PollWrite}, }; - use super::{FoctetFramed, flags}; + use super::{ + DecodedFrame, FoctetFramed, FrameHeader, PROFILE_X25519_HKDF_XCHACHA20POLY1305, flags, + }; #[derive(Default, Debug)] struct MemoryIo { @@ -814,7 +841,7 @@ mod tests { let eph_b = EphemeralKeyPair::generate(); let ss = eph_a.shared_secret(eph_b.public).expect("shared secret"); let salt = random_session_salt(); - let keys = derive_traffic_keys(&ss, &salt, 1).expect("traffic keys"); + let keys = KeyHandle::new(derive_traffic_keys(&ss, &salt, 1).expect("traffic keys")); let io = MemoryIo::default(); let mut framed = FoctetFramed::new(io, keys.clone(), Direction::C2S, Direction::C2S) @@ -843,4 +870,173 @@ mod tests { assert_eq!(item.header.stream_id, 9); assert_eq!(item.header.flags, flags::IS_CONTROL); } + + #[test] + fn async_replay_state_committed_only_after_auth() { + let eph_a = EphemeralKeyPair::generate(); + let eph_b = EphemeralKeyPair::generate(); + let ss = eph_a.shared_secret(eph_b.public).expect("shared secret"); + let salt = random_session_salt(); + let keys = KeyHandle::new(derive_traffic_keys(&ss, &salt, 1).expect("traffic keys")); + + let valid = encrypt_frame(&keys, Direction::C2S, 0, 0, 0, b"hello").expect("valid frame"); + let forged = + encrypt_frame(&keys, Direction::C2S, 0, 0, 1_000_000, b"forged").expect("forged frame"); + let mut forged_bytes = forged.to_bytes(); + let last = forged_bytes.len() - 1; + forged_bytes[last] ^= 0xff; // corrupt the AEAD tag + + let io = MemoryIo::default(); + let mut framed = FoctetFramed::new(io, keys, Direction::C2S, Direction::S2C); + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + + framed.get_mut().push_inbound(&forged_bytes); + match Pin::new(&mut framed).poll_next(&mut cx) { + Poll::Ready(Some(Err(CoreError::Aead))) => {} + other => panic!("expected aead failure, got {other:?}"), + } + + // The forged high sequence must not have advanced the replay window. + framed.get_mut().push_inbound(&valid.to_bytes()); + match Pin::new(&mut framed).poll_next(&mut cx) { + Poll::Ready(Some(Ok(frame))) => assert_eq!(frame.plaintext, b"hello"), + other => panic!("expected the genuine seq=0 frame, got {other:?}"), + } + } + + #[test] + fn start_send_rejects_plaintext_over_the_configured_limit() { + use crate::limits::ProtocolLimits; + + let eph_a = EphemeralKeyPair::generate(); + let eph_b = EphemeralKeyPair::generate(); + let ss = eph_a.shared_secret(eph_b.public).expect("shared secret"); + let salt = random_session_salt(); + let keys = KeyHandle::new(derive_traffic_keys(&ss, &salt, 1).expect("traffic keys")); + + let mut framed = + FoctetFramed::new(MemoryIo::default(), keys, Direction::C2S, Direction::C2S) + .with_limits(ProtocolLimits::default().with_max_plaintext_len(4)); + + let err = Pin::new(&mut framed) + .start_send_with(0, 0, b"way past the limit") + .expect_err("oversized plaintext must be rejected before encryption"); + assert!(matches!(err, CoreError::FrameTooLarge)); + + // A rejected send must not consume a sequence number: the next small + // frame still starts at seq 0 and decrypts. + Pin::new(&mut framed) + .start_send_with(0, 0, b"ok") + .expect("small payload still sends"); + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + match Pin::new(&mut framed).poll_flush(&mut cx) { + Poll::Ready(Ok(())) => {} + other => panic!("flush failed: {other:?}"), + } + let outbound = framed.get_ref().outbound.clone(); + framed.get_mut().push_inbound(&outbound); + match Pin::new(&mut framed).poll_next(&mut cx) { + Poll::Ready(Some(Ok(frame))) => { + assert_eq!(frame.plaintext, b"ok"); + assert_eq!(frame.header.seq, 0); + } + other => panic!("unexpected poll_next: {other:?}"), + } + } + + #[test] + fn enqueue_fails_once_the_tx_buffer_limit_is_hit() { + use crate::limits::ProtocolLimits; + + let eph_a = EphemeralKeyPair::generate(); + let eph_b = EphemeralKeyPair::generate(); + let ss = eph_a.shared_secret(eph_b.public).expect("shared secret"); + let salt = random_session_salt(); + let keys = KeyHandle::new(derive_traffic_keys(&ss, &salt, 1).expect("traffic keys")); + + // Budget: exactly one small frame fits, a second enqueue without a + // flush must fail closed instead of growing the buffer unboundedly. + let one_frame_budget = super::FRAME_HEADER_LEN + b"payload".len() + 16; + let mut framed = + FoctetFramed::new(MemoryIo::default(), keys, Direction::C2S, Direction::C2S) + .with_limits( + ProtocolLimits::default().with_max_buffered_tx_bytes(one_frame_budget), + ); + + Pin::new(&mut framed) + .start_send_with(0, 0, b"payload") + .expect("first frame fits the budget"); + let err = Pin::new(&mut framed) + .start_send_with(0, 0, b"payload") + .expect_err("second frame must exceed the buffered-tx budget"); + assert!(matches!(err, CoreError::OutboundBufferLimitExceeded)); + + // Draining the buffer makes room again. + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + match Pin::new(&mut framed).poll_flush(&mut cx) { + Poll::Ready(Ok(())) => {} + other => panic!("flush failed: {other:?}"), + } + Pin::new(&mut framed) + .start_send_with(0, 0, b"payload") + .expect("after draining, sending works again"); + } + + #[test] + fn decode_control_rejects_a_frame_without_the_control_flag() { + // A data frame whose plaintext happens to look like a valid encoded + // `ControlMessage` must still be rejected by `decode_control`: the + // `IS_CONTROL` header flag, not the payload shape, is the sole + // authority over how a frame is interpreted. + let msg = ControlMessage::Error { code: 1 }; + let frame = DecodedFrame { + header: FrameHeader::new(0, PROFILE_X25519_HKDF_XCHACHA20POLY1305, 0, 0, 0, 0), + plaintext: msg.encode(), + }; + let err = FoctetFramed::::decode_control(&frame) + .expect_err("must reject a frame without IS_CONTROL set"); + assert!(matches!(err, CoreError::UnexpectedControlMessage)); + } + + #[test] + fn handle_incoming_with_session_ignores_control_shaped_bytes_without_the_flag() { + // Same flag-confusion property, exercised through the session-aware + // dispatcher: control-shaped bytes delivered as a *data* frame + // (IS_CONTROL unset) must surface as plain application data, never be + // parsed and acted on as a control message. + let eph_a = EphemeralKeyPair::generate(); + let eph_b = EphemeralKeyPair::generate(); + let ss = eph_a.shared_secret(eph_b.public).expect("shared secret"); + let salt = random_session_salt(); + let keys = KeyHandle::new(derive_traffic_keys(&ss, &salt, 1).expect("traffic keys")); + + let (mut session, _hello) = crate::Session::new_initiator_with_auth( + crate::RekeyThresholds::default(), + crate::SessionAuthConfig::unauthenticated_for_testing(), + ); + + let control_shaped_bytes = ControlMessage::Error { code: 7 }.encode(); + let frame = encrypt_frame(&keys, Direction::C2S, 0, 0, 0, &control_shaped_bytes) + .expect("encrypt frame"); + + let io = MemoryIo::default(); + let mut framed = FoctetFramed::new(io, keys, Direction::C2S, Direction::S2C); + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + framed.get_mut().push_inbound(&frame.to_bytes()); + + let decoded = match Pin::new(&mut framed).poll_next(&mut cx) { + Poll::Ready(Some(Ok(frame))) => frame, + other => panic!("expected decoded data frame, got {other:?}"), + }; + assert_eq!(decoded.header.flags & flags::IS_CONTROL, 0); + + let result = Pin::new(&mut framed) + .handle_incoming_with_session(&mut session, decoded) + .expect("data frame must not be treated as control"); + assert_eq!(result, Some(control_shaped_bytes)); + } } diff --git a/foctet-core/src/io.rs b/foctet-core/src/io.rs index bc43dd9..8e4d8b0 100644 --- a/foctet-core/src/io.rs +++ b/foctet-core/src/io.rs @@ -7,10 +7,12 @@ use std::{ use crate::{ CoreError, control::ControlMessage, - crypto::{Direction, TrafficKeys, decrypt_frame_with_key, encrypt_frame}, + crypto::{Direction, KeyHandle, TrafficKeys, decrypt_frame_with_key, encrypt_frame}, frame::{FRAME_HEADER_LEN, Frame, FrameHeader}, + limits::ProtocolLimits, payload::{self, Tlv}, - replay::{DEFAULT_REPLAY_WINDOW, ReplayProtector}, + replay::ReplayProtector, + sequence::OutboundSequence, session::Session, }; @@ -171,7 +173,7 @@ where /// Constructs [`FoctetFramed`] from Tokio async I/O. pub fn from_tokio( io: T, - keys: TrafficKeys, + keys: KeyHandle, inbound_direction: Direction, outbound_direction: Direction, ) -> Self { @@ -192,7 +194,7 @@ where /// Constructs [`FoctetFramed`] from futures-io async I/O. pub fn from_futures( io: T, - keys: TrafficKeys, + keys: KeyHandle, inbound_direction: Direction, outbound_direction: Direction, ) -> Self { @@ -213,7 +215,7 @@ where /// Constructs [`FoctetStream`] from Tokio async I/O. pub fn from_tokio( io: T, - keys: TrafficKeys, + keys: KeyHandle, inbound_direction: Direction, outbound_direction: Direction, ) -> Self { @@ -230,7 +232,7 @@ where /// Constructs [`FoctetStream`] from futures-io async I/O. pub fn from_futures( io: T, - keys: TrafficKeys, + keys: KeyHandle, inbound_direction: Direction, outbound_direction: Direction, ) -> Self { @@ -354,15 +356,14 @@ where #[derive(Debug)] pub struct SyncIo { io: T, - keys: Vec, + keys: Vec, active_key_id: u8, - max_retained_keys: usize, + limits: ProtocolLimits, inbound_direction: Direction, outbound_direction: Direction, default_stream_id: u32, default_flags: u8, - next_seq: u64, - max_ciphertext_len: usize, + next_seq: OutboundSequence, replay: ReplayProtector, } @@ -370,22 +371,22 @@ impl SyncIo { /// Creates a blocking Foctet transport wrapper. pub fn new( io: T, - keys: TrafficKeys, + keys: KeyHandle, inbound_direction: Direction, outbound_direction: Direction, ) -> Self { + let limits = ProtocolLimits::default(); Self { io, active_key_id: keys.key_id, keys: vec![keys], - max_retained_keys: 2, inbound_direction, outbound_direction, default_stream_id: 0, default_flags: 0, - next_seq: 0, - max_ciphertext_len: 16 * 1024 * 1024, - replay: ReplayProtector::new(DEFAULT_REPLAY_WINDOW), + next_seq: OutboundSequence::default(), + replay: limits.replay_protector(), + limits, } } @@ -401,15 +402,31 @@ impl SyncIo { self } + /// Applies a complete set of [`ProtocolLimits`], rebuilding the replay + /// protector from the new replay-window size and window cap. + /// + /// Intended to be called immediately after [`SyncIo::new`], before any + /// frames are processed; it resets replay-window state. + pub fn with_limits(mut self, limits: ProtocolLimits) -> Self { + self.replay = limits.replay_protector(); + self.limits = limits; + self + } + + /// Returns the active protocol limits. + pub fn limits(&self) -> ProtocolLimits { + self.limits + } + /// Sets inbound ciphertext size limit. pub fn with_max_ciphertext_len(mut self, max_len: usize) -> Self { - self.max_ciphertext_len = max_len; + self.limits.max_ciphertext_len = max_len; self } /// Sets number of retained previous keys. pub fn with_max_retained_keys(mut self, max: usize) -> Self { - self.max_retained_keys = max.max(1); + self.limits.max_retained_keys = max.max(1); self } @@ -418,17 +435,24 @@ impl SyncIo { self.active_key_id } + /// Returns how many inbound frames this transport's replay protection has + /// rejected since creation (see [`crate::ReplayProtector::rejections`]); + /// an observability counter that carries no key material. + pub fn replay_rejections(&self) -> u64 { + self.replay.rejections() + } + /// Returns known key IDs, active first. pub fn known_key_ids(&self) -> Vec { self.keys.iter().map(|k| k.key_id).collect() } /// Installs new active keys and retains previous keys. - pub fn install_active_keys(&mut self, keys: TrafficKeys) { + pub fn install_active_keys(&mut self, keys: KeyHandle) { self.keys.retain(|k| k.key_id != keys.key_id); self.keys.insert(0, keys.clone()); self.active_key_id = keys.key_id; - let keep = self.max_retained_keys + 1; + let keep = self.limits.max_retained_keys + 1; if self.keys.len() > keep { self.keys.truncate(keep); } @@ -439,14 +463,14 @@ impl SyncIo { self.io } - fn active_keys(&self) -> Result<&TrafficKeys, CoreError> { + fn active_keys(&self) -> Result<&KeyHandle, CoreError> { self.keys .iter() .find(|k| k.key_id == self.active_key_id) .ok_or(CoreError::MissingSessionSecret) } - fn key_for_id(&self, key_id: u8) -> Option<&TrafficKeys> { + fn key_for_id(&self, key_id: u8) -> Option<&KeyHandle> { self.keys.iter().find(|k| k.key_id == key_id) } @@ -458,7 +482,7 @@ impl SyncIo { .first() .map(|k| k.key_id) .ok_or(CoreError::InvalidSessionState)?; - let keep = self.max_retained_keys + 1; + let keep = self.limits.max_retained_keys + 1; if self.keys.len() > keep { self.keys.truncate(keep); } @@ -474,17 +498,25 @@ impl SyncIo { stream_id: u32, plaintext: &[u8], ) -> Result<(), CoreError> { + if plaintext.len() > self.limits.max_plaintext_len { + return Err(CoreError::FrameTooLarge); + } let frame = encrypt_frame( keys, self.outbound_direction, flags, stream_id, - self.next_seq, + self.next_seq.current(), plaintext, )?; - self.next_seq = self.next_seq.wrapping_add(1); + // Fail closed on sequence exhaustion: never wrap the counter, otherwise + // the `(key_id, stream_id, seq)` nonce would repeat under the same key. + // This mirrors the async `FoctetFramed` path exactly so the two + // implementations cannot diverge in their exhaustion policy. + let next_seq = self.next_seq.prepared_next()?; self.io.write_all(&frame.to_bytes())?; self.io.flush()?; + self.next_seq.commit(next_seq); Ok(()) } @@ -523,16 +555,13 @@ impl SyncIo { header.validate_v0()?; let ct_len = header.ct_len as usize; - if ct_len > self.max_ciphertext_len { + if ct_len > self.limits.max_ciphertext_len { return Err(CoreError::FrameTooLarge); } let mut ciphertext = vec![0u8; ct_len]; self.io.read_exact(&mut ciphertext)?; - self.replay - .check_and_record(header.key_id, header.stream_id, header.seq)?; - let keys = self .key_for_id(header.key_id) .ok_or(CoreError::UnexpectedKeyId { @@ -540,8 +569,18 @@ impl SyncIo { actual: header.key_id, })?; + // Authenticate the ciphertext *before* committing replay-window state. + // Recording an attacker-chosen sequence number prior to AEAD + // verification would let a forged frame permanently advance the window + // and desynchronize/DoS the receiver. See replay.rs and SPEC.md. let frame = Frame { header, ciphertext }; - decrypt_frame_with_key(keys, self.inbound_direction, &frame) + let plaintext = decrypt_frame_with_key(keys, self.inbound_direction, &frame)?; + self.replay.check_and_record( + frame.header.key_id, + frame.header.stream_id, + frame.header.seq, + )?; + Ok(plaintext) } /// Sends one control message. @@ -621,16 +660,13 @@ impl SyncIo { header.validate_v0()?; let ct_len = header.ct_len as usize; - if ct_len > self.max_ciphertext_len { + if ct_len > self.limits.max_ciphertext_len { return Err(CoreError::FrameTooLarge); } let mut ciphertext = vec![0u8; ct_len]; self.io.read_exact(&mut ciphertext)?; - self.replay - .check_and_record(header.key_id, header.stream_id, header.seq)?; - let keys = self .key_for_id(header.key_id) .ok_or(CoreError::UnexpectedKeyId { @@ -638,8 +674,14 @@ impl SyncIo { actual: header.key_id, })?; + // Authenticate before committing replay state (see `recv`). let frame = Frame { header, ciphertext }; let plaintext = decrypt_frame_with_key(keys, self.inbound_direction, &frame)?; + self.replay.check_and_record( + frame.header.key_id, + frame.header.stream_id, + frame.header.seq, + )?; if frame.header.flags & crate::frame::flags::IS_CONTROL != 0 { let msg = ControlMessage::decode(&plaintext)?; @@ -660,3 +702,178 @@ impl From for std::io::Error { std::io::Error::other(value) } } + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::io::{Read, Write}; + + use super::SyncIo; + use crate::CoreError; + use crate::crypto::{ + Direction, EphemeralKeyPair, KeyHandle, derive_traffic_keys, encrypt_frame, + random_session_salt, + }; + + #[derive(Default)] + struct MockIo { + inbound: VecDeque, + outbound: Vec, + } + + impl Read for MockIo { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.inbound.is_empty() { + return Ok(0); + } + let n = buf.len().min(self.inbound.len()); + for slot in buf.iter_mut().take(n) { + *slot = self.inbound.pop_front().expect("inbound byte"); + } + Ok(n) + } + } + + impl Write for MockIo { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.outbound.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + fn test_keys() -> KeyHandle { + let a = EphemeralKeyPair::generate(); + let b = EphemeralKeyPair::generate(); + let ss = a.shared_secret(b.public).expect("shared secret"); + let salt = random_session_salt(); + KeyHandle::new(derive_traffic_keys(&ss, &salt, 1).expect("traffic keys")) + } + + #[test] + fn sync_send_fails_closed_on_sequence_exhaustion() { + let keys = test_keys(); + let mut io = SyncIo::new(MockIo::default(), keys, Direction::S2C, Direction::C2S); + + // Drive the outbound counter to the last representable sequence. + io.next_seq.set_for_test(u64::MAX - 1); + io.send(b"last").expect("final valid frame must be emitted"); + assert_eq!(io.next_seq.current(), u64::MAX); + let emitted = io.io.outbound.len(); + assert!(emitted > 0); + + // The next send would have to reuse a nonce; it must fail closed and + // must NOT emit any wrapped frame. + let err = io + .send(b"overflow") + .expect_err("must refuse to wrap the nonce"); + assert!(matches!(err, CoreError::SequenceExhausted)); + assert_eq!( + io.io.outbound.len(), + emitted, + "no wrapped frame may be written" + ); + assert_eq!(io.next_seq.current(), u64::MAX); + } + + #[test] + fn sync_replay_state_committed_only_after_auth() { + let keys = test_keys(); + + // Receiver treats inbound traffic as the C2S direction, so the peer + // encrypts with C2S keys. + let valid = encrypt_frame(&keys, Direction::C2S, 0, 0, 0, b"hello").expect("valid frame"); + let forged = + encrypt_frame(&keys, Direction::C2S, 0, 0, 1_000_000, b"forged").expect("forged frame"); + let mut forged_bytes = forged.to_bytes(); + let last = forged_bytes.len() - 1; + forged_bytes[last] ^= 0xff; // corrupt the AEAD tag -> authentication failure + + let mut mock = MockIo::default(); + mock.inbound.extend(forged_bytes.iter().copied()); + mock.inbound.extend(valid.to_bytes().iter().copied()); + + let mut io = SyncIo::new(mock, keys, Direction::C2S, Direction::S2C); + + // The forged high-sequence frame must fail authentication. + let err = io + .recv() + .expect_err("forged frame must fail authentication"); + assert!(matches!(err, CoreError::Aead)); + + // Because replay state is only committed after authentication, the + // forged seq=1_000_000 must NOT have advanced the window. The genuine + // seq=0 frame is therefore still accepted. + let plaintext = io + .recv() + .expect("legitimate low-sequence frame after forgery"); + assert_eq!(plaintext, b"hello"); + } + + #[test] + fn with_limits_enforces_max_ciphertext_len_on_receive() { + use crate::limits::ProtocolLimits; + + let keys = test_keys(); + let frame = encrypt_frame(&keys, Direction::C2S, 0, 0, 0, b"a slightly longer payload") + .expect("frame"); + + let mut mock = MockIo::default(); + mock.inbound.extend(frame.to_bytes().iter().copied()); + + // Configure an inbound ciphertext ceiling far below this frame's length. + let mut io = SyncIo::new(mock, keys, Direction::C2S, Direction::S2C) + .with_limits(ProtocolLimits::default().with_max_ciphertext_len(4)); + assert_eq!(io.limits().max_ciphertext_len, 4); + + let err = io + .recv() + .expect_err("frame exceeding the configured ciphertext limit must be rejected"); + assert!(matches!(err, CoreError::FrameTooLarge)); + } + + #[test] + fn send_rejects_plaintext_over_the_configured_limit() { + use crate::limits::ProtocolLimits; + + let keys = test_keys(); + let mut io = SyncIo::new(MockIo::default(), keys, Direction::S2C, Direction::C2S) + .with_limits(ProtocolLimits::default().with_max_plaintext_len(4)); + + let err = io + .send(b"way past the limit") + .expect_err("oversized plaintext must be rejected before encryption"); + assert!(matches!(err, CoreError::FrameTooLarge)); + assert!(io.io.outbound.is_empty(), "nothing may be written"); + + io.send(b"ok").expect("small payload still sends"); + } + + #[test] + fn with_limits_configures_replay_window_size() { + use crate::limits::ProtocolLimits; + + let keys = test_keys(); + // seq=0, then seq=8: with a window of 4, the older seq=0 falls outside + // the window once seq=8 advances it. + let first = encrypt_frame(&keys, Direction::C2S, 0, 0, 8, b"newer").expect("first"); + let stale = encrypt_frame(&keys, Direction::C2S, 0, 0, 0, b"older").expect("stale"); + + let mut mock = MockIo::default(); + mock.inbound.extend(first.to_bytes().iter().copied()); + mock.inbound.extend(stale.to_bytes().iter().copied()); + + let mut io = SyncIo::new(mock, keys, Direction::C2S, Direction::S2C) + .with_limits(ProtocolLimits::default().with_replay_window(4)); + assert_eq!(io.limits().replay_window, 4); + + assert_eq!(io.recv().expect("newer seq accepted"), b"newer"); + let err = io + .recv() + .expect_err("seq outside the small replay window must be rejected"); + assert!(matches!(err, CoreError::ReplayWindowExceeded)); + } +} diff --git a/foctet-core/src/lib.rs b/foctet-core/src/lib.rs index 8db80df..4b76eda 100644 --- a/foctet-core/src/lib.rs +++ b/foctet-core/src/lib.rs @@ -1,45 +1,26 @@ -//! Foctet Core (Draft v0) -//! - Fixed-width frame header encoding -//! - Profile 0x01 crypto primitives -//! - Native handshake key schedule helpers -//! - Replay window enforcement -//! - Runtime-agnostic streaming adapters +//! Foctet Core (Draft v0). //! -//! `foctet-core` is the low-level protocol crate for applications that need to -//! drive Foctet sessions directly. If you already have a split stream transport -//! such as QUIC, WebTransport, WebSocket multiplexing, or another byte-stream -//! abstraction, prefer `foctet-transport` for the recommended handshake and -//! channel builders. +//! `foctet-core` is the low-level protocol crate: framing, key derivation, +//! handshake/rekey state, replay protection, and the `application/foctet` body +//! envelope live here. //! -//! # Main Modules +//! If you already have split stream transports such as QUIC, WebTransport, or +//! WebSocket multiplexing, prefer `foctet-transport` for the recommended +//! handshake and channel builders. //! -//! - [`body`]: `application/foctet` one-shot encrypted body envelope -//! - [`frame`]: wire frame structures, parser/encoder, framed transport types -//! - [`crypto`]: key schedule and frame AEAD helpers -//! - [`control`]: control message wire payloads -//! - [`session`]: handshake/rekey state machine -//! - [`payload`]: encrypted payload TLV schema -//! - [`io`]: runtime adapters and blocking `SyncIo` +//! Main entry points: //! -//! # Typical Flow +//! - [`frame`] for stream framing +//! - [`message`] and [`datagram`] for discrete-message / datagram shapes +//! - [`session`] for handshake and rekey +//! - [`body`] for one-shot HTTP/body envelopes +//! - [`io`] for blocking and runtime adapters //! -//! 1. Build/derive [`TrafficKeys`] via handshake/session. -//! 2. Send/receive via [`frame::FoctetFramed`] or [`io::SyncIo`]. -//! 3. Use [`Session`] to process control frames and rotate keys. -//! 4. Encode application bytes as TLV (`APPLICATION_DATA`) via [`payload`]. +//! The native handshake is authenticated by default. For production use, prefer +//! [`SessionAuthConfig`] with local identity keys, pinned [`PeerIdentity`] +//! values, and `require_peer_authentication(true)`. //! -//! # Authentication Guidance -//! -//! - For production use, prefer [`SessionAuthConfig`] with local identity keys, -//! pinned [`PeerIdentity`] values, and -//! `SessionAuthConfig::require_peer_authentication(true)`. -//! - If Foctet runs inside an already-authenticated outer channel, you may use -//! the native handshake without identity signatures, but the outer channel -//! then carries the peer-authentication responsibility. -//! - Sequence numbers and rekey identifiers fail closed on exhaustion; callers -//! should treat those errors as terminal and establish a fresh session. -//! -//! # Typical Native Handshake +//! # Handshake Example //! //! ```rust,ignore //! use foctet_core::{ @@ -61,45 +42,85 @@ pub mod auth; /// One-shot body-complete encrypted envelope (`application/foctet`) helpers. pub mod body; +/// Streaming (chunked) `application/foctet` body with per-chunk AEAD. +pub mod body_stream; /// Control-plane message types used inside encrypted control frames. pub mod control; /// Cryptographic primitives and key-derivation helpers. pub mod crypto; +/// Datagram-oriented endpoint (one frame per datagram) for UDP/QUIC/WebTransport. +pub mod datagram; /// Frame wire format, parser/encoder, and framed transport adapters. pub mod frame; /// Runtime adapters and blocking I/O wrappers. pub mod io; +/// Centralized protocol resource limits for the stream-oriented transports. +pub mod limits; +/// Message-oriented endpoint (one frame per discrete message) for raw WebSocket. +pub mod message; +/// Observability hooks for session lifecycle events (no key material exposed). +pub mod observe; /// TLV payload encoding/decoding helpers for encrypted application bytes. pub mod payload; /// Replay-window tracking and duplicate-frame protection. pub mod replay; /// High-level blocking facade combining session/rekey and TLV application flow. pub mod secure_channel; +/// Internal fail-closed outbound sequence allocation shared by all transport shapes. +mod sequence; /// Session handshake/rekey state and key lifecycle handling. pub mod session; +/// At-rest storage envelopes that bind a record's identity (namespace / id / +/// version) into the AEAD for zero-knowledge backends. +pub mod storage; pub use auth::{ - HANDSHAKE_AUTH_ED25519, HANDSHAKE_AUTH_NONE, HandshakeAuth, IdentityKeyPair, PeerIdentity, - SessionAuthConfig, + AuthenticatedPeer, ChannelBinding, HANDSHAKE_AUTH_ED25519, HANDSHAKE_AUTH_NONE, HandshakeAuth, + HandshakeSigner, IdentityKeyPair, PeerIdentity, SessionAuthConfig, }; pub use body::{ BODY_MAGIC, BODY_PROFILE_V0, BODY_VERSION_V0, BodyEnvelopeError, BodyEnvelopeLimits, open_body, - open_body_for_key_id, open_body_for_key_id_with_limits, open_body_with_limits, seal_body, + open_body_for_key_id, open_body_for_key_id_with_context, open_body_for_key_id_with_limits, + open_body_with_context, open_body_with_limits, seal_body, seal_body_with_context, seal_body_with_limits, }; -pub use control::{ControlMessage, ControlMessageKind}; +pub use body_stream::{ + DecodedChunk, STREAM_CHUNK_OVERHEAD, STREAM_MAGIC, STREAM_NONCE_PREFIX_LEN, STREAM_PROFILE_V0, + STREAM_VERSION_V0, StreamFrameDecoder, StreamItem, StreamOpener, StreamSealer, +}; +pub use control::{ControlMessage, ControlMessageKind, MAX_CONTROL_MESSAGE_LEN}; pub use crypto::{ - Direction, EphemeralKeyPair, TrafficKeys, decrypt_frame, decrypt_frame_with_key, - derive_rekey_traffic_keys, derive_traffic_keys, encrypt_frame, make_nonce, random_session_salt, + Direction, EphemeralKeyPair, KeyHandle, TrafficKeys, decrypt_frame, decrypt_frame_with_key, + derive_ratchet_root, derive_traffic_keys, dh_ratchet_step, encrypt_frame, make_nonce, + random_session_salt, +}; +pub use datagram::{ + DATAGRAM_FRAME_OVERHEAD, DEFAULT_MAX_DATAGRAM_SIZE, DatagramConfig, DatagramEndpoint, + DecodedDatagram, }; pub use frame::{ DRAFT_MAGIC, FRAME_HEADER_LEN, FoctetFramed, FoctetStream, Frame, FrameHeader, PROFILE_X25519_HKDF_XCHACHA20POLY1305, WIRE_VERSION_V0, }; +pub use limits::{ + DEFAULT_HANDSHAKE_TIMEOUT, DEFAULT_MAX_BUFFERED_TX_BYTES, DEFAULT_MAX_CIPHERTEXT_LEN, + DEFAULT_MAX_PLAINTEXT_LEN, DEFAULT_MAX_RETAINED_KEYS, ProtocolLimits, +}; +pub use message::{ + DEFAULT_MAX_MESSAGE_SIZE, DecodedMessage, MESSAGE_FRAME_OVERHEAD, MessageConfig, + MessageEndpoint, +}; +pub use observe::{SessionEvent, SessionObserver}; pub use payload::{Tlv, decode_tlvs, encode_tlvs, tlv_type}; -pub use replay::{DEFAULT_REPLAY_WINDOW, ReplayProtector, ReplayWindow}; +pub use replay::{ + DEFAULT_MAX_REPLAY_WINDOWS, DEFAULT_REPLAY_WINDOW, ReplayProtector, ReplayWindow, +}; pub use secure_channel::{AsyncSecureChannel, SecureChannel}; pub use session::{HandshakeRole, RekeyThresholds, Session, SessionState}; +pub use storage::{ + StorageRecord, open_storage_record, open_storage_record_with_limits, seal_storage_record, + seal_storage_record_with_limits, +}; use thiserror::Error; @@ -155,6 +176,10 @@ pub enum CoreError { /// Session operation was called in an invalid state. #[error("invalid session state")] InvalidSessionState, + /// A rekey was initiated out of turn (the DH ratchet alternates between + /// peers; only the side whose turn it is may initiate the next rekey). + #[error("rekey not permitted: it is the peer's turn to ratchet")] + RekeyNotPermitted, /// Session/shared secret is not available. #[error("missing session secret")] MissingSessionSecret, @@ -170,9 +195,16 @@ pub enum CoreError { /// Frame sequence is outside replay window. #[error("frame is outside replay window")] ReplayWindowExceeded, + /// Too many distinct `(key_id, stream_id)` replay windows are being tracked. + #[error("replay window capacity exceeded")] + ReplayCapacityExceeded, /// Frame exceeds configured size limits. #[error("frame exceeds configured limit")] FrameTooLarge, + /// Outbound frame buffer exceeded its configured limit before the + /// underlying I/O drained it; flush pending frames and retry. + #[error("outbound buffer limit exceeded")] + OutboundBufferLimitExceeded, /// Unexpected EOF while reading/writing frame bytes. #[error("unexpected eof")] UnexpectedEof, @@ -197,4 +229,11 @@ pub enum CoreError { /// Peer identity did not match the pinned expectation. #[error("peer identity mismatch")] PeerIdentityMismatch, + /// The handshake did not complete within the configured deadline. + #[error("handshake timed out")] + HandshakeTimeout, + /// A handshake was refused by connection-level admission control + /// (rate limiting); retry later or drop the connection. + #[error("handshake rate limited")] + HandshakeRateLimited, } diff --git a/foctet-core/src/limits.rs b/foctet-core/src/limits.rs new file mode 100644 index 0000000..2f638b6 --- /dev/null +++ b/foctet-core/src/limits.rs @@ -0,0 +1,237 @@ +//! Centralized protocol resource limits for the stream-oriented Foctet paths. +//! +//! [`ProtocolLimits`] gathers the DoS-relevant bounds that the framed +//! (`FoctetFramed`) and blocking (`SyncIo`) transports previously hardcoded as +//! scattered magic numbers, so a single value documents and configures them in +//! one place. The defaults are the recommended production values. +//! +//! Datagram (`DatagramConfig`) and one-shot body (`BodyEnvelopeLimits`) shapes +//! keep their own limit types because their bounds differ in kind (a single +//! datagram is MTU-bounded; a body envelope is whole-buffer). They share the +//! same default ciphertext ceiling constant ([`DEFAULT_MAX_CIPHERTEXT_LEN`]) +//! where it applies. + +use std::time::Duration; + +use crate::replay::{DEFAULT_MAX_REPLAY_WINDOWS, DEFAULT_REPLAY_WINDOW, ReplayProtector}; + +/// Default upper bound on a single inbound frame's ciphertext length (16 MiB). +/// +/// A receiver rejects a frame whose declared `ct_len` exceeds this before +/// allocating a buffer for it, so a hostile peer cannot force an unbounded +/// allocation by advertising a huge length field. +pub const DEFAULT_MAX_CIPHERTEXT_LEN: usize = 16 * 1024 * 1024; + +/// Default upper bound on a single outbound frame's plaintext length. +/// +/// Chosen so that the resulting ciphertext (plaintext + 16-byte AEAD tag) never +/// exceeds [`DEFAULT_MAX_CIPHERTEXT_LEN`]: a frame a sender emits under the +/// default limits is always accepted by a receiver running the default limits. +pub const DEFAULT_MAX_PLAINTEXT_LEN: usize = DEFAULT_MAX_CIPHERTEXT_LEN - 16; + +/// Default upper bound on encrypted frames buffered for sending (64 MiB). +/// +/// The async framed transport queues encrypted frames when the underlying +/// socket is not immediately writable. This cap bounds that queue so a stalled +/// or slow peer cannot cause unbounded sender-side memory growth; once +/// exceeded, enqueueing fails with [`crate::CoreError::OutboundBufferLimitExceeded`] +/// until the buffer is drained (`poll_flush` / `poll_ready`). +pub const DEFAULT_MAX_BUFFERED_TX_BYTES: usize = 64 * 1024 * 1024; + +/// Default deadline for a native handshake to complete (10 seconds). +/// +/// Enforced by the transport builders (`foctet-transport`), which race the +/// handshake against a timer and fail with +/// [`crate::CoreError::HandshakeTimeout`] on expiry, so a peer that connects +/// and then stalls cannot pin handshake state indefinitely. +pub const DEFAULT_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); + +/// Default number of *previous* traffic-key generations retained for decrypting +/// in-flight frames across a rekey. The active key is always kept in addition to +/// these, so the receiver tolerates frames that were encrypted just before a +/// rekey took effect. +pub const DEFAULT_MAX_RETAINED_KEYS: usize = 2; + +/// Centralized resource limits for the stream-oriented transports. +/// +/// Construct with [`ProtocolLimits::default`] for the recommended production +/// values and adjust individual fields with the builder methods, or build a +/// value directly. All limits are clamped to a safe minimum on construction via +/// the builder methods (`0` is never accepted where it would disable a bound). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ProtocolLimits { + /// Maximum accepted inbound ciphertext length, in bytes, per frame. + pub max_ciphertext_len: usize, + /// Maximum outbound plaintext length, in bytes, per frame. Send paths + /// reject a larger payload with [`crate::CoreError::FrameTooLarge`] before + /// encrypting it. + pub max_plaintext_len: usize, + /// Maximum bytes of encrypted frames buffered for sending before the + /// underlying I/O accepts them. Exceeding it fails with + /// [`crate::CoreError::OutboundBufferLimitExceeded`]. Only the buffering + /// (async framed) path uses this; the blocking path writes through. + pub max_buffered_tx_bytes: usize, + /// Number of previous traffic keys retained for decryption across rekeys. + pub max_retained_keys: usize, + /// Per-`(key_id, stream_id)` sliding replay-window size, in sequence slots. + pub replay_window: u64, + /// Maximum number of distinct `(key_id, stream_id)` replay windows tracked + /// simultaneously, bounding replay-map memory growth. + /// + /// Because one window is tracked per `(key_id, stream_id)`, this is also + /// the bound on how many **distinct inbound stream IDs** a peer can force + /// the receiver to track state for. + pub max_replay_windows: usize, + /// Deadline for a native handshake to complete. Enforced by the + /// `foctet-transport` builders ([`crate::CoreError::HandshakeTimeout`] on + /// expiry); the core state machine itself is poll-driven and has no clock. + pub handshake_timeout: Duration, +} + +impl Default for ProtocolLimits { + fn default() -> Self { + Self { + max_ciphertext_len: DEFAULT_MAX_CIPHERTEXT_LEN, + max_plaintext_len: DEFAULT_MAX_PLAINTEXT_LEN, + max_buffered_tx_bytes: DEFAULT_MAX_BUFFERED_TX_BYTES, + max_retained_keys: DEFAULT_MAX_RETAINED_KEYS, + replay_window: DEFAULT_REPLAY_WINDOW, + max_replay_windows: DEFAULT_MAX_REPLAY_WINDOWS, + handshake_timeout: DEFAULT_HANDSHAKE_TIMEOUT, + } + } +} + +impl ProtocolLimits { + /// Returns the recommended production limits (same as [`Default`]). + pub fn new() -> Self { + Self::default() + } + + /// Sets the maximum accepted inbound ciphertext length per frame. + #[must_use] + pub fn with_max_ciphertext_len(mut self, max_len: usize) -> Self { + self.max_ciphertext_len = max_len; + self + } + + /// Sets the maximum outbound plaintext length per frame. + /// + /// Clamped to a minimum of `1`. + #[must_use] + pub fn with_max_plaintext_len(mut self, max_len: usize) -> Self { + self.max_plaintext_len = max_len.max(1); + self + } + + /// Sets the maximum bytes of encrypted frames buffered for sending. + /// + /// Clamped to a minimum of `1`. Must be large enough to hold at least one + /// complete encrypted frame (header + plaintext + AEAD tag), otherwise + /// every send fails. + #[must_use] + pub fn with_max_buffered_tx_bytes(mut self, max: usize) -> Self { + self.max_buffered_tx_bytes = max.max(1); + self + } + + /// Sets the handshake completion deadline enforced by transport builders. + #[must_use] + pub fn with_handshake_timeout(mut self, timeout: Duration) -> Self { + self.handshake_timeout = timeout; + self + } + + /// Sets the number of previous traffic keys retained for decryption. + /// + /// Clamped to a minimum of `1`: at least one previous key must be retained + /// to decrypt frames still in flight when a rekey takes effect. + #[must_use] + pub fn with_max_retained_keys(mut self, max: usize) -> Self { + self.max_retained_keys = max.max(1); + self + } + + /// Sets the per-stream replay-window size, in sequence slots. + /// + /// Clamped to a minimum of `1` so the window can always record at least the + /// most recently seen sequence number. + #[must_use] + pub fn with_replay_window(mut self, window: u64) -> Self { + self.replay_window = window.max(1); + self + } + + /// Sets the maximum number of distinct replay windows tracked at once. + /// + /// Clamped to a minimum of `1`. + #[must_use] + pub fn with_max_replay_windows(mut self, max: usize) -> Self { + self.max_replay_windows = max.max(1); + self + } + + /// Builds a [`ReplayProtector`] configured from these limits (window size + /// and distinct-window cap). + pub fn replay_protector(&self) -> ReplayProtector { + ReplayProtector::new(self.replay_window).with_max_windows(self.max_replay_windows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_matches_documented_constants() { + let limits = ProtocolLimits::default(); + assert_eq!(limits.max_ciphertext_len, DEFAULT_MAX_CIPHERTEXT_LEN); + assert_eq!(limits.max_plaintext_len, DEFAULT_MAX_PLAINTEXT_LEN); + assert_eq!(limits.max_buffered_tx_bytes, DEFAULT_MAX_BUFFERED_TX_BYTES); + assert_eq!(limits.max_retained_keys, DEFAULT_MAX_RETAINED_KEYS); + assert_eq!(limits.replay_window, DEFAULT_REPLAY_WINDOW); + assert_eq!(limits.max_replay_windows, DEFAULT_MAX_REPLAY_WINDOWS); + assert_eq!(limits.handshake_timeout, DEFAULT_HANDSHAKE_TIMEOUT); + } + + #[test] + fn default_plaintext_limit_fits_the_default_ciphertext_limit() { + // plaintext + 16-byte AEAD tag must not exceed the inbound ceiling. + let limits = ProtocolLimits::default(); + assert!(limits.max_plaintext_len + 16 <= limits.max_ciphertext_len); + } + + #[test] + fn builders_clamp_to_safe_minimums() { + let limits = ProtocolLimits::default() + .with_max_plaintext_len(0) + .with_max_buffered_tx_bytes(0) + .with_max_retained_keys(0) + .with_replay_window(0) + .with_max_replay_windows(0); + assert_eq!(limits.max_plaintext_len, 1); + assert_eq!(limits.max_buffered_tx_bytes, 1); + assert_eq!(limits.max_retained_keys, 1); + assert_eq!(limits.replay_window, 1); + assert_eq!(limits.max_replay_windows, 1); + } + + #[test] + fn builders_set_explicit_values() { + let limits = ProtocolLimits::new() + .with_max_ciphertext_len(1234) + .with_max_plaintext_len(1000) + .with_max_buffered_tx_bytes(4096) + .with_max_retained_keys(5) + .with_replay_window(256) + .with_max_replay_windows(64) + .with_handshake_timeout(Duration::from_secs(3)); + assert_eq!(limits.max_ciphertext_len, 1234); + assert_eq!(limits.max_plaintext_len, 1000); + assert_eq!(limits.max_buffered_tx_bytes, 4096); + assert_eq!(limits.max_retained_keys, 5); + assert_eq!(limits.replay_window, 256); + assert_eq!(limits.max_replay_windows, 64); + assert_eq!(limits.handshake_timeout, Duration::from_secs(3)); + } +} diff --git a/foctet-core/src/message.rs b/foctet-core/src/message.rs new file mode 100644 index 0000000..adb51a9 --- /dev/null +++ b/foctet-core/src/message.rs @@ -0,0 +1,361 @@ +//! Message-oriented Foctet endpoint (one frame per discrete message). +//! +//! This is the codec for **reliable, ordered, message-bounded** transports — +//! most importantly raw WebSocket messages, where each WebSocket frame is a +//! discrete unit and the application wants to preserve those boundaries instead +//! of treating the connection as an opaque byte stream +//! ([`crate::frame::FoctetFramed`]). +//! +//! It sits between the two existing shapes: +//! +//! - Unlike [`crate::frame::FoctetFramed`] (byte stream), there is **exactly one +//! complete frame per message** with no cross-message reassembly and no +//! length prefix — the transport already preserves message boundaries. +//! - Unlike [`crate::datagram::DatagramEndpoint`] (datagram), the transport is +//! reliable and ordered, so the default maximum message size is large +//! ([`DEFAULT_MAX_MESSAGE_SIZE`]) rather than MTU-bounded. The replay window is +//! still used, so duplicate or reordered messages (e.g. from a buggy or hostile +//! peer) are rejected, and **replay state is committed only after AEAD +//! authentication**. +//! +//! Outbound sequence numbers are tracked per `(key_id, stream_id)` and fail +//! closed on exhaustion, so a `(key_id, stream_id, seq)` nonce is never reused. + +use std::collections::HashMap; + +use crate::{ + CoreError, + crypto::{Direction, KeyHandle, decrypt_frame_with_key, encrypt_frame}, + frame::{FRAME_HEADER_LEN, Frame, FrameHeader}, + limits::DEFAULT_MAX_RETAINED_KEYS, + replay::{DEFAULT_MAX_REPLAY_WINDOWS, DEFAULT_REPLAY_WINDOW, ReplayProtector}, + sequence::OutboundSequence, +}; + +/// AEAD tag length added to every frame ciphertext. +const TAG_LEN: usize = 16; + +/// Per-frame wire overhead (fixed header + AEAD tag). +pub const MESSAGE_FRAME_OVERHEAD: usize = FRAME_HEADER_LEN + TAG_LEN; + +/// Default maximum on-wire message (single frame) size in bytes (16 MiB). +/// +/// Matches [`crate::limits::DEFAULT_MAX_CIPHERTEXT_LEN`] so the message shape and +/// the byte-stream shape accept the same maximum frame by default. Tune to the +/// transport's own message-size limit (for example a WebSocket server's +/// `max_message_size`). +pub const DEFAULT_MAX_MESSAGE_SIZE: usize = crate::limits::DEFAULT_MAX_CIPHERTEXT_LEN; + +/// Configuration for a [`MessageEndpoint`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MessageConfig { + /// Maximum on-wire message (single frame) size in bytes. + pub max_message_size: usize, + /// Per-`(key_id, stream_id)` replay window span. + pub replay_window: u64, + /// Maximum number of distinct replay windows tracked simultaneously. + pub max_replay_windows: usize, + /// Number of previous keys retained for inbound decryption after rekey. + pub max_retained_keys: usize, +} + +impl Default for MessageConfig { + fn default() -> Self { + Self { + max_message_size: DEFAULT_MAX_MESSAGE_SIZE, + replay_window: DEFAULT_REPLAY_WINDOW, + max_replay_windows: DEFAULT_MAX_REPLAY_WINDOWS, + max_retained_keys: DEFAULT_MAX_RETAINED_KEYS, + } + } +} + +/// One decrypted inbound message. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DecodedMessage { + /// Authenticated frame header. + pub header: FrameHeader, + /// Decrypted payload bytes. + pub plaintext: Vec, +} + +/// Seals and opens individual Foctet messages (one frame each). +/// +/// This type performs no I/O; pair it with a message transport adapter (for +/// example a raw WebSocket connection) that moves the returned bytes preserving +/// message boundaries. +#[derive(Clone, Debug)] +pub struct MessageEndpoint { + keys: Vec, + active_key_id: u8, + max_retained_keys: usize, + inbound_direction: Direction, + outbound_direction: Direction, + next_seq: HashMap<(u8, u32), OutboundSequence>, + replay: ReplayProtector, + max_message_size: usize, +} + +impl MessageEndpoint { + /// Creates a message endpoint with default configuration. + pub fn new( + keys: KeyHandle, + inbound_direction: Direction, + outbound_direction: Direction, + ) -> Self { + Self::with_config( + keys, + inbound_direction, + outbound_direction, + MessageConfig::default(), + ) + } + + /// Creates a message endpoint with explicit configuration. + pub fn with_config( + keys: KeyHandle, + inbound_direction: Direction, + outbound_direction: Direction, + config: MessageConfig, + ) -> Self { + Self { + active_key_id: keys.key_id, + keys: vec![keys], + max_retained_keys: config.max_retained_keys.max(1), + inbound_direction, + outbound_direction, + next_seq: HashMap::new(), + replay: ReplayProtector::new(config.replay_window) + .with_max_windows(config.max_replay_windows), + max_message_size: config.max_message_size.max(MESSAGE_FRAME_OVERHEAD + 1), + } + } + + /// Returns the configured maximum message size in bytes. + pub fn max_message_size(&self) -> usize { + self.max_message_size + } + + /// Returns the maximum plaintext bytes that fit in one message. + pub fn max_plaintext_len(&self) -> usize { + self.max_message_size - MESSAGE_FRAME_OVERHEAD + } + + /// Returns the active key identifier. + pub fn active_key_id(&self) -> u8 { + self.active_key_id + } + + /// Returns how many inbound frames this endpoint's replay protection has + /// rejected since creation (see + /// [`crate::ReplayProtector::rejections`]); an observability counter that + /// carries no key material. + pub fn replay_rejections(&self) -> u64 { + self.replay.rejections() + } + + /// Returns known key IDs, active first. + pub fn known_key_ids(&self) -> Vec { + self.keys.iter().map(|k| k.key_id).collect() + } + + /// Installs new active keys and retains a bounded set of previous keys. + pub fn install_active_keys(&mut self, keys: KeyHandle) { + self.keys.retain(|k| k.key_id != keys.key_id); + self.keys.insert(0, keys.clone()); + self.active_key_id = keys.key_id; + let keep = self.max_retained_keys + 1; + if self.keys.len() > keep { + self.keys.truncate(keep); + } + } + + fn active_keys(&self) -> Result<&KeyHandle, CoreError> { + self.keys + .iter() + .find(|k| k.key_id == self.active_key_id) + .ok_or(CoreError::MissingSessionSecret) + } + + fn key_for_id(&self, key_id: u8) -> Option<&KeyHandle> { + self.keys.iter().find(|k| k.key_id == key_id) + } + + /// Seals plaintext into a single message using the active key. + /// + /// Fails closed with [`CoreError::FrameTooLarge`] when the resulting frame + /// would exceed [`MessageConfig::max_message_size`], and with + /// [`CoreError::SequenceExhausted`] when the per-stream sequence space is + /// exhausted. In both cases no sequence number is consumed. + pub fn seal( + &mut self, + stream_id: u32, + flags: u8, + plaintext: &[u8], + ) -> Result, CoreError> { + let keys = self.active_keys()?.clone(); + let key_id = keys.key_id; + let sequence = self + .next_seq + .get(&(key_id, stream_id)) + .copied() + .unwrap_or_default(); + let seq = sequence.current(); + + let frame = encrypt_frame( + &keys, + self.outbound_direction, + flags, + stream_id, + seq, + plaintext, + )?; + let bytes = frame.to_bytes(); + if bytes.len() > self.max_message_size { + return Err(CoreError::FrameTooLarge); + } + + // Reserve the next sequence only after the message is known to be + // emittable, so a rejected message never consumes a nonce. + let next = sequence.prepared_next()?; + self.next_seq.insert((key_id, stream_id), next); + Ok(bytes) + } + + /// Opens one message into its decrypted payload. + /// + /// The message MUST contain exactly one complete frame and no trailing + /// bytes. The ciphertext is authenticated before replay state is committed. + pub fn open(&mut self, message: &[u8]) -> Result { + if message.len() > self.max_message_size { + return Err(CoreError::FrameTooLarge); + } + if message.len() < FRAME_HEADER_LEN { + return Err(CoreError::InvalidHeaderLength(message.len())); + } + + // `Frame::from_bytes` requires the ciphertext length to match the header + // exactly, enforcing one complete frame per message with no trailing + // bytes and no truncation. + let frame = Frame::from_bytes(message)?; + frame.header.validate_v0()?; + + let keys = self + .key_for_id(frame.header.key_id) + .ok_or(CoreError::UnexpectedKeyId { + expected: self.active_key_id, + actual: frame.header.key_id, + })?; + + let plaintext = decrypt_frame_with_key(keys, self.inbound_direction, &frame)?; + self.replay.check_and_record( + frame.header.key_id, + frame.header.stream_id, + frame.header.seq, + )?; + + Ok(DecodedMessage { + header: frame.header, + plaintext, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::{EphemeralKeyPair, derive_traffic_keys, random_session_salt}; + + fn endpoints() -> (MessageEndpoint, MessageEndpoint) { + let a = EphemeralKeyPair::generate(); + let b = EphemeralKeyPair::generate(); + let ss = a.shared_secret(b.public).expect("shared secret"); + let salt = random_session_salt(); + let keys = KeyHandle::new(derive_traffic_keys(&ss, &salt, 1).expect("traffic keys")); + // Client seals C2S / opens S2C; server is the mirror. + let client = MessageEndpoint::new(keys.clone(), Direction::S2C, Direction::C2S); + let server = MessageEndpoint::new(keys, Direction::C2S, Direction::S2C); + (client, server) + } + + #[test] + fn message_roundtrip() { + let (mut client, mut server) = endpoints(); + let msg = client.seal(0, 0, b"hello message").expect("seal"); + let opened = server.open(&msg).expect("open"); + assert_eq!(opened.plaintext, b"hello message"); + assert_eq!(opened.header.seq, 0); + } + + #[test] + fn large_message_above_datagram_mtu_roundtrips() { + // A message much larger than a datagram MTU is accepted by default, + // which is the whole point of the message shape versus the datagram one. + let (mut client, mut server) = endpoints(); + let payload = vec![0x5Au8; 64 * 1024]; + let msg = client.seal(0, 0, &payload).expect("seal large"); + let opened = server.open(&msg).expect("open large"); + assert_eq!(opened.plaintext, payload); + } + + #[test] + fn duplicate_message_is_rejected_as_replay() { + let (mut client, mut server) = endpoints(); + let m0 = client.seal(0, 0, b"zero").expect("m0"); + let m1 = client.seal(0, 0, b"one").expect("m1"); + + assert_eq!(server.open(&m0).expect("m0").plaintext, b"zero"); + assert_eq!(server.open(&m1).expect("m1").plaintext, b"one"); + + let err = server.open(&m1).expect_err("duplicate rejected"); + assert!(matches!(err, CoreError::Replay)); + } + + #[test] + fn rejects_oversized_outbound_and_keeps_sequence() { + let a = EphemeralKeyPair::generate(); + let b = EphemeralKeyPair::generate(); + let ss = a.shared_secret(b.public).expect("shared secret"); + let salt = random_session_salt(); + let keys = KeyHandle::new(derive_traffic_keys(&ss, &salt, 1).expect("traffic keys")); + let config = MessageConfig { + max_message_size: MESSAGE_FRAME_OVERHEAD + 4, + ..MessageConfig::default() + }; + let mut small = MessageEndpoint::with_config(keys, Direction::S2C, Direction::C2S, config); + + let ok = small.seal(0, 0, b"abcd").expect("fits"); + assert!(ok.len() <= small.max_message_size()); + let err = small.seal(0, 0, b"abcde").expect_err("too large"); + assert!(matches!(err, CoreError::FrameTooLarge)); + // The next valid message still uses seq 1 (only the first succeeded). + let next = small.seal(0, 0, b"efgh").expect("next"); + let frame = Frame::from_bytes(&next).expect("parse"); + assert_eq!(frame.header.seq, 1); + } + + #[test] + fn replay_state_committed_only_after_auth() { + let (mut client, mut server) = endpoints(); + let mut forged = client.seal(0, 0, b"forged").expect("seal"); + let last = forged.len() - 1; + forged[last] ^= 0xff; + + let err = server.open(&forged).expect_err("forged must fail auth"); + assert!(matches!(err, CoreError::Aead)); + + // The forged message must not have advanced the replay window, so a + // genuine message on a fresh stream is still accepted. + let m = client.seal(1, 0, b"genuine").expect("seal new stream"); + assert_eq!(server.open(&m).expect("genuine").plaintext, b"genuine"); + } + + #[test] + fn rejects_trailing_bytes() { + let (mut client, mut server) = endpoints(); + let mut msg = client.seal(0, 0, b"payload").expect("seal"); + msg.push(0x00); + let err = server.open(&msg).expect_err("trailing bytes rejected"); + assert!(matches!(err, CoreError::CiphertextLengthMismatch { .. })); + } +} diff --git a/foctet-core/src/observe.rs b/foctet-core/src/observe.rs new file mode 100644 index 0000000..6bc3be7 --- /dev/null +++ b/foctet-core/src/observe.rs @@ -0,0 +1,84 @@ +//! Observability hooks for session lifecycle events. +//! +//! [`SessionObserver`] lets applications receive handshake, rekey, and control +//! rejection events without exposing key material or plaintext. [`SessionEvent`] +//! carries only public metadata, so observers can feed metrics or tracing +//! directly. +//! +//! Replay-protection rejections are exposed separately as counters on the +//! receiving endpoints. +//! +//! Observer callbacks run synchronously on the protocol path, so they should be +//! cheap and non-blocking. + +use std::{fmt, sync::Arc}; + +use crate::session::HandshakeRole; + +/// A session lifecycle event. Carries only public metadata — no key material, +/// plaintext, or identity secrets. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum SessionEvent { + /// The native handshake completed and traffic keys are installed. + HandshakeCompleted { + /// This side's handshake role. + role: HandshakeRole, + /// Whether the peer proved a pinned Ed25519 identity (a handshake + /// relying solely on a channel binding reports `false`). + peer_authenticated: bool, + }, + /// This side initiated a DH-ratchet rekey and rotated its keys. + RekeyInitiated { + /// Key identifier rotated away from. + old_key_id: u8, + /// Newly active key identifier. + new_key_id: u8, + }, + /// A peer-initiated DH-ratchet rekey was verified and applied. + RekeyApplied { + /// Key identifier rotated away from. + old_key_id: u8, + /// Newly active key identifier. + new_key_id: u8, + }, + /// An inbound control message was rejected (failed validation, + /// authentication, or arrived unexpectedly for the current state). + /// A sustained stream of these is a probe/attack signal. + ControlRejected, +} + +/// Callback invoked by [`crate::Session`] on lifecycle events. +/// +/// Implementations must be cheap and non-blocking; they run synchronously on +/// the protocol path. See the [module docs](self) for the security contract. +pub trait SessionObserver: Send + Sync { + /// Called once per event. + fn on_session_event(&self, event: SessionEvent); +} + +/// Internal shareable, optional observer slot that keeps `Session`'s derived +/// `Clone`/`Debug` working (`dyn SessionObserver` itself is neither). +#[derive(Clone, Default)] +pub(crate) struct ObserverHandle(Option>); + +impl ObserverHandle { + pub(crate) fn set(&mut self, observer: Arc) { + self.0 = Some(observer); + } + + pub(crate) fn emit(&self, event: SessionEvent) { + if let Some(observer) = &self.0 { + observer.on_session_event(event); + } + } +} + +impl fmt::Debug for ObserverHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.0 { + Some(_) => f.write_str("ObserverHandle(Some(..))"), + None => f.write_str("ObserverHandle(None)"), + } + } +} diff --git a/foctet-core/src/replay.rs b/foctet-core/src/replay.rs index e6d6cc0..529fe4f 100644 --- a/foctet-core/src/replay.rs +++ b/foctet-core/src/replay.rs @@ -5,6 +5,11 @@ use crate::CoreError; /// Recommended replay-window size for Draft v0. pub const DEFAULT_REPLAY_WINDOW: u64 = 4096; +/// Default cap on the number of distinct `(key_id, stream_id)` replay windows a +/// single receiver will track before rejecting new ones. Bounds attacker- or +/// peer-driven memory growth in the replay map. +pub const DEFAULT_MAX_REPLAY_WINDOWS: usize = 1024; + /// Sliding replay window for sequence-number validation. #[derive(Clone, Debug)] pub struct ReplayWindow { @@ -109,29 +114,74 @@ impl ReplayWindow { pub struct ReplayProtector { windows: HashMap<(u8, u32), ReplayWindow>, window_size: u64, + max_windows: usize, + rejections: u64, } impl ReplayProtector { - /// Creates replay protection map with a default per-stream window size. + /// Creates replay protection map with a default per-stream window size and + /// the default cap on the number of tracked windows. pub fn new(window_size: u64) -> Self { Self { windows: HashMap::new(), window_size, + max_windows: DEFAULT_MAX_REPLAY_WINDOWS, + rejections: 0, } } + /// Overrides the maximum number of distinct `(key_id, stream_id)` windows + /// tracked simultaneously. A value of `0` is treated as `1`. + pub fn with_max_windows(mut self, max_windows: usize) -> Self { + self.max_windows = max_windows.max(1); + self + } + + /// Returns the number of distinct windows currently tracked. + pub fn tracked_windows(&self) -> usize { + self.windows.len() + } + + /// Returns how many frames this protector has rejected (duplicates, + /// frames outside the window, and window-capacity rejections) since + /// creation. + /// + /// This is an observability counter, not a security signal by itself: + /// lossy/reordering transports legitimately produce occasional + /// rejections, but a sustained rise indicates replay or flooding + /// activity. It carries no key material. + pub fn rejections(&self) -> u64 { + self.rejections + } + /// Validates and records sequence number for `(key_id, stream_id)`. + /// + /// Returns [`CoreError::ReplayCapacityExceeded`] when a previously unseen + /// `(key_id, stream_id)` pair would exceed the configured window cap, so a + /// peer cannot force unbounded replay-map growth. pub fn check_and_record( &mut self, key_id: u8, stream_id: u32, seq: u64, ) -> Result<(), CoreError> { - let w = self - .windows - .entry((key_id, stream_id)) - .or_insert_with(|| ReplayWindow::new(self.window_size)); - w.check_and_record(seq) + let result = match self.windows.get_mut(&(key_id, stream_id)) { + Some(w) => w.check_and_record(seq), + None => { + if self.windows.len() >= self.max_windows { + Err(CoreError::ReplayCapacityExceeded) + } else { + let mut w = ReplayWindow::new(self.window_size); + let result = w.check_and_record(seq); + self.windows.insert((key_id, stream_id), w); + result + } + } + }; + if result.is_err() { + self.rejections = self.rejections.saturating_add(1); + } + result } } @@ -215,6 +265,25 @@ mod tests { assert!(matches!(too_old, CoreError::ReplayWindowExceeded)); } + #[test] + fn replay_protector_caps_distinct_windows() { + let mut protector = ReplayProtector::new(64).with_max_windows(2); + // Two distinct (key_id, stream_id) windows are accepted. + protector.check_and_record(0, 0, 1).expect("first window"); + protector.check_and_record(0, 1, 1).expect("second window"); + assert_eq!(protector.tracked_windows(), 2); + // A third distinct window exceeds the cap and is rejected. + let err = protector + .check_and_record(0, 2, 1) + .expect_err("third distinct window must be rejected"); + assert!(matches!(err, CoreError::ReplayCapacityExceeded)); + // Existing windows keep working and do not count against the cap again. + protector + .check_and_record(0, 0, 2) + .expect("existing window still accepts new sequences"); + assert_eq!(protector.tracked_windows(), 2); + } + #[test] fn replay_window_handles_u64_high_values() { let mut replay = ReplayWindow::new(32); diff --git a/foctet-core/src/secure_channel.rs b/foctet-core/src/secure_channel.rs index dd566d4..6627d82 100644 --- a/foctet-core/src/secure_channel.rs +++ b/foctet-core/src/secure_channel.rs @@ -202,21 +202,30 @@ impl AsyncSecureChannel { /// Sends application data in an `APPLICATION_DATA` TLV with session-aware rekey handling. pub async fn send_data(&mut self, plaintext: &[u8]) -> Result<(), CoreError> { - poll_fn(|cx| { + // The frame must be encrypted and enqueued exactly once. This closure + // is re-polled from the top whenever the flush below returns + // `Pending`, so without the `queued` latch the same plaintext would be + // re-encrypted under the next sequence number and sent again — a + // silent duplicate delivery on any transport whose flush can suspend. + let mut queued = false; + poll_fn(move |cx| { let mut framed = Pin::new(&mut self.framed); - match framed.as_mut().poll_ready(cx) { - std::task::Poll::Pending => return std::task::Poll::Pending, - std::task::Poll::Ready(Err(e)) => return std::task::Poll::Ready(Err(e)), - std::task::Poll::Ready(Ok(())) => {} + if !queued { + match framed.as_mut().poll_ready(cx) { + std::task::Poll::Pending => return std::task::Poll::Pending, + std::task::Poll::Ready(Err(e)) => return std::task::Poll::Ready(Err(e)), + std::task::Poll::Ready(Ok(())) => {} + } + + framed.as_mut().start_send_data_with_session( + &mut self.session, + self.app_flags, + self.app_stream_id, + plaintext, + )?; + queued = true; } - framed.as_mut().start_send_data_with_session( - &mut self.session, - self.app_flags, - self.app_stream_id, - plaintext, - )?; - framed.poll_flush(cx) }) .await @@ -307,7 +316,7 @@ mod tests { time::Duration, }; - use crate::{ControlMessage, RekeyThresholds, Session}; + use crate::{ControlMessage, RekeyThresholds, Session, SessionAuthConfig}; use super::SecureChannel; @@ -362,8 +371,14 @@ mod tests { max_previous_keys: 2, }; - let (mut initiator, hello) = Session::new_initiator(thresholds.clone()); - let mut responder = Session::new_responder(thresholds); + let (mut initiator, hello) = Session::new_initiator_with_auth( + thresholds.clone(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut responder = Session::new_responder_with_auth( + thresholds, + SessionAuthConfig::unauthenticated_for_testing(), + ); let server_hello = responder .handle_control(&hello) .expect("responder handle client hello") @@ -410,8 +425,14 @@ mod tests { #[test] fn handshake_exchange_is_control_messages() { let thresholds = RekeyThresholds::default(); - let (_initiator, hello) = Session::new_initiator(thresholds.clone()); - let mut responder = Session::new_responder(thresholds); + let (_initiator, hello) = Session::new_initiator_with_auth( + thresholds.clone(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut responder = Session::new_responder_with_auth( + thresholds, + SessionAuthConfig::unauthenticated_for_testing(), + ); let response = responder .handle_control(&hello) .expect("valid client hello") @@ -419,4 +440,154 @@ mod tests { assert!(matches!(hello, ControlMessage::ClientHello { .. })); assert!(matches!(response, ControlMessage::ServerHello { .. })); } + + mod async_send { + use std::{ + collections::VecDeque, + future::Future, + pin::{Pin, pin}, + task::{Context, Poll, Waker}, + }; + + use crate::io::{PollRead, PollWrite}; + use crate::{RekeyThresholds, Session, SessionAuthConfig}; + + use super::super::AsyncSecureChannel; + + /// Session pair with default thresholds so no rekey control frames + /// interleave with the single application payload under test. + fn quiet_session_pair() -> (Session, Session) { + let (mut initiator, hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut responder = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let server_hello = responder + .handle_control(&hello) + .expect("responder handles hello") + .expect("server hello"); + initiator + .handle_control(&server_hello) + .expect("initiator finalizes"); + (initiator, responder) + } + + /// In-memory `PollIo` whose flush suspends a configurable number of + /// times before completing, like a transport that waits for a flush + /// acknowledgement (e.g. a multiplexed WebSocket stream). + #[derive(Default, Debug)] + struct SlowFlushIo { + inbound: VecDeque, + outbound: Vec, + pending_flushes: usize, + } + + impl PollRead for SlowFlushIo { + fn poll_read( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + let n = buf.len().min(self.inbound.len()); + for slot in buf.iter_mut().take(n) { + *slot = self.inbound.pop_front().expect("inbound byte"); + } + Poll::Ready(Ok(n)) + } + } + + impl PollWrite for SlowFlushIo { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + self.outbound.extend_from_slice(buf); + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + if self.pending_flushes > 0 { + self.pending_flushes -= 1; + return Poll::Pending; + } + Poll::Ready(Ok(())) + } + + fn poll_close( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } + + /// Regression test: `send_data` must encrypt and enqueue the payload + /// exactly once even when the transport flush suspends, forcing the + /// send future to be polled multiple times. A latch bug here once + /// re-encrypted the plaintext under the next sequence number on every + /// re-poll, silently delivering duplicates (caught by the websock-mux + /// byte-stream conformance test). + #[test] + fn send_data_is_not_duplicated_when_flush_suspends() { + let (initiator, responder) = quiet_session_pair(); + + let sender_io = SlowFlushIo { + pending_flushes: 3, + ..SlowFlushIo::default() + }; + let mut sender = AsyncSecureChannel::from_active_session(sender_io, initiator) + .expect("sender channel"); + + let waker = Waker::noop().clone(); + let mut cx = Context::from_waker(&waker); + + { + let mut fut = pin!(sender.send_data(b"ping")); + // The first polls suspend in flush; the payload must stay queued, + // not be re-encrypted. + for _ in 0..3 { + assert!(fut.as_mut().poll(&mut cx).is_pending()); + } + match fut.as_mut().poll(&mut cx) { + Poll::Ready(Ok(())) => {} + other => panic!("expected send completion, got {other:?}"), + } + } + let wire = sender.framed_ref().get_ref().outbound.clone(); + + let receiver_io = SlowFlushIo::default(); + let mut receiver = AsyncSecureChannel::from_active_session(receiver_io, responder) + .expect("receiver channel"); + receiver + .framed_mut() + .get_mut() + .inbound + .extend(wire.iter().copied()); + + { + let mut recv = pin!(receiver.recv_application()); + match recv.as_mut().poll(&mut cx) { + Poll::Ready(Ok(payload)) => assert_eq!(payload, b"ping"), + other => panic!("expected one payload, got {other:?}"), + } + } + + // Exactly one frame must have been sent: the next read hits EOF + // instead of a duplicate "ping" under a fresh sequence number. + { + let mut next = pin!(receiver.recv_application()); + match next.as_mut().poll(&mut cx) { + Poll::Ready(Err(crate::CoreError::UnexpectedEof)) => {} + other => panic!("expected EOF after the single frame, got {other:?}"), + } + } + } + } } diff --git a/foctet-core/src/sequence.rs b/foctet-core/src/sequence.rs new file mode 100644 index 0000000..4f239c2 --- /dev/null +++ b/foctet-core/src/sequence.rs @@ -0,0 +1,54 @@ +//! Internal fail-closed outbound sequence allocation. +//! +//! All outbound Foctet shapes share this small type so exhaustion handling +//! cannot drift between blocking, async, message, and datagram transports. + +use crate::CoreError; + +/// Tracks the sequence number to use for the next outbound frame. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct OutboundSequence(u64); + +impl OutboundSequence { + /// Returns the sequence number for the frame currently being built. + pub(crate) fn current(self) -> u64 { + self.0 + } + + /// Returns the post-send state without modifying this allocator. + /// + /// Call this before emitting a frame, then [`Self::commit`] only after the + /// frame has been accepted by the respective transport buffer or writer. + pub(crate) fn prepared_next(self) -> Result { + self.0 + .checked_add(1) + .map(Self) + .ok_or(CoreError::SequenceExhausted) + } + + /// Commits a previously prepared post-send state. + pub(crate) fn commit(&mut self, next: Self) { + *self = next; + } + + #[cfg(test)] + pub(crate) fn set_for_test(&mut self, sequence: u64) { + self.0 = sequence; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exhaustion_is_fail_closed() { + let last = OutboundSequence(u64::MAX - 1); + let exhausted = last.prepared_next().expect("last sequence is usable"); + assert_eq!(exhausted.current(), u64::MAX); + assert!(matches!( + exhausted.prepared_next(), + Err(CoreError::SequenceExhausted) + )); + } +} diff --git a/foctet-core/src/session.rs b/foctet-core/src/session.rs index 71502b5..56ec18f 100644 --- a/foctet-core/src/session.rs +++ b/foctet-core/src/session.rs @@ -1,17 +1,60 @@ -use std::time::{Duration, Instant}; +use std::{sync::Arc, time::Duration}; -use rand_core::{OsRng, RngCore}; use sha2::{Digest, Sha256}; use zeroize::Zeroize; +use self::mono::MonoInstant; + +/// Monotonic clock abstraction for the age-based rekey threshold. +/// +/// On native targets this is `std::time::Instant`. On `wasm32-unknown-unknown` +/// there is no monotonic clock, so `Instant::now()` aborts the module; there the +/// age-based rekey threshold is disabled (`elapsed()` always reports zero) while +/// the frame-count and byte-count thresholds still apply, and callers are +/// expected to drive rekey explicitly. See `SECURITY.md` for the WASM posture. +mod mono { + use std::time::Duration; + + #[cfg(not(target_arch = "wasm32"))] + #[derive(Clone, Copy, Debug)] + pub(super) struct MonoInstant(std::time::Instant); + + #[cfg(not(target_arch = "wasm32"))] + impl MonoInstant { + pub(super) fn now() -> Self { + Self(std::time::Instant::now()) + } + + pub(super) fn elapsed(&self) -> Duration { + self.0.elapsed() + } + } + + #[cfg(target_arch = "wasm32")] + #[derive(Clone, Copy, Debug)] + pub(super) struct MonoInstant; + + #[cfg(target_arch = "wasm32")] + impl MonoInstant { + pub(super) fn now() -> Self { + Self + } + + pub(super) fn elapsed(&self) -> Duration { + Duration::ZERO + } + } +} + use crate::{ CoreError, - auth::{HandshakeAuth, SessionAuthConfig}, + auth::{AuthenticatedPeer, HandshakeAuth, SessionAuthConfig}, control::ControlMessage, crypto::{ - Direction, EphemeralKeyPair, TrafficKeys, derive_rekey_traffic_keys, derive_traffic_keys, - random_session_salt, + Direction, EphemeralKeyPair, KeyHandle, TrafficKeys, derive_ratchet_root, + derive_traffic_keys, dh_ratchet_step, random_session_salt, }, + observe::{ObserverHandle, SessionEvent, SessionObserver}, }; /// Role of this endpoint in the native handshake. @@ -67,23 +110,27 @@ pub struct Session { state: SessionState, local_eph: EphemeralKeyPair, peer_eph_public: Option<[u8; 32]>, - shared_secret: Option<[u8; 32]>, session_salt: [u8; 32], - active_keys: Option, - previous_keys: Vec, + /// DH-ratchet root key, advanced by a fresh DH output at every rekey. + ratchet_root: [u8; 32], + /// Whether this side may initiate the next rekey. The DH ratchet alternates: + /// after initiating a rekey this becomes `false` until the peer rekeys. + can_rekey: bool, + active_keys: Option, + previous_keys: Vec, thresholds: RekeyThresholds, auth: SessionAuthConfig, peer_authenticated: bool, + authenticated_peer_key: Option<[u8; 32]>, outbound_frames: u64, outbound_bytes: u64, - last_rekey_at: Instant, + last_rekey_at: MonoInstant, + observer: ObserverHandle, } impl Drop for Session { fn drop(&mut self) { - if let Some(shared) = &mut self.shared_secret { - shared.zeroize(); - } + self.ratchet_root.zeroize(); self.session_salt.zeroize(); } } @@ -101,10 +148,11 @@ impl Session { ) -> (Self, ControlMessage) { let local_eph = EphemeralKeyPair::generate(); let session_salt = random_session_salt(); - let binding = client_hello_binding(local_eph.public, session_salt); - let auth_payload = auth.local_identity().map(|identity| { + let binding = + client_hello_binding(local_eph.public, session_salt, auth.channel_binding_bytes()); + let auth_payload = auth.local_signer().map(|signer| { HandshakeAuth::sign( - identity, + signer, &client_auth_message(local_eph.public, session_salt, binding), ) }); @@ -122,16 +170,19 @@ impl Session { state: SessionState::WaitingPeerHello, local_eph, peer_eph_public: None, - shared_secret: None, session_salt, + ratchet_root: [0u8; 32], + can_rekey: false, active_keys: None, previous_keys: Vec::new(), thresholds, auth, peer_authenticated: false, + authenticated_peer_key: None, outbound_frames: 0, outbound_bytes: 0, - last_rekey_at: Instant::now(), + last_rekey_at: MonoInstant::now(), + observer: ObserverHandle::default(), }, msg, ) @@ -149,16 +200,19 @@ impl Session { state: SessionState::WaitingPeerHello, local_eph: EphemeralKeyPair::generate(), peer_eph_public: None, - shared_secret: None, session_salt: [0u8; 32], + ratchet_root: [0u8; 32], + can_rekey: false, active_keys: None, previous_keys: Vec::new(), thresholds, auth, peer_authenticated: false, + authenticated_peer_key: None, outbound_frames: 0, outbound_bytes: 0, - last_rekey_at: Instant::now(), + last_rekey_at: MonoInstant::now(), + observer: ObserverHandle::default(), } } @@ -177,6 +231,17 @@ impl Session { self.peer_authenticated } + /// Returns the peer whose Ed25519 identity was proven during the handshake, + /// if any. + /// + /// This is the typed form of [`Session::peer_authenticated`]: it returns + /// `Some` only after a successful handshake in which the remote presented a + /// valid identity signature. A handshake authenticated solely by a + /// [`crate::ChannelBinding`] (no Foctet identity) returns `None`. + pub fn authenticated_peer(&self) -> Option { + self.authenticated_peer_key.map(AuthenticatedPeer::new) + } + /// Returns outbound traffic direction for this role. pub fn outbound_direction(&self) -> Direction { match self.role { @@ -193,10 +258,34 @@ impl Session { } } + /// Installs an observer notified of session lifecycle events + /// (see [`crate::observe`]). Events carry no key material. + #[must_use] + pub fn with_observer(mut self, observer: Arc) -> Self { + self.observer.set(observer); + self + } + + /// Installs an observer on an existing session; see [`Self::with_observer`]. + pub fn set_observer(&mut self, observer: Arc) { + self.observer.set(observer); + } + /// Applies an incoming control message and optionally returns a response. pub fn handle_control( &mut self, msg: &ControlMessage, + ) -> Result, CoreError> { + let result = self.handle_control_inner(msg); + if result.is_err() { + self.observer.emit(SessionEvent::ControlRejected); + } + result + } + + fn handle_control_inner( + &mut self, + msg: &ControlMessage, ) -> Result, CoreError> { match (self.role, self.state, msg) { ( @@ -209,11 +298,15 @@ impl Session { auth, }, ) => { - let expected = client_hello_binding(*eph_public, *session_salt); + let expected = client_hello_binding( + *eph_public, + *session_salt, + self.auth.channel_binding_bytes(), + ); if transcript_binding != &expected { return Err(CoreError::InvalidControlMessage); } - let peer_authenticated = self.verify_client_auth( + let authenticated_peer = self.verify_client_auth( *eph_public, *session_salt, *transcript_binding, @@ -222,20 +315,32 @@ impl Session { self.peer_eph_public = Some(*eph_public); self.session_salt = *session_salt; - let shared = self.local_eph.shared_secret(*eph_public)?; + let mut shared = self.local_eph.shared_secret(*eph_public)?; let keys = derive_traffic_keys(&shared, &self.session_salt, 0)?; + self.ratchet_root = derive_ratchet_root(&self.session_salt, &shared)?; + shared.zeroize(); - self.shared_secret = Some(shared); - self.active_keys = Some(keys); + self.active_keys = Some(KeyHandle::new(keys)); self.state = SessionState::Active; - self.peer_authenticated = peer_authenticated; - self.last_rekey_at = Instant::now(); + // The DH ratchet alternates; the initiator takes the first turn. + self.can_rekey = false; + self.peer_authenticated = authenticated_peer.is_some(); + self.authenticated_peer_key = authenticated_peer; + self.last_rekey_at = MonoInstant::now(); + self.observer.emit(SessionEvent::HandshakeCompleted { + role: self.role, + peer_authenticated: self.peer_authenticated, + }); - let server_binding = - server_hello_binding(*eph_public, self.local_eph.public, self.session_salt); - let server_auth = self.auth.local_identity().map(|identity| { + let server_binding = server_hello_binding( + *eph_public, + self.local_eph.public, + self.session_salt, + self.auth.channel_binding_bytes(), + ); + let server_auth = self.auth.local_signer().map(|signer| { HandshakeAuth::sign( - identity, + signer, &server_auth_message( *eph_public, self.local_eph.public, @@ -259,23 +364,35 @@ impl Session { auth, }, ) => { - let expected = - server_hello_binding(self.local_eph.public, *eph_public, self.session_salt); + let expected = server_hello_binding( + self.local_eph.public, + *eph_public, + self.session_salt, + self.auth.channel_binding_bytes(), + ); if transcript_binding != &expected { return Err(CoreError::InvalidControlMessage); } - let peer_authenticated = + let authenticated_peer = self.verify_server_auth(*eph_public, *transcript_binding, auth.as_ref())?; self.peer_eph_public = Some(*eph_public); - let shared = self.local_eph.shared_secret(*eph_public)?; + let mut shared = self.local_eph.shared_secret(*eph_public)?; let keys = derive_traffic_keys(&shared, &self.session_salt, 0)?; + self.ratchet_root = derive_ratchet_root(&self.session_salt, &shared)?; + shared.zeroize(); - self.shared_secret = Some(shared); - self.active_keys = Some(keys); + self.active_keys = Some(KeyHandle::new(keys)); self.state = SessionState::Active; - self.peer_authenticated = peer_authenticated; - self.last_rekey_at = Instant::now(); + // The initiator takes the first DH-ratchet turn. + self.can_rekey = true; + self.peer_authenticated = authenticated_peer.is_some(); + self.authenticated_peer_key = authenticated_peer; + self.last_rekey_at = MonoInstant::now(); + self.observer.emit(SessionEvent::HandshakeCompleted { + role: self.role, + peer_authenticated: self.peer_authenticated, + }); Ok(None) } ( @@ -284,7 +401,7 @@ impl Session { ControlMessage::Rekey { old_key_id, new_key_id, - rekey_salt, + ratchet_public, transcript_binding, }, ) => { @@ -295,22 +412,32 @@ impl Session { if *old_key_id != active.key_id { return Err(CoreError::UnexpectedControlMessage); } + if *new_key_id != old_key_id.wrapping_add(1) { + return Err(CoreError::InvalidControlMessage); + } let expected = - rekey_binding(*old_key_id, *new_key_id, *rekey_salt, self.session_salt); + rekey_binding(*old_key_id, *new_key_id, ratchet_public, self.session_salt); if transcript_binding != &expected { return Err(CoreError::InvalidControlMessage); } - let shared = self.shared_secret.ok_or(CoreError::MissingSessionSecret)?; - let next = derive_rekey_traffic_keys( - &shared, - &self.session_salt, - rekey_salt, - *new_key_id, - )?; + // DH-ratchet receive step: mix DH(my current ratchet key, the + // peer's fresh ratchet public) into the root chain, then adopt + // the peer's new public. After receiving it becomes our turn to + // initiate the next rekey. + let mut dh = self.local_eph.shared_secret(*ratchet_public)?; + let (new_root, next) = dh_ratchet_step(&self.ratchet_root, &dh, *new_key_id)?; + dh.zeroize(); + self.peer_eph_public = Some(*ratchet_public); + self.ratchet_root = new_root; self.install_new_active_key(next); - self.last_rekey_at = Instant::now(); + self.can_rekey = true; + self.last_rekey_at = MonoInstant::now(); + self.observer.emit(SessionEvent::RekeyApplied { + old_key_id: *old_key_id, + new_key_id: *new_key_id, + }); Ok(None) } (_, SessionState::Active, ControlMessage::Error { .. }) => Ok(None), @@ -318,13 +445,17 @@ impl Session { } } - /// Returns the currently active traffic keys, if session is active. - pub fn active_keys(&self) -> Option { + /// Returns a handle to the currently active traffic keys, if session is + /// active. + /// + /// The returned [`KeyHandle`] shares the underlying key bytes by reference + /// count; it does not copy the secret material. + pub fn active_keys(&self) -> Option { self.active_keys.clone() } - /// Returns active key followed by retained previous keys. - pub fn active_and_previous_keys(&self) -> Option> { + /// Returns handles to the active key followed by retained previous keys. + pub fn active_and_previous_keys(&self) -> Option> { let mut out = Vec::new(); let active = self.active_keys.clone()?; out.push(active); @@ -332,8 +463,8 @@ impl Session { Some(out) } - /// Returns current key ring as transport-ready list. - pub fn key_ring(&self) -> Result, CoreError> { + /// Returns current key ring as transport-ready list of handles. + pub fn key_ring(&self) -> Result, CoreError> { self.active_and_previous_keys() .ok_or(CoreError::InvalidSessionState) } @@ -350,7 +481,11 @@ impl Session { self.outbound_frames = self.outbound_frames.saturating_add(1); self.outbound_bytes = self.outbound_bytes.saturating_add(plaintext_len as u64); - if self.should_rekey() { + // Threshold-driven rekey is best-effort and respects the DH-ratchet + // turn: if it is the peer's turn to ratchet, defer rather than fail — + // we keep using the current key until the peer rekeys (which hands the + // turn back) or the threshold is re-checked on a later send. + if self.should_rekey() && self.can_rekey { let msg = self.force_rekey()?; return Ok(Some(msg)); } @@ -358,11 +493,30 @@ impl Session { Ok(None) } - /// Forces immediate rekey and returns the `Rekey` control message. + /// Whether it is this side's turn to initiate the next DH-ratchet rekey. + /// + /// Rekeys strictly alternate: the initiator holds the first turn, and each + /// applied rekey hands the turn to the other side. When this returns + /// `false`, [`Session::force_rekey`] fails with + /// [`CoreError::RekeyNotPermitted`]. + pub fn can_rekey(&self) -> bool { + self.state == SessionState::Active && self.can_rekey + } + + /// Forces an immediate rekey (one DH-ratchet step) and returns the `Rekey` + /// control message to send to the peer. + /// + /// Fails with [`CoreError::RekeyNotPermitted`] when it is the peer's turn to + /// ratchet: rekeys strictly alternate between the two sides so the root + /// chain never forks and both peers' ratchet keys rotate (giving forward + /// secrecy and post-compromise security in both directions). pub fn force_rekey(&mut self) -> Result { if self.state != SessionState::Active { return Err(CoreError::InvalidSessionState); } + if !self.can_rekey { + return Err(CoreError::RekeyNotPermitted); + } let active = self .active_keys @@ -370,24 +524,38 @@ impl Session { .ok_or(CoreError::InvalidSessionState)?; let old_key_id = active.key_id; let new_key_id = old_key_id.checked_add(1).ok_or(CoreError::KeyIdExhausted)?; + let peer_public = self + .peer_eph_public + .ok_or(CoreError::MissingSessionSecret)?; - let mut rekey_salt = [0u8; 32]; - OsRng.fill_bytes(&mut rekey_salt); - - let shared = self.shared_secret.ok_or(CoreError::MissingSessionSecret)?; - let next = derive_rekey_traffic_keys(&shared, &self.session_salt, &rekey_salt, new_key_id)?; + // DH-ratchet send step: rotate to a fresh ephemeral key and mix + // DH(new ephemeral, peer's current ratchet public) into the root chain. + let new_eph = EphemeralKeyPair::generate(); + let mut dh = new_eph.shared_secret(peer_public)?; + let (new_root, next) = dh_ratchet_step(&self.ratchet_root, &dh, new_key_id)?; + dh.zeroize(); + let ratchet_public = new_eph.public; + self.local_eph = new_eph; + self.ratchet_root = new_root; self.install_new_active_key(next); + // It is now the peer's turn to initiate the next rekey. + self.can_rekey = false; self.outbound_frames = 0; self.outbound_bytes = 0; - self.last_rekey_at = Instant::now(); + self.last_rekey_at = MonoInstant::now(); + + self.observer.emit(SessionEvent::RekeyInitiated { + old_key_id, + new_key_id, + }); let transcript_binding = - rekey_binding(old_key_id, new_key_id, rekey_salt, self.session_salt); + rekey_binding(old_key_id, new_key_id, &ratchet_public, self.session_salt); Ok(ControlMessage::Rekey { old_key_id, new_key_id, - rekey_salt, + ratchet_public, transcript_binding, }) } @@ -406,7 +574,7 @@ impl Session { .truncate(self.thresholds.max_previous_keys); } } - self.active_keys = Some(next); + self.active_keys = Some(KeyHandle::new(next)); } fn verify_client_auth( @@ -415,7 +583,7 @@ impl Session { session_salt: [u8; 32], transcript_binding: [u8; 32], auth: Option<&HandshakeAuth>, - ) -> Result { + ) -> Result, CoreError> { let message = client_auth_message(eph_public, session_salt, transcript_binding); self.verify_auth_payload(auth, &message) } @@ -425,7 +593,7 @@ impl Session { server_public: [u8; 32], transcript_binding: [u8; 32], auth: Option<&HandshakeAuth>, - ) -> Result { + ) -> Result, CoreError> { let message = server_auth_message( self.local_eph.public, server_public, @@ -435,11 +603,16 @@ impl Session { self.verify_auth_payload(auth, &message) } + /// Verifies an optional handshake auth payload. + /// + /// Returns `Ok(Some(identity_public_key))` when the peer proved a (possibly + /// pinned) Ed25519 identity, `Ok(None)` when the peer presented no identity + /// and that is explicitly permitted, and an error otherwise. fn verify_auth_payload( &self, auth: Option<&HandshakeAuth>, message: &[u8], - ) -> Result { + ) -> Result, CoreError> { match auth { Some(auth) => { auth.verify(message)?; @@ -448,23 +621,46 @@ impl Session { { return Err(CoreError::PeerIdentityMismatch); } - Ok(true) + Ok(Some(auth.identity_public_key)) } + // Peer presented no authentication. Fail closed unless the caller + // explicitly opted into an unauthenticated handshake. A pinned peer + // identity or an explicit requirement always demands authentication. None if self.auth.requires_peer_authentication() || self.auth.peer_identity().is_some() => { Err(CoreError::MissingPeerAuthentication) } - None => Ok(false), + None if self.auth.allows_unauthenticated() => Ok(None), + None => Err(CoreError::MissingPeerAuthentication), } } } -fn client_hello_binding(client_public: [u8; 32], session_salt: [u8; 32]) -> [u8; 32] { +/// Mixes an optional outer-channel binding into a transcript hash. +/// +/// When `channel_binding` is empty this is a no-op, so a handshake configured +/// without a binding hashes byte-identically to before this field existed. When +/// present it is added length-prefixed under a domain separator so distinct +/// bindings can never collide with other transcript fields. +fn mix_channel_binding(hasher: &mut Sha256, channel_binding: &[u8]) { + if !channel_binding.is_empty() { + hasher.update(b"foctet channel-binding"); + hasher.update((channel_binding.len() as u64).to_be_bytes()); + hasher.update(channel_binding); + } +} + +fn client_hello_binding( + client_public: [u8; 32], + session_salt: [u8; 32], + channel_binding: &[u8], +) -> [u8; 32] { let mut hasher = Sha256::new(); hasher.update(b"foctet hs client"); hasher.update(client_public); hasher.update(session_salt); + mix_channel_binding(&mut hasher, channel_binding); hasher.finalize().into() } @@ -485,12 +681,14 @@ fn server_hello_binding( client_public: [u8; 32], server_public: [u8; 32], session_salt: [u8; 32], + channel_binding: &[u8], ) -> [u8; 32] { let mut hasher = Sha256::new(); hasher.update(b"foctet hs server"); hasher.update(client_public); hasher.update(server_public); hasher.update(session_salt); + mix_channel_binding(&mut hasher, channel_binding); hasher.finalize().into() } @@ -512,14 +710,14 @@ fn server_auth_message( fn rekey_binding( old_key_id: u8, new_key_id: u8, - rekey_salt: [u8; 32], + ratchet_public: &[u8; 32], session_salt: [u8; 32], ) -> [u8; 32] { let mut hasher = Sha256::new(); hasher.update(b"foctet rekey"); hasher.update([old_key_id]); hasher.update([new_key_id]); - hasher.update(rekey_salt); + hasher.update(ratchet_public); hasher.update(session_salt); hasher.finalize().into() } @@ -531,8 +729,14 @@ mod tests { #[test] fn session_handshake_and_rekey() { - let (mut client, hello) = Session::new_initiator(RekeyThresholds::default()); - let mut server = Session::new_responder(RekeyThresholds::default()); + let (mut client, hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut server = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); let server_hello = server .handle_control(&hello) @@ -552,6 +756,67 @@ mod tests { let client_key = client.active_keys().expect("client active key"); let server_key = server.active_keys().expect("server active key"); assert_eq!(client_key.key_id, server_key.key_id); + // The DH-ratchet step must derive byte-identical traffic keys on both + // sides, or the channel would desync after rekey. + assert_eq!(client_key, server_key); + } + + #[test] + fn dh_ratchet_alternates_and_rotates_both_sides_keys() { + let (mut client, mut server) = active_pair(); + + // The initiator takes the first turn; the responder cannot rekey yet. + assert!(matches!( + server.force_rekey(), + Err(CoreError::RekeyNotPermitted) + )); + + let mut last_key: Option = None; + // Several alternating rounds: client, server, client, server, ... + for round in 0..4 { + let (rekeyer, receiver) = if round % 2 == 0 { + (&mut client, &mut server) + } else { + (&mut server, &mut client) + }; + + // The side out of turn cannot initiate. + assert!(matches!( + receiver.force_rekey(), + Err(CoreError::RekeyNotPermitted) + )); + + let rekey = rekeyer.force_rekey().expect("force rekey on turn"); + // Having just rekeyed, the same side may not rekey again. + assert!(matches!( + rekeyer.force_rekey(), + Err(CoreError::RekeyNotPermitted) + )); + receiver.handle_control(&rekey).expect("peer applies rekey"); + + let ck = client.active_keys().expect("client key"); + let sk = server.active_keys().expect("server key"); + assert_eq!(ck.key_id, (round as u8) + 1); + assert_eq!(ck, sk, "both sides must derive the same key"); + // Each ratchet step must yield a fresh key, never a repeat. + if let Some(prev) = &last_key { + assert_ne!(prev, &ck, "rekey must rotate to a fresh key"); + } + last_key = Some(ck.clone()); + } + } + + #[test] + fn rekey_with_a_jumped_new_key_id_is_rejected() { + let (mut client, mut server) = active_pair(); + let mut rekey = client.force_rekey().expect("client force rekey"); + if let ControlMessage::Rekey { new_key_id, .. } = &mut rekey { + *new_key_id = 5; // not old_key_id + 1 + } + let err = server + .handle_control(&rekey) + .expect_err("a non-sequential new_key_id must be rejected"); + assert!(matches!(err, CoreError::InvalidControlMessage)); } #[test] @@ -581,5 +846,496 @@ mod tests { assert!(client.peer_authenticated()); assert!(server.peer_authenticated()); + + // The typed authenticated-peer record names the verified identity. + let server_seen = client.authenticated_peer().expect("client sees a peer"); + assert_eq!( + server_seen.identity_public_key(), + server_identity.public_key() + ); + assert!(server_seen.matches(&PeerIdentity::new(server_identity.public_key()))); + let client_seen = server.authenticated_peer().expect("server sees a peer"); + assert_eq!( + client_seen.identity_public_key(), + client_identity.public_key() + ); + } + + #[test] + fn external_handshake_signer_authenticates_like_a_software_identity() { + use crate::HandshakeSigner; + + // A stand-in for a hardware/KMS signer: it implements `HandshakeSigner` + // without being an `IdentityKeyPair`, exercising `with_local_signer` and + // the `&dyn HandshakeSigner` handshake path. + struct ExternalSigner(IdentityKeyPair); + impl HandshakeSigner for ExternalSigner { + fn public_key(&self) -> [u8; 32] { + self.0.public_key() + } + fn sign(&self, message: &[u8]) -> [u8; 64] { + self.0.sign(message) + } + } + + let client_identity = IdentityKeyPair::from_secret_key_bytes([0x71; 32]); + let server_identity = IdentityKeyPair::from_secret_key_bytes([0x72; 32]); + let client_pub = client_identity.public_key(); + let server_pub = server_identity.public_key(); + + let client_auth = SessionAuthConfig::new() + .with_local_signer(ExternalSigner(client_identity)) + .with_peer_identity(PeerIdentity::new(server_pub)) + .require_peer_authentication(true); + let server_auth = SessionAuthConfig::new() + .with_local_signer(ExternalSigner(server_identity)) + .with_peer_identity(PeerIdentity::new(client_pub)) + .require_peer_authentication(true); + + let (mut client, hello) = + Session::new_initiator_with_auth(RekeyThresholds::default(), client_auth); + let mut server = Session::new_responder_with_auth(RekeyThresholds::default(), server_auth); + + let server_hello = server + .handle_control(&hello) + .expect("server handles client hello") + .expect("server hello"); + client + .handle_control(&server_hello) + .expect("client finalizes"); + + assert!(client.peer_authenticated() && server.peer_authenticated()); + assert_eq!( + client + .authenticated_peer() + .expect("peer") + .identity_public_key(), + server_pub + ); + assert_eq!( + server + .authenticated_peer() + .expect("peer") + .identity_public_key(), + client_pub + ); + } + + #[test] + fn channel_binding_only_handshake_has_no_authenticated_peer() { + use crate::ChannelBinding; + let binding = ChannelBinding::new(b"tls-exporter:no-identity".to_vec()); + let (mut client, hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::bound_to_channel(binding.clone()), + ); + let mut server = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::bound_to_channel(binding), + ); + let server_hello = server + .handle_control(&hello) + .expect("server handles hello") + .expect("server hello"); + client + .handle_control(&server_hello) + .expect("client finalizes"); + + // No Foctet identity was proven, so there is no authenticated peer even + // though the handshake completed (its MITM resistance is the channel). + assert!(client.authenticated_peer().is_none()); + assert!(server.authenticated_peer().is_none()); + } + + #[test] + fn matching_channel_binding_completes_handshake_without_identity() { + use crate::ChannelBinding; + let binding = ChannelBinding::new(b"tls-exporter:matching-outer-channel".to_vec()); + let (mut client, hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::bound_to_channel(binding.clone()), + ); + let mut server = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::bound_to_channel(binding), + ); + + let server_hello = server + .handle_control(&hello) + .expect("server accepts a matching channel binding") + .expect("server hello response"); + client + .handle_control(&server_hello) + .expect("client accepts a matching channel binding"); + + assert_eq!(client.state(), SessionState::Active); + assert_eq!(server.state(), SessionState::Active); + // The channel — not a Foctet identity — provided MITM resistance. + assert!(!client.peer_authenticated()); + assert!(!server.peer_authenticated()); + } + + #[test] + fn mismatched_channel_binding_fails_handshake() { + use crate::ChannelBinding; + // Models a relay: each side is bound to a different outer channel. + let (_client, hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::bound_to_channel(ChannelBinding::new(b"channel-A".to_vec())), + ); + let mut server = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::bound_to_channel(ChannelBinding::new(b"channel-B".to_vec())), + ); + + let err = server + .handle_control(&hello) + .expect_err("a channel-binding mismatch must fail closed"); + assert!(matches!(err, CoreError::InvalidControlMessage)); + } + + #[test] + fn channel_binding_must_be_present_on_both_sides() { + use crate::ChannelBinding; + // The initiator binds to a channel; the responder does not. + let (_client, hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::bound_to_channel(ChannelBinding::new(b"channel-A".to_vec())), + ); + let mut server = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + + let err = server + .handle_control(&hello) + .expect_err("a one-sided channel binding must fail closed"); + assert!(matches!(err, CoreError::InvalidControlMessage)); + } + + #[test] + fn channel_binding_strengthens_identity_authenticated_handshake() { + use crate::ChannelBinding; + let client_identity = IdentityKeyPair::from_secret_key_bytes([0x41; 32]); + let server_identity = IdentityKeyPair::from_secret_key_bytes([0x61; 32]); + let binding = ChannelBinding::new(b"tls-exporter:bound".to_vec()); + let client_auth = SessionAuthConfig::new() + .with_local_identity(client_identity.clone()) + .with_peer_identity(PeerIdentity::new(server_identity.public_key())) + .require_peer_authentication(true) + .with_channel_binding(binding.clone()); + let server_auth = SessionAuthConfig::new() + .with_local_identity(server_identity.clone()) + .with_peer_identity(PeerIdentity::new(client_identity.public_key())) + .require_peer_authentication(true) + .with_channel_binding(binding); + + let (mut client, hello) = + Session::new_initiator_with_auth(RekeyThresholds::default(), client_auth); + let mut server = Session::new_responder_with_auth(RekeyThresholds::default(), server_auth); + + let server_hello = server + .handle_control(&hello) + .expect("server handle client hello") + .expect("server hello response"); + client + .handle_control(&server_hello) + .expect("client handle server hello"); + + assert!(client.peer_authenticated()); + assert!(server.peer_authenticated()); + } + + #[test] + fn responder_rejects_unauthenticated_hello_by_default() { + // A default (fail-closed) responder must refuse a ClientHello that + // carries no authentication, even though the transcript binding is + // valid. This is the baseline downgrade/MITM defense. + let (_client, hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut server = Session::new_responder(RekeyThresholds::default()); + let err = server + .handle_control(&hello) + .expect_err("default responder must reject unauthenticated hello"); + assert!(matches!(err, CoreError::MissingPeerAuthentication)); + assert_eq!(server.state(), SessionState::WaitingPeerHello); + } + + #[test] + fn initiator_rejects_unauthenticated_server_hello_by_default() { + // The initiator is fail-closed: an unauthenticated ServerHello is + // rejected unless the caller explicitly allowed unauthenticated mode. + let (mut client, hello) = Session::new_initiator(RekeyThresholds::default()); + let mut server = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let server_hello = server + .handle_control(&hello) + .expect("responder accepts hello in unauthenticated test mode") + .expect("server hello"); + let err = client + .handle_control(&server_hello) + .expect_err("default initiator must reject unauthenticated server hello"); + assert!(matches!(err, CoreError::MissingPeerAuthentication)); + } + + #[test] + fn unauthenticated_handshake_requires_explicit_opt_in_on_both_sides() { + let (mut client, hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut server = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let server_hello = server + .handle_control(&hello) + .expect("server handle hello") + .expect("server hello"); + client + .handle_control(&server_hello) + .expect("client handle server hello"); + assert_eq!(client.state(), SessionState::Active); + assert_eq!(server.state(), SessionState::Active); + // No identities were configured, so neither side is authenticated. + assert!(!client.peer_authenticated()); + assert!(!server.peer_authenticated()); + } + + fn active_pair() -> (Session, Session) { + let (mut client, hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut server = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let server_hello = server + .handle_control(&hello) + .expect("server handle client hello") + .expect("server hello response"); + client + .handle_control(&server_hello) + .expect("client handle server hello"); + (client, server) + } + + #[test] + fn replayed_rekey_message_is_rejected_after_a_real_rekey() { + // A Rekey control message names the `old_key_id` it rotates away + // from. Once that rotation has happened, the same message replayed + // (e.g. captured off the wire) must be rejected: `old_key_id` no + // longer matches the active key, so it cannot be re-applied or roll + // the session back to the previous key. + let (mut client, mut server) = active_pair(); + + let rekey = client.force_rekey().expect("client force rekey"); + server + .handle_control(&rekey) + .expect("server applies first rekey"); + + let err = server + .handle_control(&rekey) + .expect_err("replaying the same rekey message must be rejected"); + assert!(matches!(err, CoreError::UnexpectedControlMessage)); + } + + #[test] + fn rekey_message_with_stale_old_key_id_is_rejected() { + // A rekey collision/out-of-order scenario: the responder is still on + // key 0, but receives a `Rekey` claiming to rotate away from a key it + // never activated. It must reject this instead of silently + // installing a derived key the two sides disagree about. + let (_client, mut server) = active_pair(); + let forged_rekey = ControlMessage::Rekey { + old_key_id: 99, + new_key_id: 100, + ratchet_public: [0x42; 32], + transcript_binding: [0u8; 32], + }; + let err = server + .handle_control(&forged_rekey) + .expect_err("rekey from an unrecognized old_key_id must be rejected"); + assert!(matches!(err, CoreError::UnexpectedControlMessage)); + } + + #[test] + fn observer_sees_handshake_rekey_and_rejections_without_secrets() { + use std::sync::Mutex; + + #[derive(Default)] + struct Recorder(Mutex>); + impl SessionObserver for Recorder { + fn on_session_event(&self, event: SessionEvent) { + self.0.lock().expect("recorder lock").push(event); + } + } + + let client_events = Arc::new(Recorder::default()); + let server_events = Arc::new(Recorder::default()); + + let (client, hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut client = client.with_observer(client_events.clone()); + let mut server = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ) + .with_observer(server_events.clone()); + + let server_hello = server + .handle_control(&hello) + .expect("server handles hello") + .expect("server hello"); + client + .handle_control(&server_hello) + .expect("client finalizes"); + + assert_eq!( + *client_events.0.lock().expect("lock"), + vec![SessionEvent::HandshakeCompleted { + role: HandshakeRole::Initiator, + peer_authenticated: false, + }] + ); + assert_eq!( + *server_events.0.lock().expect("lock"), + vec![SessionEvent::HandshakeCompleted { + role: HandshakeRole::Responder, + peer_authenticated: false, + }] + ); + + // Rekey: initiator emits RekeyInitiated, receiver RekeyApplied. + let rekey = client.force_rekey().expect("client rekeys"); + server.handle_control(&rekey).expect("server applies"); + assert_eq!( + client_events.0.lock().expect("lock").last(), + Some(&SessionEvent::RekeyInitiated { + old_key_id: 0, + new_key_id: 1, + }) + ); + assert_eq!( + server_events.0.lock().expect("lock").last(), + Some(&SessionEvent::RekeyApplied { + old_key_id: 0, + new_key_id: 1, + }) + ); + + // A rejected control message (replayed rekey) emits ControlRejected. + assert!(server.handle_control(&rekey).is_err()); + assert_eq!( + server_events.0.lock().expect("lock").last(), + Some(&SessionEvent::ControlRejected) + ); + } + + #[test] + fn rekey_delivered_ahead_of_order_is_rejected_and_state_is_unchanged() { + // Out-of-order delivery in the *forward* direction: the receiver is + // active on key `k`, but a `Rekey` arrives that rotates away from + // `k + 1` — the transition a *future* rekey would name, as if a later + // ratchet message overtook the pending one. Even with a transcript + // binding that is internally consistent for its own key ids, it must + // be rejected (the ratchet chain cannot skip a step), and the session + // must remain usable: the correctly ordered rekey still applies. + let (mut client, mut server) = active_pair(); + + let active_id = server.active_keys().expect("server active key").key_id; + let ahead_old = active_id.wrapping_add(1); + let ahead_new = active_id.wrapping_add(2); + let ratchet_public = [0x42; 32]; + let ahead_rekey = ControlMessage::Rekey { + old_key_id: ahead_old, + new_key_id: ahead_new, + ratchet_public, + transcript_binding: rekey_binding( + ahead_old, + ahead_new, + &ratchet_public, + server.session_salt, + ), + }; + + let err = server + .handle_control(&ahead_rekey) + .expect_err("a rekey skipping ahead of the active key must be rejected"); + assert!(matches!(err, CoreError::UnexpectedControlMessage)); + + // No key was installed and the ratchet did not advance: the genuine + // in-order rekey from the peer still lands on both sides. + assert_eq!( + server.active_keys().expect("server key").key_id, + active_id, + "rejected rekey must not rotate the active key" + ); + let rekey = client.force_rekey().expect("client force rekey"); + server + .handle_control(&rekey) + .expect("in-order rekey still applies after the rejected one"); + assert_eq!( + client.active_keys().expect("client key"), + server.active_keys().expect("server key"), + "both sides must still converge on the same key" + ); + } + + #[test] + fn rekey_message_with_forged_transcript_binding_is_rejected() { + // Even with a correct `old_key_id`, a `Rekey` whose transcript + // binding doesn't match the recomputed hash (tampered `rekey_salt`, + // wrong `new_key_id`, or wrong binding outright) must be rejected + // rather than installing an attacker-influenced key. + let (mut client, mut server) = active_pair(); + let mut forged_rekey = client.force_rekey().expect("client force rekey"); + if let ControlMessage::Rekey { + transcript_binding, .. + } = &mut forged_rekey + { + transcript_binding[0] ^= 0xff; + } + let err = server + .handle_control(&forged_rekey) + .expect_err("tampered rekey transcript binding must be rejected"); + assert!(matches!(err, CoreError::InvalidControlMessage)); + } + + #[test] + fn control_message_unexpected_for_current_state_is_rejected() { + // A ClientHello/ServerHello replayed onto an already-Active session + // (or any control message that doesn't match the (role, state) + // dispatch table) must be rejected rather than reprocessed as a new + // handshake, which would let a captured hello desynchronize or + // downgrade an established session. + let (mut client, hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut server = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let server_hello = server + .handle_control(&hello) + .expect("server handle client hello") + .expect("server hello response"); + client + .handle_control(&server_hello) + .expect("client handle server hello"); + assert_eq!(server.state(), SessionState::Active); + + let err = server + .handle_control(&hello) + .expect_err("replayed ClientHello onto an active session must be rejected"); + assert!(matches!(err, CoreError::UnexpectedControlMessage)); } } diff --git a/foctet-core/src/storage.rs b/foctet-core/src/storage.rs new file mode 100644 index 0000000..a8abcc5 --- /dev/null +++ b/foctet-core/src/storage.rs @@ -0,0 +1,248 @@ +//! At-rest storage envelopes with record-identity binding. +//! +//! [`seal_storage_record`] / [`open_storage_record`] wrap the body envelope +//! ([`crate::body`]) and bind a record's identity — namespace, record id, and a +//! monotonic version — into the payload AEAD. A storage backend (Cloudflare KV, +//! D1, Durable Object storage, an object store, …) therefore only ever holds +//! opaque ciphertext it cannot read, and it cannot undetectably: +//! +//! - **substitute** one record's ciphertext for another (the namespace / record +//! id in the reader's descriptor would not match), or +//! - **roll back** a record to a stale ciphertext (the bound version would not +//! match the version the reader expects). +//! +//! The binding is authenticated but not secret: the descriptor bytes are folded +//! into the AEAD associated data and are **not** stored in the envelope, so the +//! reader must supply a byte-identical [`StorageRecord`] to open it. This is a +//! zero-knowledge storage building block — only the client holds a key; the +//! server stores and returns bytes. +//! +//! Rollback protection is only as strong as the reader's knowledge of the +//! current version: bind the version the caller *expects* (tracked client-side +//! or via an authenticated version pointer), so serving an older ciphertext +//! fails to open. + +use crate::body::{ + BodyEnvelopeError, BodyEnvelopeLimits, open_body_with_context, seal_body_with_context, +}; + +/// Domain-separation tag so a storage-record AAD can never collide with another +/// context-bound envelope (for example an HTTP protected context). +const STORAGE_RECORD_AAD_DOMAIN: &[u8] = b"foctet-storage-record-v1"; + +/// Identity of a stored record, bound into the envelope AEAD. +/// +/// An envelope opens only with the *same* descriptor used to seal it, so the +/// fields pin which record and which version a ciphertext belongs to. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct StorageRecord<'a> { + /// Logical collection the record lives in (for example `b"vault-items"`). + /// Separates records that share an id across different collections. + pub namespace: &'a [u8], + /// Stable identifier of the record within `namespace`. + pub record_id: &'a [u8], + /// Monotonic version of the record. Bind the version the caller expects so a + /// backend cannot serve a stale ciphertext without detection. + pub version: u64, +} + +impl<'a> StorageRecord<'a> { + /// Creates a record descriptor. + pub fn new(namespace: &'a [u8], record_id: &'a [u8], version: u64) -> Self { + Self { + namespace, + record_id, + version, + } + } + + /// Encodes the descriptor into canonical, unambiguous AAD bytes: a domain + /// tag, then each variable-length field length-prefixed, then the + /// fixed-width version. Length-prefixing prevents a `(namespace, record_id)` + /// pair from colliding with a different split of the same concatenated bytes. + fn to_aad(self) -> Vec { + let mut aad = Vec::with_capacity( + STORAGE_RECORD_AAD_DOMAIN.len() + + 8 + + self.namespace.len() + + 8 + + self.record_id.len() + + 8, + ); + aad.extend_from_slice(STORAGE_RECORD_AAD_DOMAIN); + aad.extend_from_slice(&(self.namespace.len() as u64).to_be_bytes()); + aad.extend_from_slice(self.namespace); + aad.extend_from_slice(&(self.record_id.len() as u64).to_be_bytes()); + aad.extend_from_slice(self.record_id); + aad.extend_from_slice(&self.version.to_be_bytes()); + aad + } +} + +/// Seals `plaintext` for at-rest storage, binding `record` into the AEAD. +/// +/// `recipient_public_key` is the key the data is encrypted to — the caller's own +/// key for a personal vault, or a peer's key for a one-to-one share. The +/// resulting bytes are opaque and safe to hand to an untrusted store; only a +/// holder of the matching secret key **and** the same [`StorageRecord`] can open +/// them. +pub fn seal_storage_record( + plaintext: &[u8], + recipient_public_key: [u8; 32], + recipient_key_id: &[u8], + record: StorageRecord<'_>, +) -> Result, BodyEnvelopeError> { + seal_storage_record_with_limits( + plaintext, + recipient_public_key, + recipient_key_id, + record, + &BodyEnvelopeLimits::default(), + ) +} + +/// [`seal_storage_record`] with explicit parser/encoder limits. +pub fn seal_storage_record_with_limits( + plaintext: &[u8], + recipient_public_key: [u8; 32], + recipient_key_id: &[u8], + record: StorageRecord<'_>, + limits: &BodyEnvelopeLimits, +) -> Result, BodyEnvelopeError> { + seal_body_with_context( + plaintext, + recipient_public_key, + recipient_key_id, + &record.to_aad(), + limits, + ) +} + +/// Opens a storage envelope, requiring the same [`StorageRecord`] it was sealed +/// with. +/// +/// Fails with [`BodyEnvelopeError::DecryptFailed`] if the descriptor does not +/// match — for example when the store returned a different record's ciphertext +/// (substitution) or a stale version (rollback). +pub fn open_storage_record( + envelope: &[u8], + recipient_secret_key: [u8; 32], + record: StorageRecord<'_>, +) -> Result, BodyEnvelopeError> { + open_storage_record_with_limits( + envelope, + recipient_secret_key, + record, + &BodyEnvelopeLimits::default(), + ) +} + +/// [`open_storage_record`] with explicit parser limits. +pub fn open_storage_record_with_limits( + envelope: &[u8], + recipient_secret_key: [u8; 32], + record: StorageRecord<'_>, + limits: &BodyEnvelopeLimits, +) -> Result, BodyEnvelopeError> { + open_body_with_context(envelope, recipient_secret_key, &record.to_aad(), limits) +} + +#[cfg(test)] +mod tests { + use super::*; + use rand_core::OsRng; + use x25519_dalek::{PublicKey, StaticSecret}; + + fn keypair() -> ([u8; 32], [u8; 32]) { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret).to_bytes(); + (secret.to_bytes(), public) + } + + #[test] + fn roundtrip_with_matching_descriptor() { + let (secret, public) = keypair(); + let record = StorageRecord::new(b"vault-items", b"item-42", 7); + let sealed = seal_storage_record(b"super secret note", public, b"account-kid", record) + .expect("seal"); + let opened = open_storage_record(&sealed, secret, record).expect("open"); + assert_eq!(opened, b"super secret note"); + } + + #[test] + fn substituted_record_id_is_rejected() { + let (secret, public) = keypair(); + let sealed = seal_storage_record( + b"secret", + public, + b"kid", + StorageRecord::new(b"vault-items", b"item-a", 1), + ) + .expect("seal"); + // The store returns item-a's ciphertext when the client asked for item-b. + let err = open_storage_record( + &sealed, + secret, + StorageRecord::new(b"vault-items", b"item-b", 1), + ) + .expect_err("substitution must fail"); + assert!(matches!(err, BodyEnvelopeError::DecryptFailed)); + } + + #[test] + fn substituted_namespace_is_rejected() { + let (secret, public) = keypair(); + let sealed = seal_storage_record( + b"secret", + public, + b"kid", + StorageRecord::new(b"vault-items", b"shared-id", 1), + ) + .expect("seal"); + let err = open_storage_record( + &sealed, + secret, + StorageRecord::new(b"secure-notes", b"shared-id", 1), + ) + .expect_err("cross-namespace reuse must fail"); + assert!(matches!(err, BodyEnvelopeError::DecryptFailed)); + } + + #[test] + fn rolled_back_version_is_rejected() { + let (secret, public) = keypair(); + let sealed = seal_storage_record( + b"v3 secret", + public, + b"kid", + StorageRecord::new(b"vault-items", b"item-42", 3), + ) + .expect("seal"); + // The client expects the current version (4); a stale v3 ciphertext must + // not open. + let err = open_storage_record( + &sealed, + secret, + StorageRecord::new(b"vault-items", b"item-42", 4), + ) + .expect_err("rollback must fail"); + assert!(matches!(err, BodyEnvelopeError::DecryptFailed)); + } + + #[test] + fn length_prefixing_prevents_field_boundary_ambiguity() { + let (secret, public) = keypair(); + // ("ab", "c") and ("a", "bc") share the concatenation "abc"; the length + // prefixes must keep their descriptors distinct. + let sealed = seal_storage_record( + b"secret", + public, + b"kid", + StorageRecord::new(b"ab", b"c", 1), + ) + .expect("seal"); + let err = open_storage_record(&sealed, secret, StorageRecord::new(b"a", b"bc", 1)) + .expect_err("shifted field boundary must fail"); + assert!(matches!(err, BodyEnvelopeError::DecryptFailed)); + } +} diff --git a/foctet-http/Cargo.toml b/foctet-http/Cargo.toml index 59c6d5c..3f8d1fd 100644 --- a/foctet-http/Cargo.toml +++ b/foctet-http/Cargo.toml @@ -2,6 +2,7 @@ name = "foctet-http" version.workspace = true edition.workspace = true +rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true @@ -12,21 +13,26 @@ readme = "../README.md" foctet-core = { workspace = true } http = "1.4" thiserror.workspace = true +zeroize.workspace = true axum = { version = "0.8", optional = true } +http-body-util = { version = "0.1", optional = true } +redis = { version = "0.27", optional = true, default-features = false, features = ["tokio-comp"] } [dev-dependencies] rand_core.workspace = true x25519-dalek.workspace = true tokio = { version = "1.48", features = ["macros", "rt-multi-thread"] } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } +futures-util = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] worker = { version = "0.7.5", optional = true } [features] default = [] -axum = ["dep:axum"] +axum = ["dep:axum", "dep:http-body-util"] workers = ["dep:worker"] +redis = ["dep:redis"] [[example]] name = "axum_body_echo_server" @@ -38,3 +44,6 @@ required-features = ["axum"] [[example]] name = "workers_echo_client" + +[[example]] +name = "workers_kv_vault_client" diff --git a/foctet-http/examples/README.md b/foctet-http/examples/README.md index 9b41a26..a90ac34 100644 --- a/foctet-http/examples/README.md +++ b/foctet-http/examples/README.md @@ -14,11 +14,25 @@ Run the client in another terminal: cargo run -p foctet-http --example axum_body_echo_client --features axum ``` +The client also has turn-key negative tests (each asserts the expected rejection +and exits non-zero otherwise): + +- `--replay` — re-sends the identical sealed request; the second is rejected with + **409** (single-use message id). +- `--wrong-path` — POSTs a request sealed for `/foctet` to `/foctet-elsewhere`; + rejected with **401** (the path is bound into the AEAD). +- `--expired` — seals with an already-elapsed expiry; rejected with **401**. + +See `tests.md` (§4) for the full runbook. + Notes: - Demo keys are hardcoded and are not production-safe. - The examples use the recommended high-level `HttpSealer` / `HttpOpener` path. -- `application/foctet` v0 encrypts and authenticates the body bytes only, not request metadata. -- HTTP method, URL, status code, and most headers remain visible to the outer HTTP stack. +- `application/foctet` v0 encrypts the body bytes; with the protected-context + path used here, the request method/path/query/message-id/timestamp/expiry are + also authenticated and replay-protected. +- HTTP method, URL, status code, and most headers still remain visible to the + outer HTTP stack. - For production use, pair body envelopes with an authenticated outer transport such as HTTPS, WebTransport, or an authenticated Foctet transport channel. - The examples show one-shot body-complete encryption/decryption flow, not streaming transport sessions. diff --git a/foctet-http/examples/axum_body_echo_client.rs b/foctet-http/examples/axum_body_echo_client.rs index 442c0ba..44df5f7 100644 --- a/foctet-http/examples/axum_body_echo_client.rs +++ b/foctet-http/examples/axum_body_echo_client.rs @@ -1,6 +1,18 @@ +// Protected-context echo client: it uses the production-recommended +// `seal_request_with_context` / `open_response_with_context` path. The request +// carrier (message-id/timestamp/expiry) travels in `x-foctet-*` headers and is +// bound into the AEAD together with the method/path/query, so a captured +// request cannot be replayed onto a different route or re-sent after expiry. +// +// Only the body bytes are encrypted; the surrounding HTTP metadata stays +// visible. Authenticate the outer transport (TLS) separately. + use foctet_http::{ - HttpOpenOptions, HttpOpener, HttpSealOptions, HttpSealer, + BodyEnvelopeLimits, ContextBinding, ContextCarrier, DEFAULT_CONTEXT_TTL_SECS, + DEFAULT_MAX_CLOCK_SKEW_SECS, HttpOpenOptions, HttpOpener, HttpSealOptions, HttpSealer, + HttpStreamSealer, http::{self}, + unix_now_secs, }; use reqwest::Client; use x25519_dalek::{PublicKey, StaticSecret}; @@ -8,6 +20,12 @@ use x25519_dalek::{PublicKey, StaticSecret}; const SERVER_SECRET_KEY: [u8; 32] = [0x11; 32]; const CLIENT_SECRET_KEY: [u8; 32] = [0x22; 32]; const SERVER_URL: &str = "http://127.0.0.1:3000/foctet"; +// A different route on the same server, used by `--wrong-path` to deliver a +// request sealed for `/foctet` onto another path (the AEAD binds the path, so +// the server rejects it with 401). +const SERVER_URL_ALT: &str = "http://127.0.0.1:3000/foctet-elsewhere"; +// Streaming-upload route, used by `--stream` / `--stream-truncated`. +const SERVER_URL_STREAM: &str = "http://127.0.0.1:3000/foctet-stream"; #[tokio::main] async fn main() { @@ -18,27 +36,85 @@ async fn main() { )); let opener = HttpOpener::new(HttpOpenOptions::new(CLIENT_SECRET_KEY)); + // `--replay` re-sends the identical sealed request; the server's ReplayStore + // must reject the second one with HTTP 409 (single-use message id). + let replay = std::env::args().any(|arg| arg == "--replay"); + // `--wrong-path` posts a request sealed for `/foctet` to `/foctet-elsewhere`; + // the path is bound into the AEAD, so the server must reject it with 401. + let wrong_path = std::env::args().any(|arg| arg == "--wrong-path"); + // `--expired` seals with an already-elapsed expiry (fresh timestamp skew, but + // past `expiry_secs`); the server must reject it with 401. + let expired = std::env::args().any(|arg| arg == "--expired"); + // `--stream` uploads a chunked streaming body decrypted per chunk (200); + // `--stream-truncated` drops the authenticated final chunk so the server + // rejects the truncated upload with 400. + let stream = std::env::args().any(|arg| arg == "--stream"); + let stream_truncated = std::env::args().any(|arg| arg == "--stream-truncated"); + if stream || stream_truncated { + run_streaming(&client, stream_truncated).await; + return; + } + + let now = unix_now_secs(); + // For `--expired`, backdate the carrier so `now` is already past its expiry + // while the timestamp still passes the clock-skew check. + let carrier = if expired { + let age = DEFAULT_CONTEXT_TTL_SECS + DEFAULT_MAX_CLOCK_SKEW_SECS + 10; + ContextCarrier::generate(now.saturating_sub(age), DEFAULT_CONTEXT_TTL_SECS) + } else { + ContextCarrier::generate(now, DEFAULT_CONTEXT_TTL_SECS) + }; + // Remember our request id so we can confirm the response answers it. + let request_message_id = carrier.message_id; + let plaintext_request = b"hello axum".to_vec(); let encrypted_request = sealer - .seal_request( + .seal_request_with_context( http::Request::builder() .method("POST") .uri(SERVER_URL) .body(plaintext_request) .expect("build request"), + carrier, + ContextBinding::default(), ) .expect("seal request"); - let mut request_builder = client.post(SERVER_URL); - for (name, value) in encrypted_request.headers() { - request_builder = request_builder.header(name, value); + // Capture the sealed request (headers + body) so we can optionally re-send the + // exact same bytes to demonstrate replay rejection. + let sealed_headers = encrypted_request.headers().clone(); + let sealed_body = encrypted_request.body().clone(); + + let send_sealed_to = |client: &Client, url: &str| { + let mut request_builder = client.post(url); + for (name, value) in &sealed_headers { + request_builder = request_builder.header(name, value); + } + request_builder.body(sealed_body.clone()).send() + }; + let send_sealed = |client: &Client| send_sealed_to(client, SERVER_URL); + + // `--wrong-path` / `--expired` are single-shot negative tests: the one request + // must be rejected with 401, and there is no encrypted response to open. + if wrong_path || expired { + let (label, url) = if wrong_path { + ("wrong-path", SERVER_URL_ALT) + } else { + ("expired", SERVER_URL) + }; + let response = send_sealed_to(&client, url).await.expect("send request"); + let status = response.status(); + println!("{label} status: {status}"); + if status == reqwest::StatusCode::UNAUTHORIZED { + println!("{label} correctly rejected with 401 Unauthorized"); + } else { + println!("UNEXPECTED: {label} was not rejected with 401"); + std::process::exit(1); + } + return; } - let response = request_builder - .body(encrypted_request.body().clone()) - .send() - .await - .expect("send request"); + let response = send_sealed(&client).await.expect("send request"); let status = response.status(); let version = response.version(); @@ -53,10 +129,20 @@ async fn main() { } let decrypted_response = opener - .open_response(response_builder.body(body).expect("build response")) + .open_response_with_context( + response_builder.body(body).expect("build response"), + unix_now_secs(), + DEFAULT_MAX_CLOCK_SKEW_SECS, + ) .expect("open response"); + // The response carrier echoes the request id it answers. + let response_carrier = + ContextCarrier::from_headers(decrypted_response.headers()).expect("response carrier"); + let answered = response_carrier.request_message_id == Some(request_message_id); + println!("status: {}", decrypted_response.status()); + println!("answers our request id: {answered}"); println!( "x-foctet-example: {}", decrypted_response @@ -78,6 +164,96 @@ async fn main() { String::from_utf8_lossy(decrypted_response.body()) ); println!("note: request path, method, and headers are still outer HTTP metadata."); + + if replay { + let replayed = send_sealed(&client).await.expect("send replay"); + let replay_status = replayed.status(); + println!("replay status: {replay_status}"); + if replay_status == reqwest::StatusCode::CONFLICT { + println!("replay correctly rejected with 409 Conflict"); + } else { + println!("UNEXPECTED: replay was not rejected with 409"); + std::process::exit(1); + } + } +} + +/// Uploads a chunked streaming body to `/foctet-stream`, sealing one Foctet +/// stream frame per HTTP chunk. With `truncate`, the authenticated final frame +/// is dropped so the server rejects the truncated upload with HTTP 400. +/// +/// The streaming response direction is out of scope here: the server replies +/// with a plain acknowledgement, so this exercises the upload path only. +async fn run_streaming(client: &Client, truncate: bool) { + let limits = BodyEnvelopeLimits::default(); + let now = unix_now_secs(); + let carrier = ContextCarrier::generate(now, DEFAULT_CONTEXT_TTL_SECS); + + // Build the request parts (method/path bound into the stream header), then + // seal one frame per plaintext chunk. + let base = http::Request::builder() + .method("POST") + .uri(SERVER_URL_STREAM) + .body(()) + .expect("build request"); + let (mut parts, ()) = base.into_parts(); + let (mut sealer, header) = HttpStreamSealer::for_request( + &parts, + &carrier, + ContextBinding::default(), + demo_public_key(SERVER_SECRET_KEY), + b"demo-server-kid", + &limits, + ) + .expect("stream sealer"); + carrier + .apply_to_headers(&mut parts.headers) + .expect("apply carrier"); + + let mut frames: Vec> = vec![header]; + let plaintext_chunks: [&[u8]; 3] = [b"streamed ", b"axum ", b"upload"]; + for (index, chunk) in plaintext_chunks.iter().enumerate() { + let is_final = index == plaintext_chunks.len() - 1; + frames.push(sealer.seal_chunk(chunk, is_final).expect("seal chunk")); + } + if truncate { + // Drop the authenticated FINAL frame so the server never sees the end. + frames.pop(); + } + + // A stream body makes reqwest send one HTTP chunk per frame (chunked + // transfer-encoding), so the server decrypts as chunks arrive. + let body = reqwest::Body::wrap_stream(futures_util::stream::iter( + frames.into_iter().map(Ok::, std::io::Error>), + )); + let mut request_builder = client.post(SERVER_URL_STREAM); + for (name, value) in &parts.headers { + request_builder = request_builder.header(name, value); + } + let response = request_builder + .body(body) + .send() + .await + .expect("send stream"); + let status = response.status(); + + if truncate { + println!("stream-truncated status: {status}"); + if status == reqwest::StatusCode::BAD_REQUEST { + println!("truncated stream correctly rejected with 400 Bad Request"); + } else { + println!("UNEXPECTED: truncated stream was not rejected with 400"); + std::process::exit(1); + } + } else { + let text = response.text().await.unwrap_or_default(); + println!("stream status: {status}"); + println!("server: {text}"); + if status != reqwest::StatusCode::OK { + println!("UNEXPECTED: stream upload was not accepted with 200"); + std::process::exit(1); + } + } } fn demo_public_key(secret_key: [u8; 32]) -> [u8; 32] { diff --git a/foctet-http/examples/axum_body_echo_server.rs b/foctet-http/examples/axum_body_echo_server.rs index 8ef5ca7..1d688ff 100644 --- a/foctet-http/examples/axum_body_echo_server.rs +++ b/foctet-http/examples/axum_body_echo_server.rs @@ -1,13 +1,27 @@ +// Protected-context echo server: it uses the production-recommended +// `open_request_with_context` / `seal_response_with_context` path, which binds +// the HTTP method/path/query/message-id/timestamp/expiry into the AEAD and +// enforces single use through a `ReplayStore`. A replayed request is rejected +// with HTTP 409. This demo keeps an in-memory store; deploy a durable store +// (`RedisReplayStore`) for multi-instance or serverless targets. +// +// Only the body bytes are encrypted; the surrounding HTTP metadata stays +// visible. Authenticate the outer transport (TLS) separately. + +use std::sync::Arc; + use axum::{ Router, - extract::Request, + extract::{Request, State}, http::StatusCode, response::{IntoResponse, Response}, routing::post, }; use foctet_http::{ - HttpOpenOptions, HttpSealOptions, - axum::{AxumOpener, AxumSealer}, + BodyEnvelopeLimits, ContextBinding, ContextCarrier, DEFAULT_CONTEXT_TTL_SECS, + DEFAULT_MAX_CLOCK_SKEW_SECS, HttpOpenOptions, HttpSealOptions, InMemoryReplayStore, + axum::{AxumError, AxumOpener, AxumSealer, open_request_stream}, + unix_now_secs, }; use x25519_dalek::{PublicKey, StaticSecret}; @@ -15,9 +29,38 @@ const SERVER_SECRET_KEY: [u8; 32] = [0x11; 32]; const CLIENT_SECRET_KEY: [u8; 32] = [0x22; 32]; const MAX_BODY_BYTES: usize = 1024 * 1024; +/// Shared application state: the opener authenticates and decrypts request +/// bodies, the replay store enforces single use, and the sealer encrypts +/// responses back to the client. +struct AppState { + opener: AxumOpener, + sealer: AxumSealer, + store: InMemoryReplayStore, +} + #[tokio::main] async fn main() { - let app = Router::new().route("/foctet", post(handle_foctet)); + let state = Arc::new(AppState { + opener: AxumOpener::new(HttpOpenOptions::new(SERVER_SECRET_KEY), MAX_BODY_BYTES), + sealer: AxumSealer::new(HttpSealOptions::new( + demo_public_key(CLIENT_SECRET_KEY), + b"demo-client-kid", + )), + store: InMemoryReplayStore::new(), + }); + + // `/foctet-elsewhere` is wired to the *same* handler purely so the client's + // `--wrong-path` negative test can deliver a request sealed for `/foctet` + // onto a different route: the handler still runs, but the path bound into + // the AEAD no longer matches, so opening fails closed with HTTP 401. + let app = Router::new() + .route("/foctet", post(handle_foctet)) + .route("/foctet-elsewhere", post(handle_foctet)) + // Streaming-upload route: decrypts a chunked body chunk by chunk and + // rejects a truncated upload with 400. Driven by the client's + // `--stream` / `--stream-truncated` flags. + .route("/foctet-stream", post(handle_foctet_stream)) + .with_state(state); let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await @@ -32,12 +75,28 @@ async fn main() { axum::serve(listener, app).await.expect("serve app"); } -async fn handle_foctet(request: Request) -> Result { - let opener = AxumOpener::new(HttpOpenOptions::new(SERVER_SECRET_KEY), MAX_BODY_BYTES); - let opened = opener - .open_request(request) - .await - .map_err(|_| StatusCode::BAD_REQUEST)?; +async fn handle_foctet( + State(state): State>, + request: Request, +) -> Result { + let now = unix_now_secs(); + + // Authenticate the bound context, enforce freshness, then single use. + // A replayed request returns HTTP 409 via `AxumError`'s `IntoResponse`. + let opened = state + .opener + .open_request_with_context( + request, + &state.store, + now, + DEFAULT_MAX_CLOCK_SKEW_SECS, + ContextBinding::default(), + ) + .await?; + + // The carrier headers survive opening, so we can answer the request's + // message id and let the client correlate the response. + let request_carrier = ContextCarrier::from_headers(opened.headers())?; let transformed = opened .body() @@ -50,17 +109,60 @@ async fn handle_foctet(request: Request) -> Result { .header("x-foctet-example", "axum") .header("x-foctet-scope", "body-only") .body(transformed) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .expect("build response"); + + let response_carrier = ContextCarrier::generate(now, DEFAULT_CONTEXT_TTL_SECS) + .answering(request_carrier.message_id); + let encrypted = state + .sealer + .seal_response_with_context(plaintext_response, response_carrier)?; + + Ok(encrypted) +} + +/// Streaming upload handler. +/// +/// `open_request_stream` reassembles the Foctet stream frames from the chunked +/// HTTP body, authenticates the bound context and enforces single use when the +/// stream header arrives, and invokes the callback per decrypted chunk without +/// buffering the whole body. If the body ends before the authenticated final +/// chunk (truncated or cancelled upload), it returns `HttpError::StreamIncomplete`, +/// which `AxumError`'s `IntoResponse` maps to HTTP 400. +async fn handle_foctet_stream( + State(state): State>, + request: Request, +) -> Result { + let now = unix_now_secs(); + let limits = BodyEnvelopeLimits::default(); + let mut chunk_count = 0usize; + let mut total_bytes = 0usize; - let sealer = AxumSealer::new(HttpSealOptions::new( - demo_public_key(CLIENT_SECRET_KEY), - b"demo-client-kid", - )); - let encrypted = sealer - .seal_response(plaintext_response) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + open_request_stream( + request, + SERVER_SECRET_KEY, + &state.store, + now, + DEFAULT_MAX_CLOCK_SKEW_SECS, + ContextBinding::default(), + &limits, + |plaintext| { + chunk_count += 1; + total_bytes += plaintext.len(); + println!( + "stream: decrypted chunk {chunk_count} ({} bytes)", + plaintext.len() + ); + Ok(()) + }, + ) + .await?; - Ok(encrypted.into_response()) + println!("stream: reassembled {total_bytes} bytes over {chunk_count} chunks"); + Ok(( + StatusCode::OK, + format!("received {total_bytes} bytes in {chunk_count} chunks"), + ) + .into_response()) } fn demo_public_key(secret_key: [u8; 32]) -> [u8; 32] { diff --git a/foctet-http/examples/workers-echo/README.md b/foctet-http/examples/workers-echo/README.md index 145a881..d0cce3c 100644 --- a/foctet-http/examples/workers-echo/README.md +++ b/foctet-http/examples/workers-echo/README.md @@ -14,6 +14,36 @@ Run the Rust client against the local worker in another terminal: cargo run -p foctet-http --example workers_echo_client ``` +Target a deployed Worker with `WORKERS_URL`: + +```bash +WORKERS_URL=https://..workers.dev/foctet \ + cargo run -p foctet-http --example workers_echo_client +``` + +## Key rotation demo + +The Worker's opener holds a keyring of two server key generations — `v2` +(current) and `v1` (retiring) — so it accepts requests sealed to either during +an overlap window. Pick which key the client seals to with `SERVER_KEY_VERSION`: + +| `SERVER_KEY_VERSION` | Sealed to | Expected result | +| --- | --- | --- | +| `v2` (default) | current key | `200`, then replay `409` | +| `v1` | retiring key (still in keyring) | `200`, then replay `409` | +| `retired` | key the Worker does not hold | `401`, then replay `401` | + +```bash +SERVER_KEY_VERSION=v1 cargo run -p foctet-http --example workers_echo_client +SERVER_KEY_VERSION=retired cargo run -p foctet-http --example workers_echo_client +``` + +`v1` and `v2` both returning `200` demonstrates overlap acceptance; `retired` +returning `401` (not `500`, and never `409` on retry) demonstrates clean failure +handling — a request sealed to a retired key fails authentication before the +replay store is consulted. See the [key-rotation guide](../../../docs/key-rotation.md) +for the production rotation procedure. + Notes: - Demo keys are hardcoded and are not production-safe. diff --git a/foctet-http/examples/workers-echo/package-lock.json b/foctet-http/examples/workers-echo/package-lock.json index 8fed02b..4f81220 100644 --- a/foctet-http/examples/workers-echo/package-lock.json +++ b/foctet-http/examples/workers-echo/package-lock.json @@ -5,28 +5,28 @@ "packages": { "": { "devDependencies": { - "wrangler": "^4.73.0" + "wrangler": "^4.107.0" } }, "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", - "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", "dev": true, "license": "MIT OR Apache-2.0", "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@cloudflare/unenv-preset": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.15.0.tgz", - "integrity": "sha512-EGYmJaGZKWl+X8tXxcnx4v2bOZSjQeNI5dWFeXivgX9+YCT69AkzHHwlNbVpqtEUTbew8eQurpyOpeN8fg00nw==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", "dev": true, "license": "MIT OR Apache-2.0", "peerDependencies": { "unenv": "2.0.0-rc.24", - "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" + "workerd": ">1.20260305.0 <2.0.0-0" }, "peerDependenciesMeta": { "workerd": { @@ -35,9 +35,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260312.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260312.1.tgz", - "integrity": "sha512-HUAtDWaqUduS6yasV6+NgsK7qBpP1qGU49ow/Wb117IHjYp+PZPUGReDYocpB4GOMRoQlvdd4L487iFxzdARpw==", + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260701.1.tgz", + "integrity": "sha512-Zd9Y1bah6DwwBN2RW8vJohffQrIUazb8UXnqSNecOxM+jJLhUuvv5IOG8dbHcV83TyZAubea6gsQXo2yH1lDdw==", "cpu": [ "x64" ], @@ -52,9 +52,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260312.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260312.1.tgz", - "integrity": "sha512-DOn7TPTHSxJYfi4m4NYga/j32wOTqvJf/pY4Txz5SDKWIZHSTXFyGz2K4B+thoPWLop/KZxGoyTv7db0mk/qyw==", + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260701.1.tgz", + "integrity": "sha512-yBLsjS1qCWqFyCY37qRUrYfzHHvMGvjh8zRKJ6MvUivYDhkZTzqduppK38FoqYvayLJ5KbcxH7zo5rkxGqbsaA==", "cpu": [ "arm64" ], @@ -69,9 +69,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260312.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260312.1.tgz", - "integrity": "sha512-TdkIh3WzPXYHuvz7phAtFEEvAxvFd30tHrm4gsgpw0R0F5b8PtoM3hfL2uY7EcBBWVYUBtkY2ahDYFfufnXw/g==", + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260701.1.tgz", + "integrity": "sha512-vMfqSIMfoo4xmZXEuUVqLpSFS921YKjiR9q7kDXPi6Vld1PK74UHg9LZuBavT2KSyemHUCTpj9y/4JSYOEyQbQ==", "cpu": [ "x64" ], @@ -86,9 +86,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260312.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260312.1.tgz", - "integrity": "sha512-kNauZhL569Iy94t844OMwa1zP6zKFiL3xiJ4tGLS+TFTEfZ3pZsRH6lWWOtkXkjTyCmBEOog0HSEKjIV4oAffw==", + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260701.1.tgz", + "integrity": "sha512-HRfwbKU2pK44V2NhoM0+iH0JJSj7nQ9Wv13ifIiGYCmTtDL8/zKtEhX7kQ3D4Vy/Cpjhttl0FkfqXj1aqLDPPg==", "cpu": [ "arm64" ], @@ -103,9 +103,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260312.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260312.1.tgz", - "integrity": "sha512-5dBrlSK+nMsZy5bYQpj8t9iiQNvCRlkm9GGvswJa9vVU/1BNO4BhJMlqOLWT24EmFyApZ+kaBiPJMV8847NDTg==", + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260701.1.tgz", + "integrity": "sha512-ngxCiIN9s/fM2o1IBMD0o1/mcXrv2NJVdyznh51UH8sQuvrTrXvV2nM0Uj/qU2wMwF6prgNBcdcd7AZeZGiBQA==", "cpu": [ "x64" ], @@ -133,9 +133,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "dev": true, "license": "MIT", "optional": true, @@ -144,9 +144,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -161,9 +161,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -178,9 +178,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -195,9 +195,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -212,9 +212,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -229,9 +229,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -246,9 +246,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -263,9 +263,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -280,9 +280,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -297,9 +297,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -314,9 +314,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -331,9 +331,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -348,9 +348,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -365,9 +365,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -382,9 +382,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -399,9 +399,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -416,9 +416,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -433,9 +433,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -450,9 +450,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -467,9 +467,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -484,9 +484,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -501,9 +501,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -518,9 +518,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -535,9 +535,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -552,9 +552,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -569,9 +569,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1146,9 +1146,9 @@ } }, "node_modules/@speed-highlight/core": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz", - "integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==", + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", "dev": true, "license": "CC0-1.0" }, @@ -1194,9 +1194,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1207,32 +1207,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/fsevents": { @@ -1261,24 +1261,24 @@ } }, "node_modules/miniflare": { - "version": "4.20260312.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260312.0.tgz", - "integrity": "sha512-pieP2rfXynPT6VRINYaiHe/tfMJ4c5OIhqRlIdLF6iZ9g5xgpEmvimvIgMpgAdDJuFlrLcwDUi8MfAo2R6dt/w==", + "version": "4.20260701.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260701.0.tgz", + "integrity": "sha512-L6eAAi6IKtyb/7J6L+YsH2vb1yBrJWKRXI293JYDiMl70+6nncdAgigex58w6WBd+CwvdMsqOyNyGs95Op5gWQ==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "sharp": "^0.34.5", - "undici": "7.18.2", - "workerd": "1.20260312.1", - "ws": "8.18.0", + "sharp": "0.34.5", + "undici": "7.28.0", + "workerd": "1.20260701.1", + "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/path-to-regexp": { @@ -1296,9 +1296,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -1375,9 +1375,9 @@ "optional": true }, "node_modules/undici": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", - "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -1395,9 +1395,9 @@ } }, "node_modules/workerd": { - "version": "1.20260312.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260312.1.tgz", - "integrity": "sha512-nNpPkw9jaqo79B+iBCOiksx+N62xC+ETIfyzofUEdY3cSOHJg6oNnVSHm7vHevzVblfV76c8Gr0cXHEapYMBEg==", + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260701.1.tgz", + "integrity": "sha512-uF813NG09JwNRRUfJ0zBomyTslSPM810dMj9LVvkQ7RAkLrQLzAlPU8Xh/3dIqZDo2bfd7tChbf2PtqLRARRJQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -1408,41 +1408,42 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260312.1", - "@cloudflare/workerd-darwin-arm64": "1.20260312.1", - "@cloudflare/workerd-linux-64": "1.20260312.1", - "@cloudflare/workerd-linux-arm64": "1.20260312.1", - "@cloudflare/workerd-windows-64": "1.20260312.1" + "@cloudflare/workerd-darwin-64": "1.20260701.1", + "@cloudflare/workerd-darwin-arm64": "1.20260701.1", + "@cloudflare/workerd-linux-64": "1.20260701.1", + "@cloudflare/workerd-linux-arm64": "1.20260701.1", + "@cloudflare/workerd-windows-64": "1.20260701.1" } }, "node_modules/wrangler": { - "version": "4.73.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.73.0.tgz", - "integrity": "sha512-VJXsqKDFCp6OtFEHXITSOR5kh95JOknwPY8m7RyQuWJQguSybJy43m4vhoCSt42prutTef7eeuw7L4V4xiynGw==", + "version": "4.107.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.107.0.tgz", + "integrity": "sha512-fw69ThymNitZ0oIEBU2yNeq3kK59UKz/jyA3udwRrQIAIsxX57q5qLOpPTN7qc5t8n9pnUeofe0uxtMuhQZW8w==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { - "@cloudflare/kv-asset-handler": "0.4.2", - "@cloudflare/unenv-preset": "2.15.0", + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", - "esbuild": "0.27.3", - "miniflare": "4.20260312.0", + "esbuild": "0.28.1", + "miniflare": "4.20260701.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260312.1" + "workerd": "1.20260701.1" }, "bin": { + "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260312.1" + "@cloudflare/workers-types": "^4.20260701.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -1451,9 +1452,9 @@ } }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { diff --git a/foctet-http/examples/workers-echo/package.json b/foctet-http/examples/workers-echo/package.json index ef6efdb..6292385 100644 --- a/foctet-http/examples/workers-echo/package.json +++ b/foctet-http/examples/workers-echo/package.json @@ -1,5 +1,5 @@ { "devDependencies": { - "wrangler": "^4.73.0" + "wrangler": "^4.107.0" } } diff --git a/foctet-http/examples/workers-echo/src/lib.rs b/foctet-http/examples/workers-echo/src/lib.rs index ebd9d77..cdf7e79 100644 --- a/foctet-http/examples/workers-echo/src/lib.rs +++ b/foctet-http/examples/workers-echo/src/lib.rs @@ -1,11 +1,22 @@ use foctet_http::{ - HttpOpenOptions, HttpSealOptions, - workers::{WorkersOpener, WorkersSealer}, + ContextBinding, DEFAULT_MAX_CLOCK_SKEW_SECS, HttpOpenOptions, HttpSealOptions, + workers::{ + DurableObjectReplayStore, WorkersOpener, WorkersSealer, check_and_insert_in_durable_object, + expire_durable_object_replay_entry, + }, +}; +use worker::wasm_bindgen; +use worker::{ + Context, DurableObject, Env, Error, Request, Response, Result, State, durable_object, event, }; -use worker::{Context, Env, Error, Request, Response, Result, event}; use x25519_dalek::{PublicKey, StaticSecret}; -const SERVER_SECRET_KEY: [u8; 32] = [0x11; 32]; +// Two server key generations. During a rotation overlap window the Worker +// accepts requests sealed to either key; to retire `v1`, drop it from the +// keyring below and redeploy. Demo keys are hardcoded and not production-safe; +// in production load these from `wrangler secret`. +const SERVER_SECRET_KEY_V1: [u8; 32] = [0x11; 32]; +const SERVER_SECRET_KEY_V2: [u8; 32] = [0x33; 32]; const CLIENT_SECRET_KEY: [u8; 32] = [0x22; 32]; #[event(fetch)] @@ -14,14 +25,33 @@ pub async fn fetch(request: Request, _env: Env, _ctx: Context) -> Result opened, + // Answer client-caused failures with a status-only response (e.g. a + // replay -> 409) and never echo the error detail into the body. + Err(err) => return Ok(Response::empty()?.with_status(err.status_code())), + }; let transformed = opened - .plaintext + .into_body() .into_iter() .map(|byte| byte.to_ascii_uppercase()) .collect::>(); @@ -40,6 +70,27 @@ pub async fn fetch(request: Request, _env: Env, _ctx: Context) -> Result Self { + Self { state } + } + + async fn fetch(&self, request: Request) -> Result { + check_and_insert_in_durable_object(&self.state.storage(), request).await + } + + async fn alarm(&self) -> Result { + expire_durable_object_replay_entry(&self.state.storage()).await + } +} + fn demo_public_key(secret_key: [u8; 32]) -> [u8; 32] { PublicKey::from(&StaticSecret::from(secret_key)).to_bytes() } diff --git a/foctet-http/examples/workers-echo/wrangler.toml b/foctet-http/examples/workers-echo/wrangler.toml index e6f840f..069f7f6 100644 --- a/foctet-http/examples/workers-echo/wrangler.toml +++ b/foctet-http/examples/workers-echo/wrangler.toml @@ -2,5 +2,13 @@ name = "foctet-http-workers-echo" main = "build/worker/shim.mjs" compatibility_date = "2026-03-08" +[[durable_objects.bindings]] +name = "FOCTET_REPLAY" +class_name = "FoctetReplay" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["FoctetReplay"] + [build] -command = "cargo install -q worker-build && worker-build --release" +command = "cargo install -q worker-build@^0.7 && worker-build --release" diff --git a/foctet-http/examples/workers-kv-vault/.gitignore b/foctet-http/examples/workers-kv-vault/.gitignore new file mode 100644 index 0000000..a616f6c --- /dev/null +++ b/foctet-http/examples/workers-kv-vault/.gitignore @@ -0,0 +1,9 @@ +target +node_modules +.wrangler + +#macOS +.DS_Store + +# Build directories +build diff --git a/foctet-http/examples/workers-kv-vault/Cargo.toml b/foctet-http/examples/workers-kv-vault/Cargo.toml new file mode 100644 index 0000000..fe304c9 --- /dev/null +++ b/foctet-http/examples/workers-kv-vault/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "foctet-http-workers-kv-vault" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +# This Worker is a zero-knowledge blind store: it holds no key and never +# decrypts, so it depends on `worker` only — there is deliberately no foctet or +# crypto dependency here. All sealing/opening happens on the client with +# `foctet_core::storage`. +[dependencies] +worker = "0.7.5" + +[workspace] diff --git a/foctet-http/examples/workers-kv-vault/README.md b/foctet-http/examples/workers-kv-vault/README.md new file mode 100644 index 0000000..22074b2 --- /dev/null +++ b/foctet-http/examples/workers-kv-vault/README.md @@ -0,0 +1,70 @@ +# Zero-knowledge KV vault (blind storage Worker) + +A Cloudflare Workers + KV backend that stores end-to-end-encrypted vault items +**without holding any key**. The Worker only moves opaque `application/foctet` +blobs in and out of KV; all sealing and opening happens on the client with +[`foctet_core::storage`](../../../foctet-core/src/storage.rs). + +Routes: + +- `PUT /vault/:id` — store the request body under `id` +- `GET /vault/:id` — return the stored bytes (404 if absent) +- `DELETE /vault/:id` — remove the record + +## Local dev + +```bash +cd foctet-http/examples/workers-kv-vault +npm i -D wrangler@latest +npx wrangler dev +``` + +Run the Rust client against the local Worker in another terminal (from the repo +root): + +```bash +cargo run -p foctet-http --example workers_kv_vault_client +``` + +Expected: + +``` +stored ciphertext bytes to http://127.0.0.1:8787/vault/login-github +opened: github password: correct horse battery staple +substitution correctly rejected (wrong record id fails to open) +rollback correctly rejected (stale version fails to open) +``` + +The client seals a vault item, uploads the blob, fetches it back and opens it, +then proves that the same blob does **not** open under a different record id +(substitution) or a newer expected version (rollback). + +## Deploy + +```bash +# Create a KV namespace and paste the id into wrangler.toml (kv_namespaces.id). +npx wrangler kv namespace create VAULT_KV +export CLOUDFLARE_API_TOKEN=... +npx wrangler deploy + +VAULT_URL=https://..workers.dev \ + cargo run -p foctet-http --example workers_kv_vault_client +``` + +## What this proves + +- **Zero knowledge:** the Worker has no foctet or crypto dependency (see + `Cargo.toml`) and never sees a key, so a compromised Worker or KV store yields + only opaque ciphertext. +- **Substitution / rollback resistance:** each value binds its namespace / id / + version into the AEAD, so a backend cannot answer a query for one record with + another record's ciphertext, or serve a stale version, without the client's + open failing. + +Notes: + +- Demo keys are hardcoded and not production-safe; a real client derives the + account key from the user's master password and never uploads it. +- Transport is plain HTTP because the payload is already end-to-end encrypted; + keep HTTPS in production and add your normal Worker auth/authorization. +- See the [zero-knowledge storage guide](../../../docs/zero-knowledge-workers-storage.md). diff --git a/foctet-http/examples/workers-kv-vault/package-lock.json b/foctet-http/examples/workers-kv-vault/package-lock.json new file mode 100644 index 0000000..c7bd594 --- /dev/null +++ b/foctet-http/examples/workers-kv-vault/package-lock.json @@ -0,0 +1,1502 @@ +{ + "name": "workers-kv-vault", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "wrangler": "^4.107.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260701.1.tgz", + "integrity": "sha512-Zd9Y1bah6DwwBN2RW8vJohffQrIUazb8UXnqSNecOxM+jJLhUuvv5IOG8dbHcV83TyZAubea6gsQXo2yH1lDdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260701.1.tgz", + "integrity": "sha512-yBLsjS1qCWqFyCY37qRUrYfzHHvMGvjh8zRKJ6MvUivYDhkZTzqduppK38FoqYvayLJ5KbcxH7zo5rkxGqbsaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260701.1.tgz", + "integrity": "sha512-vMfqSIMfoo4xmZXEuUVqLpSFS921YKjiR9q7kDXPi6Vld1PK74UHg9LZuBavT2KSyemHUCTpj9y/4JSYOEyQbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260701.1.tgz", + "integrity": "sha512-HRfwbKU2pK44V2NhoM0+iH0JJSj7nQ9Wv13ifIiGYCmTtDL8/zKtEhX7kQ3D4Vy/Cpjhttl0FkfqXj1aqLDPPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260701.1.tgz", + "integrity": "sha512-ngxCiIN9s/fM2o1IBMD0o1/mcXrv2NJVdyznh51UH8sQuvrTrXvV2nM0Uj/qU2wMwF6prgNBcdcd7AZeZGiBQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260701.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260701.0.tgz", + "integrity": "sha512-L6eAAi6IKtyb/7J6L+YsH2vb1yBrJWKRXI293JYDiMl70+6nncdAgigex58w6WBd+CwvdMsqOyNyGs95Op5gWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.34.5", + "undici": "7.28.0", + "workerd": "1.20260701.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260701.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260701.1.tgz", + "integrity": "sha512-uF813NG09JwNRRUfJ0zBomyTslSPM810dMj9LVvkQ7RAkLrQLzAlPU8Xh/3dIqZDo2bfd7tChbf2PtqLRARRJQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260701.1", + "@cloudflare/workerd-darwin-arm64": "1.20260701.1", + "@cloudflare/workerd-linux-64": "1.20260701.1", + "@cloudflare/workerd-linux-arm64": "1.20260701.1", + "@cloudflare/workerd-windows-64": "1.20260701.1" + } + }, + "node_modules/wrangler": { + "version": "4.107.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.107.0.tgz", + "integrity": "sha512-fw69ThymNitZ0oIEBU2yNeq3kK59UKz/jyA3udwRrQIAIsxX57q5qLOpPTN7qc5t8n9pnUeofe0uxtMuhQZW8w==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "4.20260701.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260701.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260701.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/foctet-http/examples/workers-kv-vault/package.json b/foctet-http/examples/workers-kv-vault/package.json new file mode 100644 index 0000000..6292385 --- /dev/null +++ b/foctet-http/examples/workers-kv-vault/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "wrangler": "^4.107.0" + } +} diff --git a/foctet-http/examples/workers-kv-vault/src/lib.rs b/foctet-http/examples/workers-kv-vault/src/lib.rs new file mode 100644 index 0000000..a8b0614 --- /dev/null +++ b/foctet-http/examples/workers-kv-vault/src/lib.rs @@ -0,0 +1,50 @@ +//! Zero-knowledge KV vault: a blind storage Worker. +//! +//! This Worker holds no key and never decrypts. It only moves opaque +//! `application/foctet` blobs in and out of Cloudflare KV, keyed by record id: +//! +//! - `PUT /vault/:id` — store the request body bytes under `id`. +//! - `GET /vault/:id` — return the stored bytes (404 if absent). +//! - `DELETE /vault/:id` — remove the record. +//! +//! Confidentiality and integrity come entirely from the client, which seals each +//! value with `foctet_core::storage::seal_storage_record` — binding the record's +//! namespace / id / version into the AEAD — before it ever reaches this Worker. +//! A compromised Worker or KV store therefore cannot read a value, nor swap one +//! record's ciphertext for another or roll back to a stale version without the +//! client's open failing. + +use worker::{Context, Env, Method, Request, Response, Result, event}; + +#[event(fetch)] +pub async fn fetch(mut req: Request, env: Env, _ctx: Context) -> Result { + let id = match req.path().strip_prefix("/vault/") { + Some(id) if !id.is_empty() => id.to_string(), + _ => return Response::error("Not Found", 404), + }; + + let kv = env.kv("VAULT_KV")?; + match req.method() { + Method::Put => { + // Store opaque ciphertext; the Worker cannot read it. + let bytes = req.bytes().await?; + kv.put_bytes(&id, &bytes)?.execute().await?; + Ok(Response::empty()?.with_status(204)) + } + Method::Get => match kv.get(&id).bytes().await? { + Some(bytes) => { + let mut response = Response::from_bytes(bytes)?; + response + .headers_mut() + .set("content-type", "application/foctet")?; + Ok(response) + } + None => Response::error("Not Found", 404), + }, + Method::Delete => { + kv.delete(&id).await?; + Ok(Response::empty()?.with_status(204)) + } + _ => Response::error("Method Not Allowed", 405), + } +} diff --git a/foctet-http/examples/workers-kv-vault/wrangler.toml b/foctet-http/examples/workers-kv-vault/wrangler.toml new file mode 100644 index 0000000..52ac94b --- /dev/null +++ b/foctet-http/examples/workers-kv-vault/wrangler.toml @@ -0,0 +1,13 @@ +name = "foctet-http-workers-kv-vault" +main = "build/worker/shim.mjs" +compatibility_date = "2026-03-08" + +# KV namespace holding opaque `application/foctet` blobs. `wrangler dev` +# simulates this locally; for `wrangler deploy`, create a namespace with +# `npx wrangler kv namespace create VAULT_KV` and paste the returned id below. +[[kv_namespaces]] +binding = "VAULT_KV" +id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" + +[build] +command = "cargo install -q worker-build@^0.7 && worker-build --release" diff --git a/foctet-http/examples/workers_echo_client.rs b/foctet-http/examples/workers_echo_client.rs index 9d2dc59..ab41cb7 100644 --- a/foctet-http/examples/workers_echo_client.rs +++ b/foctet-http/examples/workers_echo_client.rs @@ -1,57 +1,112 @@ use foctet_http::{ - HttpOpenOptions, HttpOpener, HttpSealOptions, HttpSealer, + ContextBinding, ContextCarrier, DEFAULT_CONTEXT_TTL_SECS, HttpOpenOptions, HttpOpener, + HttpSealOptions, HttpSealer, http::{self}, }; -use reqwest::Client; +use reqwest::{Client, StatusCode}; use x25519_dalek::{PublicKey, StaticSecret}; -const SERVER_SECRET_KEY: [u8; 32] = [0x11; 32]; +// Server key generations. The Worker's opener keyring holds v2 (current) and v1 +// (retiring); `retired` is a key the Worker does not hold, used to exercise +// failure handling. +const SERVER_SECRET_KEY_V1: [u8; 32] = [0x11; 32]; +const SERVER_SECRET_KEY_V2: [u8; 32] = [0x33; 32]; +const SERVER_SECRET_KEY_RETIRED: [u8; 32] = [0x44; 32]; const CLIENT_SECRET_KEY: [u8; 32] = [0x22; 32]; -const WORKERS_URL: &str = "http://127.0.0.1:8787/foctet"; +const DEFAULT_WORKERS_URL: &str = "http://127.0.0.1:8787/foctet"; + +/// Which server key the client seals the request to, selected by the +/// `SERVER_KEY_VERSION` environment variable. +struct ServerKeyChoice { + public_key: [u8; 32], + key_id: &'static [u8], + /// Whether the Worker is expected to accept (open) this key. + accepted: bool, +} + +fn select_server_key() -> ServerKeyChoice { + match std::env::var("SERVER_KEY_VERSION").as_deref() { + // Retiring key: still in the Worker's keyring during the overlap window. + Ok("v1") => ServerKeyChoice { + public_key: demo_public_key(SERVER_SECRET_KEY_V1), + key_id: b"server-v1", + accepted: true, + }, + // A key the Worker does not hold: authentication fails -> 401. + Ok("retired") => ServerKeyChoice { + public_key: demo_public_key(SERVER_SECRET_KEY_RETIRED), + key_id: b"server-retired", + accepted: false, + }, + // Current key (default). + _ => ServerKeyChoice { + public_key: demo_public_key(SERVER_SECRET_KEY_V2), + key_id: b"server-v2", + accepted: true, + }, + } +} #[tokio::main] async fn main() { + // Override with a deployed Worker URL to run against a real environment, + // e.g. WORKERS_URL=https://..workers.dev/foctet + let workers_url = + std::env::var("WORKERS_URL").unwrap_or_else(|_| DEFAULT_WORKERS_URL.to_string()); + let server_key = select_server_key(); + println!( + "sealing to server kid={} (expected accepted={})", + String::from_utf8_lossy(server_key.key_id), + server_key.accepted + ); + let client = Client::new(); let sealer = HttpSealer::new(HttpSealOptions::new( - demo_public_key(SERVER_SECRET_KEY), - b"demo-server-kid", + server_key.public_key, + server_key.key_id, )); let opener = HttpOpener::new(HttpOpenOptions::new(CLIENT_SECRET_KEY)); let plaintext_request = b"hello workers".to_vec(); + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock before Unix epoch") + .as_secs(); let encrypted_request = sealer - .seal_request( + .seal_request_with_context( http::Request::builder() .method("POST") - .uri(WORKERS_URL) + .uri(workers_url.as_str()) .body(plaintext_request) .expect("build request"), + ContextCarrier::generate(now_secs, DEFAULT_CONTEXT_TTL_SECS), + ContextBinding::default(), ) .expect("seal request"); - let mut request_builder = client.post(WORKERS_URL); - for (name, value) in encrypted_request.headers() { - request_builder = request_builder.header(name, value); - } - - let response = request_builder - .body(encrypted_request.body().clone()) - .send() - .await - .expect("send request"); - + let response = send(&client, workers_url.as_str(), &encrypted_request).await; let status = response.status(); let version = response.version(); let headers = response.headers().clone(); let body = response.bytes().await.expect("read response body").to_vec(); - let mut response_builder = http::Response::builder().status(status); - response_builder = response_builder.version(version); + if !server_key.accepted { + // Failure handling: a request sealed to a key the Worker does not hold + // fails authentication (before the replay store) and is answered 401. + assert_eq!(status, StatusCode::UNAUTHORIZED, "{body:?}"); + println!("status: {status} (rejected as expected)"); + let replay = send(&client, workers_url.as_str(), &encrypted_request).await; + // Authentication runs before the replay store, so a rejected request + // never consumes a replay slot: the retry is still 401, not 409. + assert_eq!(replay.status(), StatusCode::UNAUTHORIZED); + println!("replay status: {} (still rejected)", replay.status()); + return; + } + let mut response_builder = http::Response::builder().status(status).version(version); for (name, value) in &headers { response_builder = response_builder.header(name, value); } - let decrypted_response = opener .open_response(response_builder.body(body).expect("build response")) .expect("open response"); @@ -61,6 +116,25 @@ async fn main() { "plaintext body: {}", String::from_utf8_lossy(decrypted_response.body()) ); + + let replay = send(&client, workers_url.as_str(), &encrypted_request).await; + let replay_status = replay.status(); + let replay_body = replay.bytes().await.expect("read replay response"); + assert_eq!(replay_status, StatusCode::CONFLICT, "{replay_body:?}"); + println!("replay status: {replay_status}"); +} + +/// Sends a sealed request, copying its headers and body onto a fresh POST. +async fn send(client: &Client, url: &str, request: &http::Request>) -> reqwest::Response { + let mut builder = client.post(url); + for (name, value) in request.headers() { + builder = builder.header(name, value); + } + builder + .body(request.body().clone()) + .send() + .await + .expect("send request") } fn demo_public_key(secret_key: [u8; 32]) -> [u8; 32] { diff --git a/foctet-http/examples/workers_kv_vault_client.rs b/foctet-http/examples/workers_kv_vault_client.rs new file mode 100644 index 0000000..4435520 --- /dev/null +++ b/foctet-http/examples/workers_kv_vault_client.rs @@ -0,0 +1,72 @@ +// Zero-knowledge KV vault client. +// +// The client seals each vault item with `foctet_core::storage`, binding the +// record's namespace / id / version into the AEAD, then stores the opaque blob +// in a blind Cloudflare Workers + KV backend (see `examples/workers-kv-vault`). +// The Worker holds no key: confidentiality and record integrity live entirely +// on the client. Transport is plain HTTP here because the payload is already +// end-to-end encrypted; add TLS in production. + +use foctet_core::{StorageRecord, open_storage_record, seal_storage_record}; +use reqwest::{Client, StatusCode}; +use x25519_dalek::{PublicKey, StaticSecret}; + +// Demo account key. In a real client this is derived from the user's master +// password (e.g. Argon2id) and never leaves the device. +const ACCOUNT_SECRET_KEY: [u8; 32] = [0x55; 32]; +const ACCOUNT_KID: &[u8] = b"account-v1"; +const NAMESPACE: &[u8] = b"vault-items"; +const DEFAULT_BASE_URL: &str = "http://127.0.0.1:8787"; + +#[tokio::main] +async fn main() { + // Override with a deployed Worker URL, e.g. + // VAULT_URL=https://..workers.dev + let base_url = std::env::var("VAULT_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()); + let client = Client::new(); + let account_public = PublicKey::from(&StaticSecret::from(ACCOUNT_SECRET_KEY)).to_bytes(); + + let record_id = "login-github"; + let version = 1u64; + let record = StorageRecord::new(NAMESPACE, record_id.as_bytes(), version); + let secret = b"github password: correct horse battery staple"; + let url = format!("{base_url}/vault/{record_id}"); + + // 1. Seal client-side and upload the opaque blob. The Worker never sees a key. + let sealed = seal_storage_record(secret, account_public, ACCOUNT_KID, record).expect("seal"); + let put = client + .put(&url) + .body(sealed.clone()) + .send() + .await + .expect("put request"); + assert_eq!(put.status(), StatusCode::NO_CONTENT, "put failed"); + println!("stored {} ciphertext bytes to {url}", sealed.len()); + + // 2. Fetch it back and open with the matching descriptor. + let got = client.get(&url).send().await.expect("get request"); + assert_eq!(got.status(), StatusCode::OK, "get failed"); + let blob = got.bytes().await.expect("read body").to_vec(); + let opened = open_storage_record(&blob, ACCOUNT_SECRET_KEY, record).expect("open"); + assert_eq!(opened, secret); + println!("opened: {}", String::from_utf8_lossy(&opened)); + + // 3. Substitution defense: the same blob must not open under a different + // record descriptor, so a malicious store cannot answer a query for one + // record with another record's ciphertext. + let wrong_record = StorageRecord::new(NAMESPACE, b"login-elsewhere", version); + if open_storage_record(&blob, ACCOUNT_SECRET_KEY, wrong_record).is_ok() { + println!("UNEXPECTED: blob opened under the wrong record descriptor"); + std::process::exit(1); + } + println!("substitution correctly rejected (wrong record id fails to open)"); + + // 4. Rollback defense: the stale blob must not open under a newer expected + // version, so a store cannot serve an old ciphertext undetected. + let newer_version = StorageRecord::new(NAMESPACE, record_id.as_bytes(), version + 1); + if open_storage_record(&blob, ACCOUNT_SECRET_KEY, newer_version).is_ok() { + println!("UNEXPECTED: stale blob opened under a newer version"); + std::process::exit(1); + } + println!("rollback correctly rejected (stale version fails to open)"); +} diff --git a/foctet-http/src/axum.rs b/foctet-http/src/axum.rs index a26782c..65d4e41 100644 --- a/foctet-http/src/axum.rs +++ b/foctet-http/src/axum.rs @@ -2,13 +2,45 @@ //! //! These adapters preserve the surrounding HTTP request and response metadata. //! Only the body bytes are protected by the Foctet envelope. +//! +//! # Body limits and backpressure +//! +//! Every opener bounds the request body (`max_body_bytes`) **before** +//! decryption, so a hostile client cannot make the server buffer or decrypt +//! an unbounded body. Recommended values: +//! +//! - **One-shot envelopes** (`open_request_with_context`): size for your +//! actual payloads, not your tolerance — typical API bodies fit in +//! **1–4 MiB**; treat ≥ 16 MiB as a signal to switch to the streaming path. +//! The whole ciphertext is buffered in memory, so the worst-case memory per +//! in-flight request is `max_body_bytes` × concurrency; set your HTTP +//! server's concurrency limit (e.g. `tower::limit::ConcurrencyLimitLayer`) +//! with that product in mind. +//! - **Streaming bodies** ([`open_request_stream`]): memory use is bounded by +//! the chunk size (default ≤ 64 KiB sealed) rather than the body size — +//! prefer it for uploads beyond a few MiB. Backpressure is natural: chunks +//! are decrypted only as your callback consumes them, so a slow consumer +//! slows the sender through the HTTP layer's own flow control. Tuning: the +//! sealer's chunk size trades per-chunk overhead (AEAD tag + frame header, +//! ~tens of bytes) against buffering granularity — **64 KiB–256 KiB** chunks +//! are a good default; below ~4 KiB the overhead dominates, above ~1 MiB you +//! lose backpressure granularity and hold larger buffers. +//! +//! Whichever path you use, reject early: the context (freshness, replay, +//! route binding) is verified from the headers/stream header **before** body +//! chunks are processed, so replayed or expired requests cost no decryption. use ::axum::body::{Body, to_bytes}; use ::axum::extract::Request as AxumRequest; use ::axum::response::Response as AxumResponse; +use foctet_core::BodyEnvelopeLimits; +use http_body_util::BodyExt; use thiserror::Error; -use crate::{HttpError, HttpOpenOptions, HttpOpener, HttpSealOptions, HttpSealer}; +use crate::{ + AsyncReplayStore, ContextBinding, ContextCarrier, HttpError, HttpOpenOptions, HttpOpener, + HttpRequestStreamReader, HttpSealOptions, HttpSealer, ReplayStore, +}; /// Error type for Axum adapter operations. #[derive(Debug, Error)] @@ -62,6 +94,16 @@ impl AxumOpener { } /// Opens an encrypted Axum request body into plaintext bytes. + /// + /// Body-only; no replay protection or HTTP-context binding. Prefer + /// [`AxumOpener::open_request_with_context`] for production. + #[deprecated( + since = "0.3.0", + note = "stateless full-request protection has no replay defense or HTTP-context \ + binding and is replayable by design; use open_request_with_context (or \ + open_request_with_async_store) with a ReplayStore for production" + )] + #[allow(deprecated)] pub async fn open_request( &self, request: AxumRequest, @@ -73,6 +115,57 @@ impl AxumOpener { let request = http::Request::from_parts(parts, body_bytes.to_vec()); self.opener.open_request(request).map_err(AxumError::Http) } + + /// Opens an encrypted Axum request, enforcing the bound HTTP protected + /// context, freshness, and single use against `store`. + /// + /// The request body is bounded by `max_body_bytes` before decryption, so + /// the library — not the application — caps memory use here. + pub async fn open_request_with_context( + &self, + request: AxumRequest, + store: &S, + now_secs: u64, + max_skew_secs: u64, + binding: ContextBinding, + ) -> Result>, AxumError> + where + S: ReplayStore + ?Sized, + { + let (parts, body) = request.into_parts(); + let body_bytes = to_bytes(body, self.max_body_bytes) + .await + .map_err(AxumError::BodyRead)?; + let request = http::Request::from_parts(parts, body_bytes.to_vec()); + self.opener + .open_request_with_context(request, store, now_secs, max_skew_secs, binding) + .map_err(AxumError::Http) + } + + /// Opens an encrypted Axum request using a durable [`AsyncReplayStore`] + /// (Redis, Cloudflare KV, a shared SQL table, …) for multi-instance + /// deployments. + pub async fn open_request_with_async_store( + &self, + request: AxumRequest, + store: &S, + now_secs: u64, + max_skew_secs: u64, + binding: ContextBinding, + ) -> Result>, AxumError> + where + S: AsyncReplayStore + ?Sized, + { + let (parts, body) = request.into_parts(); + let body_bytes = to_bytes(body, self.max_body_bytes) + .await + .map_err(AxumError::BodyRead)?; + let request = http::Request::from_parts(parts, body_bytes.to_vec()); + self.opener + .open_request_with_async_store(request, store, now_secs, max_skew_secs, binding) + .await + .map_err(AxumError::Http) + } } impl AxumSealer { @@ -101,9 +194,27 @@ impl AxumSealer { let encrypted = self.sealer.seal_response(response)?; Ok(http_response_vec_to_axum(encrypted)) } + + /// Seals a plaintext HTTP response with bound protected context and converts + /// it into an Axum response. + pub fn seal_response_with_context( + &self, + response: http::Response>, + carrier: ContextCarrier, + ) -> Result { + let encrypted = self.sealer.seal_response_with_context(response, carrier)?; + Ok(http_response_vec_to_axum(encrypted)) + } } /// Opens an encrypted Axum request body into plaintext bytes. +#[deprecated( + since = "0.3.0", + note = "stateless full-request protection has no replay defense or HTTP-context binding \ + and is replayable by design; use AxumOpener::open_request_with_context with a \ + ReplayStore for production" +)] +#[allow(deprecated)] pub async fn open_axum_request_body( request: AxumRequest, recipient_secret_key: [u8; 32], @@ -115,6 +226,13 @@ pub async fn open_axum_request_body( } /// Opens an encrypted Axum request body into plaintext bytes with explicit envelope limits. +#[deprecated( + since = "0.3.0", + note = "stateless full-request protection has no replay defense or HTTP-context binding \ + and is replayable by design; use AxumOpener::open_request_with_context with a \ + ReplayStore for production" +)] +#[allow(deprecated)] pub async fn open_axum_request_body_with_limits( request: AxumRequest, recipient_secret_key: [u8; 32], @@ -157,15 +275,232 @@ fn http_response_vec_to_axum(response: http::Response>) -> AxumResponse AxumResponse::from_parts(parts, Body::from(body)) } +impl ::axum::response::IntoResponse for AxumError { + /// Maps to a status code only; never includes the source error's detail + /// in the response body, so an opening/replay failure cannot leak + /// ciphertext, key, or internal-state information to the caller. + fn into_response(self) -> AxumResponse { + use ::axum::http::StatusCode; + let status = match &self { + AxumError::BodyRead(_) => StatusCode::BAD_REQUEST, + AxumError::Http(HttpError::MissingContentType | HttpError::InvalidContentType) => { + StatusCode::BAD_REQUEST + } + AxumError::Http( + HttpError::MissingContext(_) + | HttpError::InvalidContext(_) + | HttpError::ContextTimestampInFuture, + ) => StatusCode::BAD_REQUEST, + AxumError::Http(HttpError::ContextExpired) => StatusCode::UNAUTHORIZED, + AxumError::Http(HttpError::OpenFailed(_)) => StatusCode::UNAUTHORIZED, + // A truncated/cancelled streaming body is a malformed request. + AxumError::Http(HttpError::StreamIncomplete) => StatusCode::BAD_REQUEST, + AxumError::Http(HttpError::Replayed) => StatusCode::CONFLICT, + AxumError::Http(HttpError::SealFailed(_) | HttpError::ReplayStore(_)) => { + StatusCode::INTERNAL_SERVER_ERROR + } + }; + status.into_response() + } +} + +/// Application state required to use the [`ProtectedRequest`] extractor. +/// +/// Implement this on your Axum `State` type to wire up a ready-made, +/// `FromRequest`-based extractor that authenticates the protected context, +/// enforces single use against a replay store, and hands the handler a +/// decrypted `http::Request>` — without per-handler boilerplate. +/// +/// Scoped to the synchronous [`ReplayStore`] trait, not [`AsyncReplayStore`]: +/// axum's `FromRequest` requires the extraction future to be `Send`, but +/// `AsyncReplayStore`'s future is intentionally *not* required to be `Send` +/// (so it stays usable from `!Send` runtimes such as Cloudflare Workers), +/// so a durable/networked store (e.g. [`crate::RedisReplayStore`]) can't be +/// plugged into this extractor in general. Use +/// [`AxumOpener::open_request_with_async_store`] directly in the handler for +/// that case. +pub trait ProtectedHttpState: Send + Sync { + /// The anti-replay store backing this state. + type Store: ReplayStore + Send + Sync; + + /// Returns the opener used to authenticate and decrypt request bodies. + fn protected_opener(&self) -> &AxumOpener; + + /// Returns the anti-replay store consulted after authentication. + fn protected_replay_store(&self) -> &Self::Store; + + /// Returns the current time in Unix seconds, used for freshness checks. + fn protected_now_secs(&self) -> u64; + + /// Returns the tolerated clock skew, in seconds. Defaults to + /// [`crate::DEFAULT_MAX_CLOCK_SKEW_SECS`]. + fn protected_max_skew_secs(&self) -> u64 { + crate::DEFAULT_MAX_CLOCK_SKEW_SECS + } + + /// Returns the context-binding policy to enforce. Defaults to + /// [`ContextBinding::default`] (method/path/query/message-id/expiry, no + /// authority or extra headers). + fn protected_context_binding(&self) -> ContextBinding { + ContextBinding::default() + } +} + +/// Axum extractor that authenticates a context-bound, replay-protected +/// request body and yields the decrypted `http::Request>`. +/// +/// Requires the application's `State` to implement [`ProtectedHttpState`]: +/// +/// ```ignore +/// async fn handler(ProtectedRequest(request): ProtectedRequest) -> impl IntoResponse { +/// let plaintext = request.body(); +/// // ... +/// } +/// ``` +#[derive(Debug)] +pub struct ProtectedRequest(pub http::Request>); + +impl ::axum::extract::FromRequest for ProtectedRequest +where + S: ProtectedHttpState, +{ + type Rejection = AxumError; + + async fn from_request(req: AxumRequest, state: &S) -> Result { + let now = state.protected_now_secs(); + let opened = state + .protected_opener() + .open_request_with_context( + req, + state.protected_replay_store(), + now, + state.protected_max_skew_secs(), + state.protected_context_binding(), + ) + .await?; + Ok(ProtectedRequest(opened)) + } +} + +/// Opens a **streaming** Foctet request body, invoking `on_plaintext` for each +/// decrypted chunk as it arrives — without buffering the whole body. +/// +/// This is the turn-key Axum wiring for [`crate::HttpStreamSealer`]: it reads the +/// request body frame by frame, reassembles the Foctet stream frames, validates +/// the protected context's freshness and single use against `store` when the +/// stream header arrives, and yields plaintext chunks through the callback. It +/// fails with [`HttpError::StreamIncomplete`] (via [`AxumError::Http`]) if the +/// body ends before the authenticated final chunk, so a truncated or cancelled +/// upload is rejected. +#[allow(clippy::too_many_arguments)] +pub async fn open_request_stream( + request: AxumRequest, + recipient_secret_key: [u8; 32], + store: &S, + now_secs: u64, + max_skew_secs: u64, + binding: ContextBinding, + limits: &BodyEnvelopeLimits, + mut on_plaintext: F, +) -> Result<(), AxumError> +where + S: ReplayStore + ?Sized, + F: FnMut(&[u8]) -> Result<(), AxumError>, +{ + let (parts, mut body) = request.into_parts(); + let mut reader = HttpRequestStreamReader::new( + parts, + recipient_secret_key, + store, + now_secs, + max_skew_secs, + binding, + limits, + ); + + while let Some(frame) = body.frame().await { + let frame = frame.map_err(AxumError::BodyRead)?; + if let Ok(data) = frame.into_data() { + for plaintext in reader.push(&data)? { + on_plaintext(&plaintext)?; + } + } + } + + reader.finish()?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; - use crate::{BODY_ONLY_SCOPE, CONTENT_TYPE, HttpSealer, SCOPE_HEADER, raw::seal_http_request}; + #[allow(deprecated)] // seal_http_request is deprecated; used to build a test fixture + use crate::raw::seal_http_request; + use crate::{BODY_ONLY_SCOPE, CONTENT_TYPE, HttpSealer, SCOPE_HEADER}; use http::{Request, Response, StatusCode, Version, header}; use rand_core::OsRng; use x25519_dalek::{PublicKey, StaticSecret}; #[tokio::test] + async fn open_request_stream_decodes_a_streaming_upload() { + use crate::{HttpStreamSealer, InMemoryReplayStore}; + + let recipient_priv = StaticSecret::random_from_rng(OsRng); + let recipient_secret = recipient_priv.to_bytes(); + let recipient_pub = PublicKey::from(&recipient_priv).to_bytes(); + let limits = BodyEnvelopeLimits::default(); + let now = 1234; + + // Seal a streaming upload and lay it out as the request body. + let carrier = ContextCarrier::generate(now, 60); + let base = Request::builder() + .method("POST") + .uri("https://example.com/upload") + .body(()) + .expect("request"); + let (mut parts, _) = base.into_parts(); + let (mut sealer, header) = HttpStreamSealer::for_request( + &parts, + &carrier, + ContextBinding::default(), + recipient_pub, + b"kid", + &limits, + ) + .expect("sealer"); + carrier + .apply_to_headers(&mut parts.headers) + .expect("apply carrier"); + + let mut wire = header; + for part in [b"axum ".as_slice(), b"streaming ", b"upload"] { + let is_final = part == b"upload"; + wire.extend_from_slice(&sealer.seal_chunk(part, is_final).expect("seal")); + } + + let request = Request::from_parts(parts, Body::from(wire)); + let store = InMemoryReplayStore::new(); + let mut body = Vec::new(); + open_request_stream( + request, + recipient_secret, + &store, + now, + 5, + ContextBinding::default(), + &limits, + |plaintext| { + body.extend_from_slice(plaintext); + Ok(()) + }, + ) + .await + .expect("stream opened"); + assert_eq!(body, b"axum streaming upload"); + } + + #[tokio::test] + #[allow(deprecated)] // exercises the deprecated stateless request path on purpose async fn open_axum_request_body_roundtrip() { let recipient_priv = StaticSecret::random_from_rng(OsRng); let recipient_pub = PublicKey::from(&recipient_priv).to_bytes(); @@ -220,10 +555,128 @@ mod tests { assert_eq!(sealed.headers()[SCOPE_HEADER], BODY_ONLY_SCOPE); } + #[tokio::test] + async fn open_axum_request_with_context_enforces_replay() { + use crate::{ContextBinding, ContextCarrier, InMemoryReplayStore}; + + let recipient_priv = StaticSecret::random_from_rng(OsRng); + let recipient_pub = PublicKey::from(&recipient_priv).to_bytes(); + + let sealer = HttpSealer::new(HttpSealOptions::new(recipient_pub, b"axum-kid")); + let opener = AxumOpener::new(HttpOpenOptions::new(recipient_priv.to_bytes()), 1024 * 1024); + let store = InMemoryReplayStore::new(); + let binding = ContextBinding::default(); + let now = 5_000u64; + + let plain = Request::builder() + .method("POST") + .uri("https://example.com/axum/pay") + .body(b"axum charge".to_vec()) + .expect("request"); + let carrier = ContextCarrier::generate(now, 300); + let sealed = sealer + .seal_request_with_context(plain, carrier, binding) + .expect("seal"); + + let make_axum = |req: &Request>| { + let mut builder = Request::builder() + .method(req.method().clone()) + .uri(req.uri().clone()); + for (name, value) in req.headers() { + builder = builder.header(name, value); + } + let r = builder.body(req.body().clone()).expect("clone"); + let (parts, body) = r.into_parts(); + AxumRequest::from_parts(parts, Body::from(body)) + }; + + let opened = opener + .open_request_with_context(make_axum(&sealed), &store, now, 30, binding) + .await + .expect("first open"); + assert_eq!(opened.body(), b"axum charge"); + + let err = opener + .open_request_with_context(make_axum(&sealed), &store, now, 30, binding) + .await + .expect_err("replay rejected"); + assert!(matches!(err, AxumError::Http(HttpError::Replayed))); + } + #[test] fn sealer_wrapper_uses_http_core() { let sealer = AxumSealer::from_http_sealer(HttpSealer::new(HttpSealOptions::new([1u8; 32], b"kid"))); assert_eq!(sealer.sealer().options().recipient_key_id(), b"kid"); } + + struct TestAppState { + opener: AxumOpener, + store: crate::InMemoryReplayStore, + now: std::sync::atomic::AtomicU64, + } + + impl ProtectedHttpState for TestAppState { + type Store = crate::InMemoryReplayStore; + + fn protected_opener(&self) -> &AxumOpener { + &self.opener + } + + fn protected_replay_store(&self) -> &Self::Store { + &self.store + } + + fn protected_now_secs(&self) -> u64 { + self.now.load(std::sync::atomic::Ordering::Relaxed) + } + } + + #[tokio::test] + async fn protected_request_extractor_authenticates_and_rejects_replay() { + use ::axum::extract::FromRequest; + + let recipient_priv = StaticSecret::random_from_rng(OsRng); + let recipient_pub = PublicKey::from(&recipient_priv).to_bytes(); + let now = 1_000_000u64; + + let state = TestAppState { + opener: AxumOpener::new(HttpOpenOptions::new(recipient_priv.to_bytes()), 1024 * 1024), + store: crate::InMemoryReplayStore::new(), + now: std::sync::atomic::AtomicU64::new(now), + }; + + let sealer = HttpSealer::new(HttpSealOptions::new(recipient_pub, b"kid")); + let plain = Request::builder() + .method("POST") + .uri("https://example.com/extractor") + .body(b"via extractor".to_vec()) + .expect("request"); + let carrier = ContextCarrier::generate(now, 300); + let sealed = sealer + .seal_request_with_context(plain, carrier, ContextBinding::default()) + .expect("seal"); + + let make_axum = |req: &Request>| { + let mut builder = Request::builder() + .method(req.method().clone()) + .uri(req.uri().clone()); + for (name, value) in req.headers() { + builder = builder.header(name, value); + } + let r = builder.body(req.body().clone()).expect("clone"); + let (parts, body) = r.into_parts(); + AxumRequest::from_parts(parts, Body::from(body)) + }; + + let ProtectedRequest(opened) = ProtectedRequest::from_request(make_axum(&sealed), &state) + .await + .expect("extractor authenticates first delivery"); + assert_eq!(opened.body(), b"via extractor"); + + let err = ProtectedRequest::from_request(make_axum(&sealed), &state) + .await + .expect_err("extractor must reject replay"); + assert!(matches!(err, AxumError::Http(HttpError::Replayed))); + } } diff --git a/foctet-http/src/config.rs b/foctet-http/src/config.rs index 501b8d4..a192dcc 100644 --- a/foctet-http/src/config.rs +++ b/foctet-http/src/config.rs @@ -1,4 +1,5 @@ use foctet_core::BodyEnvelopeLimits; +use zeroize::Zeroizing; /// Shared HTTP behavior configuration for high-level opener/sealer helpers. #[derive(Clone, Debug, Eq, PartialEq)] @@ -86,30 +87,100 @@ impl HttpSealOptions { } /// High-level options used to construct an [`crate::HttpOpener`]. -#[derive(Clone, Debug, Eq, PartialEq)] +/// +/// Holds an ordered, non-empty **keyring** of recipient X25519 secret keys. +/// Opening tries each key in order and succeeds on the first that +/// authenticates, which is what lets a recipient accept both the current and a +/// previous key during a rotation overlap window (see +/// [`HttpOpenOptions::with_recipient_key`]). Each key is stored in a zeroizing +/// wrapper (wiped on drop), is **not** printed by [`Debug`] (which redacts the +/// key material), and is only retrievable through the explicitly named +/// [`HttpOpenOptions::expose_recipient_secret_key`] / +/// [`HttpOpenOptions::expose_recipient_secret_keys`]. +#[derive(Clone)] pub struct HttpOpenOptions { - recipient_secret_key: [u8; 32], + // Invariant: always non-empty. Keys are tried in order; index 0 is the + // primary key returned by `expose_recipient_secret_key`. + recipient_secret_keys: Vec>, limits: Option, } +impl core::fmt::Debug for HttpOpenOptions { + /// Redacts the recipient secret keys so they cannot leak into logs. + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("HttpOpenOptions") + .field("recipient_secret_keys", &"") + .field("recipient_key_count", &self.recipient_secret_keys.len()) + .field("limits", &self.limits) + .finish() + } +} + impl HttpOpenOptions { - /// Creates opening options for a recipient secret key. + /// Creates opening options for a single recipient secret key. pub fn new(recipient_secret_key: [u8; 32]) -> Self { Self { - recipient_secret_key, + recipient_secret_keys: vec![Zeroizing::new(recipient_secret_key)], limits: None, } } + /// Builds opening options from an ordered set of recipient secret keys. + /// + /// Keys are tried in iteration order. Returns `None` if the iterator is + /// empty, since an opener with no key can never authenticate anything. + pub fn from_recipient_keys(keys: impl IntoIterator) -> Option { + let recipient_secret_keys: Vec> = + keys.into_iter().map(Zeroizing::new).collect(); + if recipient_secret_keys.is_empty() { + return None; + } + Some(Self { + recipient_secret_keys, + limits: None, + }) + } + + /// Appends an additional recipient secret key to the keyring. + /// + /// During a key-rotation overlap window, add the retiring key(s) so the + /// opener accepts envelopes sealed to either the current or a previous + /// recipient key. Keys are tried in insertion order, so place the key that + /// serves the most traffic first. Trial decryption is safe: a non-matching + /// key fails authentication and, on the context-bound path, is rejected + /// before the replay store is consulted, so it cannot consume a replay slot. + #[must_use] + pub fn with_recipient_key(mut self, recipient_secret_key: [u8; 32]) -> Self { + self.recipient_secret_keys + .push(Zeroizing::new(recipient_secret_key)); + self + } + /// Applies explicit body envelope limits. pub fn with_limits(mut self, limits: BodyEnvelopeLimits) -> Self { self.limits = Some(limits); self } - /// Returns the recipient secret key. - pub fn recipient_secret_key(&self) -> [u8; 32] { - self.recipient_secret_key + /// Exposes a zeroizing copy of the primary (first) recipient secret key. + /// + /// Named with an `expose_` prefix so secret extraction is greppable and + /// obvious at the call site. The returned [`Zeroizing`] wipes its copy on + /// drop. + #[must_use] + pub fn expose_recipient_secret_key(&self) -> Zeroizing<[u8; 32]> { + self.recipient_secret_keys[0].clone() + } + + /// Exposes zeroizing copies of every recipient secret key, in try order. + #[must_use] + pub fn expose_recipient_secret_keys(&self) -> Vec> { + self.recipient_secret_keys.clone() + } + + /// Returns the number of recipient keys in the keyring (always at least 1). + pub fn recipient_key_count(&self) -> usize { + self.recipient_secret_keys.len() } /// Returns explicit opening limits, if configured. @@ -117,3 +188,64 @@ impl HttpOpenOptions { self.limits.as_ref() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_options_debug_redacts_secret_key() { + let options = HttpOpenOptions::new([0x4D; 32]); + let rendered = format!("{options:?}"); + assert!(rendered.contains("")); + let leaked = format!("{:?}", [0x4D_u8; 32]); + assert!( + !rendered.contains(&leaked), + "recipient secret leaked into Debug output: {rendered}" + ); + } + + #[test] + fn open_options_expose_round_trips() { + let secret = [0x7E; 32]; + let options = HttpOpenOptions::new(secret); + assert_eq!(*options.expose_recipient_secret_key(), secret); + assert_eq!(options.recipient_key_count(), 1); + } + + #[test] + fn keyring_tracks_all_keys_in_insertion_order() { + let options = HttpOpenOptions::new([0x01; 32]).with_recipient_key([0x02; 32]); + assert_eq!(options.recipient_key_count(), 2); + // The primary key is the first one; try order is insertion order. + assert_eq!(*options.expose_recipient_secret_key(), [0x01; 32]); + let keys = options.expose_recipient_secret_keys(); + assert_eq!(*keys[0], [0x01; 32]); + assert_eq!(*keys[1], [0x02; 32]); + } + + #[test] + fn from_recipient_keys_rejects_empty_keyring() { + assert!(HttpOpenOptions::from_recipient_keys(Vec::<[u8; 32]>::new()).is_none()); + let options = + HttpOpenOptions::from_recipient_keys([[0x03; 32], [0x04; 32]]).expect("non-empty"); + assert_eq!(options.recipient_key_count(), 2); + } + + #[test] + fn open_options_debug_redacts_every_key_but_shows_count() { + let options = HttpOpenOptions::new([0x4D; 32]).with_recipient_key([0x5E; 32]); + let rendered = format!("{options:?}"); + assert!(rendered.contains("")); + assert!(rendered.contains("recipient_key_count")); + for leaked in [ + format!("{:?}", [0x4D_u8; 32]), + format!("{:?}", [0x5E_u8; 32]), + ] { + assert!( + !rendered.contains(&leaked), + "a recipient secret leaked into Debug output: {rendered}" + ); + } + } +} diff --git a/foctet-http/src/context.rs b/foctet-http/src/context.rs new file mode 100644 index 0000000..a406ae0 --- /dev/null +++ b/foctet-http/src/context.rs @@ -0,0 +1,629 @@ +//! Versioned HTTP protected-context schema. +//! +//! A body envelope by itself is replayable. This module binds selected HTTP +//! request/response metadata into AEAD associated data so a captured envelope +//! cannot be replayed onto a different route or operation, and pairs that with +//! a [`crate::ReplayStore`] for single-use enforcement. +//! +//! The authenticated context includes protocol/direction, route metadata +//! (method/path/query and optionally authority), freshness fields, and sender +//! carrier values such as message ID and expiry. Carrier values travel in +//! `x-foctet-*` headers; route fields are recomputed from the received HTTP +//! message. +//! +//! Time is supplied by the caller (`now_secs`), so this module works on native +//! targets and on environments such as Cloudflare Workers. + +use http::HeaderMap; +use http::header::{HeaderName, HeaderValue}; + +use crate::HttpError; + +/// Domain-separation label and version for the protected-context encoding. +pub const CONTEXT_DOMAIN_V1: &[u8] = b"foctet-http-ctx-v1"; + +/// Header carrying the hex-encoded 16-byte message ID. +pub const MSG_ID_HEADER: &str = "x-foctet-msg-id"; +/// Header carrying the decimal Unix-seconds timestamp. +pub const TIMESTAMP_HEADER: &str = "x-foctet-timestamp"; +/// Header carrying the decimal Unix-seconds absolute expiry. +pub const EXPIRY_HEADER: &str = "x-foctet-expiry"; +/// Header carrying an optional application idempotency key. +pub const IDEMPOTENCY_HEADER: &str = "x-foctet-idempotency-key"; +/// Response-only header carrying the hex-encoded request message ID answered. +pub const REQUEST_MSG_ID_HEADER: &str = "x-foctet-req-msg-id"; + +/// Length of the random message ID in bytes. +pub const MESSAGE_ID_LEN: usize = 16; + +/// Suggested default protected-context time-to-live, in seconds. +pub const DEFAULT_CONTEXT_TTL_SECS: u64 = 300; + +/// Suggested default tolerance for clock skew between peers, in seconds. +pub const DEFAULT_MAX_CLOCK_SKEW_SECS: u64 = 30; + +/// Direction discriminator bound into the associated data. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ContextDirection { + /// Client-to-server request. + Request, + /// Server-to-client response. + Response, +} + +impl ContextDirection { + fn tag(self) -> u8 { + match self { + ContextDirection::Request => 1, + ContextDirection::Response => 2, + } + } +} + +/// Controls which optional, mismatch-prone fields are bound. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ContextBinding { + /// Bind the request/response authority (host\[:port\]) into the context. + /// + /// Off by default: the authority a client places in the request URI and the + /// authority a server reconstructs (from the `Host` header) frequently + /// differ, which would cause spurious authentication failures. Enable only + /// when both peers are guaranteed to derive byte-identical, normalized + /// authorities. Method, path, query, message ID, and expiry already prevent + /// route substitution and replay without it. + pub bind_authority: bool, + /// Header names to additionally bind into the request context, in this + /// order. Each header's presence and raw value bytes are authenticated, + /// so adding, removing, or modifying a bound header fails authentication. + /// Only the header's *first* value is bound (multi-value headers are not + /// disambiguated). Empty by default — preserves the exact associated-data + /// bytes of a [`ContextBinding`] that doesn't bind any headers, so this is + /// purely opt-in. Currently applies to **requests only**; see + /// [`ProtectedContext::for_request`]. + pub bound_headers: &'static [&'static str], +} + +impl ContextBinding { + /// Returns the default binding policy. + pub fn new() -> Self { + Self::default() + } + + /// Sets whether the authority is bound. + /// + /// # Authority normalization (read before enabling) + /// + /// The authority is bound as **raw bytes**: `example.com`, + /// `EXAMPLE.COM`, `example.com:443`, and a punycoded form are four + /// different values, and any client/server disagreement fails + /// authentication. HTTP infrastructure routinely rewrites this value + /// (proxies adding default ports, clients title-casing `Host`, HTTP/2 + /// `:authority` vs HTTP/1.1 `Host` differences), so before enabling, + /// both peers MUST derive the authority through the same normalization: + /// + /// 1. lowercase the host, + /// 2. IDNA/punycode-encode it (bind the `xn--…` form, never the Unicode + /// form), + /// 3. strip the port when it is the scheme default (`:443` for https, + /// `:80` for http) and keep it otherwise, + /// 4. on the server, reconstruct from the same source the client bound + /// (the request-target/`:authority` when present, else `Host`) — + /// **before** any reverse-proxy rewriting, or configure the expected + /// external authority statically instead of trusting headers. + /// + /// If you cannot guarantee all four, leave this off: method, path, + /// query, message ID, and expiry already prevent route substitution and + /// replay, and an unverifiable authority binding only produces spurious + /// failures (or, worse, pressure to disable protection entirely). + pub fn with_authority(mut self, bind_authority: bool) -> Self { + self.bind_authority = bind_authority; + self + } + + /// Sets the additional header names to bind into the request context. + pub fn with_bound_headers(mut self, headers: &'static [&'static str]) -> Self { + self.bound_headers = headers; + self + } +} + +/// Sender-chosen values that travel in `x-foctet-*` headers. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContextCarrier { + /// Unique per-message identifier (anti-replay key). + pub message_id: [u8; MESSAGE_ID_LEN], + /// Unix-seconds timestamp the message was sealed. + pub timestamp_secs: u64, + /// Absolute Unix-seconds time after which the message must be rejected. + pub expiry_secs: u64, + /// Optional application idempotency key. + pub idempotency_key: Option, + /// For responses, the request message ID being answered. + pub request_message_id: Option<[u8; MESSAGE_ID_LEN]>, +} + +impl ContextCarrier { + /// Builds a fresh carrier with a random message ID and `now + ttl` expiry. + pub fn generate(now_secs: u64, ttl_secs: u64) -> Self { + let salt = foctet_core::random_session_salt(); + let mut message_id = [0u8; MESSAGE_ID_LEN]; + message_id.copy_from_slice(&salt[..MESSAGE_ID_LEN]); + Self { + message_id, + timestamp_secs: now_secs, + expiry_secs: now_secs.saturating_add(ttl_secs), + idempotency_key: None, + request_message_id: None, + } + } + + /// Sets the optional idempotency key. + pub fn with_idempotency_key(mut self, key: impl Into) -> Self { + self.idempotency_key = Some(key.into()); + self + } + + /// Sets the request message ID this response answers. + pub fn answering(mut self, request_message_id: [u8; MESSAGE_ID_LEN]) -> Self { + self.request_message_id = Some(request_message_id); + self + } + + /// Writes the carrier values into HTTP headers. + pub fn apply_to_headers(&self, headers: &mut HeaderMap) -> Result<(), HttpError> { + insert_str(headers, MSG_ID_HEADER, &to_hex(&self.message_id))?; + insert_str(headers, TIMESTAMP_HEADER, &self.timestamp_secs.to_string())?; + insert_str(headers, EXPIRY_HEADER, &self.expiry_secs.to_string())?; + if let Some(idem) = &self.idempotency_key { + insert_str(headers, IDEMPOTENCY_HEADER, idem)?; + } + if let Some(req_id) = &self.request_message_id { + insert_str(headers, REQUEST_MSG_ID_HEADER, &to_hex(req_id))?; + } + Ok(()) + } + + /// Parses the carrier values from HTTP headers. + pub fn from_headers(headers: &HeaderMap) -> Result { + let message_id = parse_hex_id(get_str(headers, MSG_ID_HEADER)?) + .ok_or(HttpError::InvalidContext("message id"))?; + let timestamp_secs = get_str(headers, TIMESTAMP_HEADER)? + .parse::() + .map_err(|_| HttpError::InvalidContext("timestamp"))?; + let expiry_secs = get_str(headers, EXPIRY_HEADER)? + .parse::() + .map_err(|_| HttpError::InvalidContext("expiry"))?; + + let idempotency_key = match headers.get(IDEMPOTENCY_HEADER) { + Some(value) => Some( + value + .to_str() + .map_err(|_| HttpError::InvalidContext("idempotency key"))? + .to_string(), + ), + None => None, + }; + + let request_message_id = match headers.get(REQUEST_MSG_ID_HEADER) { + Some(value) => { + let s = value + .to_str() + .map_err(|_| HttpError::InvalidContext("request message id"))?; + Some(parse_hex_id(s).ok_or(HttpError::InvalidContext("request message id"))?) + } + None => None, + }; + + Ok(Self { + message_id, + timestamp_secs, + expiry_secs, + idempotency_key, + request_message_id, + }) + } +} + +/// Canonical HTTP protected context bound into a body envelope's associated data. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProtectedContext { + direction: ContextDirection, + method: Option, + authority: Option, + path: String, + query: Option, + status: Option, + carrier: ContextCarrier, + /// `(header name, value bytes)` for each configured bound header name + /// that was present on the message; absent headers are still bound (as a + /// "not present" marker) so a header's removal also fails authentication. + bound_headers: Vec<(String, Option>)>, +} + +impl ProtectedContext { + /// Builds the request context from `http` request parts and a carrier. + pub fn for_request( + parts: &http::request::Parts, + carrier: ContextCarrier, + binding: ContextBinding, + ) -> Self { + Self::for_request_with_header_binding( + parts, + carrier, + binding.bind_authority, + binding.bound_headers, + ) + } + + /// Builds the request context with a dynamically supplied header-binding + /// policy. + /// + /// This is equivalent to [`ProtectedContext::for_request`] but accepts + /// runtime-owned header names, which is useful for host-language bindings + /// such as the WebAssembly / TypeScript SDK. Header names are bound in the + /// order supplied by the caller. + pub fn for_request_with_header_binding( + parts: &http::request::Parts, + carrier: ContextCarrier, + bind_authority: bool, + bound_header_names: impl IntoIterator>, + ) -> Self { + let authority = if bind_authority { + request_authority(parts) + } else { + None + }; + let bound_headers = bound_header_names + .into_iter() + .map(|name| { + let name = name.as_ref().to_string(); + let value = parts + .headers + .get(name.as_str()) + .map(|v| v.as_bytes().to_vec()); + (name, value) + }) + .collect(); + Self { + direction: ContextDirection::Request, + method: Some(parts.method.as_str().to_ascii_uppercase()), + authority, + path: parts.uri.path().to_string(), + query: parts.uri.query().map(|q| q.to_string()), + status: None, + carrier, + bound_headers, + } + } + + /// Builds the response context from `http` response parts and a carrier. + /// + /// The request path/query bound on the request side are not visible to the + /// response side, so response binding authenticates direction, status, + /// timestamp/expiry, the response message ID, and (when set) the request + /// message ID being answered. + pub fn for_response(parts: &http::response::Parts, carrier: ContextCarrier) -> Self { + Self { + direction: ContextDirection::Response, + method: None, + authority: None, + path: String::new(), + query: None, + status: Some(parts.status.as_u16()), + carrier, + bound_headers: Vec::new(), + } + } + + /// Returns the carrier values for this context. + pub fn carrier(&self) -> &ContextCarrier { + &self.carrier + } + + /// Validates the timestamp/expiry against `now_secs` with `max_skew_secs` + /// tolerance for clock differences. + pub fn validate_freshness(&self, now_secs: u64, max_skew_secs: u64) -> Result<(), HttpError> { + if self.carrier.expiry_secs <= self.carrier.timestamp_secs { + return Err(HttpError::InvalidContext("expiry not after timestamp")); + } + if self.carrier.timestamp_secs > now_secs.saturating_add(max_skew_secs) { + return Err(HttpError::ContextTimestampInFuture); + } + if now_secs > self.carrier.expiry_secs.saturating_add(max_skew_secs) { + return Err(HttpError::ContextExpired); + } + Ok(()) + } + + /// Produces the canonical associated-data bytes for this context. + pub fn to_aad_bytes(&self) -> Vec { + let mut out = Vec::with_capacity(128); + out.extend_from_slice(CONTEXT_DOMAIN_V1); + out.push(self.direction.tag()); + + push_field( + &mut out, + b'M', + self.method.as_deref().unwrap_or("").as_bytes(), + ); + push_optional(&mut out, b'A', self.authority.as_deref().map(str::as_bytes)); + push_field(&mut out, b'P', self.path.as_bytes()); + push_optional(&mut out, b'Q', self.query.as_deref().map(str::as_bytes)); + + match self.status { + Some(status) => { + out.push(1); + out.extend_from_slice(&status.to_be_bytes()); + } + None => out.push(0), + } + + out.extend_from_slice(&self.carrier.timestamp_secs.to_be_bytes()); + out.extend_from_slice(&self.carrier.expiry_secs.to_be_bytes()); + out.extend_from_slice(&self.carrier.message_id); + + match &self.carrier.request_message_id { + Some(id) => { + out.push(1); + out.extend_from_slice(id); + } + None => out.push(0), + } + + push_optional( + &mut out, + b'I', + self.carrier.idempotency_key.as_deref().map(str::as_bytes), + ); + + for (name, value) in &self.bound_headers { + out.push(b'H'); + push_field(&mut out, b'N', name.as_bytes()); + push_optional(&mut out, b'V', value.as_deref()); + } + + out + } +} + +/// Returns the current time in Unix seconds. +/// +/// Convenience for native (non-`wasm32`) callers; on `wasm32` targets such as +/// Cloudflare Workers, obtain the time from the runtime and pass it explicitly. +#[cfg(not(target_arch = "wasm32"))] +pub fn unix_now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn request_authority(parts: &http::request::Parts) -> Option { + if let Some(authority) = parts.uri.authority() { + return Some(authority.as_str().to_ascii_lowercase()); + } + parts + .headers + .get(http::header::HOST) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_ascii_lowercase()) +} + +fn push_field(out: &mut Vec, tag: u8, bytes: &[u8]) { + out.push(tag); + out.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + out.extend_from_slice(bytes); +} + +fn push_optional(out: &mut Vec, tag: u8, bytes: Option<&[u8]>) { + match bytes { + Some(b) => { + out.push(1); + push_field(out, tag, b); + } + None => out.push(0), + } +} + +fn insert_str(headers: &mut HeaderMap, name: &'static str, value: &str) -> Result<(), HttpError> { + let header_value = HeaderValue::from_str(value).map_err(|_| HttpError::InvalidContext(name))?; + headers.insert(HeaderName::from_static(name), header_value); + Ok(()) +} + +fn get_str<'a>(headers: &'a HeaderMap, name: &'static str) -> Result<&'a str, HttpError> { + headers + .get(name) + .ok_or(HttpError::MissingContext(name))? + .to_str() + .map_err(|_| HttpError::InvalidContext(name)) +} + +fn to_hex(bytes: &[u8]) -> String { + use std::fmt::Write; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(out, "{byte:02x}"); + } + out +} + +fn parse_hex_id(value: &str) -> Option<[u8; MESSAGE_ID_LEN]> { + if value.len() != MESSAGE_ID_LEN * 2 { + return None; + } + let bytes = value.as_bytes(); + let mut out = [0u8; MESSAGE_ID_LEN]; + for (i, slot) in out.iter_mut().enumerate() { + let hi = hex_val(bytes[2 * i])?; + let lo = hex_val(bytes[2 * i + 1])?; + *slot = (hi << 4) | lo; + } + Some(out) +} + +fn hex_val(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{Request, Response, StatusCode}; + + #[test] + fn carrier_header_roundtrip() { + let carrier = ContextCarrier::generate(1000, 60) + .with_idempotency_key("idem-123") + .answering([7u8; MESSAGE_ID_LEN]); + let mut headers = HeaderMap::new(); + carrier.apply_to_headers(&mut headers).expect("apply"); + let parsed = ContextCarrier::from_headers(&headers).expect("parse"); + assert_eq!(parsed, carrier); + } + + #[test] + fn distinct_routes_produce_distinct_aad() { + let carrier = ContextCarrier::generate(1000, 60); + let pay = Request::builder() + .method("POST") + .uri("https://api.example.com/pay") + .body(()) + .expect("request"); + let refund = Request::builder() + .method("POST") + .uri("https://api.example.com/refund") + .body(()) + .expect("request"); + let (pay_parts, _) = pay.into_parts(); + let (refund_parts, _) = refund.into_parts(); + let binding = ContextBinding::default(); + let pay_aad = + ProtectedContext::for_request(&pay_parts, carrier.clone(), binding).to_aad_bytes(); + let refund_aad = + ProtectedContext::for_request(&refund_parts, carrier, binding).to_aad_bytes(); + assert_ne!(pay_aad, refund_aad); + } + + #[test] + fn freshness_rejects_expired_and_future() { + let carrier = ContextCarrier::generate(1000, 60); + let req = Request::builder().uri("/x").body(()).expect("request"); + let (parts, _) = req.into_parts(); + let ctx = ProtectedContext::for_request(&parts, carrier, ContextBinding::default()); + + ctx.validate_freshness(1030, 5).expect("within window"); + assert!(matches!( + ctx.validate_freshness(2000, 5), + Err(HttpError::ContextExpired) + )); + assert!(matches!( + ctx.validate_freshness(900, 5), + Err(HttpError::ContextTimestampInFuture) + )); + } + + #[test] + fn bound_header_change_produces_distinct_aad() { + let carrier = ContextCarrier::generate(1000, 60); + let binding = ContextBinding::default().with_bound_headers(&["x-tenant-id"]); + + let tenant_a = Request::builder() + .uri("/x") + .header("x-tenant-id", "tenant-a") + .body(()) + .expect("request"); + let tenant_b = Request::builder() + .uri("/x") + .header("x-tenant-id", "tenant-b") + .body(()) + .expect("request"); + let no_header = Request::builder().uri("/x").body(()).expect("request"); + + let (a_parts, _) = tenant_a.into_parts(); + let (b_parts, _) = tenant_b.into_parts(); + let (none_parts, _) = no_header.into_parts(); + + let a_aad = + ProtectedContext::for_request(&a_parts, carrier.clone(), binding).to_aad_bytes(); + let b_aad = + ProtectedContext::for_request(&b_parts, carrier.clone(), binding).to_aad_bytes(); + let none_aad = ProtectedContext::for_request(&none_parts, carrier, binding).to_aad_bytes(); + + assert_ne!(a_aad, b_aad, "different header values must diverge"); + assert_ne!(a_aad, none_aad, "missing the bound header must diverge"); + } + + #[test] + fn bound_header_name_bytes_are_not_normalized_in_aad() { + let carrier = ContextCarrier::generate(1000, 60); + let request = Request::builder() + .uri("/x") + .header("x-tenant-id", "tenant-a") + .body(()) + .expect("request"); + let (parts, _) = request.into_parts(); + + let lower = ProtectedContext::for_request( + &parts, + carrier.clone(), + ContextBinding::default().with_bound_headers(&["x-tenant-id"]), + ) + .to_aad_bytes(); + let mixed = ProtectedContext::for_request( + &parts, + carrier, + ContextBinding::default().with_bound_headers(&["X-Tenant-Id"]), + ) + .to_aad_bytes(); + + assert_ne!(lower, mixed); + } + + #[test] + fn unbound_header_changes_do_not_affect_aad() { + // Without `with_bound_headers`, an arbitrary header is not part of the + // protected context at all. + let carrier = ContextCarrier::generate(1000, 60); + let binding = ContextBinding::default(); + + let with_header = Request::builder() + .uri("/x") + .header("x-tenant-id", "tenant-a") + .body(()) + .expect("request"); + let without_header = Request::builder().uri("/x").body(()).expect("request"); + + let (with_parts, _) = with_header.into_parts(); + let (without_parts, _) = without_header.into_parts(); + + let with_aad = + ProtectedContext::for_request(&with_parts, carrier.clone(), binding).to_aad_bytes(); + let without_aad = + ProtectedContext::for_request(&without_parts, carrier, binding).to_aad_bytes(); + assert_eq!(with_aad, without_aad); + } + + #[test] + fn response_status_is_bound() { + let carrier = ContextCarrier::generate(1000, 60).answering([1u8; MESSAGE_ID_LEN]); + let ok = Response::builder() + .status(StatusCode::OK) + .body(()) + .expect("response"); + let created = Response::builder() + .status(StatusCode::CREATED) + .body(()) + .expect("response"); + let (ok_parts, _) = ok.into_parts(); + let (created_parts, _) = created.into_parts(); + let ok_aad = ProtectedContext::for_response(&ok_parts, carrier.clone()).to_aad_bytes(); + let created_aad = ProtectedContext::for_response(&created_parts, carrier).to_aad_bytes(); + assert_ne!(ok_aad, created_aad); + } +} diff --git a/foctet-http/src/error.rs b/foctet-http/src/error.rs index 6d8461d..77febaa 100644 --- a/foctet-http/src/error.rs +++ b/foctet-http/src/error.rs @@ -16,4 +16,26 @@ pub enum HttpError { /// Body opening failed. #[error("failed to open HTTP body")] OpenFailed(#[source] BodyEnvelopeError), + /// A required `x-foctet-*` protected-context header is missing. + #[error("missing protected-context value: {0}")] + MissingContext(&'static str), + /// A protected-context value is malformed. + #[error("invalid protected-context value: {0}")] + InvalidContext(&'static str), + /// The protected-context timestamp is too far in the future. + #[error("protected-context timestamp is in the future")] + ContextTimestampInFuture, + /// The protected-context has expired. + #[error("protected-context has expired")] + ContextExpired, + /// The message was already seen: a replay. + #[error("replayed request rejected")] + Replayed, + /// The anti-replay store failed. + #[error("replay store error")] + ReplayStore(#[source] crate::ReplayStoreError), + /// A streaming body ended before its authenticated final chunk (truncated or + /// cancelled); the partial plaintext must be discarded. + #[error("streaming body incomplete (no final chunk)")] + StreamIncomplete, } diff --git a/foctet-http/src/lib.rs b/foctet-http/src/lib.rs index 5168eaa..9c9180f 100644 --- a/foctet-http/src/lib.rs +++ b/foctet-http/src/lib.rs @@ -1,47 +1,64 @@ //! High-level HTTP integration for `application/foctet` body envelopes. //! -//! `foctet-http` adapts HTTP requests and responses onto the body-complete -//! envelope format. +//! `foctet-http` encrypts HTTP body bytes. For production requests, prefer the +//! context-bound APIs: +//! [`HttpSealer::seal_request_with_context`] and +//! [`HttpOpener::open_request_with_context`]. They bind selected request +//! metadata into the AEAD and enforce single-use replay protection through a +//! [`ReplayStore`]. //! -//! Foctet HTTP integration encrypts and authenticates the body bytes only. The -//! outer HTTP method, URI, status code, and headers remain visible to the -//! surrounding transport and should be protected by an authenticated outer -//! channel such as HTTPS, authenticated WebTransport, or an authenticated -//! Foctet transport session. +//! The outer HTTP method, URI, status code, and headers remain visible to the +//! surrounding transport, so deployments should still use an authenticated +//! outer channel such as HTTPS. //! -//! # Layers +//! Main layers: //! -//! - Recommended high-level API: -//! [`HttpSealer`] and [`HttpOpener`] -//! - Framework adapters: -//! `axum` and `workers` -//! - Lower-level helpers: -//! [`raw`] -//! -//! Sealed requests and responses also carry an advisory -//! `x-foctet-scope: body-only` header so downstream systems can distinguish -//! Foctet body envelopes from full-message protection. +//! - [`HttpSealer`] and [`HttpOpener`] for the primary API +//! - [`context`] and [`ReplayStore`] for protected-context request binding +//! - `axum` and `workers` for framework adapters +//! - [`raw`] for lower-level helpers //! /// Re-export of the `http` crate used by this adapter. pub use http; mod config; +pub mod context; mod error; pub mod raw; +mod replay_store; +pub mod stream; #[cfg(feature = "axum")] pub mod axum; #[cfg(all(feature = "workers", target_arch = "wasm32"))] pub mod workers; +use foctet_core::{open_body_with_context, seal_body_with_context}; use http::{ Request, Response, header::{self}, }; pub use config::{HttpConfig, HttpOpenOptions, HttpSealOptions}; +#[cfg(not(target_arch = "wasm32"))] +pub use context::unix_now_secs; +pub use context::{ + ContextBinding, ContextCarrier, ContextDirection, DEFAULT_CONTEXT_TTL_SECS, + DEFAULT_MAX_CLOCK_SKEW_SECS, MESSAGE_ID_LEN, ProtectedContext, +}; pub use error::HttpError; +// Re-exported because it appears in public signatures (`HttpSealOptions`, +// `open_request_stream`, `HttpStreamSealer::for_request`, …), so callers do not +// need a direct `foctet-core` dependency to name it. +pub use foctet_core::BodyEnvelopeLimits; +#[cfg(feature = "redis")] +pub use replay_store::RedisReplayStore; +pub use replay_store::{ + AsyncReplayStore, DEFAULT_MAX_REPLAY_ENTRIES, InMemoryReplayStore, ReplayCheck, ReplayStore, + ReplayStoreError, +}; +pub use stream::{HttpRequestStreamReader, HttpStreamOpener, HttpStreamSealer}; /// Foctet HTTP media type. pub const CONTENT_TYPE: &str = "application/foctet"; @@ -105,11 +122,83 @@ impl HttpSealer { } } + /// Seals the body and binds the supplied associated data into its AEAD. + fn seal_body_with_aad(&self, plaintext: &[u8], aad: &[u8]) -> Result, HttpError> { + let default_limits; + let limits = match self.options.limits() { + Some(limits) => limits, + None => { + default_limits = BodyEnvelopeLimits::default(); + &default_limits + } + }; + seal_body_with_context( + plaintext, + self.options.recipient_public_key(), + self.options.recipient_key_id(), + aad, + limits, + ) + .map_err(HttpError::SealFailed) + } + + /// Seals a request and binds the full HTTP protected context (method, path, + /// query, message ID, timestamp, expiry, …) into the envelope. + /// + /// This is the recommended path for production HTTP: it makes a captured + /// envelope non-replayable onto a different route and, paired with + /// [`HttpOpener::open_request_with_context`] and a [`ReplayStore`], enforces + /// single use. The carrier values travel in `x-foctet-*` headers. + pub fn seal_request_with_context( + &self, + request: Request>, + carrier: ContextCarrier, + binding: ContextBinding, + ) -> Result>, HttpError> { + let (mut parts, body) = request.into_parts(); + let context = ProtectedContext::for_request(&parts, carrier.clone(), binding); + let aad = context.to_aad_bytes(); + let sealed = self.seal_body_with_aad(&body, &aad)?; + raw::set_foctet_content_type(&mut parts.headers); + if self.config.set_scope_header_on_seal() { + raw::set_foctet_scope_header(&mut parts.headers); + } + carrier.apply_to_headers(&mut parts.headers)?; + Ok(Request::from_parts(parts, sealed)) + } + + /// Seals a response and binds the HTTP protected context (status, message + /// ID, timestamp, expiry, and the answered request message ID). + pub fn seal_response_with_context( + &self, + response: Response>, + carrier: ContextCarrier, + ) -> Result>, HttpError> { + let (mut parts, body) = response.into_parts(); + let context = ProtectedContext::for_response(&parts, carrier.clone()); + let aad = context.to_aad_bytes(); + let sealed = self.seal_body_with_aad(&body, &aad)?; + raw::set_foctet_content_type(&mut parts.headers); + if self.config.set_scope_header_on_seal() { + raw::set_foctet_scope_header(&mut parts.headers); + } + carrier.apply_to_headers(&mut parts.headers)?; + Ok(Response::from_parts(parts, sealed)) + } + /// Seals a plaintext request and sets `Content-Type: application/foctet`. /// - /// By default this also adds the advisory `x-foctet-scope: body-only` - /// header so downstream consumers do not mistake body protection for - /// full HTTP message protection. + /// This protects the body only and provides **no** replay protection or + /// HTTP-context binding; prefer [`HttpSealer::seal_request_with_context`] + /// for production. By default this also adds the advisory + /// `x-foctet-scope: body-only` header so downstream consumers do not mistake + /// body protection for full HTTP message protection. + #[deprecated( + since = "0.3.0", + note = "stateless full-request protection has no replay defense or HTTP-context \ + binding and is replayable by design; use seal_request_with_context with a \ + ReplayStore for production (see module docs)" + )] pub fn seal_request(&self, request: Request>) -> Result>, HttpError> { let (mut parts, body) = request.into_parts(); let sealed = self.seal_body(&body)?; @@ -163,19 +252,200 @@ impl HttpOpener { &self.config } + /// Tries each recipient key in the keyring, returning the plaintext from the + /// first key that authenticates. + /// + /// Because every keyring entry is one of the recipient's own secret keys and + /// each attempt is on context-bound, authenticated ciphertext, a + /// non-matching key simply fails to open (no decryption oracle). If no key + /// succeeds, the first attempt's error is returned. The keyring is + /// guaranteed non-empty by [`HttpOpenOptions`]. + fn open_with_keyring(&self, mut attempt: F) -> Result, HttpError> + where + F: FnMut([u8; 32]) -> Result, HttpError>, + { + let mut first_err = None; + for key in self.options.expose_recipient_secret_keys() { + match attempt(*key) { + Ok(plain) => return Ok(plain), + Err(err) => { + if first_err.is_none() { + first_err = Some(err); + } + } + } + } + Err(first_err.expect("HttpOpenOptions guarantees a non-empty keyring")) + } + /// Opens an `application/foctet` body into plaintext bytes. pub fn open_body(&self, envelope: &[u8]) -> Result, HttpError> { - match self.options.limits() { - Some(limits) => raw::open_http_body_with_limits( - envelope, - self.options.recipient_secret_key(), - limits, - ), - None => raw::open_http_body(envelope, self.options.recipient_secret_key()), + self.open_with_keyring(|key| match self.options.limits() { + Some(limits) => raw::open_http_body_with_limits(envelope, key, limits), + None => raw::open_http_body(envelope, key), + }) + } + + /// Opens the body using the supplied associated data. + fn open_body_with_aad(&self, envelope: &[u8], aad: &[u8]) -> Result, HttpError> { + let default_limits; + let limits = match self.options.limits() { + Some(limits) => limits, + None => { + default_limits = BodyEnvelopeLimits::default(); + &default_limits + } + }; + self.open_with_keyring(|key| { + open_body_with_context(envelope, key, aad, limits).map_err(HttpError::OpenFailed) + }) + } + + /// Opens a request sealed with [`HttpSealer::seal_request_with_context`], + /// validating the bound HTTP context, freshness, and single use. + /// + /// The order is deliberate and matters for security: + /// 1. parse the carrier headers and reconstruct the bound context, + /// 2. validate timestamp/expiry against `now_secs` (± `max_skew_secs`), + /// 3. **authenticate** the body via the context-bound AEAD, + /// 4. only then consult the [`ReplayStore`] for single-use enforcement. + /// + /// Recording replay state only after authentication prevents an + /// unauthenticated request from populating the store. + pub fn open_request_with_context( + &self, + request: Request>, + store: &S, + now_secs: u64, + max_skew_secs: u64, + binding: ContextBinding, + ) -> Result>, HttpError> + where + S: ReplayStore + ?Sized, + { + let (parts, plain, carrier) = + self.open_request_prepare(request, now_secs, max_skew_secs, binding)?; + + match ReplayStore::check_and_insert( + store, + &carrier.message_id, + carrier.expiry_secs, + now_secs, + ) + .map_err(HttpError::ReplayStore)? + { + ReplayCheck::Accepted => {} + ReplayCheck::Replay => return Err(HttpError::Replayed), + } + + Ok(self.open_request_finalize(parts, plain)) + } + + /// Opens a context-bound request using a durable [`AsyncReplayStore`]. + /// + /// Identical to [`HttpOpener::open_request_with_context`] but awaits the + /// store, so it works with networked/durable backends (Redis, Cloudflare KV, + /// a Durable Object, or a shared SQL table) needed once more than one + /// instance serves traffic. Authentication still happens before the store is + /// consulted. + pub async fn open_request_with_async_store( + &self, + request: Request>, + store: &S, + now_secs: u64, + max_skew_secs: u64, + binding: ContextBinding, + ) -> Result>, HttpError> + where + S: AsyncReplayStore + ?Sized, + { + let (parts, plain, carrier) = + self.open_request_prepare(request, now_secs, max_skew_secs, binding)?; + + match AsyncReplayStore::check_and_insert( + store, + &carrier.message_id, + carrier.expiry_secs, + now_secs, + ) + .await + .map_err(HttpError::ReplayStore)? + { + ReplayCheck::Accepted => {} + ReplayCheck::Replay => return Err(HttpError::Replayed), + } + + Ok(self.open_request_finalize(parts, plain)) + } + + /// Shared request-open logic up to (but excluding) the replay-store check: + /// validates content type, parses the carrier, reconstructs and freshness- + /// checks the context, and authenticates the body. + fn open_request_prepare( + &self, + request: Request>, + now_secs: u64, + max_skew_secs: u64, + binding: ContextBinding, + ) -> Result<(http::request::Parts, Vec, ContextCarrier), HttpError> { + let (parts, body) = request.into_parts(); + raw::ensure_foctet_content_type(&parts.headers)?; + let carrier = ContextCarrier::from_headers(&parts.headers)?; + let context = ProtectedContext::for_request(&parts, carrier.clone(), binding); + context.validate_freshness(now_secs, max_skew_secs)?; + let aad = context.to_aad_bytes(); + let plain = self.open_body_with_aad(&body, &aad)?; + Ok((parts, plain, carrier)) + } + + fn open_request_finalize( + &self, + mut parts: http::request::Parts, + plain: Vec, + ) -> Request> { + if self.config.strip_content_type_on_open() { + parts.headers.remove(header::CONTENT_TYPE); } + Request::from_parts(parts, plain) + } + + /// Opens a response sealed with [`HttpSealer::seal_response_with_context`], + /// validating the bound context and freshness. + /// + /// A client typically expects a single response, so no replay store is + /// required here; callers may additionally check that the carrier's + /// `request_message_id` matches the request they sent. + pub fn open_response_with_context( + &self, + response: Response>, + now_secs: u64, + max_skew_secs: u64, + ) -> Result>, HttpError> { + let (mut parts, body) = response.into_parts(); + raw::ensure_foctet_content_type(&parts.headers)?; + let carrier = ContextCarrier::from_headers(&parts.headers)?; + let context = ProtectedContext::for_response(&parts, carrier); + context.validate_freshness(now_secs, max_skew_secs)?; + + let aad = context.to_aad_bytes(); + let plain = self.open_body_with_aad(&body, &aad)?; + + if self.config.strip_content_type_on_open() { + parts.headers.remove(header::CONTENT_TYPE); + } + Ok(Response::from_parts(parts, plain)) } /// Opens an encrypted request body into plaintext bytes. + /// + /// This provides **no** replay protection or HTTP-context binding; prefer + /// [`HttpOpener::open_request_with_context`] for production. + #[deprecated( + since = "0.3.0", + note = "stateless full-request protection has no replay defense or HTTP-context \ + binding and is replayable by design; use open_request_with_context with a \ + ReplayStore for production (see module docs)" + )] pub fn open_request(&self, request: Request>) -> Result>, HttpError> { let (mut parts, body) = request.into_parts(); raw::ensure_foctet_content_type(&parts.headers)?; @@ -211,6 +481,7 @@ mod tests { use super::*; #[test] + #[allow(deprecated)] // exercises the deprecated stateless request path on purpose fn sealer_and_opener_roundtrip_request_and_response() { let recipient_priv = StaticSecret::random_from_rng(OsRng); let recipient_pub = PublicKey::from(&recipient_priv).to_bytes(); @@ -298,6 +569,214 @@ mod tests { assert!(!sealed.headers().contains_key(SCOPE_HEADER)); } + #[test] + fn context_bound_request_roundtrip_and_replay_rejected() { + let recipient_priv = StaticSecret::random_from_rng(OsRng); + let recipient_pub = PublicKey::from(&recipient_priv).to_bytes(); + + let sealer = HttpSealer::new(HttpSealOptions::new(recipient_pub, b"kid")); + let opener = HttpOpener::new(HttpOpenOptions::new(recipient_priv.to_bytes())); + let store = InMemoryReplayStore::new(); + let binding = ContextBinding::default(); + let now = 1_000_000u64; + + let request = Request::builder() + .method("POST") + .uri("https://api.example.com/pay?amount=10") + .body(b"charge".to_vec()) + .expect("request"); + + let carrier = ContextCarrier::generate(now, DEFAULT_CONTEXT_TTL_SECS); + let sealed = sealer + .seal_request_with_context(request, carrier, binding) + .expect("seal"); + + // First delivery authenticates and is accepted. + let opened = opener + .open_request_with_context(clone_request(&sealed), &store, now, 30, binding) + .expect("first open"); + assert_eq!(opened.method(), "POST"); + assert_eq!(opened.body(), b"charge"); + + // Replaying the identical captured request is rejected. + let err = opener + .open_request_with_context(clone_request(&sealed), &store, now, 30, binding) + .expect_err("replay must be rejected"); + assert!(matches!(err, HttpError::Replayed)); + } + + #[test] + fn context_bound_request_with_bound_header_rejects_header_tamper() { + let recipient_priv = StaticSecret::random_from_rng(OsRng); + let recipient_pub = PublicKey::from(&recipient_priv).to_bytes(); + + let sealer = HttpSealer::new(HttpSealOptions::new(recipient_pub, b"kid")); + let opener = HttpOpener::new(HttpOpenOptions::new(recipient_priv.to_bytes())); + let store = InMemoryReplayStore::new(); + let binding = ContextBinding::default().with_bound_headers(&["x-tenant-id"]); + let now = 1_000_000u64; + + let request = Request::builder() + .method("POST") + .uri("https://api.example.com/pay") + .header("x-tenant-id", "tenant-a") + .body(b"charge".to_vec()) + .expect("request"); + let carrier = ContextCarrier::generate(now, DEFAULT_CONTEXT_TTL_SECS); + let sealed = sealer + .seal_request_with_context(request, carrier, binding) + .expect("seal"); + + // Genuine request opens fine. + let opened = opener + .open_request_with_context(clone_request(&sealed), &store, now, 30, binding) + .expect("open with matching bound header"); + assert_eq!(opened.headers()["x-tenant-id"], "tenant-a"); + + // An on-path party swapping the tenant header (but leaving the + // ciphertext, route, and carrier headers untouched) must fail + // authentication rather than silently reattributing the request. + let (mut parts, body) = sealed.into_parts(); + parts + .headers + .insert("x-tenant-id", "tenant-b".parse().expect("header value")); + let tampered = Request::from_parts(parts, body); + + let err = opener + .open_request_with_context(tampered, &store, now, 30, binding) + .expect_err("tampered bound header must fail authentication"); + assert!(matches!(err, HttpError::OpenFailed(_))); + } + + #[tokio::test] + async fn context_bound_request_async_store_roundtrip_and_replay() { + let recipient_priv = StaticSecret::random_from_rng(OsRng); + let recipient_pub = PublicKey::from(&recipient_priv).to_bytes(); + + let sealer = HttpSealer::new(HttpSealOptions::new(recipient_pub, b"kid")); + let opener = HttpOpener::new(HttpOpenOptions::new(recipient_priv.to_bytes())); + // InMemoryReplayStore is usable through the async path via the blanket impl. + let store = InMemoryReplayStore::new(); + let binding = ContextBinding::default(); + let now = 1_000_000u64; + + let request = Request::builder() + .method("POST") + .uri("https://api.example.com/pay") + .body(b"charge".to_vec()) + .expect("request"); + let carrier = ContextCarrier::generate(now, DEFAULT_CONTEXT_TTL_SECS); + let sealed = sealer + .seal_request_with_context(request, carrier, binding) + .expect("seal"); + + let opened = opener + .open_request_with_async_store(clone_request(&sealed), &store, now, 30, binding) + .await + .expect("first open"); + assert_eq!(opened.body(), b"charge"); + + let err = opener + .open_request_with_async_store(clone_request(&sealed), &store, now, 30, binding) + .await + .expect_err("replay must be rejected"); + assert!(matches!(err, HttpError::Replayed)); + } + + #[test] + fn context_bound_request_rejects_route_substitution() { + let recipient_priv = StaticSecret::random_from_rng(OsRng); + let recipient_pub = PublicKey::from(&recipient_priv).to_bytes(); + + let sealer = HttpSealer::new(HttpSealOptions::new(recipient_pub, b"kid")); + let opener = HttpOpener::new(HttpOpenOptions::new(recipient_priv.to_bytes())); + let store = InMemoryReplayStore::new(); + let binding = ContextBinding::default(); + let now = 1_000_000u64; + + let request = Request::builder() + .method("POST") + .uri("https://api.example.com/pay") + .body(b"charge".to_vec()) + .expect("request"); + let carrier = ContextCarrier::generate(now, DEFAULT_CONTEXT_TTL_SECS); + let sealed = sealer + .seal_request_with_context(request, carrier, binding) + .expect("seal"); + + // Attacker moves the captured ciphertext + headers onto a different path. + let (mut parts, body) = sealed.into_parts(); + parts.uri = "https://api.example.com/refund".parse().expect("uri"); + let moved = Request::from_parts(parts, body); + + let err = opener + .open_request_with_context(moved, &store, now, 30, binding) + .expect_err("route substitution must fail authentication"); + assert!(matches!(err, HttpError::OpenFailed(_))); + } + + #[test] + fn context_bound_request_rejects_expired() { + let recipient_priv = StaticSecret::random_from_rng(OsRng); + let recipient_pub = PublicKey::from(&recipient_priv).to_bytes(); + + let sealer = HttpSealer::new(HttpSealOptions::new(recipient_pub, b"kid")); + let opener = HttpOpener::new(HttpOpenOptions::new(recipient_priv.to_bytes())); + let store = InMemoryReplayStore::new(); + let binding = ContextBinding::default(); + + let request = Request::builder() + .method("GET") + .uri("https://api.example.com/data") + .body(Vec::new()) + .expect("request"); + let carrier = ContextCarrier::generate(1_000, 60); + let sealed = sealer + .seal_request_with_context(request, carrier, binding) + .expect("seal"); + + let err = opener + .open_request_with_context(sealed, &store, 5_000, 30, binding) + .expect_err("expired context must be rejected"); + assert!(matches!(err, HttpError::ContextExpired)); + } + + #[test] + fn context_bound_response_roundtrip() { + let recipient_priv = StaticSecret::random_from_rng(OsRng); + let recipient_pub = PublicKey::from(&recipient_priv).to_bytes(); + + let sealer = HttpSealer::new(HttpSealOptions::new(recipient_pub, b"kid")); + let opener = HttpOpener::new(HttpOpenOptions::new(recipient_priv.to_bytes())); + let now = 2_000u64; + + let response = Response::builder() + .status(StatusCode::OK) + .body(b"result".to_vec()) + .expect("response"); + let carrier = ContextCarrier::generate(now, DEFAULT_CONTEXT_TTL_SECS).answering([3u8; 16]); + let sealed = sealer + .seal_response_with_context(response, carrier) + .expect("seal"); + + let opened = opener + .open_response_with_context(sealed, now, 30) + .expect("open"); + assert_eq!(opened.status(), StatusCode::OK); + assert_eq!(opened.body(), b"result"); + } + + fn clone_request(request: &Request>) -> Request> { + let mut builder = Request::builder() + .method(request.method().clone()) + .uri(request.uri().clone()) + .version(request.version()); + for (name, value) in request.headers() { + builder = builder.header(name, value); + } + builder.body(request.body().clone()).expect("clone request") + } + #[test] fn options_support_explicit_limits() { let limits = BodyEnvelopeLimits { @@ -307,4 +786,114 @@ mod tests { let options = HttpSealOptions::new([1u8; 32], b"kid").with_limits(limits.clone()); assert_eq!(options.limits(), Some(&limits)); } + + #[test] + fn key_rotation_overlap_accepts_current_and_previous_key() { + let old_priv = StaticSecret::random_from_rng(OsRng); + let old_pub = PublicKey::from(&old_priv).to_bytes(); + let new_priv = StaticSecret::random_from_rng(OsRng); + let new_pub = PublicKey::from(&new_priv).to_bytes(); + + // During the overlap window the recipient accepts both the current + // (v2) key and the retiring (v1) key. + let opener = HttpOpener::new( + HttpOpenOptions::new(new_priv.to_bytes()).with_recipient_key(old_priv.to_bytes()), + ); + assert_eq!(opener.options().recipient_key_count(), 2); + + let binding = ContextBinding::default(); + let now = 1_000_000u64; + + for (recipient_pub, kid, body) in [ + (old_pub, &b"server-v1"[..], &b"pre-rotation"[..]), + (new_pub, &b"server-v2"[..], &b"post-rotation"[..]), + ] { + let sealer = HttpSealer::new(HttpSealOptions::new(recipient_pub, kid)); + let store = InMemoryReplayStore::new(); + let request = Request::builder() + .method("POST") + .uri("https://api.example.com/pay") + .body(body.to_vec()) + .expect("request"); + let carrier = ContextCarrier::generate(now, DEFAULT_CONTEXT_TTL_SECS); + let sealed = sealer + .seal_request_with_context(request, carrier, binding) + .expect("seal"); + let opened = opener + .open_request_with_context(sealed, &store, now, 30, binding) + .expect("keyring opens an envelope sealed to either key"); + assert_eq!(opened.body(), body); + } + } + + #[test] + fn key_rotation_rejects_key_after_it_is_retired() { + let old_priv = StaticSecret::random_from_rng(OsRng); + let old_pub = PublicKey::from(&old_priv).to_bytes(); + let new_priv = StaticSecret::random_from_rng(OsRng); + + // Overlap window is over: the recipient holds only the current key. + let opener = HttpOpener::new(HttpOpenOptions::new(new_priv.to_bytes())); + let sealer_old = HttpSealer::new(HttpSealOptions::new(old_pub, b"server-v1")); + let store = InMemoryReplayStore::new(); + let binding = ContextBinding::default(); + let now = 1_000_000u64; + + let request = Request::builder() + .method("POST") + .uri("https://api.example.com/pay") + .body(b"charge".to_vec()) + .expect("request"); + let carrier = ContextCarrier::generate(now, DEFAULT_CONTEXT_TTL_SECS); + let sealed = sealer_old + .seal_request_with_context(request, carrier, binding) + .expect("seal"); + + let err = opener + .open_request_with_context(sealed, &store, now, 30, binding) + .expect_err("a request sealed to a retired key must be rejected"); + assert!(matches!(err, HttpError::OpenFailed(_))); + } + + #[tokio::test] + async fn key_rotation_trial_decryption_does_not_consume_replay_slot() { + let old_priv = StaticSecret::random_from_rng(OsRng); + let new_priv = StaticSecret::random_from_rng(OsRng); + let new_pub = PublicKey::from(&new_priv).to_bytes(); + + // The non-matching old key is tried FIRST and fails authentication + // before the matching new key succeeds. + let opener = HttpOpener::new( + HttpOpenOptions::new(old_priv.to_bytes()).with_recipient_key(new_priv.to_bytes()), + ); + let sealer = HttpSealer::new(HttpSealOptions::new(new_pub, b"server-v2")); + let store = InMemoryReplayStore::new(); + let binding = ContextBinding::default(); + let now = 1_000_000u64; + + let request = Request::builder() + .method("POST") + .uri("https://api.example.com/pay") + .body(b"charge".to_vec()) + .expect("request"); + let carrier = ContextCarrier::generate(now, DEFAULT_CONTEXT_TTL_SECS); + let sealed = sealer + .seal_request_with_context(request, carrier, binding) + .expect("seal"); + + // First delivery: the failing old-key attempt must not populate the + // replay store, so authentication (before the store) still succeeds. + let opened = opener + .open_request_with_async_store(clone_request(&sealed), &store, now, 30, binding) + .await + .expect("the second key in the ring opens the envelope"); + assert_eq!(opened.body(), b"charge"); + + // The genuine replay is still detected exactly once. + let err = opener + .open_request_with_async_store(sealed, &store, now, 30, binding) + .await + .expect_err("replay must be rejected"); + assert!(matches!(err, HttpError::Replayed)); + } } diff --git a/foctet-http/src/raw.rs b/foctet-http/src/raw.rs index d8c9d87..1479f25 100644 --- a/foctet-http/src/raw.rs +++ b/foctet-http/src/raw.rs @@ -99,6 +99,13 @@ pub fn open_http_body_with_limits( } /// Seals request body and sets `Content-Type: application/foctet`. +#[deprecated( + since = "0.3.0", + note = "stateless full-request protection has no replay defense or HTTP-context binding \ + and is replayable by design; use the *_with_context API with a ReplayStore for \ + production (see crate docs)" +)] +#[allow(deprecated)] pub fn seal_http_request( request: Request>, recipient_public_key: [u8; 32], @@ -112,6 +119,13 @@ pub fn seal_http_request( } /// Seals request body with explicit limits and sets `Content-Type: application/foctet`. +#[deprecated( + since = "0.3.0", + note = "stateless full-request protection has no replay defense or HTTP-context binding \ + and is replayable by design; use the *_with_context API with a ReplayStore for \ + production (see crate docs)" +)] +#[allow(deprecated)] pub fn seal_http_request_with_limits( request: Request>, recipient_public_key: [u8; 32], @@ -126,6 +140,13 @@ pub fn seal_http_request_with_limits( } /// Validates foctet content type and opens request body. +#[deprecated( + since = "0.3.0", + note = "stateless full-request protection has no replay defense or HTTP-context binding \ + and is replayable by design; use the *_with_context API with a ReplayStore for \ + production (see crate docs)" +)] +#[allow(deprecated)] pub fn open_http_request( request: Request>, recipient_secret_key: [u8; 32], @@ -135,6 +156,13 @@ pub fn open_http_request( } /// Validates foctet content type and opens request body with explicit limits. +#[deprecated( + since = "0.3.0", + note = "stateless full-request protection has no replay defense or HTTP-context binding \ + and is replayable by design; use the *_with_context API with a ReplayStore for \ + production (see crate docs)" +)] +#[allow(deprecated)] pub fn open_http_request_with_limits( request: Request>, recipient_secret_key: [u8; 32], @@ -249,6 +277,7 @@ mod tests { } #[test] + #[allow(deprecated)] // exercises the deprecated stateless request path on purpose fn wrong_content_type_rejected_on_request_open() { let recipient_priv = StaticSecret::random_from_rng(OsRng); @@ -263,6 +292,7 @@ mod tests { } #[test] + #[allow(deprecated)] // exercises the deprecated stateless request path on purpose fn request_and_response_helpers_roundtrip() { let recipient_priv = StaticSecret::random_from_rng(OsRng); let recipient_pub = PublicKey::from(&recipient_priv).to_bytes(); diff --git a/foctet-http/src/replay_store.rs b/foctet-http/src/replay_store.rs new file mode 100644 index 0000000..f82c840 --- /dev/null +++ b/foctet-http/src/replay_store.rs @@ -0,0 +1,331 @@ +//! Anti-replay storage for HTTP protected contexts. +//! +//! A protected context binds a unique message ID and an absolute expiry into a +//! body envelope. The [`ReplayStore`] records message IDs that have been +//! accepted so a captured-and-resent request is rejected on its second use. The +//! contract is a single **atomic check-and-insert**: an implementation must, in +//! one indivisible step, report whether the ID was already present and record +//! it if not. The store is consulted **after** the envelope authenticates, so +//! unauthenticated input can never populate it. + +use std::collections::HashMap; +use std::sync::Mutex; + +use thiserror::Error; + +use crate::context::MESSAGE_ID_LEN; + +/// Default cap on the number of retained message IDs in [`InMemoryReplayStore`]. +pub const DEFAULT_MAX_REPLAY_ENTRIES: usize = 1 << 20; + +/// Error returned by a [`ReplayStore`] implementation. +#[derive(Debug, Error)] +pub enum ReplayStoreError { + /// The store has reached its capacity and cannot accept a new entry. + #[error("replay store is at capacity")] + AtCapacity, + /// A backend-specific failure occurred. + #[error("replay store backend error: {0}")] + Backend(String), +} + +/// Outcome of an atomic check-and-insert. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReplayCheck { + /// The message ID had not been seen before and was recorded. + Accepted, + /// The message ID was already present: this is a replay. + Replay, +} + +impl ReplayCheck { + /// Returns `true` when the message was a replay. + pub fn is_replay(self) -> bool { + matches!(self, ReplayCheck::Replay) + } +} + +/// Anti-replay store with an atomic check-and-insert contract. +pub trait ReplayStore { + /// Atomically records `message_id` (valid until `expires_at_secs`) and + /// reports whether it had already been seen. + /// + /// `now_secs` lets the implementation evict expired entries. The operation + /// MUST be atomic: concurrent calls with the same ID must yield exactly one + /// [`ReplayCheck::Accepted`]. + fn check_and_insert( + &self, + message_id: &[u8; MESSAGE_ID_LEN], + expires_at_secs: u64, + now_secs: u64, + ) -> Result; +} + +/// Asynchronous anti-replay store for durable / networked backends. +/// +/// This is the trait to implement for stores that must perform I/O — Redis, +/// Cloudflare KV, a Durable Object, or a SQL table shared across instances — +/// which is required once requests are served by more than one process or +/// serverless isolate (an [`InMemoryReplayStore`] is single-process only). +/// +/// The futures intentionally do **not** require `Send`, so the trait is usable +/// from `!Send` runtimes such as Cloudflare Workers. Implementations must keep +/// the same **atomic check-and-insert** contract as [`ReplayStore`]. +/// +/// Every synchronous [`ReplayStore`] is also an [`AsyncReplayStore`] via a +/// blanket implementation, so an [`InMemoryReplayStore`] works with the async +/// opener path too. +#[allow(async_fn_in_trait)] +pub trait AsyncReplayStore { + /// Atomically records `message_id` (valid until `expires_at_secs`) and + /// reports whether it had already been seen. + async fn check_and_insert( + &self, + message_id: &[u8; MESSAGE_ID_LEN], + expires_at_secs: u64, + now_secs: u64, + ) -> Result; +} + +impl AsyncReplayStore for T +where + T: ReplayStore + ?Sized, +{ + async fn check_and_insert( + &self, + message_id: &[u8; MESSAGE_ID_LEN], + expires_at_secs: u64, + now_secs: u64, + ) -> Result { + ReplayStore::check_and_insert(self, message_id, expires_at_secs, now_secs) + } +} + +/// Single-process in-memory [`ReplayStore`]. +/// +/// Suitable for a single server instance. It is **not** shared across processes +/// or across serverless isolates (e.g. Cloudflare Workers), which require a +/// durable backend implementing [`ReplayStore`] over shared storage. +#[derive(Debug)] +pub struct InMemoryReplayStore { + entries: Mutex>, + max_entries: usize, +} + +impl InMemoryReplayStore { + /// Creates a store with the default capacity. + pub fn new() -> Self { + Self::with_capacity(DEFAULT_MAX_REPLAY_ENTRIES) + } + + /// Creates a store with an explicit capacity (`0` is treated as `1`). + pub fn with_capacity(max_entries: usize) -> Self { + Self { + entries: Mutex::new(HashMap::new()), + max_entries: max_entries.max(1), + } + } + + /// Returns the number of retained (not yet evicted) entries. + pub fn len(&self) -> usize { + self.entries.lock().expect("replay store mutex").len() + } + + /// Returns whether the store currently holds no entries. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +impl Default for InMemoryReplayStore { + fn default() -> Self { + Self::new() + } +} + +impl ReplayStore for InMemoryReplayStore { + fn check_and_insert( + &self, + message_id: &[u8; MESSAGE_ID_LEN], + expires_at_secs: u64, + now_secs: u64, + ) -> Result { + let mut entries = self.entries.lock().expect("replay store mutex"); + + // Drop expired entries so the cap reflects live, still-replayable IDs. + entries.retain(|_, &mut expiry| expiry > now_secs); + + if entries.contains_key(message_id) { + return Ok(ReplayCheck::Replay); + } + if entries.len() >= self.max_entries { + return Err(ReplayStoreError::AtCapacity); + } + entries.insert(*message_id, expires_at_secs); + Ok(ReplayCheck::Accepted) + } +} + +/// Encodes a replay-store key as `{prefix}{hex(message_id)}`. +#[cfg(any(feature = "redis", test))] +fn replay_key(prefix: &str, message_id: &[u8; MESSAGE_ID_LEN]) -> String { + use std::fmt::Write; + let mut key = String::with_capacity(prefix.len() + MESSAGE_ID_LEN * 2); + key.push_str(prefix); + for byte in message_id { + let _ = write!(key, "{byte:02x}"); + } + key +} + +/// Durable, multi-instance [`AsyncReplayStore`] backed by Redis. +/// +/// Uses a single atomic `SET key 1 NX PX ` per check: Redis sets the key +/// only if absent and reports whether it did, giving the required atomic +/// check-and-insert, while `PX` lets Redis expire entries at the context's +/// expiry so the keyspace stays bounded without manual eviction. +/// +/// Requires the `redis` feature and a reachable Redis server. +#[cfg(feature = "redis")] +#[derive(Clone)] +pub struct RedisReplayStore { + client: redis::Client, + prefix: String, +} + +#[cfg(feature = "redis")] +impl RedisReplayStore { + /// Default key prefix. + pub const DEFAULT_PREFIX: &'static str = "foctet:replay:"; + + /// Creates a store from an existing Redis client. + pub fn new(client: redis::Client) -> Self { + Self { + client, + prefix: Self::DEFAULT_PREFIX.to_string(), + } + } + + /// Opens a Redis client from a connection URL (e.g. `redis://127.0.0.1/`). + pub fn open(url: &str) -> Result { + Ok(Self::new(redis::Client::open(url)?)) + } + + /// Overrides the key prefix used for replay entries. + pub fn with_prefix(mut self, prefix: impl Into) -> Self { + self.prefix = prefix.into(); + self + } +} + +#[cfg(feature = "redis")] +impl AsyncReplayStore for RedisReplayStore { + async fn check_and_insert( + &self, + message_id: &[u8; MESSAGE_ID_LEN], + expires_at_secs: u64, + now_secs: u64, + ) -> Result { + let key = replay_key(&self.prefix, message_id); + let ttl_ms = expires_at_secs + .saturating_sub(now_secs) + .max(1) + .saturating_mul(1000); + + let mut conn = self + .client + .get_multiplexed_async_connection() + .await + .map_err(|e| ReplayStoreError::Backend(e.to_string()))?; + + // `SET key 1 NX PX ttl` returns "OK" when the key was set (first use) + // and nil (→ None) when it already existed (replay). + let set: Option = redis::cmd("SET") + .arg(&key) + .arg(1i64) + .arg("NX") + .arg("PX") + .arg(ttl_ms) + .query_async(&mut conn) + .await + .map_err(|e| ReplayStoreError::Backend(e.to_string()))?; + + Ok(if set.is_some() { + ReplayCheck::Accepted + } else { + ReplayCheck::Replay + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replay_key_is_prefixed_hex() { + let mut id = [0u8; MESSAGE_ID_LEN]; + id[0] = 0xAB; + id[15] = 0x01; + assert_eq!( + replay_key("foctet:replay:", &id), + "foctet:replay:ab000000000000000000000000000001" + ); + } + + #[tokio::test] + async fn async_blanket_impl_enforces_replay() { + // Any sync ReplayStore is also an AsyncReplayStore. + let store = InMemoryReplayStore::new(); + let id = [5u8; MESSAGE_ID_LEN]; + assert_eq!( + AsyncReplayStore::check_and_insert(&store, &id, 100, 10) + .await + .expect("first"), + ReplayCheck::Accepted + ); + assert_eq!( + AsyncReplayStore::check_and_insert(&store, &id, 100, 10) + .await + .expect("second"), + ReplayCheck::Replay + ); + } + + #[test] + fn first_use_accepted_second_use_is_replay() { + let store = InMemoryReplayStore::new(); + let id = [9u8; MESSAGE_ID_LEN]; + assert_eq!( + ReplayStore::check_and_insert(&store, &id, 100, 10).expect("first"), + ReplayCheck::Accepted + ); + assert_eq!( + ReplayStore::check_and_insert(&store, &id, 100, 10).expect("second"), + ReplayCheck::Replay + ); + } + + #[test] + fn expired_entries_are_evicted_and_capacity_enforced() { + let store = InMemoryReplayStore::with_capacity(1); + let id_a = [1u8; MESSAGE_ID_LEN]; + let id_b = [2u8; MESSAGE_ID_LEN]; + + assert_eq!( + ReplayStore::check_and_insert(&store, &id_a, 100, 10).expect("a"), + ReplayCheck::Accepted + ); + // At capacity while id_a is still live. + assert!(matches!( + ReplayStore::check_and_insert(&store, &id_b, 200, 50), + Err(ReplayStoreError::AtCapacity) + )); + // After id_a expires it is evicted and id_b fits. + assert_eq!( + ReplayStore::check_and_insert(&store, &id_b, 200, 150).expect("b after expiry"), + ReplayCheck::Accepted + ); + assert_eq!(store.len(), 1); + } +} diff --git a/foctet-http/src/stream.rs b/foctet-http/src/stream.rs new file mode 100644 index 0000000..e80e396 --- /dev/null +++ b/foctet-http/src/stream.rs @@ -0,0 +1,492 @@ +//! Streaming (chunked) HTTP bodies bound to the protected context. +//! +//! This is the streaming counterpart to the one-shot +//! [`HttpSealer::seal_request_with_context`](crate::HttpSealer) path: instead of +//! buffering the whole body, a large request body is sealed and opened as an +//! ordered sequence of per-chunk-authenticated frames +//! ([`foctet_core::body_stream`]), each binding the same HTTP protected context +//! (method, path, query, message id, timestamp, expiry). The unique message id +//! makes the whole stream single-use via a [`ReplayStore`]. +//! +//! # Wire shape +//! +//! Send the carrier headers and the **stream header** (returned by +//! [`HttpStreamSealer::for_request`]) first, then each chunk from +//! [`HttpStreamSealer::seal_chunk`]. The receiver reconstructs the carrier from +//! the headers, validates freshness and single use, then opens chunks with +//! [`HttpStreamOpener`]. +//! +//! # Completion +//! +//! A complete stream ends with a chunk for which +//! [`HttpStreamOpener::is_finished`] becomes `true`. If the request body ends +//! before that (truncation or a cancelled upload), the assembled plaintext MUST +//! be discarded — `is_finished()` stays `false`. + +use foctet_core::{ + BodyEnvelopeError, BodyEnvelopeLimits, DecodedChunk, StreamFrameDecoder, StreamItem, + StreamOpener, StreamSealer, +}; + +use crate::{ + ContextBinding, ContextCarrier, HttpError, ProtectedContext, ReplayCheck, ReplayStore, +}; + +/// Seals an HTTP request body as a context-bound stream of chunks. +pub struct HttpStreamSealer { + inner: StreamSealer, +} + +impl HttpStreamSealer { + /// Begins a context-bound request stream. + /// + /// Returns the sealer and the **stream header** bytes to send before any + /// chunk. Apply `carrier` to the outgoing request headers (with + /// [`ContextCarrier::apply_to_headers`]) so the opener reconstructs the same + /// protected context. + pub fn for_request( + parts: &http::request::Parts, + carrier: &ContextCarrier, + binding: ContextBinding, + recipient_public_key: [u8; 32], + recipient_key_id: &[u8], + limits: &BodyEnvelopeLimits, + ) -> Result<(Self, Vec), HttpError> { + let context = ProtectedContext::for_request(parts, carrier.clone(), binding); + let aad = context.to_aad_bytes(); + let (inner, header) = + StreamSealer::new(recipient_public_key, recipient_key_id, &aad, limits) + .map_err(HttpError::SealFailed)?; + Ok((Self { inner }, header)) + } + + /// Seals one body chunk; pass `is_final = true` for the last chunk. + pub fn seal_chunk(&mut self, plaintext: &[u8], is_final: bool) -> Result, HttpError> { + self.inner + .seal_chunk(plaintext, is_final) + .map_err(HttpError::SealFailed) + } + + /// Returns whether the final chunk has been sealed. + pub fn is_finished(&self) -> bool { + self.inner.is_finished() + } +} + +/// Opens a context-bound HTTP request body stream. +pub struct HttpStreamOpener { + inner: StreamOpener, +} + +impl HttpStreamOpener { + /// Begins opening a context-bound request stream. + /// + /// Parses the carrier from the request headers, validates freshness, and + /// enforces single use against `store` (the message id is consumed once for + /// the whole stream), then prepares to open chunks bound to the protected + /// context. `stream_header` is the prologue produced by + /// [`HttpStreamSealer::for_request`]. + #[allow(clippy::too_many_arguments)] + pub fn for_request( + parts: &http::request::Parts, + recipient_secret_key: [u8; 32], + stream_header: &[u8], + store: &S, + now_secs: u64, + max_skew_secs: u64, + binding: ContextBinding, + limits: &BodyEnvelopeLimits, + ) -> Result + where + S: ReplayStore + ?Sized, + { + let carrier = ContextCarrier::from_headers(&parts.headers)?; + let context = ProtectedContext::for_request(parts, carrier.clone(), binding); + context.validate_freshness(now_secs, max_skew_secs)?; + + match ReplayStore::check_and_insert( + store, + &carrier.message_id, + carrier.expiry_secs, + now_secs, + ) + .map_err(HttpError::ReplayStore)? + { + ReplayCheck::Accepted => {} + ReplayCheck::Replay => return Err(HttpError::Replayed), + } + + let aad = context.to_aad_bytes(); + let inner = StreamOpener::new(recipient_secret_key, stream_header, &aad, limits) + .map_err(HttpError::OpenFailed)?; + Ok(Self { inner }) + } + + /// Opens one received body chunk into its plaintext. + pub fn open_chunk(&mut self, chunk: &[u8]) -> Result { + self.inner.open_chunk(chunk).map_err(HttpError::OpenFailed) + } + + /// Returns whether the final chunk has been opened. A complete stream MUST + /// end with this `true`; otherwise it was truncated or cancelled and its + /// plaintext must be discarded. + pub fn is_finished(&self) -> bool { + self.inner.is_finished() + } +} + +/// Turn-key, framework-agnostic reader for a context-bound streaming request +/// body. +/// +/// Feed it the raw body bytes as they arrive — from an `axum`/`hyper` body data +/// stream, a Cloudflare Workers `ReadableStream`, or any other byte source — and +/// it incrementally reassembles the stream frames ([`StreamFrameDecoder`]), +/// builds an [`HttpStreamOpener`] when the header completes (validating freshness +/// and single use against the replay store at that point), and returns decrypted +/// plaintext chunks. +/// +/// After the body ends, call [`HttpRequestStreamReader::finish`]: it errors with +/// [`HttpError::StreamIncomplete`] unless the authenticated final chunk was seen, +/// so a truncated or cancelled upload is rejected rather than silently accepted. +/// +/// ```rust,ignore +/// // axum handler sketch +/// let (parts, body) = request.into_parts(); +/// let mut reader = HttpRequestStreamReader::new( +/// parts, recipient_secret_key, &store, now, skew, ContextBinding::default(), &limits); +/// let mut stream = body.into_data_stream(); +/// while let Some(frame) = stream.next().await { +/// for plaintext in reader.push(&frame?)? { +/// sink.write_all(&plaintext).await?; // process without buffering the whole body +/// } +/// } +/// reader.finish()?; // rejects a truncated upload +/// ``` +pub struct HttpRequestStreamReader<'s, S: ?Sized> { + decoder: StreamFrameDecoder, + opener: Option, + parts: http::request::Parts, + recipient_secret_key: [u8; 32], + store: &'s S, + now_secs: u64, + max_skew_secs: u64, + binding: ContextBinding, + limits: BodyEnvelopeLimits, +} + +impl<'s, S: ReplayStore + ?Sized> HttpRequestStreamReader<'s, S> { + /// Creates a reader bound to the request `parts` and replay `store`. + #[allow(clippy::too_many_arguments)] + pub fn new( + parts: http::request::Parts, + recipient_secret_key: [u8; 32], + store: &'s S, + now_secs: u64, + max_skew_secs: u64, + binding: ContextBinding, + limits: &BodyEnvelopeLimits, + ) -> Self { + Self { + decoder: StreamFrameDecoder::new(limits), + opener: None, + parts, + recipient_secret_key, + store, + now_secs, + max_skew_secs, + binding, + limits: limits.clone(), + } + } + + /// Feeds received body bytes and returns any plaintext chunks now available + /// (possibly none, if a frame is still incomplete). + pub fn push(&mut self, bytes: &[u8]) -> Result>, HttpError> { + self.decoder.push(bytes); + let mut out = Vec::new(); + while let Some(item) = self.decoder.decode_next().map_err(HttpError::OpenFailed)? { + match item { + StreamItem::Header(header) => { + if self.opener.is_some() { + return Err(HttpError::OpenFailed(BodyEnvelopeError::InvalidHeader( + "duplicate stream header", + ))); + } + self.opener = Some(HttpStreamOpener::for_request( + &self.parts, + self.recipient_secret_key, + &header, + self.store, + self.now_secs, + self.max_skew_secs, + self.binding, + &self.limits, + )?); + } + StreamItem::Chunk(chunk) => { + let opener = self.opener.as_mut().ok_or(HttpError::OpenFailed( + BodyEnvelopeError::InvalidHeader("chunk before stream header"), + ))?; + out.push(opener.open_chunk(&chunk)?.plaintext); + } + } + } + Ok(out) + } + + /// Whether the authenticated final chunk has been opened. + pub fn is_finished(&self) -> bool { + self.opener + .as_ref() + .is_some_and(HttpStreamOpener::is_finished) + } + + /// Consumes the reader, succeeding only if the stream reached its final + /// chunk; otherwise the body was truncated/cancelled + /// ([`HttpError::StreamIncomplete`]). + pub fn finish(self) -> Result<(), HttpError> { + if self.is_finished() { + Ok(()) + } else { + Err(HttpError::StreamIncomplete) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::InMemoryReplayStore; + use http::Request; + use rand_core::OsRng; + use x25519_dalek::{PublicKey, StaticSecret}; + + fn recipient() -> ([u8; 32], [u8; 32]) { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret).to_bytes(); + (secret.to_bytes(), public) + } + + fn request() -> Request<()> { + Request::builder() + .method("POST") + .uri("https://example.com/upload?id=7") + .body(()) + .expect("request") + } + + #[test] + fn streaming_request_roundtrip_with_replay_protection() { + let (secret, public) = recipient(); + let limits = BodyEnvelopeLimits::default(); + let now = 1000; + + // Seal: build the carrier, bind it to the request, and seal chunks. + let carrier = ContextCarrier::generate(now, 60); + let (mut req_parts, _) = request().into_parts(); + let (mut sealer, header) = HttpStreamSealer::for_request( + &req_parts, + &carrier, + ContextBinding::default(), + public, + b"kid", + &limits, + ) + .expect("sealer"); + carrier + .apply_to_headers(&mut req_parts.headers) + .expect("apply carrier"); + + let chunks = vec![ + sealer.seal_chunk(b"streamed ", false).expect("c0"), + sealer.seal_chunk(b"http ", false).expect("c1"), + sealer.seal_chunk(b"body", true).expect("c2"), + ]; + assert!(sealer.is_finished()); + + // Open: the opener reconstructs the carrier from the (now populated) + // request headers, checks freshness + single use, and opens the chunks. + let store = InMemoryReplayStore::new(); + let mut opener = HttpStreamOpener::for_request( + &req_parts, + secret, + &header, + &store, + now, + 5, + ContextBinding::default(), + &limits, + ) + .expect("opener"); + + let mut body = Vec::new(); + for chunk in &chunks { + body.extend_from_slice(&opener.open_chunk(chunk).expect("open chunk").plaintext); + } + assert!(opener.is_finished()); + assert_eq!(body, b"streamed http body"); + + // The same request opened again is rejected as a replay (the message id + // was consumed for the whole stream). + let replay = HttpStreamOpener::for_request( + &req_parts, + secret, + &header, + &store, + now, + 5, + ContextBinding::default(), + &limits, + ); + assert!(matches!(replay, Err(HttpError::Replayed))); + } + + #[test] + fn stream_reader_decodes_a_split_body_and_rejects_replay() { + let (secret, public) = recipient(); + let limits = BodyEnvelopeLimits::default(); + let now = 3000; + + // Seal a stream and lay it out as one contiguous request body: + // stream header followed by the self-delimiting chunks. + let carrier = ContextCarrier::generate(now, 60); + let (mut req_parts, _) = request().into_parts(); + let (mut sealer, header) = HttpStreamSealer::for_request( + &req_parts, + &carrier, + ContextBinding::default(), + public, + b"kid", + &limits, + ) + .expect("sealer"); + carrier + .apply_to_headers(&mut req_parts.headers) + .expect("apply carrier"); + + let mut wire = header; + for part in [b"chunk-one ".as_slice(), b"chunk-two ", b"chunk-three"] { + let is_final = part == b"chunk-three"; + wire.extend_from_slice(&sealer.seal_chunk(part, is_final).expect("seal")); + } + + // Drive the reader with 5-byte body pieces (frames split across pushes). + let store = InMemoryReplayStore::new(); + let mut reader = HttpRequestStreamReader::new( + req_parts.clone(), + secret, + &store, + now, + 5, + ContextBinding::default(), + &limits, + ); + let mut body = Vec::new(); + for piece in wire.chunks(5) { + for plaintext in reader.push(piece).expect("push") { + body.extend_from_slice(&plaintext); + } + } + reader.finish().expect("stream completed"); + assert_eq!(body, b"chunk-one chunk-two chunk-three"); + + // A second reader over the same request must be rejected as a replay when + // its header completes (the message id was already consumed). + let mut replay_reader = HttpRequestStreamReader::new( + req_parts, + secret, + &store, + now, + 5, + ContextBinding::default(), + &limits, + ); + assert!(matches!( + replay_reader.push(&wire), + Err(HttpError::Replayed) + )); + } + + #[test] + fn stream_reader_finish_rejects_a_truncated_body() { + let (secret, public) = recipient(); + let limits = BodyEnvelopeLimits::default(); + let now = 3100; + + let carrier = ContextCarrier::generate(now, 60); + let (mut req_parts, _) = request().into_parts(); + let (mut sealer, header) = HttpStreamSealer::for_request( + &req_parts, + &carrier, + ContextBinding::default(), + public, + b"kid", + &limits, + ) + .expect("sealer"); + carrier + .apply_to_headers(&mut req_parts.headers) + .expect("apply carrier"); + + // Only the non-final chunk reaches the reader; the final chunk is dropped. + let mut wire = header; + wire.extend_from_slice(&sealer.seal_chunk(b"partial", false).expect("seal")); + let _final = sealer.seal_chunk(b"rest", true).expect("seal final"); + + let store = InMemoryReplayStore::new(); + let mut reader = HttpRequestStreamReader::new( + req_parts, + secret, + &store, + now, + 5, + ContextBinding::default(), + &limits, + ); + reader.push(&wire).expect("push"); + assert!(matches!(reader.finish(), Err(HttpError::StreamIncomplete))); + } + + #[test] + fn streaming_request_truncation_is_detectable() { + let (secret, public) = recipient(); + let limits = BodyEnvelopeLimits::default(); + let now = 2000; + + let carrier = ContextCarrier::generate(now, 60); + let (mut req_parts, _) = request().into_parts(); + let (mut sealer, header) = HttpStreamSealer::for_request( + &req_parts, + &carrier, + ContextBinding::default(), + public, + b"kid", + &limits, + ) + .expect("sealer"); + carrier + .apply_to_headers(&mut req_parts.headers) + .expect("apply carrier"); + + let c0 = sealer.seal_chunk(b"first", false).expect("c0"); + let _c1_final = sealer.seal_chunk(b"second", true).expect("c1"); + + let store = InMemoryReplayStore::new(); + let mut opener = HttpStreamOpener::for_request( + &req_parts, + secret, + &header, + &store, + now, + 5, + ContextBinding::default(), + &limits, + ) + .expect("opener"); + + opener.open_chunk(&c0).expect("open c0"); + // The final chunk is withheld (truncated/cancelled upload): the stream + // must not be considered complete. + assert!(!opener.is_finished()); + } +} diff --git a/foctet-http/src/workers.rs b/foctet-http/src/workers.rs index 5fb9d2b..93ee82a 100644 --- a/foctet-http/src/workers.rs +++ b/foctet-http/src/workers.rs @@ -8,10 +8,145 @@ use foctet_core::BodyEnvelopeLimits; use thiserror::Error; use crate::{ - BODY_ONLY_SCOPE, CONTENT_TYPE, HttpError, HttpOpenOptions, HttpOpener, HttpSealOptions, - HttpSealer, SCOPE_HEADER, + AsyncReplayStore, BODY_ONLY_SCOPE, CONTENT_TYPE, ContextBinding, ContextCarrier, HttpError, + HttpOpenOptions, HttpOpener, HttpSealOptions, HttpSealer, ReplayCheck, ReplayStore, + ReplayStoreError, SCOPE_HEADER, }; +/// Durable Object path used by [`DurableObjectReplayStore`] and +/// [`check_and_insert_in_durable_object`]. This endpoint is internal to a +/// Worker-to-Durable-Object binding and must not be exposed by the public fetch +/// handler. +pub const DURABLE_REPLAY_PATH: &str = "/foctet/replay/v1"; +const DURABLE_REPLAY_URL: &str = "https://foctet.internal/foctet/replay/v1"; + +/// Atomic Durable Object-backed replay store for production Workers deployments. +/// +/// Each message ID is routed to its own deterministically named Durable Object, +/// whose strongly consistent storage makes the check-and-insert operation +/// atomic. Its alarm deletes the one retained entry at expiry. The Durable +/// Object class must delegate its fetch and alarm handlers to +/// [`check_and_insert_in_durable_object`] and +/// [`expire_durable_object_replay_entry`]. +#[derive(Clone, Debug)] +pub struct DurableObjectReplayStore { + namespace: worker::ObjectNamespace, + object_name: String, +} + +impl DurableObjectReplayStore { + /// Creates a replay store backed by the named Durable Object instance. + pub fn new(namespace: worker::ObjectNamespace, object_name: impl Into) -> Self { + Self { + namespace, + object_name: object_name.into(), + } + } +} + +impl AsyncReplayStore for DurableObjectReplayStore { + async fn check_and_insert( + &self, + message_id: &[u8; crate::MESSAGE_ID_LEN], + expires_at_secs: u64, + now_secs: u64, + ) -> Result { + let mut payload = [0u8; crate::MESSAGE_ID_LEN + 16]; + payload[..crate::MESSAGE_ID_LEN].copy_from_slice(message_id); + payload[crate::MESSAGE_ID_LEN..crate::MESSAGE_ID_LEN + 8] + .copy_from_slice(&expires_at_secs.to_be_bytes()); + payload[crate::MESSAGE_ID_LEN + 8..].copy_from_slice(&now_secs.to_be_bytes()); + let bytes = worker::js_sys::Uint8Array::new_with_length(payload.len() as u32); + bytes.copy_from(&payload); + let mut init = worker::RequestInit::new(); + init.with_method(worker::Method::Post) + .with_body(Some(bytes.into())); + let request = worker::Request::new_with_init(DURABLE_REPLAY_URL, &init) + .map_err(|error| ReplayStoreError::Backend(error.to_string()))?; + let response = self + .namespace + .get_by_name(&format!("{}:{}", self.object_name, hex_id(message_id))) + .map_err(|error| ReplayStoreError::Backend(error.to_string()))? + .fetch_with_request(request) + .await + .map_err(|error| ReplayStoreError::Backend(error.to_string()))?; + match response.status_code() { + 201 => Ok(ReplayCheck::Accepted), + 409 => Ok(ReplayCheck::Replay), + status => Err(ReplayStoreError::Backend(format!( + "Durable Object replay endpoint returned HTTP {status}" + ))), + } + } +} + +/// Handles one internal Durable Object replay-store request. +/// +/// Call this from a `worker::DurableObject` fetch method. Durable Object +/// storage input gates serialize the read-then-write sequence, so exactly one +/// concurrent request for a message ID can receive `201 Created`; later calls +/// receive `409 Conflict`. +pub async fn check_and_insert_in_durable_object( + storage: &worker::Storage, + mut request: worker::Request, +) -> worker::Result { + if request.method() != worker::Method::Post || request.path() != DURABLE_REPLAY_PATH { + return worker::Response::error("Not Found", 404); + } + let payload = request.bytes().await?; + if payload.len() != crate::MESSAGE_ID_LEN + 16 { + return worker::Response::error("Bad Request", 400); + } + let mut id = [0u8; crate::MESSAGE_ID_LEN]; + id.copy_from_slice(&payload[..crate::MESSAGE_ID_LEN]); + let mut expiry = [0u8; 8]; + expiry.copy_from_slice(&payload[crate::MESSAGE_ID_LEN..crate::MESSAGE_ID_LEN + 8]); + let expires_at_secs = u64::from_be_bytes(expiry); + let mut now = [0u8; 8]; + now.copy_from_slice(&payload[crate::MESSAGE_ID_LEN + 8..]); + let now_secs = u64::from_be_bytes(now); + if expires_at_secs <= now_secs { + return worker::Response::error("Bad Request", 400); + } + // The client deterministically routes a message ID to this one object. The + // ID is retained in the request format as defense in depth and to make the + // internal protocol self-describing. + let key = "foctet-replay-expiry"; + if let Some(existing_expiry) = storage.get::(key).await? { + let existing_expiry = existing_expiry + .parse::() + .map_err(|_| worker::Error::RustError("invalid Durable Object replay expiry".into()))?; + if existing_expiry > now_secs { + return worker::Response::empty().map(|response| response.with_status(409)); + } + } + storage.put(key, expires_at_secs.to_string()).await?; + storage + .set_alarm(expires_at_secs.saturating_mul(1_000).min(i64::MAX as u64) as i64) + .await?; + worker::Response::empty().map(|response| response.with_status(201)) +} + +/// Removes the one replay entry held by a per-message Durable Object. +/// +/// Call this from the Durable Object's `alarm` handler. An alarm is at-least +/// once, so deleting all state is intentionally idempotent. +pub async fn expire_durable_object_replay_entry( + storage: &worker::Storage, +) -> worker::Result { + storage.delete_all().await?; + worker::Response::empty() +} + +fn hex_id(message_id: &[u8; crate::MESSAGE_ID_LEN]) -> String { + use core::fmt::Write; + let mut out = String::with_capacity(crate::MESSAGE_ID_LEN * 2); + for byte in message_id { + let _ = write!(out, "{byte:02x}"); + } + out +} + /// Lightweight request metadata extracted before body decryption. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkerRequestMetadata { @@ -45,6 +180,35 @@ pub enum WorkersError { Http(#[from] HttpError), } +impl WorkersError { + /// Maps this error to the HTTP status code a Worker should return. + /// + /// The mapping mirrors the axum adapter (`AxumError::into_response`) so both + /// integrations answer a given failure identically: replays are `409`, + /// expired or unopenable contexts are `401`, malformed requests are `400`, + /// and only genuine server-side faults are `500`. + /// + /// Return *only* this status, with no error detail in the response body, so + /// an opening or replay failure cannot leak ciphertext, key, or + /// internal-state information to the caller. + pub fn status_code(&self) -> u16 { + match self { + WorkersError::Http( + HttpError::MissingContentType + | HttpError::InvalidContentType + | HttpError::MissingContext(_) + | HttpError::InvalidContext(_) + | HttpError::ContextTimestampInFuture + | HttpError::StreamIncomplete, + ) => 400, + WorkersError::Http(HttpError::ContextExpired | HttpError::OpenFailed(_)) => 401, + WorkersError::Http(HttpError::Replayed) => 409, + WorkersError::Http(HttpError::SealFailed(_) | HttpError::ReplayStore(_)) => 500, + WorkersError::Worker(_) => 500, + } + } +} + /// High-level Workers request opener. #[derive(Clone, Debug)] pub struct WorkersOpener { @@ -86,6 +250,15 @@ impl WorkersOpener { } /// Opens an encrypted Workers request into convenience metadata and plaintext bytes. + /// + /// Body-only; no replay protection or HTTP-context binding. Prefer + /// [`WorkersOpener::open_request_with_context`] for production. + #[deprecated( + since = "0.3.0", + note = "stateless full-request protection has no replay defense or HTTP-context \ + binding and is replayable by design; use open_request_with_context (or \ + open_request_with_async_store) with a ReplayStore for production" + )] pub async fn open_request( &self, request: worker::Request, @@ -97,6 +270,53 @@ impl WorkersOpener { plaintext, }) } + + /// Opens an encrypted Workers request, enforcing the bound HTTP protected + /// context, freshness, and single use against `store`. + /// + /// This is the recommended path for production Workers deployments: pair + /// it with a durable [`AsyncReplayStore`] (e.g. Cloudflare KV) when more + /// than one Worker instance may see the same request. + pub async fn open_request_with_context( + &self, + mut request: worker::Request, + store: &S, + now_secs: u64, + max_skew_secs: u64, + binding: ContextBinding, + ) -> Result>, WorkersError> + where + S: ReplayStore + ?Sized, + { + let parts = worker_request_to_http_parts(&request)?; + let body = request.bytes().await?; + let http_request = http::Request::from_parts(parts, body); + self.opener + .open_request_with_context(http_request, store, now_secs, max_skew_secs, binding) + .map_err(WorkersError::Http) + } + + /// Opens an encrypted Workers request using a durable [`AsyncReplayStore`] + /// (Cloudflare KV, a Durable Object, or any other shared backend). + pub async fn open_request_with_async_store( + &self, + mut request: worker::Request, + store: &S, + now_secs: u64, + max_skew_secs: u64, + binding: ContextBinding, + ) -> Result>, WorkersError> + where + S: AsyncReplayStore + ?Sized, + { + let parts = worker_request_to_http_parts(&request)?; + let body = request.bytes().await?; + let http_request = http::Request::from_parts(parts, body); + self.opener + .open_request_with_async_store(http_request, store, now_secs, max_skew_secs, binding) + .await + .map_err(WorkersError::Http) + } } impl WorkersSealer { @@ -125,6 +345,31 @@ impl WorkersSealer { response.headers_mut().set(SCOPE_HEADER, BODY_ONLY_SCOPE)?; Ok(response) } + + /// Seals a plaintext response with bound protected context (status, + /// message ID, timestamp, expiry, answered request message ID) into a + /// Workers response. + pub fn seal_response_with_context( + &self, + status: u16, + plaintext: Vec, + carrier: ContextCarrier, + ) -> Result { + let response = http::Response::builder() + .status(status) + .body(plaintext) + .expect("status and empty header map always build a valid response"); + let sealed = self.sealer.seal_response_with_context(response, carrier)?; + let (parts, body) = sealed.into_parts(); + let mut response = worker::Response::from_bytes(body)?; + response = response.with_status(parts.status.as_u16()); + for (name, value) in parts.headers.iter() { + response + .headers_mut() + .set(name.as_str(), value.to_str().unwrap_or_default())?; + } + Ok(response) + } } /// Opens an encrypted Workers request body into plaintext bytes. @@ -149,6 +394,13 @@ pub async fn open_worker_request_body_with_limits( } /// Opens an encrypted Workers request into metadata and plaintext body bytes. +#[deprecated( + since = "0.3.0", + note = "stateless full-request protection has no replay defense or HTTP-context binding \ + and is replayable by design; use WorkersOpener::open_request_with_context with a \ + ReplayStore for production" +)] +#[allow(deprecated)] pub async fn open_worker_request( request: worker::Request, recipient_secret_key: [u8; 32], @@ -159,6 +411,13 @@ pub async fn open_worker_request( } /// Opens an encrypted Workers request into metadata and plaintext bytes with explicit limits. +#[deprecated( + since = "0.3.0", + note = "stateless full-request protection has no replay defense or HTTP-context binding \ + and is replayable by design; use WorkersOpener::open_request_with_context with a \ + ReplayStore for production" +)] +#[allow(deprecated)] pub async fn open_worker_request_with_limits( request: worker::Request, recipient_secret_key: [u8; 32], @@ -195,6 +454,32 @@ fn extract_worker_request_metadata( }) } +/// Builds `http::request::Parts` (method, URI, headers) from a `worker::Request` +/// so the protected-context binding sees the same routing metadata the Worker +/// runtime dispatched on. +fn worker_request_to_http_parts( + request: &worker::Request, +) -> Result { + let method = http::Method::from_bytes(request.method().to_string().as_bytes()) + .map_err(|err| worker::Error::RustError(err.to_string()))?; + let url = request.url()?; + let uri = url + .as_str() + .parse::() + .map_err(|err| worker::Error::RustError(err.to_string()))?; + let headers = worker_headers_to_http(request)?; + + let mut builder = http::Request::builder().method(method).uri(uri); + for (name, value) in headers.iter() { + builder = builder.header(name, value); + } + let (parts, _) = builder + .body(()) + .map_err(|err| worker::Error::RustError(err.to_string()))? + .into_parts(); + Ok(parts) +} + fn worker_headers_to_http(request: &worker::Request) -> Result { let mut out = http::HeaderMap::new(); for (name, value) in request.headers().entries() { diff --git a/foctet-transport/Cargo.toml b/foctet-transport/Cargo.toml index 2fd50d4..11d98ba 100644 --- a/foctet-transport/Cargo.toml +++ b/foctet-transport/Cargo.toml @@ -2,6 +2,7 @@ name = "foctet-transport" version.workspace = true edition.workspace = true +rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true @@ -14,23 +15,36 @@ runtime-tokio = ["dep:tokio", "foctet-core/runtime-tokio", "dep:futures-sink"] runtime-futures = ["dep:futures-io", "dep:futures-sink", "dep:futures-util", "foctet-core/runtime-futures"] transport-muxtls = ["dep:muxtls"] transport-webtrans = ["dep:webtrans"] -transport-websock = ["dep:websock", "dep:websock-tungstenite-mux"] -transport-quinn = ["dep:quinn"] +# Cross-platform raw-WebSocket message transport (`WebsockMessageTransport`), +# usable on native and in the browser (wasm) via the `websock` crate's backends. +transport-websock = ["dep:websock", "dep:futures-util"] +# Native multiplexed-WebSocket byte-stream helpers (require a Tokio runtime). +transport-websock-mux = ["transport-websock", "runtime-tokio", "dep:websock-tungstenite-mux"] +transport-quinn = ["dep:quinn", "dep:bytes"] +# Browser WebTransport datagram adapter (wasm32 only): implements +# `DatagramTransport` over a `WebTransport.datagrams` duplex handed in from JS. +transport-webtrans-browser = ["dep:wasm-bindgen", "dep:js-sys", "dep:wasm-bindgen-futures"] [dependencies] foctet-core = { workspace = true } pin-project-lite = "0.2" futures-io = { version = "0.3", optional = true } futures-sink = { version = "0.3", optional = true } -futures-util = { version = "0.3", default-features = false, features = ["io"], optional = true } -tokio = { version = "1.48", features = ["io-util"], optional = true } +futures-util = { version = "0.3", default-features = false, features = ["io", "std"], optional = true } +tokio = { version = "1.48", features = ["io-util", "time", "sync"], optional = true } muxtls = { version = "0.1.0", default-features = false, optional = true } webtrans = { version = "0.4.0", default-features = false, optional = true } websock = { version = "0.4.0", default-features = false, optional = true } websock-tungstenite-mux = { version = "0.4.0", optional = true } quinn = { version = "0.11.9", default-features = false, optional = true } +bytes = { version = "1", optional = true } thiserror = { workspace = true } +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = { version = "0.2", optional = true } +js-sys = { version = "0.3", optional = true } +wasm-bindgen-futures = { version = "0.4", optional = true } + [dev-dependencies] tokio = { version = "1.48", features = ["macros", "rt", "rt-multi-thread", "io-util", "time"] } foctet-core = { workspace = true, features = ["runtime-tokio"] } @@ -41,6 +55,7 @@ rcgen = "0.14" url = "2" clap = { version = "4", features = ["derive"] } rustls-pemfile = "2" +bytes = "1" [[example]] name = "muxtls_split" @@ -55,9 +70,24 @@ required-features = ["runtime-tokio", "transport-webtrans"] [[example]] name = "websock_split" path = "examples/websock_split.rs" -required-features = ["runtime-tokio", "transport-websock"] +required-features = ["runtime-tokio", "transport-websock-mux"] [[example]] name = "quinn_split" path = "examples/quinn_split.rs" required-features = ["runtime-tokio", "transport-quinn"] + +[[example]] +name = "udp_datagram_split" +path = "examples/udp_datagram_split.rs" +required-features = ["runtime-tokio"] + +[[example]] +name = "websock_message_server" +path = "examples/websock_message_server.rs" +required-features = ["runtime-tokio", "transport-websock"] + +[[example]] +name = "webtrans_datagram_split" +path = "examples/webtrans_datagram_split.rs" +required-features = ["runtime-tokio", "transport-webtrans"] diff --git a/foctet-transport/examples/README.md b/foctet-transport/examples/README.md index 4057868..7768c7c 100644 --- a/foctet-transport/examples/README.md +++ b/foctet-transport/examples/README.md @@ -16,6 +16,58 @@ Common properties: - They pin peer identities with `SessionAuthConfig`. - They assert `peer_authenticated()` before exchanging application data. +## Running across two processes / two hosts + +`quinn_split` and `websock_split` take a `--role`: + +- `--role loopback` (default): both peers in one process, ephemeral port — a + quick smoke test (`cargo run --example quinn_split ...` with no args). +- `--role server --addr --tls-cert devcert/localhost.crt --tls-key devcert/localhost.key` +- `--role client --addr --tls-cert devcert/localhost.crt` + +Generate the dev cert first with `devcert/generate.sh`. Add `--wrong-identity` +to the client to see the server reject a mismatched pinned identity +(`peer identity mismatch`). For two real hosts, copy `devcert/localhost.crt` to +the client and dial the server's address; the client validates SNI `localhost`, +which the cert's SAN covers. See `tests.md` (§3) for the full runbook. + +`quinn_split` also takes `--messages ` (request/reply round-trips per stream) +and `--rekey-frames ` (lower `RekeyThresholds::max_frames` to force frequent +DH-ratchet rekeys); it prints each rekey so the alternating ratchet is +observable. See `tests.md` (§7) for the rekey runbook. + +`udp_datagram_split` is the raw-UDP two-process driver: it runs the Foctet +handshake over a reliable TCP control channel, then exchanges sealed datagrams +over a connected `UdpDatagramTransport`. The server enables +`with_anti_amplification(3)` and refuses to send until the first client datagram +validates the address. It takes `--role`, `--control-addr`, `--udp-addr`, +`--datagrams`, and `--anti-amplification`. See `tests.md` (§3.6). + +`websock_message_server` is the native raw-WebSocket **message** endpoint +(`WebsockMessageTransport` + `SecureMessageChannel`, one Foctet frame per binary +WebSocket message) — the counterpart the browser WASM SDK speaks with +`sealMessage`/`openMessage`. `--role server` is the responder for the browser +interop page (`foctet-wasm/examples/browser/websocket.html`); `--role client` / +`--role loopback` drive the same wire format natively. Needs +`--features "runtime-tokio transport-websock"`. See `tests.md` (§3.3). + +`webtrans_datagram_split` is the native WebTransport **datagram** endpoint: the +authenticated Foctet handshake runs over a reliable bidi stream, then sealed data +flows as WebTransport datagrams (one Foctet datagram frame each — what the browser +SDK's `sealDatagram`/`openDatagram` produce). `--role server` (with +`--tls-cert`/`--tls-key`) is the responder for the browser page +(`foctet-wasm/examples/browser/webtransport.html`, which pins the cert via +`serverCertificateHashes`); `--role client` / `--role loopback` drive the same +wire format natively. Needs `--features "runtime-tokio transport-webtrans"`. See +`tests.md` (§3.5). (`webtrans_split` remains the streams-only loopback smoke +test.) + +`muxtls_split` and `webtrans_split` are currently **loopback-only** smoke tests: +muxtls pre-establishes its Foctet sessions in-process (mutual TLS is the peer +authenticator), and WebTransport's meaningful real test is a browser client +against a native server (see `tests.md` §3.5). A two-process muxtls variant +(running the Foctet handshake over the muxtls stream) is future work. + Notes: - Demo certificates and keys are for local development only. diff --git a/foctet-transport/examples/muxtls_split.rs b/foctet-transport/examples/muxtls_split.rs index a863d07..1c64be2 100644 --- a/foctet-transport/examples/muxtls_split.rs +++ b/foctet-transport/examples/muxtls_split.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use ::muxtls::{ClientConfig, Endpoint, ServerConfig}; use clap::Parser; -use foctet_core::{RekeyThresholds, Session}; +use foctet_core::{RekeyThresholds, Session, SessionAuthConfig}; use foctet_transport::{TokioTransportBuilder, TransportConfig}; use rustls::pki_types::CertificateDer; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -26,8 +26,16 @@ struct Args { fn make_session_pair() -> Result<(Session, Session), foctet_core::CoreError> { let thresholds = RekeyThresholds::default(); - let (mut initiator, hello) = Session::new_initiator(thresholds.clone()); - let mut responder = Session::new_responder(thresholds); + // The mutually authenticated muxtls/TLS transport authenticates the peer, + // so the inner Foctet handshake runs in explicit unauthenticated mode. + let (mut initiator, hello) = Session::new_initiator_with_auth( + thresholds.clone(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut responder = Session::new_responder_with_auth( + thresholds, + SessionAuthConfig::unauthenticated_for_testing(), + ); let server_hello = responder .handle_control(&hello)? diff --git a/foctet-transport/examples/quinn_split.rs b/foctet-transport/examples/quinn_split.rs index 725a6db..7b8e6d2 100644 --- a/foctet-transport/examples/quinn_split.rs +++ b/foctet-transport/examples/quinn_split.rs @@ -4,26 +4,66 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use ::quinn as quinn_transport; -use clap::Parser; +use clap::{Parser, ValueEnum}; +use foctet_core::observe::{SessionEvent, SessionObserver}; use foctet_core::{IdentityKeyPair, PeerIdentity, RekeyThresholds, SessionAuthConfig}; use foctet_transport::adapter::SplitIo; use foctet_transport::{TokioTransportBuilder, TransportConfig}; use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; use tokio::io::AsyncWriteExt; -use tokio::sync::oneshot; use tokio::task::JoinSet; const STREAM_COUNT: usize = 2; const STREAM_TAG_LEN: usize = 4; +/// How to run the example. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] +enum Role { + /// Run both peers in one process over the loopback (quick smoke test). + #[default] + Loopback, + /// Run only the server: bind `--addr` and serve clients until Ctrl+C. + Server, + /// Run only the client: connect to a server at `--addr`. + Client, +} + #[derive(Debug, Parser)] struct Args { - /// TLS certificate path (PEM/DER). Use together with --tls-key. + /// Which side to run. `loopback` (default) runs both in one process; use + /// `server` and `client` in separate terminals / on two hosts. + #[arg(long, value_enum, default_value_t = Role::Loopback)] + role: Role, + /// Server: address to bind. Client: address to connect to. + /// Ignored for `loopback`. Default `127.0.0.1:4433`. + #[arg(long, default_value = "127.0.0.1:4433")] + addr: SocketAddr, + /// TLS server name (SNI) the client validates against. Must match a cert SAN + /// (the dev cert uses `localhost`). Default `localhost`. + #[arg(long, default_value = "localhost")] + server_name: String, + /// TLS certificate path (PEM/DER). Required for `server`/`client` roles + /// (server presents it; client trusts it). Use together with --tls-key on + /// the server. #[arg(long)] tls_cert: Option, - /// TLS private key path (PEM/DER). Use together with --tls-cert. + /// TLS private key path (PEM/DER). Required for the `server` role. #[arg(long)] tls_key: Option, + /// Client only: present an identity the server did NOT pin, to demonstrate + /// that identity-mismatch is rejected (the handshake must fail). + #[arg(long, default_value_t = false)] + wrong_identity: bool, + /// Number of application messages to exchange per stream (request/reply + /// round-trips). Raise it together with `--rekey-frames` to cross several + /// DH-ratchet rekeys over a single live session. Default `1`. + #[arg(long, default_value_t = 1)] + messages: usize, + /// Override `RekeyThresholds::max_frames` (frames sent before a rekey is + /// triggered). Lower it (e.g. `--rekey-frames 4`) to force frequent rekeys + /// for testing; unset keeps the library default. See §7 of `tests.md`. + #[arg(long)] + rekey_frames: Option, } fn auth_config_pair(idx: usize) -> (SessionAuthConfig, SessionAuthConfig) { @@ -141,13 +181,50 @@ fn take_tagged_auth( Ok((idx, auth)) } -async fn run_server( - endpoint: quinn_transport::Endpoint, +fn rekey_thresholds(rekey_frames: Option) -> RekeyThresholds { + let mut thresholds = RekeyThresholds::default(); + if let Some(max_frames) = rekey_frames { + thresholds.max_frames = max_frames; + } + thresholds +} + +/// Prints DH-ratchet rekey events so a live run can confirm that both sides' +/// keys actually rotate (see §7 of `tests.md`) — successful message delivery +/// alone would not distinguish a working ratchet from one that never fires. +struct RekeyLogger { + side: &'static str, + idx: usize, +} + +impl SessionObserver for RekeyLogger { + fn on_session_event(&self, event: SessionEvent) { + match event { + SessionEvent::RekeyInitiated { + old_key_id, + new_key_id, + } => println!( + "[{} stream {}] rekey initiated {old_key_id}->{new_key_id}", + self.side, self.idx + ), + SessionEvent::RekeyApplied { + old_key_id, + new_key_id, + } => println!( + "[{} stream {}] rekey applied {old_key_id}->{new_key_id}", + self.side, self.idx + ), + _ => {} + } + } +} + +async fn serve_connection( + connection: quinn_transport::Connection, server_auth_configs: Vec, - shutdown: oneshot::Receiver<()>, + messages: usize, + thresholds: RekeyThresholds, ) -> Result<(), Box> { - let incoming = endpoint.accept().await.ok_or("endpoint closed")?; - let connection = incoming.await?; let config = TransportConfig::default().with_app_stream_id(1); let builder = TokioTransportBuilder::new().with_config(config); let mut server_auth_configs = server_auth_configs @@ -169,7 +246,7 @@ async fn run_server( let channel = builder .establish_responder_with_auth( SplitIo::from_split(recv, send), - RekeyThresholds::default(), + thresholds.clone(), auth, ) .await?; @@ -180,12 +257,18 @@ async fn run_server( for (idx, mut channel) in channels { tasks.spawn(async move { assert!(channel.session().peer_authenticated()); - let incoming = channel.recv_application().await?; - let reply = format!( - "quinn stream {idx} reply to: {}", - String::from_utf8_lossy(&incoming) - ); - channel.send_application(reply.as_bytes()).await?; + channel.session_mut().set_observer(Arc::new(RekeyLogger { + side: "server", + idx, + })); + for msg_idx in 0..messages { + let incoming = channel.recv_application().await?; + let reply = format!( + "quinn stream {idx} message {msg_idx} reply to: {}", + String::from_utf8_lossy(&incoming) + ); + channel.send_application(reply.as_bytes()).await?; + } Ok::<(), Box>(()) }); } @@ -198,16 +281,20 @@ async fn run_server( Err(err) => return Err(Box::new(err)), } } - let _ = shutdown.await; + // Keep the connection alive until the client has read its replies and closed. + connection.closed().await; Ok(()) } async fn run_client( endpoint: quinn_transport::Endpoint, remote: SocketAddr, + server_name: &str, client_auth_configs: Vec, + messages: usize, + thresholds: RekeyThresholds, ) -> Result<(), Box> { - let connection = endpoint.connect(remote, "localhost")?.await?; + let connection = endpoint.connect(remote, server_name)?.await?; let config = TransportConfig::default().with_app_stream_id(1); let builder = TokioTransportBuilder::new().with_config(config); let mut channels = Vec::with_capacity(client_auth_configs.len()); @@ -225,7 +312,7 @@ async fn run_client( let channel = builder .establish_initiator_with_auth( SplitIo::from_split(recv, send), - RekeyThresholds::default(), + thresholds.clone(), auth, ) .await?; @@ -236,14 +323,20 @@ async fn run_client( for (idx, mut channel) in channels { tasks.spawn(async move { assert!(channel.session().peer_authenticated()); - let payload = format!("hello from quinn stream {idx}"); - channel.send_application(payload.as_bytes()).await?; - let response = channel.recv_application().await?; - - println!( - "client stream {idx} got: {}", - String::from_utf8_lossy(&response) - ); + channel.session_mut().set_observer(Arc::new(RekeyLogger { + side: "client", + idx, + })); + for msg_idx in 0..messages { + let payload = format!("hello from quinn stream {idx} message {msg_idx}"); + channel.send_application(payload.as_bytes()).await?; + let response = channel.recv_application().await?; + + println!( + "client stream {idx} message {msg_idx} got: {}", + String::from_utf8_lossy(&response) + ); + } Ok::<(), Box>(()) }); } @@ -259,13 +352,30 @@ async fn run_client( Ok(()) } -#[tokio::main(flavor = "multi_thread")] -async fn main() -> Result<(), Box> { - let args = Args::parse(); - let (client_auth_configs, server_auth_configs): (Vec<_>, Vec<_>) = - (0..STREAM_COUNT).map(auth_config_pair).unzip(); - let (cert_chain, key) = resolve_cert_pair(&args)?; +fn server_auth_configs() -> Vec { + (0..STREAM_COUNT) + .map(|idx| auth_config_pair(idx).1) + .collect() +} +fn client_auth_configs(wrong_identity: bool) -> Vec { + (0..STREAM_COUNT) + .map(|idx| { + let client = auth_config_pair(idx).0; + if wrong_identity { + // Replace the local identity with one the server never pinned, so + // the server's `require_peer_authentication` check must reject. + client.with_local_identity(IdentityKeyPair::from_secret_key_bytes([0xFF; 32])) + } else { + client + } + }) + .collect() +} + +/// Loopback: both peers in one process over an ephemeral port (quick smoke test). +async fn run_loopback(args: &Args) -> Result<(), Box> { + let (cert_chain, key) = resolve_cert_pair(args)?; let bind_addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)); let server_config = configure_server(cert_chain.clone(), key)?; let server_endpoint = quinn_transport::Endpoint::server(server_config, bind_addr)?; @@ -275,26 +385,118 @@ async fn main() -> Result<(), Box> { let mut client_endpoint = quinn_transport::Endpoint::client(bind_addr)?; client_endpoint.set_default_client_config(client_config); - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - let server_task = tokio::spawn(run_server( - server_endpoint, - server_auth_configs, - shutdown_rx, - )); - if let Err(err) = run_client(client_endpoint, server_addr, client_auth_configs).await + let messages = args.messages; + let server_thresholds = rekey_thresholds(args.rekey_frames); + let client_thresholds = rekey_thresholds(args.rekey_frames); + + let server_task = tokio::spawn(async move { + let incoming = server_endpoint.accept().await.ok_or("endpoint closed")?; + let connection = incoming.await?; + serve_connection( + connection, + server_auth_configs(), + messages, + server_thresholds, + ) + .await + }); + if let Err(err) = run_client( + client_endpoint, + server_addr, + "localhost", + client_auth_configs(false), + messages, + client_thresholds, + ) + .await && !is_graceful_quinn_close(err.as_ref()) { return Err(err); } - let _ = shutdown_tx.send(()); - match server_task.await { Ok(Ok(())) => {} Ok(Err(err)) if is_graceful_quinn_close(err.as_ref()) => {} Ok(Err(err)) => return Err(err), Err(err) => return Err(Box::new(err)), } - println!("quinn multi-stream foctet E2EE example finished"); Ok(()) } + +/// Server role: bind `addr` and serve incoming connections until Ctrl+C. +async fn run_server_role(args: &Args) -> Result<(), Box> { + if args.tls_cert.is_none() || args.tls_key.is_none() { + return Err( + "the `server` role requires --tls-cert and --tls-key (e.g. devcert/localhost.crt \ + and devcert/localhost.key); the client must trust the same cert" + .into(), + ); + } + let (cert_chain, key) = resolve_cert_pair(args)?; + let server_config = configure_server(cert_chain, key)?; + let endpoint = quinn_transport::Endpoint::server(server_config, args.addr)?; + println!("quinn server listening on {} (Ctrl+C to stop)", args.addr); + + loop { + let Some(incoming) = endpoint.accept().await else { + break; + }; + let connection = match incoming.await { + Ok(connection) => connection, + Err(err) => { + eprintln!("connection failed: {err}"); + continue; + } + }; + let peer = connection.remote_address(); + println!("accepted connection from {peer}"); + + let messages = args.messages; + let thresholds = rekey_thresholds(args.rekey_frames); + + match serve_connection(connection, server_auth_configs(), messages, thresholds).await { + Ok(()) => println!("served {peer}"), + Err(err) if is_graceful_quinn_close(err.as_ref()) => {} + Err(err) => eprintln!("error serving {peer}: {err}"), + } + } + Ok(()) +} + +/// Client role: connect to a server at `addr`. +async fn run_client_role(args: &Args) -> Result<(), Box> { + let Some(cert) = &args.tls_cert else { + return Err( + "the `client` role requires --tls-cert (the server's cert, to trust it; \ + e.g. devcert/localhost.crt)" + .into(), + ); + }; + let cert_chain = load_cert_chain(cert)?; + let client_config = configure_client(&cert_chain)?; + let bind_addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)); + let mut endpoint = quinn_transport::Endpoint::client(bind_addr)?; + endpoint.set_default_client_config(client_config); + println!("quinn client connecting to {}", args.addr); + run_client( + endpoint, + args.addr, + &args.server_name, + client_auth_configs(args.wrong_identity), + args.messages, + rekey_thresholds(args.rekey_frames), + ) + .await?; + println!("quinn client finished"); + Ok(()) +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + match args.role { + Role::Loopback => run_loopback(&args).await, + Role::Server => run_server_role(&args).await, + Role::Client => run_client_role(&args).await, + } +} diff --git a/foctet-transport/examples/udp_datagram_split.rs b/foctet-transport/examples/udp_datagram_split.rs new file mode 100644 index 0000000..101d374 --- /dev/null +++ b/foctet-transport/examples/udp_datagram_split.rs @@ -0,0 +1,238 @@ +// Two-process raw-UDP datagram example (TODO §3.5, tests.md §3.6). +// +// Raw UDP has no handshake of its own, so this example follows the recommended +// pattern: run the authenticated Foctet handshake over a *reliable* control +// channel (here a plain TCP stream), then build a `SecureDatagramChannel` from +// the resulting session over a *connected* `UdpDatagramTransport` on each side +// and exchange sealed datagrams. +// +// It also demonstrates anti-amplification: the server enables +// `with_anti_amplification(3)`, so before the client has proven it can receive +// at its claimed address the server refuses to send (a spoofed source cannot +// turn the server into a reflector). Once the first client datagram arrives the +// server calls `mark_peer_validated()` and the cap is lifted. +// +// The two peers derive their pinned identities from fixed seeds, so the +// processes authenticate each other without any out-of-band key exchange. + +use std::error::Error; +use std::io::ErrorKind; +use std::net::SocketAddr; + +use clap::{Parser, ValueEnum}; +use foctet_core::{IdentityKeyPair, PeerIdentity, RekeyThresholds, SessionAuthConfig}; +use foctet_transport::udp::UdpDatagramTransport; +use foctet_transport::{DatagramChannelError, SecureDatagramChannel, TokioTransportBuilder}; +use tokio::net::{TcpListener, TcpStream, UdpSocket}; + +/// How to run the example. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] +enum Role { + /// Run both peers in one process over the loopback (quick smoke test). + #[default] + Loopback, + /// Run only the server: bind the control/UDP addresses and serve one client. + Server, + /// Run only the client: connect to a server's control address. + Client, +} + +#[derive(Debug, Parser)] +struct Args { + /// Which side to run. `loopback` (default) runs both in one process; use + /// `server` and `client` in separate terminals / on two hosts. + #[arg(long, value_enum, default_value_t = Role::Loopback)] + role: Role, + /// Reliable control channel (TCP). Server binds it; client connects to it. + /// Ignored for `loopback`. Default `127.0.0.1:4455`. + #[arg(long, default_value = "127.0.0.1:4455")] + control_addr: SocketAddr, + /// UDP socket to bind on this side. The server advertises its bound address + /// over the control channel, so the client leaves this ephemeral. Server + /// default `127.0.0.1:4456`; client default `127.0.0.1:0`. + #[arg(long)] + udp_addr: Option, + /// Number of datagrams the client sends (each echoed back). Default `4`. + #[arg(long, default_value_t = 4)] + datagrams: usize, + /// Anti-amplification factor the server enforces until the client is + /// address-validated (QUIC's default is 3). Default `3`. + #[arg(long, default_value_t = 3)] + anti_amplification: u64, +} + +fn auth_config_pair() -> (SessionAuthConfig, SessionAuthConfig) { + let client_identity = IdentityKeyPair::from_secret_key_bytes([0x41; 32]); + let server_identity = IdentityKeyPair::from_secret_key_bytes([0x61; 32]); + let client = SessionAuthConfig::new() + .with_local_identity(client_identity.clone()) + .with_peer_identity(PeerIdentity::new(server_identity.public_key())) + .require_peer_authentication(true); + let server = SessionAuthConfig::new() + .with_local_identity(server_identity) + .with_peer_identity(PeerIdentity::new(client_identity.public_key())) + .require_peer_authentication(true); + (client, server) +} + +/// Server side: authenticate over `control`, agree on UDP addresses, then run +/// the anti-amplification-gated datagram echo over `udp`. The client dictates +/// how many datagrams it will send (over the control channel), so the two +/// processes agree without a shared count argument. +async fn serve( + control: TcpStream, + udp: UdpSocket, + factor: u64, +) -> Result<(), Box> { + let (_client_auth, server_auth) = auth_config_pair(); + let mut channel = TokioTransportBuilder::new() + .establish_responder_with_auth(control, RekeyThresholds::default(), server_auth) + .await?; + assert!(channel.session().peer_authenticated()); + + // Advertise our bound UDP address, then learn the client's address and how + // many datagrams it will send. Ordering (server sends first, client + // receives first) avoids a control-channel deadlock. + let local_udp = udp.local_addr()?; + channel + .send_application(local_udp.to_string().as_bytes()) + .await?; + let peer_udp: SocketAddr = String::from_utf8(channel.recv_application().await?)?.parse()?; + let datagrams: usize = String::from_utf8(channel.recv_application().await?)?.parse()?; + udp.connect(peer_udp).await?; + + let transport = UdpDatagramTransport::new(udp).with_anti_amplification(factor); + let mut datagram = SecureDatagramChannel::from_active_session(transport, channel.session())?; + + // Anti-amplification: with zero bytes received the budget is zero, so an + // unsolicited send is refused. Proves a spoofed peer cannot be amplified. + match datagram.send_datagram(0, 0, b"unsolicited probe").await { + Err(DatagramChannelError::Transport(err)) if err.kind() == ErrorKind::WouldBlock => { + println!("anti-amplification: refused to send before validation (WouldBlock) — ok"); + } + Ok(()) => return Err("anti-amplification failed: unsolicited send was allowed".into()), + Err(err) => return Err(Box::new(err)), + } + + for idx in 0..datagrams { + let incoming = datagram.recv_datagram().await?; + if idx == 0 { + // The client reached us at its claimed address; lift the cap. + datagram.transport().mark_peer_validated(); + println!("received first client datagram — marked peer validated, cap lifted"); + } + let reply = format!( + "udp echo {idx} reply to: {}", + String::from_utf8_lossy(&incoming.plaintext) + ); + datagram.send_datagram(0, 0, reply.as_bytes()).await?; + } + println!("server echoed {datagrams} datagram(s)"); + Ok(()) +} + +/// Client side: authenticate over `control`, agree on UDP addresses, then send +/// `datagrams` sealed datagrams and read each echo. +async fn run_client( + control: TcpStream, + udp: UdpSocket, + datagrams: usize, +) -> Result<(), Box> { + let (client_auth, _server_auth) = auth_config_pair(); + let mut channel = TokioTransportBuilder::new() + .establish_initiator_with_auth(control, RekeyThresholds::default(), client_auth) + .await?; + assert!(channel.session().peer_authenticated()); + + // Learn the server's UDP address, connect, then advertise ours and the + // number of datagrams we will send so the server echoes exactly that many. + let peer_udp: SocketAddr = String::from_utf8(channel.recv_application().await?)?.parse()?; + udp.connect(peer_udp).await?; + let local_udp = udp.local_addr()?; + channel + .send_application(local_udp.to_string().as_bytes()) + .await?; + channel + .send_application(datagrams.to_string().as_bytes()) + .await?; + + let transport = UdpDatagramTransport::new(udp); + let mut datagram = SecureDatagramChannel::from_active_session(transport, channel.session())?; + + for idx in 0..datagrams { + let payload = format!("hello udp datagram {idx}"); + datagram.send_datagram(0, 0, payload.as_bytes()).await?; + let echo = datagram.recv_datagram().await?; + println!( + "client datagram {idx} got: {}", + String::from_utf8_lossy(&echo.plaintext) + ); + } + Ok(()) +} + +async fn run_loopback(args: &Args) -> Result<(), Box> { + // Bind everything on ephemeral ports so repeated runs never collide. + let control_listener = TcpListener::bind("127.0.0.1:0").await?; + let control_addr = control_listener.local_addr()?; + let server_udp = UdpSocket::bind("127.0.0.1:0").await?; + let factor = args.anti_amplification; + let datagrams = args.datagrams; + + let server_task = tokio::spawn(async move { + let (control, _peer) = control_listener.accept().await?; + serve(control, server_udp, factor).await + }); + + let control = TcpStream::connect(control_addr).await?; + let client_udp = UdpSocket::bind("127.0.0.1:0").await?; + run_client(control, client_udp, datagrams).await?; + server_task.await??; + Ok(()) +} + +async fn run_server_role(args: &Args) -> Result<(), Box> { + let udp_addr = args.udp_addr.unwrap_or("127.0.0.1:4456".parse()?); + let control_listener = TcpListener::bind(args.control_addr).await?; + println!( + "udp datagram server: control on tcp://{}, datagrams on udp://{} (Ctrl+C to stop)", + args.control_addr, udp_addr + ); + loop { + let (control, peer) = control_listener.accept().await?; + println!("accepted control connection from {peer}"); + // Fresh UDP socket per connection so the previous one's port is free. + let udp = UdpSocket::bind(udp_addr).await?; + match serve(control, udp, args.anti_amplification).await { + Ok(()) => println!("served {peer}"), + Err(err) => eprintln!("error serving {peer}: {err}"), + } + } +} + +async fn run_client_role(args: &Args) -> Result<(), Box> { + let udp_addr = args.udp_addr.unwrap_or("127.0.0.1:0".parse()?); + println!( + "udp datagram client connecting to tcp://{}", + args.control_addr + ); + let control = TcpStream::connect(args.control_addr).await?; + let udp = UdpSocket::bind(udp_addr).await?; + run_client(control, udp, args.datagrams).await?; + println!("udp datagram client finished"); + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + match args.role { + Role::Loopback => { + run_loopback(&args).await?; + println!("udp datagram loopback example finished"); + } + Role::Server => run_server_role(&args).await?, + Role::Client => run_client_role(&args).await?, + } + Ok(()) +} diff --git a/foctet-transport/examples/websock_message_server.rs b/foctet-transport/examples/websock_message_server.rs new file mode 100644 index 0000000..152ce51 --- /dev/null +++ b/foctet-transport/examples/websock_message_server.rs @@ -0,0 +1,240 @@ +// Native raw-WebSocket message echo endpoint for the browser interop test +// (tests.md §3.3). A browser page drives the WASM `FoctetSession` as the +// handshake *initiator* over a browser `WebSocket`; the `server` role here is +// the *responder*. Each WebSocket binary message carries exactly one Foctet +// frame: first the handshake control messages, then sealed application +// messages. +// +// Unlike `websock_split` (which uses the multiplexed `websock-mux` byte-stream +// shape), this speaks the raw-message shape (`WebsockMessageTransport` + +// `SecureMessageChannel`) that the browser SDK produces with +// `sealMessage`/`openMessage`, so it is the native counterpart for a Rust/wasm +// front-end. The `client`/`loopback` roles drive the same wire format from +// native code, so the protocol can be smoke-tested without a browser. +// +// The demo keys are hardcoded for local examples only; each side pins the +// other's identity. + +use std::error::Error; +use std::net::SocketAddr; + +use clap::{Parser, ValueEnum}; +use foctet_core::{ + ControlMessage, IdentityKeyPair, PeerIdentity, RekeyThresholds, Session, SessionAuthConfig, +}; +use foctet_transport::websock::WebsockMessageTransport; +use foctet_transport::{MessageTransport, SecureMessageChannel}; +use websock::{ClientBuilder, ServerBuilder, WebSocketConnection}; + +// Demo identities shared with the browser page (see examples/browser). The +// initiator (browser or `client` role) uses `CLIENT_SECRET`; the responder +// (`server` role) uses `SERVER_SECRET`. Each side pins the other's public key. +const CLIENT_SECRET: [u8; 32] = [0x41; 32]; +const SERVER_SECRET: [u8; 32] = [0x61; 32]; + +/// How to run the example. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] +enum Role { + /// Run a native client and server in one process (quick smoke test). + #[default] + Loopback, + /// Run only the responder: bind `--addr` and serve browser/native clients. + Server, + /// Run only the native initiator: connect to a server at `--addr`. + Client, +} + +#[derive(Debug, Parser)] +struct Args { + /// Which side to run. `loopback` (default) runs both natively in one + /// process; use `server` for the browser test and `client` for a native + /// initiator. + #[arg(long, value_enum, default_value_t = Role::Loopback)] + role: Role, + /// Server: address to bind (`ws://`). Client: address to connect to. + /// Ignored for `loopback`. Default `127.0.0.1:4460`. + #[arg(long, default_value = "127.0.0.1:4460")] + addr: SocketAddr, + /// Client/loopback: number of application messages to send. Default `2`. + #[arg(long, default_value_t = 2)] + messages: usize, +} + +fn client_auth() -> SessionAuthConfig { + let client_identity = IdentityKeyPair::from_secret_key_bytes(CLIENT_SECRET); + let server_public = IdentityKeyPair::from_secret_key_bytes(SERVER_SECRET).public_key(); + SessionAuthConfig::new() + .with_local_identity(client_identity) + .with_peer_identity(PeerIdentity::new(server_public)) + .require_peer_authentication(true) +} + +fn server_auth() -> SessionAuthConfig { + let server_identity = IdentityKeyPair::from_secret_key_bytes(SERVER_SECRET); + let client_public = IdentityKeyPair::from_secret_key_bytes(CLIENT_SECRET).public_key(); + SessionAuthConfig::new() + .with_local_identity(server_identity) + .with_peer_identity(PeerIdentity::new(client_public)) + .require_peer_authentication(true) +} + +/// Drives a handshake to completion over discrete WebSocket messages, decoding +/// each inbound control frame and sending any reply as one binary message. +async fn run_handshake( + transport: &WebsockMessageTransport, + session: &mut Session, +) -> Result<(), Box> +where + C: WebSocketConnection, +{ + while session.active_keys().is_none() { + let bytes = transport.recv_message().await?; + let control = ControlMessage::decode(&bytes)?; + if let Some(reply) = session.handle_control(&control)? { + transport.send_message(reply.encode()).await?; + } + } + Ok(()) +} + +/// Server side: responder handshake, then echo each sealed application message. +async fn serve_connection( + transport: WebsockMessageTransport, +) -> Result<(), Box> +where + C: WebSocketConnection, +{ + let mut session = Session::new_responder_with_auth(RekeyThresholds::default(), server_auth()); + run_handshake(&transport, &mut session).await?; + assert!( + session.peer_authenticated(), + "peer failed identity authentication" + ); + println!("handshake complete; peer authenticated"); + + let mut channel = SecureMessageChannel::from_active_session(transport, &session)?; + loop { + let opened = match channel.recv_message().await { + Ok(message) => message, + // A normal peer close ends the read loop. + Err(_) => break, + }; + let reply = format!( + "websock-message echo: {}", + String::from_utf8_lossy(&opened.plaintext) + ); + channel + .send_message(opened.header.stream_id, 0, reply.as_bytes()) + .await?; + println!( + "echoed {} byte(s) on stream {}", + opened.plaintext.len(), + opened.header.stream_id + ); + } + Ok(()) +} + +/// Client side: initiator handshake, then send `messages` sealed messages and +/// read each echo. +async fn run_client( + transport: WebsockMessageTransport, + messages: usize, +) -> Result<(), Box> +where + C: WebSocketConnection, +{ + let (mut session, hello) = + Session::new_initiator_with_auth(RekeyThresholds::default(), client_auth()); + transport.send_message(hello.encode()).await?; + run_handshake(&transport, &mut session).await?; + assert!( + session.peer_authenticated(), + "peer failed identity authentication" + ); + + let mut channel = SecureMessageChannel::from_active_session(transport, &session)?; + for idx in 0..messages { + let payload = format!("hello from native client {idx}"); + channel.send_message(0, 0, payload.as_bytes()).await?; + let echo = channel.recv_message().await?; + println!( + "client message {idx} got: {}", + String::from_utf8_lossy(&echo.plaintext) + ); + } + Ok(()) +} + +async fn connect_client( + addr: SocketAddr, +) -> Result, Box> { + let connection = ClientBuilder::new() + .build() + .connect(&format!("ws://{addr}/")) + .await?; + Ok(WebsockMessageTransport::new(connection)) +} + +async fn run_server_role(addr: SocketAddr) -> Result<(), Box> { + let server = ServerBuilder::new().with_addr(addr).build().await?; + let bound = server.local_addr()?; + println!("websock message server listening on ws://{bound}/ (Ctrl+C to stop)"); + println!("demo keys are hardcoded for local examples only. do not use in production."); + loop { + let connection = match server.accept().await { + Ok(connection) => connection, + Err(err) => { + eprintln!("accept failed: {err}"); + continue; + } + }; + println!("accepted a WebSocket connection"); + let transport = WebsockMessageTransport::new(connection); + match serve_connection(transport).await { + Ok(()) => println!("connection finished"), + Err(err) => eprintln!("error serving connection: {err}"), + } + } +} + +async fn run_loopback(messages: usize) -> Result<(), Box> { + let server = ServerBuilder::new() + .with_addr("127.0.0.1:0".parse::()?) + .build() + .await?; + let addr = server.local_addr()?; + + // The server connection type is not `Send`, so run both sides concurrently + // on this task with `join!` rather than spawning. + let server_side = async { + let connection = server.accept().await?; + serve_connection(WebsockMessageTransport::new(connection)).await + }; + let client_side = async { + let transport = connect_client(addr).await?; + run_client(transport, messages).await + }; + let (server_res, client_res) = tokio::join!(server_side, client_side); + server_res?; + client_res?; + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + match args.role { + Role::Loopback => { + run_loopback(args.messages).await?; + println!("websock message loopback example finished"); + } + Role::Server => run_server_role(args.addr).await?, + Role::Client => { + let transport = connect_client(args.addr).await?; + run_client(transport, args.messages).await?; + println!("websock message client finished"); + } + } + Ok(()) +} diff --git a/foctet-transport/examples/websock_split.rs b/foctet-transport/examples/websock_split.rs index 4424d98..5b97a14 100644 --- a/foctet-transport/examples/websock_split.rs +++ b/foctet-transport/examples/websock_split.rs @@ -2,7 +2,7 @@ use std::error::Error; use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener}; use std::path::PathBuf; -use clap::Parser; +use clap::{Parser, ValueEnum}; use foctet_core::{IdentityKeyPair, PeerIdentity, RekeyThresholds, SessionAuthConfig}; use foctet_transport::adapter::SplitIo; use foctet_transport::{TokioTransportBuilder, TransportConfig}; @@ -14,14 +14,40 @@ use websock_tungstenite_mux::{ClientBuilder, ServerBuilder}; const STREAM_COUNT: usize = 2; const STREAM_TAG_LEN: usize = 4; +/// How to run the example. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] +enum Role { + /// Run both peers in one process over the loopback (quick smoke test). + #[default] + Loopback, + /// Run only the server: bind `--addr` and serve clients until Ctrl+C. + Server, + /// Run only the client: connect to a server at `--addr`. + Client, +} + #[derive(Debug, Parser)] struct Args { - /// TLS certificate path (PEM/DER). Use together with --tls-key. + /// Which side to run. `loopback` (default) runs both in one process; use + /// `server` and `client` in separate terminals / on two hosts. + #[arg(long, value_enum, default_value_t = Role::Loopback)] + role: Role, + /// Server: address to bind. Client: address to connect to. + /// Ignored for `loopback`. Default `127.0.0.1:4433`. + #[arg(long, default_value = "127.0.0.1:4433")] + addr: SocketAddr, + /// TLS certificate path (PEM/DER). Required for `server`/`client` roles + /// (server presents it; client trusts it). Use together with --tls-key on + /// the server. #[arg(long)] tls_cert: Option, - /// TLS private key path (PEM/DER). Use together with --tls-cert. + /// TLS private key path (PEM/DER). Required for the `server` role. #[arg(long)] tls_key: Option, + /// Client only: present an identity the server did NOT pin, to demonstrate + /// that identity-mismatch is rejected (the handshake must fail). + #[arg(long, default_value_t = false)] + wrong_identity: bool, } fn auth_config_pair(idx: usize) -> (SessionAuthConfig, SessionAuthConfig) { @@ -38,6 +64,27 @@ fn auth_config_pair(idx: usize) -> (SessionAuthConfig, SessionAuthConfig) { (client, server) } +fn server_auth_configs() -> Vec { + (0..STREAM_COUNT) + .map(|idx| auth_config_pair(idx).1) + .collect() +} + +fn client_auth_configs(wrong_identity: bool) -> Vec { + (0..STREAM_COUNT) + .map(|idx| { + let client = auth_config_pair(idx).0; + if wrong_identity { + // Replace the local identity with one the server never pinned, so + // the server's `require_peer_authentication` check must reject. + client.with_local_identity(IdentityKeyPair::from_secret_key_bytes([0xFF; 32])) + } else { + client + } + }) + .collect() +} + fn find_free_tcp_addr() -> Result> { let sock = TcpListener::bind(SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)))?; let addr = sock.local_addr()?; @@ -66,6 +113,21 @@ fn resolve_cert_pair( } } +fn load_cert_chain( + path: &std::path::Path, +) -> Result>, Box> { + let data = std::fs::read(path)?; + if path.extension().is_some_and(|ext| ext == "der") { + return Ok(vec![rustls::pki_types::CertificateDer::from(data)]); + } + let mut reader = std::io::BufReader::new(&data[..]); + let certs = rustls_pemfile::certs(&mut reader).collect::, _>>()?; + if certs.is_empty() { + return Err("no certificate found in tls-cert".into()); + } + Ok(certs) +} + fn build_client_tls( cert_chain: &[rustls::pki_types::CertificateDer<'static>], ) -> Result> { @@ -102,31 +164,12 @@ fn take_tagged_auth( Ok((idx, auth)) } -async fn run_server( - addr: SocketAddr, +/// Serve one accepted websock-mux session: bind a Foctet session per raw stream, +/// echo each application payload back uppercased-tagged. +async fn serve_session( + session: websock_tungstenite_mux::Session, server_auth_configs: Vec, - server_tls: rustls::ServerConfig, - ready: oneshot::Sender>, - shutdown: oneshot::Receiver<()>, ) -> Result<(), Box> { - let server = match ServerBuilder::new() - .with_addr(addr) - .with_default_alpn() - .with_tls_config(server_tls) - .build() - .await - { - Ok(server) => { - let _ = ready.send(Ok(())); - server - } - Err(err) => { - let _ = ready.send(Err(err.to_string())); - return Err(Box::new(err)); - } - }; - let session = server.accept().await?; - let config = TransportConfig::default().with_app_stream_id(1); let builder = TokioTransportBuilder::new().with_config(config); let mut server_auth_configs = server_auth_configs @@ -168,9 +211,6 @@ async fn run_server( while let Some(result) = tasks.join_next().await { result??; } - - let _ = shutdown.await; - Ok(()) } @@ -229,34 +269,115 @@ async fn run_client( Ok(()) } -#[tokio::main(flavor = "multi_thread")] -async fn main() -> Result<(), Box> { - let args = Args::parse(); - let (client_auth_configs, server_auth_configs): (Vec<_>, Vec<_>) = - (0..STREAM_COUNT).map(auth_config_pair).unzip(); - let (cert_chain, key) = resolve_cert_pair(&args)?; +/// Loopback: both peers in one process over an ephemeral port (quick smoke test). +async fn run_loopback(args: &Args) -> Result<(), Box> { + let (cert_chain, key) = resolve_cert_pair(args)?; let addr = find_free_tcp_addr()?; - let client_tls = build_client_tls(&cert_chain)?; let server_tls = build_server_tls(cert_chain, key)?; let (ready_tx, ready_rx) = oneshot::channel(); let (shutdown_tx, shutdown_rx) = oneshot::channel(); - let server_task = tokio::spawn(run_server( - addr, - server_auth_configs, - server_tls, - ready_tx, - shutdown_rx, - )); + let server_task = tokio::spawn(async move { + let server = match ServerBuilder::new() + .with_addr(addr) + .with_default_alpn() + .with_tls_config(server_tls) + .build() + .await + { + Ok(server) => { + let _ = ready_tx.send(Ok::<(), String>(())); + server + } + Err(err) => { + let _ = ready_tx.send(Err(err.to_string())); + return Err(Box::new(err) as Box); + } + }; + let session = server.accept().await?; + serve_session(session, server_auth_configs()).await?; + let _ = shutdown_rx.await; + Ok(()) + }); ready_rx .await .map_err(|_| "websock server readiness channel closed")??; - run_client(addr, client_auth_configs, client_tls).await?; + run_client(addr, client_auth_configs(false), client_tls).await?; let _ = shutdown_tx.send(()); server_task.await??; println!("websock multi-stream foctet E2EE example finished"); Ok(()) } + +/// Server role: bind `addr` and serve incoming sessions until Ctrl+C. +async fn run_server_role(args: &Args) -> Result<(), Box> { + if args.tls_cert.is_none() || args.tls_key.is_none() { + return Err( + "the `server` role requires --tls-cert and --tls-key (e.g. devcert/localhost.crt \ + and devcert/localhost.key); the client must trust the same cert" + .into(), + ); + } + let (cert_chain, key) = resolve_cert_pair(args)?; + let server_tls = build_server_tls(cert_chain, key)?; + let server = ServerBuilder::new() + .with_addr(args.addr) + .with_default_alpn() + .with_tls_config(server_tls) + .build() + .await?; + println!( + "websock server listening on wss://{} (Ctrl+C to stop)", + args.addr + ); + + loop { + let session = match server.accept().await { + Ok(session) => session, + Err(err) => { + eprintln!("accept failed: {err}"); + continue; + } + }; + println!("accepted a websock session"); + match serve_session(session, server_auth_configs()).await { + Ok(()) => println!("served session"), + Err(err) => eprintln!("error serving session: {err}"), + } + } +} + +/// Client role: connect to a server at `addr`. +async fn run_client_role(args: &Args) -> Result<(), Box> { + let Some(cert) = &args.tls_cert else { + return Err( + "the `client` role requires --tls-cert (the server's cert, to trust it; \ + e.g. devcert/localhost.crt)" + .into(), + ); + }; + let cert_chain = load_cert_chain(cert)?; + let client_tls = build_client_tls(&cert_chain)?; + println!("websock client connecting to wss://{}", args.addr); + run_client( + args.addr, + client_auth_configs(args.wrong_identity), + client_tls, + ) + .await?; + println!("websock client finished"); + Ok(()) +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + match args.role { + Role::Loopback => run_loopback(&args).await, + Role::Server => run_server_role(&args).await, + Role::Client => run_client_role(&args).await, + } +} diff --git a/foctet-transport/examples/webtrans_datagram_split.rs b/foctet-transport/examples/webtrans_datagram_split.rs new file mode 100644 index 0000000..fd682eb --- /dev/null +++ b/foctet-transport/examples/webtrans_datagram_split.rs @@ -0,0 +1,298 @@ +// WebTransport datagram example (tests.md §3.5). A browser page drives the WASM +// `FoctetSession` against the `server` role here, over a real `WebTransport`. +// +// Raw datagrams have no handshake of their own, so this follows the documented +// pattern: run the authenticated Foctet handshake over a *reliable* bidi +// WebTransport stream, then carry the sealed application data as +// loss/reorder-tolerant WebTransport datagrams (one Foctet datagram frame per +// transport datagram — the exact shape the browser SDK's +// `sealDatagram`/`openDatagram` produce). The `client`/`loopback` roles drive +// the same wire format natively, so the protocol can be smoke-tested without a +// browser. +// +// TLS: for a browser, generate a short-lived ECDSA P-256 dev cert with +// `devcert/generate.sh` and pass `--tls-cert devcert/localhost.crt --tls-key +// devcert/localhost.key`; the browser pins its SHA-256 from `devcert/localhost.hex` +// via `serverCertificateHashes`. With no `--tls-*` the example self-signs (fine +// for the native `client`/`loopback` roles). +// +// Demo identities are hardcoded for local examples only. + +use std::error::Error; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket}; +use std::path::PathBuf; +use std::sync::Arc; + +use ::webtrans::quinn::SessionError; +use ::webtrans::{ClientBuilder, ServerBuilder, Session as WebtransSession, tls}; +use bytes::Bytes; +use clap::{Parser, ValueEnum}; +use foctet_core::{IdentityKeyPair, PeerIdentity, RekeyThresholds, SessionAuthConfig}; +use foctet_transport::adapter::SplitIo; +use foctet_transport::{DatagramTransport, SecureDatagramChannel, TokioTransportBuilder}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use tokio::io::AsyncWriteExt; +use url::Url; + +// Demo identities shared with the browser page (see examples/browser). The +// initiator (browser or `client` role) uses `CLIENT_SECRET`; the responder +// (`server` role) uses `SERVER_SECRET`. Each side pins the other's public key. +const CLIENT_SECRET: [u8; 32] = [0x41; 32]; +const SERVER_SECRET: [u8; 32] = [0x61; 32]; + +/// How to run the example. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] +enum Role { + /// Run a native client and server in one process (quick smoke test). + #[default] + Loopback, + /// Run only the responder: bind `--addr` and serve browser/native clients. + Server, + /// Run only the native initiator: connect to a server at `--addr`. + Client, +} + +#[derive(Debug, Parser)] +struct Args { + /// Which side to run. `loopback` (default) runs both natively in one + /// process; use `server` for the browser test and `client` for a native + /// initiator. + #[arg(long, value_enum, default_value_t = Role::Loopback)] + role: Role, + /// Server: UDP address to bind. Client: address to connect to. Ignored for + /// `loopback` (ephemeral). Default `127.0.0.1:4470`. + #[arg(long, default_value = "127.0.0.1:4470")] + addr: SocketAddr, + /// TLS certificate path (PEM/DER). Use together with `--tls-key`. Required + /// for a browser client so it can pin the cert hash. + #[arg(long)] + tls_cert: Option, + /// TLS private key path (PEM/DER). Use together with `--tls-cert`. + #[arg(long)] + tls_key: Option, + /// Client/loopback: number of datagrams to send. Default `4`. + #[arg(long, default_value_t = 4)] + datagrams: usize, +} + +fn client_auth() -> SessionAuthConfig { + let client_identity = IdentityKeyPair::from_secret_key_bytes(CLIENT_SECRET); + let server_public = IdentityKeyPair::from_secret_key_bytes(SERVER_SECRET).public_key(); + SessionAuthConfig::new() + .with_local_identity(client_identity) + .with_peer_identity(PeerIdentity::new(server_public)) + .require_peer_authentication(true) +} + +fn server_auth() -> SessionAuthConfig { + let server_identity = IdentityKeyPair::from_secret_key_bytes(SERVER_SECRET); + let client_public = IdentityKeyPair::from_secret_key_bytes(CLIENT_SECRET).public_key(); + SessionAuthConfig::new() + .with_local_identity(server_identity) + .with_peer_identity(PeerIdentity::new(client_public)) + .require_peer_authentication(true) +} + +fn find_free_udp_addr() -> Result> { + let sock = UdpSocket::bind(SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)))?; + Ok(sock.local_addr()?) +} + +fn resolve_cert_pair( + args: &Args, +) -> Result<(Vec>, PrivateKeyDer<'static>), Box> { + match (&args.tls_cert, &args.tls_key) { + (Some(cert), Some(key)) => Ok(tls::load_cert(cert, key)?), + (None, None) => Ok(tls::generate_self_signed_pair_der(vec![ + "localhost".to_owned(), + "127.0.0.1".to_owned(), + "::1".to_owned(), + ])?), + _ => Err("both --tls-cert and --tls-key must be provided together".into()), + } +} + +/// Adapts a WebTransport session's datagram side to [`DatagramTransport`], so a +/// [`SecureDatagramChannel`] can seal/open Foctet datagram frames over it. The +/// session's own session-ID header is added/stripped by the webtrans layer, so +/// each datagram here carries exactly one Foctet frame. +struct WebtransDatagramTransport { + session: Arc, +} + +impl DatagramTransport for WebtransDatagramTransport { + type Error = SessionError; + + async fn send_datagram(&self, datagram: Vec) -> Result<(), Self::Error> { + self.session.send_datagram(Bytes::from(datagram)) + } + + async fn recv_datagram(&self) -> Result, Self::Error> { + Ok(self.session.read_datagram().await?.to_vec()) + } + + fn max_datagram_size(&self) -> Option { + // The channel clamps its own default down to this reported maximum. + Some(self.session.max_datagram_size()) + } +} + +/// Server side: authenticate over one bidi stream, then echo sealed datagrams. +async fn serve(session: WebtransSession) -> Result<(), Box> { + // Handshake over a reliable bidi stream (byte-framed Foctet handshake). + let (send, recv) = session.accept_bi().await?; + let channel = TokioTransportBuilder::new() + .establish_responder_with_auth( + SplitIo::from_split(recv, send), + RekeyThresholds::default(), + server_auth(), + ) + .await?; + assert!( + channel.session().peer_authenticated(), + "peer failed identity authentication" + ); + println!("handshake complete; peer authenticated"); + + let session = Arc::new(session); + let transport = WebtransDatagramTransport { + session: session.clone(), + }; + let mut datagram = SecureDatagramChannel::from_active_session(transport, channel.session())?; + loop { + let incoming = match datagram.recv_datagram().await { + Ok(message) => message, + // A normal peer close ends the read loop. + Err(_) => break, + }; + let reply = format!( + "webtrans datagram echo: {}", + String::from_utf8_lossy(&incoming.plaintext) + ); + datagram.send_datagram(0, 0, reply.as_bytes()).await?; + println!("echoed {} byte(s)", incoming.plaintext.len()); + } + Ok(()) +} + +/// Client side: authenticate over one bidi stream, then send `datagrams` sealed +/// datagrams and read each echo. +async fn run_client( + session: WebtransSession, + datagrams: usize, +) -> Result<(), Box> { + let (mut send, recv) = session.open_bi().await?; + // Nudge the stream open so the server's `accept_bi` fires before we block. + send.flush().await?; + let channel = TokioTransportBuilder::new() + .establish_initiator_with_auth( + SplitIo::from_split(recv, send), + RekeyThresholds::default(), + client_auth(), + ) + .await?; + assert!( + channel.session().peer_authenticated(), + "peer failed identity authentication" + ); + + let session = Arc::new(session); + let transport = WebtransDatagramTransport { + session: session.clone(), + }; + let mut datagram = SecureDatagramChannel::from_active_session(transport, channel.session())?; + for idx in 0..datagrams { + let payload = format!("hello from native client {idx}"); + datagram.send_datagram(0, 0, payload.as_bytes()).await?; + let echo = datagram.recv_datagram().await?; + println!( + "client datagram {idx} got: {}", + String::from_utf8_lossy(&echo.plaintext) + ); + } + Ok(()) +} + +async fn connect_client( + addr: SocketAddr, + cert_chain: Vec>, +) -> Result> { + let client = ClientBuilder::new().with_server_certificates(cert_chain)?; + let url = Url::parse(&format!("https://127.0.0.1:{}/", addr.port()))?; + Ok(client.connect(url).await?) +} + +async fn run_server_role(args: &Args) -> Result<(), Box> { + let (cert_chain, key) = resolve_cert_pair(args)?; + let mut server = ServerBuilder::new() + .with_addr(args.addr) + .with_certificate(cert_chain, key)?; + println!( + "webtrans datagram server listening on https://{}/ (Ctrl+C to stop)", + args.addr + ); + println!("demo keys are hardcoded for local examples only. do not use in production."); + loop { + let request = match server.accept().await { + Some(request) => request, + None => break, + }; + let session = match request.ok().await { + Ok(session) => session, + Err(err) => { + eprintln!("session setup failed: {err}"); + continue; + } + }; + println!("accepted a WebTransport session"); + match serve(session).await { + Ok(()) => println!("session finished"), + Err(err) => eprintln!("error serving session: {err}"), + } + } + Ok(()) +} + +async fn run_loopback(args: &Args) -> Result<(), Box> { + let (cert_chain, key) = resolve_cert_pair(args)?; + let addr = find_free_udp_addr()?; + let datagrams = args.datagrams; + + let mut server = ServerBuilder::new() + .with_addr(addr) + .with_certificate(cert_chain.clone(), key)?; + let server_side = async move { + let request = server.accept().await.ok_or("server closed")?; + let session = request.ok().await?; + serve(session).await + }; + let client_side = async move { + let session = connect_client(addr, cert_chain).await?; + run_client(session, datagrams).await + }; + // The webtrans session type is not `Send`, so run both concurrently on this + // task with `join!` rather than spawning. + let (server_res, client_res) = tokio::join!(server_side, client_side); + server_res?; + client_res?; + Ok(()) +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + match args.role { + Role::Loopback => { + run_loopback(&args).await?; + println!("webtrans datagram loopback example finished"); + } + Role::Server => run_server_role(&args).await?, + Role::Client => { + let (cert_chain, _key) = resolve_cert_pair(&args)?; + let session = connect_client(args.addr, cert_chain).await?; + run_client(session, args.datagrams).await?; + println!("webtrans datagram client finished"); + } + } + Ok(()) +} diff --git a/foctet-transport/src/datagram.rs b/foctet-transport/src/datagram.rs new file mode 100644 index 0000000..7839f42 --- /dev/null +++ b/foctet-transport/src/datagram.rs @@ -0,0 +1,295 @@ +//! Generic, backend-agnostic datagram transport abstraction. +//! +//! [`DatagramTransport`] is the datagram counterpart to the byte-stream +//! integrations in this crate: it moves whole, message-bounded datagrams. +//! [`SecureDatagramChannel`] layers Foctet's [`DatagramEndpoint`] on top of any +//! `DatagramTransport`, so a single secure-datagram implementation works over +//! QUIC datagrams, WebTransport datagrams, raw UDP, or any other datagram +//! backend that implements the trait. +//! +//! Each `send` seals exactly one Foctet frame into one datagram; each `recv` +//! opens exactly one. Loss and reordering are tolerated by the replay window, +//! and replay state is committed only after AEAD authentication. +//! +//! # Rekey over datagrams +//! +//! Foctet's rekey is a DH ratchet driven by [`Session`] control messages, and +//! those control messages MUST travel over a **reliable, ordered** channel (a +//! control stream) — the datagram path itself is lossy and reordering, so a lost +//! ratchet message would desynchronize the peers. This mirrors QUIC, where the +//! handshake and key updates ride reliable streams while application data rides +//! datagrams under the negotiated keys. +//! +//! The flow is therefore: rekey the [`Session`] over its control channel on both +//! peers, then call [`SecureDatagramChannel::rekey_from_session`] on each side to +//! adopt the rotated key. Each new key gets a new `key_id`, and the endpoint +//! retains the previous key(s) ([`DatagramConfig::max_retained_keys`]), so +//! datagrams sealed under the **old** key that arrive (reordered or delayed) +//! after the rekey still decrypt — datagrams carry their `key_id`, and the +//! receiver selects the matching retained key. + +use foctet_core::{ + CoreError, DatagramConfig, DatagramEndpoint, DecodedDatagram, KeyHandle, Session, +}; +use thiserror::Error; + +/// A message-oriented datagram transport that sends and receives whole datagrams. +/// +/// The futures intentionally do **not** require `Send`, so the trait is usable +/// from `!Send` runtimes such as browser WebTransport. Implementations should +/// move exactly the bytes they are given per datagram, preserving message +/// boundaries. +#[allow(async_fn_in_trait)] +pub trait DatagramTransport { + /// Transport-specific error type. + type Error: std::error::Error + Send + Sync + 'static; + + /// Sends one datagram. + async fn send_datagram(&self, datagram: Vec) -> Result<(), Self::Error>; + + /// Receives one datagram. + async fn recv_datagram(&self) -> Result, Self::Error>; + + /// Returns the maximum datagram payload size the transport accepts, if known. + fn max_datagram_size(&self) -> Option; +} + +/// Error returned by [`SecureDatagramChannel`] operations. +#[derive(Debug, Error)] +pub enum DatagramChannelError +where + E: std::error::Error + Send + Sync + 'static, +{ + /// Foctet datagram seal/open failed. + #[error(transparent)] + Core(#[from] CoreError), + /// The underlying datagram transport failed. + #[error("datagram transport error: {0}")] + Transport(E), +} + +/// A secure Foctet datagram channel over any [`DatagramTransport`]. +/// +/// Negotiate keys with a normal Foctet handshake (for example over a control +/// stream) first, then build this channel from the resulting [`Session`]. +#[derive(Debug)] +pub struct SecureDatagramChannel { + transport: T, + endpoint: DatagramEndpoint, +} + +impl SecureDatagramChannel +where + T: DatagramTransport, +{ + /// Builds a channel from a transport and an active [`Session`], clamping the + /// datagram size to the transport's reported maximum when available. + pub fn from_active_session(transport: T, session: &Session) -> Result { + let mut config = DatagramConfig::default(); + if let Some(max) = transport.max_datagram_size() { + config.max_datagram_size = config.max_datagram_size.min(max); + } + Self::from_active_session_with_config(transport, session, config) + } + + /// Builds a channel from a transport, an active [`Session`], and an explicit + /// datagram configuration. + pub fn from_active_session_with_config( + transport: T, + session: &Session, + config: DatagramConfig, + ) -> Result { + let keys = session + .active_keys() + .ok_or(CoreError::InvalidSessionState)?; + let endpoint = DatagramEndpoint::with_config( + keys, + session.inbound_direction(), + session.outbound_direction(), + config, + ); + Ok(Self { + transport, + endpoint, + }) + } + + /// Returns the maximum plaintext bytes that fit in one datagram. + pub fn max_plaintext_len(&self) -> usize { + self.endpoint.max_plaintext_len() + } + + /// Installs a freshly rotated set of traffic keys (after a rekey). + pub fn install_active_keys(&mut self, keys: KeyHandle) { + self.endpoint.install_active_keys(keys); + } + + /// Adopts the session's current active traffic keys after it has rekeyed + /// over its (reliable) control channel. + /// + /// See the module-level "Rekey over datagrams" section: drive the rekey on + /// the [`Session`] over a reliable control channel, then call this on both + /// peers. The previous key is retained, so datagrams sealed under the old + /// key that arrive after the rekey still decrypt. + pub fn rekey_from_session(&mut self, session: &Session) -> Result<(), CoreError> { + let keys = session + .active_keys() + .ok_or(CoreError::InvalidSessionState)?; + self.endpoint.install_active_keys(keys); + Ok(()) + } + + /// Returns a reference to the underlying transport. + pub fn transport(&self) -> &T { + &self.transport + } + + /// Seals `plaintext` into one frame and sends it as a single datagram. + pub async fn send_datagram( + &mut self, + stream_id: u32, + flags: u8, + plaintext: &[u8], + ) -> Result<(), DatagramChannelError> { + let bytes = self.endpoint.seal(stream_id, flags, plaintext)?; + self.transport + .send_datagram(bytes) + .await + .map_err(DatagramChannelError::Transport)?; + Ok(()) + } + + /// Receives one datagram and opens it into a decrypted payload. + pub async fn recv_datagram( + &mut self, + ) -> Result> { + let bytes = self + .transport + .recv_datagram() + .await + .map_err(DatagramChannelError::Transport)?; + Ok(self.endpoint.open(&bytes)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::collections::VecDeque; + use std::rc::Rc; + + use foctet_core::{RekeyThresholds, Session, SessionAuthConfig}; + + /// In-memory, lossy/reorderable datagram transport for one direction. + #[derive(Default)] + struct MemoryDatagramTransport { + inbox: Rc>>>, + outbox: Rc>>>, + } + + #[derive(Debug, thiserror::Error)] + #[error("memory datagram transport closed")] + struct MemoryError; + + impl DatagramTransport for MemoryDatagramTransport { + type Error = MemoryError; + + async fn send_datagram(&self, datagram: Vec) -> Result<(), Self::Error> { + self.outbox.borrow_mut().push_back(datagram); + Ok(()) + } + + async fn recv_datagram(&self) -> Result, Self::Error> { + self.inbox.borrow_mut().pop_front().ok_or(MemoryError) + } + + fn max_datagram_size(&self) -> Option { + None + } + } + + fn linked_pair() -> (MemoryDatagramTransport, MemoryDatagramTransport) { + let a_to_b: Rc>>> = Rc::default(); + let b_to_a: Rc>>> = Rc::default(); + let a = MemoryDatagramTransport { + inbox: b_to_a.clone(), + outbox: a_to_b.clone(), + }; + let b = MemoryDatagramTransport { + inbox: a_to_b, + outbox: b_to_a, + }; + (a, b) + } + + fn session_pair() -> (Session, Session) { + let (mut initiator, hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut responder = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let server_hello = responder + .handle_control(&hello) + .expect("responder handles hello") + .expect("server hello"); + initiator + .handle_control(&server_hello) + .expect("initiator finalizes"); + (initiator, responder) + } + + #[tokio::test] + async fn datagrams_decrypt_across_a_rekey_including_a_reordered_old_key_datagram() { + let (mut session_init, mut session_resp) = session_pair(); + let (transport_a, transport_b) = linked_pair(); + let mut a = + SecureDatagramChannel::from_active_session(transport_a, &session_init).expect("a"); + let mut b = + SecureDatagramChannel::from_active_session(transport_b, &session_resp).expect("b"); + + // A sends a datagram under the original key (key_id 0); capture it off + // the wire and withhold it so it arrives *after* the rekey. + a.send_datagram(0, 0, b"sealed before rekey") + .await + .expect("send old"); + let old_key_datagram = b + .transport() + .inbox + .borrow_mut() + .pop_front() + .expect("one datagram queued"); + + // Rekey the sessions over the (reliable) control channel, then adopt the + // rotated key into both datagram channels. + let rekey = session_init.force_rekey().expect("initiator rekeys"); + session_resp + .handle_control(&rekey) + .expect("responder applies rekey"); + a.rekey_from_session(&session_init).expect("a rekey"); + b.rekey_from_session(&session_resp).expect("b rekey"); + + // A sends a datagram under the new key (key_id 1). + a.send_datagram(0, 0, b"sealed after rekey") + .await + .expect("send new"); + + // Deliver the OLD-key datagram first (reordered across the rekey): the + // retained previous key must still open it. + b.transport() + .inbox + .borrow_mut() + .push_front(old_key_datagram); + let old = b.recv_datagram().await.expect("recv old"); + assert_eq!(old.header.key_id, 0); + assert_eq!(old.plaintext, b"sealed before rekey"); + + // Then the new-key datagram opens under the rotated key. + let new = b.recv_datagram().await.expect("recv new"); + assert_eq!(new.header.key_id, 1); + assert_eq!(new.plaintext, b"sealed after rekey"); + } +} diff --git a/foctet-transport/src/futures.rs b/foctet-transport/src/futures.rs index 06f6fc4..89236c6 100644 --- a/foctet-transport/src/futures.rs +++ b/foctet-transport/src/futures.rs @@ -1,4 +1,8 @@ -use std::{future::poll_fn, pin::Pin}; +use std::{ + future::{Future, poll_fn}, + pin::{Pin, pin}, + task::Poll, +}; use foctet_core::{ AsyncSecureChannel, ControlMessage, CoreError, FoctetFramed, RekeyThresholds, Session, @@ -85,8 +89,12 @@ impl FuturesTransportBuilder { where T: AsyncRead + AsyncWrite + Unpin, { - let session = - run_initiator_handshake(&mut io, thresholds, SessionAuthConfig::default()).await?; + let session = run_initiator_handshake( + &mut io, + thresholds, + SessionAuthConfig::unauthenticated_for_testing(), + ) + .await?; self.build(io, session) } @@ -113,8 +121,12 @@ impl FuturesTransportBuilder { where T: AsyncRead + AsyncWrite + Unpin, { - let session = - run_responder_handshake(&mut io, thresholds, SessionAuthConfig::default()).await?; + let session = run_responder_handshake( + &mut io, + thresholds, + SessionAuthConfig::unauthenticated_for_testing(), + ) + .await?; self.build(io, session) } @@ -132,6 +144,52 @@ impl FuturesTransportBuilder { self.build(io, session) } + /// Runs the native Foctet handshake as initiator with explicit authentication + /// config, bounded by a caller-supplied `timeout` future. + /// + /// Because this builder is runtime-agnostic, the deadline is provided as a + /// future rather than a `Duration`: pass `tokio::time::sleep(dur)`, + /// `async_io::Timer::after(dur)`, a browser timer, or any future that + /// resolves when the handshake should be abandoned. If `timeout` resolves + /// before the handshake completes, this fails with + /// [`CoreError::HandshakeTimeout`] and the transport is dropped. + pub async fn establish_initiator_with_auth_and_timeout( + self, + mut io: T, + thresholds: RekeyThresholds, + auth: SessionAuthConfig, + timeout: F, + ) -> Result, CoreError> + where + T: AsyncRead + AsyncWrite + Unpin, + F: Future, + { + let session = + run_with_timeout(run_initiator_handshake(&mut io, thresholds, auth), timeout).await?; + self.build(io, session) + } + + /// Runs the native Foctet handshake as responder with explicit authentication + /// config, bounded by a caller-supplied `timeout` future. + /// + /// See [`Self::establish_initiator_with_auth_and_timeout`] for how the + /// runtime-agnostic timeout future is supplied. + pub async fn establish_responder_with_auth_and_timeout( + self, + mut io: T, + thresholds: RekeyThresholds, + auth: SessionAuthConfig, + timeout: F, + ) -> Result, CoreError> + where + T: AsyncRead + AsyncWrite + Unpin, + F: Future, + { + let session = + run_with_timeout(run_responder_handshake(&mut io, thresholds, auth), timeout).await?; + self.build(io, session) + } + /// Runs the native Foctet handshake as initiator on split transport halves, then builds a secure channel. pub async fn establish_initiator_from_split( self, @@ -163,6 +221,30 @@ impl FuturesTransportBuilder { } } +/// Drives `work` to completion, but resolves to [`CoreError::HandshakeTimeout`] +/// if `timer` completes first. Runtime-agnostic: `timer` is any future, so the +/// caller supplies the deadline source. +async fn run_with_timeout(work: W, timer: F) -> Result +where + W: Future>, + F: Future, +{ + let mut work = pin!(work); + let mut timer = pin!(timer); + poll_fn(move |cx| { + // Poll the handshake first so a handshake that is already complete wins + // even if the timer is also ready in the same poll. + if let Poll::Ready(result) = work.as_mut().poll(cx) { + return Poll::Ready(result); + } + if timer.as_mut().poll(cx).is_ready() { + return Poll::Ready(Err(CoreError::HandshakeTimeout)); + } + Poll::Pending + }) + .await +} + async fn write_control(io: &mut T, msg: &ControlMessage) -> Result<(), CoreError> where T: AsyncWrite + Unpin, @@ -296,3 +378,72 @@ where (io.into_inner(), session) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io; + use std::task::Context; + + /// A futures-io transport whose writes succeed instantly but whose reads + /// never produce data, so any handshake stalls waiting for the peer's reply. + #[derive(Debug)] + struct StalledIo; + + impl AsyncRead for StalledIo { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buf: &mut [u8], + ) -> Poll> { + Poll::Pending + } + } + + impl AsyncWrite for StalledIo { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + #[tokio::test] + async fn initiator_handshake_times_out_when_timer_fires_first() { + // An already-ready timer must abort the stalled handshake. + let err = FuturesTransportBuilder::new() + .establish_initiator_with_auth_and_timeout( + StalledIo, + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + std::future::ready(()), + ) + .await + .expect_err("stalled handshake must time out"); + assert!(matches!(err, CoreError::HandshakeTimeout)); + } + + #[tokio::test] + async fn responder_handshake_times_out_when_timer_fires_first() { + let err = FuturesTransportBuilder::new() + .establish_responder_with_auth_and_timeout( + StalledIo, + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + std::future::ready(()), + ) + .await + .expect_err("stalled handshake must time out"); + assert!(matches!(err, CoreError::HandshakeTimeout)); + } +} diff --git a/foctet-transport/src/lib.rs b/foctet-transport/src/lib.rs index 5b0697c..cbb2206 100644 --- a/foctet-transport/src/lib.rs +++ b/foctet-transport/src/lib.rs @@ -2,36 +2,17 @@ //! //! `foctet-transport` is the recommended entry point when you want Foctet to //! run over an existing stream abstraction. The builders in this crate perform -//! the native Foctet handshake, wire up framing, and expose a secure channel -//! with minimal boilerplate. +//! the native handshake, wire up framing, and expose authenticated secure +//! channels with minimal boilerplate. //! -//! # Layers +//! Start with `TokioTransportBuilder` or `FuturesTransportBuilder` when you +//! already have split I/O halves. Use transport-specific modules such as +//! `quinn`, `webtrans`, `websock`, or `muxtls` when you want convenience +//! wrappers around those transports. //! -//! - Recommended high-level API: -//! [`TransportConfig`], `TokioTransportBuilder`, `FuturesTransportBuilder`, -//! `TokioTransportChannel`, and `FuturesTransportChannel` -//! - Transport-specific helpers: -//! feature-gated modules such as `muxtls` and `quinn` -//! - Low-level escape hatch: -//! [`adapter`] and [`SplitIo`] -//! -//! # Recommended Production Path -//! -//! - Use `*_with_auth` builder methods together with -//! `foctet_core::SessionAuthConfig`. -//! - Pin the expected remote identity with `foctet_core::PeerIdentity`. -//! - Require authenticated peers unless the outer transport already provides -//! strong peer authentication that your application trusts. -//! -//! # Choosing an Integration Style -//! -//! - Use `TokioTransportBuilder` or `FuturesTransportBuilder` when you -//! already have split I/O halves and want runtime-generic Foctet channels. -//! - Use transport-specific modules such as `quinn`, `webtrans`, -//! `websock`, or `muxtls` when you want convenience wrappers that open or -//! accept streams and immediately wrap them as Foctet channels. -//! - Use [`adapter`] and [`SplitIo`] only when you need a custom integration -//! path that the high-level builders do not cover. +//! For production use, prefer `*_with_auth` methods together with +//! `foctet_core::SessionAuthConfig` and pinned `foctet_core::PeerIdentity` +//! values. //! //! # Quick Start //! @@ -59,9 +40,14 @@ pub mod adapter; mod config; +pub mod datagram; mod error; #[cfg(feature = "runtime-futures")] mod futures; +pub mod message; +#[cfg(not(target_arch = "wasm32"))] +pub mod rate_limit; +pub mod shape; #[cfg(feature = "runtime-tokio")] mod tokio; @@ -69,15 +55,28 @@ mod tokio; pub mod muxtls; #[cfg(feature = "transport-quinn")] pub mod quinn; +#[cfg(feature = "runtime-tokio")] +pub mod udp; #[cfg(feature = "transport-websock")] pub mod websock; #[cfg(feature = "transport-webtrans")] pub mod webtrans; +#[cfg(all(target_arch = "wasm32", feature = "transport-webtrans-browser"))] +pub mod webtrans_browser; pub use adapter::SplitIo; pub use config::TransportConfig; +pub use datagram::{DatagramChannelError, DatagramTransport, SecureDatagramChannel}; pub use error::TransportChannelError; #[cfg(feature = "runtime-futures")] pub use futures::{FuturesTransportBuilder, FuturesTransportChannel}; +pub use message::{MessageChannelError, MessageTransport, SecureMessageChannel}; +#[cfg(not(target_arch = "wasm32"))] +pub use rate_limit::HandshakeRateLimiter; +#[cfg(feature = "runtime-futures")] +pub use shape::ByteStreamTransport; +pub use shape::SecureChannel; #[cfg(feature = "runtime-tokio")] -pub use tokio::{TokioTransportBuilder, TokioTransportChannel}; +pub use tokio::{DEFAULT_HANDSHAKE_TIMEOUT, TokioTransportBuilder, TokioTransportChannel}; +#[cfg(all(target_arch = "wasm32", feature = "transport-webtrans-browser"))] +pub use webtrans_browser::{BrowserWebTransportDatagrams, BrowserWebTransportError}; diff --git a/foctet-transport/src/message.rs b/foctet-transport/src/message.rs new file mode 100644 index 0000000..2b0eee4 --- /dev/null +++ b/foctet-transport/src/message.rs @@ -0,0 +1,307 @@ +//! Generic, backend-agnostic message transport abstraction. +//! +//! [`MessageTransport`] is the message counterpart to [`crate::DatagramTransport`] +//! and the byte-stream builders in this crate. It moves whole, reliable, +//! ordered, message-bounded units — most importantly **raw WebSocket messages**, +//! where each message is a discrete frame and the application wants to keep those +//! boundaries instead of treating the connection as an opaque byte stream. +//! +//! [`SecureMessageChannel`] layers Foctet's [`MessageEndpoint`] on top of any +//! `MessageTransport`, so a single secure-message implementation works over raw +//! WebSocket, an in-process message queue, or any other discrete-message backend +//! that implements the trait. +//! +//! Each `send` seals exactly one Foctet frame into one message; each `recv` +//! opens exactly one. Because the transport is reliable and ordered, the default +//! maximum message size is large (see [`foctet_core::DEFAULT_MAX_MESSAGE_SIZE`]), +//! unlike the MTU-bounded datagram shape. Replay state is committed only after +//! AEAD authentication, so a forged message cannot advance the window. + +use foctet_core::{CoreError, DecodedMessage, KeyHandle, MessageConfig, MessageEndpoint, Session}; +use thiserror::Error; + +/// A message-oriented transport that sends and receives whole, discrete messages. +/// +/// The futures intentionally do **not** require `Send`, so the trait is usable +/// from `!Send` runtimes such as browser WebSocket bindings. Implementations +/// must preserve message boundaries: the bytes passed to one `send_message` +/// arrive as exactly one `recv_message` on the peer. +#[allow(async_fn_in_trait)] +pub trait MessageTransport { + /// Transport-specific error type. + type Error: std::error::Error + Send + Sync + 'static; + + /// Sends one message. + async fn send_message(&self, message: Vec) -> Result<(), Self::Error>; + + /// Receives one message. + async fn recv_message(&self) -> Result, Self::Error>; + + /// Returns the maximum message payload size the transport accepts, if known. + fn max_message_size(&self) -> Option; +} + +/// Error returned by [`SecureMessageChannel`] operations. +#[derive(Debug, Error)] +pub enum MessageChannelError +where + E: std::error::Error + Send + Sync + 'static, +{ + /// Foctet message seal/open failed. + #[error(transparent)] + Core(#[from] CoreError), + /// The underlying message transport failed. + #[error("message transport error: {0}")] + Transport(E), +} + +/// A secure Foctet message channel over any [`MessageTransport`]. +/// +/// Negotiate keys with a normal Foctet handshake (for example over a control +/// stream) first, then build this channel from the resulting [`Session`]. +#[derive(Debug)] +pub struct SecureMessageChannel { + transport: T, + endpoint: MessageEndpoint, +} + +impl SecureMessageChannel +where + T: MessageTransport, +{ + /// Builds a channel from a transport and an active [`Session`], clamping the + /// message size to the transport's reported maximum when available. + pub fn from_active_session(transport: T, session: &Session) -> Result { + let mut config = MessageConfig::default(); + if let Some(max) = transport.max_message_size() { + config.max_message_size = config.max_message_size.min(max); + } + Self::from_active_session_with_config(transport, session, config) + } + + /// Builds a channel from a transport, an active [`Session`], and an explicit + /// message configuration. + pub fn from_active_session_with_config( + transport: T, + session: &Session, + config: MessageConfig, + ) -> Result { + let keys = session + .active_keys() + .ok_or(CoreError::InvalidSessionState)?; + let endpoint = MessageEndpoint::with_config( + keys, + session.inbound_direction(), + session.outbound_direction(), + config, + ); + Ok(Self { + transport, + endpoint, + }) + } + + /// Returns the maximum plaintext bytes that fit in one message. + pub fn max_plaintext_len(&self) -> usize { + self.endpoint.max_plaintext_len() + } + + /// Installs a freshly rotated set of traffic keys (after a rekey). + pub fn install_active_keys(&mut self, keys: KeyHandle) { + self.endpoint.install_active_keys(keys); + } + + /// Adopts the session's current active traffic keys after it has rekeyed. + /// + /// Drive the DH-ratchet rekey on the [`Session`] (over a reliable control + /// channel), then call this on both peers to install the rotated key. The + /// previous key is retained, so a message sealed under the old key that is + /// still in flight opens correctly. + pub fn rekey_from_session(&mut self, session: &Session) -> Result<(), CoreError> { + let keys = session + .active_keys() + .ok_or(CoreError::InvalidSessionState)?; + self.endpoint.install_active_keys(keys); + Ok(()) + } + + /// Returns a reference to the underlying transport. + pub fn transport(&self) -> &T { + &self.transport + } + + /// Seals `plaintext` into one frame and sends it as a single message. + pub async fn send_message( + &mut self, + stream_id: u32, + flags: u8, + plaintext: &[u8], + ) -> Result<(), MessageChannelError> { + let bytes = self.endpoint.seal(stream_id, flags, plaintext)?; + self.transport + .send_message(bytes) + .await + .map_err(MessageChannelError::Transport)?; + Ok(()) + } + + /// Receives one message and opens it into a decrypted payload. + pub async fn recv_message(&mut self) -> Result> { + let bytes = self + .transport + .recv_message() + .await + .map_err(MessageChannelError::Transport)?; + Ok(self.endpoint.open(&bytes)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::collections::VecDeque; + use std::rc::Rc; + + use foctet_core::{ + Direction, EphemeralKeyPair, KeyHandle, RekeyThresholds, Session, SessionAuthConfig, + derive_traffic_keys, random_session_salt, + }; + + /// In-memory, reliable, ordered message transport for one direction. + #[derive(Default)] + struct MemoryMessageTransport { + // Messages this side will read (its inbox) and write (peer's inbox). + inbox: Rc>>>, + outbox: Rc>>>, + } + + #[derive(Debug, thiserror::Error)] + #[error("memory message transport closed")] + struct MemoryError; + + impl MessageTransport for MemoryMessageTransport { + type Error = MemoryError; + + async fn send_message(&self, message: Vec) -> Result<(), Self::Error> { + self.outbox.borrow_mut().push_back(message); + Ok(()) + } + + async fn recv_message(&self) -> Result, Self::Error> { + self.inbox.borrow_mut().pop_front().ok_or(MemoryError) + } + + fn max_message_size(&self) -> Option { + None + } + } + + fn linked_pair() -> (MemoryMessageTransport, MemoryMessageTransport) { + let a_to_b: Rc>>> = Rc::default(); + let b_to_a: Rc>>> = Rc::default(); + let client = MemoryMessageTransport { + inbox: b_to_a.clone(), + outbox: a_to_b.clone(), + }; + let server = MemoryMessageTransport { + inbox: a_to_b, + outbox: b_to_a, + }; + (client, server) + } + + fn shared_session_keys() -> (Session, Session) { + // Drive a real native handshake so both sides share traffic keys and the + // channel exercises `from_active_session`. + let (mut initiator, client_hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut responder = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let server_hello = responder + .handle_control(&client_hello) + .expect("responder handles client hello") + .expect("responder returns a server hello"); + let none = initiator + .handle_control(&server_hello) + .expect("initiator finalizes"); + assert!(none.is_none(), "initiator must not reply to server hello"); + (initiator, responder) + } + + #[tokio::test] + async fn secure_message_channel_roundtrip_and_replay() { + let (initiator, responder) = shared_session_keys(); + let (client_io, server_io) = linked_pair(); + + let mut client = SecureMessageChannel::from_active_session(client_io, &initiator) + .expect("client channel"); + let mut server = SecureMessageChannel::from_active_session(server_io, &responder) + .expect("server channel"); + + client + .send_message(0, 0, b"hello over messages") + .await + .expect("send"); + + // Capture the on-wire bytes before the server consumes them, so we can + // re-deliver the exact same message afterwards. + let on_wire = server + .transport() + .inbox + .borrow() + .front() + .expect("one message queued") + .clone(); + + let opened = server.recv_message().await.expect("recv"); + assert_eq!(opened.plaintext, b"hello over messages"); + + // Re-deliver the same bytes: a duplicate must be rejected as a replay. + server.transport().inbox.borrow_mut().push_back(on_wire); + let err = server + .recv_message() + .await + .expect_err("duplicate must be replay-rejected"); + assert!(matches!(err, MessageChannelError::Core(CoreError::Replay))); + } + + #[tokio::test] + async fn secure_message_channel_after_key_rotation() { + // Build channels directly from raw keys so we can rotate them in lockstep. + let a = EphemeralKeyPair::generate(); + let b = EphemeralKeyPair::generate(); + let ss = a.shared_secret(b.public).expect("shared secret"); + let salt = random_session_salt(); + let k1 = KeyHandle::new(derive_traffic_keys(&ss, &salt, 1).expect("keys gen 1")); + let k2 = KeyHandle::new(derive_traffic_keys(&ss, &salt, 2).expect("keys gen 2")); + + let (client_io, server_io) = linked_pair(); + let mut client = SecureMessageChannel { + transport: client_io, + endpoint: MessageEndpoint::new(k1.clone(), Direction::S2C, Direction::C2S), + }; + let mut server = SecureMessageChannel { + transport: server_io, + endpoint: MessageEndpoint::new(k1, Direction::C2S, Direction::S2C), + }; + + client.send_message(0, 0, b"before").await.expect("send 1"); + assert_eq!( + server.recv_message().await.expect("recv 1").plaintext, + b"before" + ); + + client.install_active_keys(k2.clone()); + server.install_active_keys(k2); + + client.send_message(0, 0, b"after").await.expect("send 2"); + let opened = server.recv_message().await.expect("recv 2"); + assert_eq!(opened.plaintext, b"after"); + assert_eq!(opened.header.key_id, 2); + } +} diff --git a/foctet-transport/src/muxtls.rs b/foctet-transport/src/muxtls.rs index 04996f9..3a69ea3 100644 --- a/foctet-transport/src/muxtls.rs +++ b/foctet-transport/src/muxtls.rs @@ -48,7 +48,7 @@ pub async fn open_secure_channel_with_handshake( open_secure_channel_with_handshake_and_auth_config( connection, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), TransportConfig::default(), ) .await @@ -66,7 +66,7 @@ pub async fn open_secure_channel_with_handshake_and_config( open_secure_channel_with_handshake_and_auth_config( connection, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), config, ) .await @@ -134,7 +134,7 @@ pub async fn accept_secure_channel_with_handshake( accept_secure_channel_with_handshake_and_auth_config( connection, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), TransportConfig::default(), ) .await @@ -152,7 +152,7 @@ pub async fn accept_secure_channel_with_handshake_and_config( accept_secure_channel_with_handshake_and_auth_config( connection, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), config, ) .await diff --git a/foctet-transport/src/quinn.rs b/foctet-transport/src/quinn.rs index 20a0e32..4756da5 100644 --- a/foctet-transport/src/quinn.rs +++ b/foctet-transport/src/quinn.rs @@ -1,12 +1,156 @@ //! High-level Foctet integration helpers for `quinn`. -use foctet_core::{RekeyThresholds, Session, SessionAuthConfig}; +use bytes::Bytes; +use foctet_core::{ + CoreError, DatagramConfig, DatagramEndpoint, DecodedDatagram, RekeyThresholds, Session, + SessionAuthConfig, +}; +use thiserror::Error; use crate::{ TokioTransportBuilder, TokioTransportChannel, TransportChannelError, TransportConfig, adapter::SplitIo, }; +/// Error returned by the [`crate::DatagramTransport`] implementation for +/// [`quinn::Connection`]. +#[derive(Debug, Error)] +pub enum QuinnDatagramTransportError { + /// Quinn refused to send the datagram (too large, disabled, or closed). + #[error("quinn send datagram error: {0}")] + Send(#[from] quinn::SendDatagramError), + /// The Quinn connection failed while receiving a datagram. + #[error("quinn connection error: {0}")] + Connection(#[from] quinn::ConnectionError), +} + +/// Generic datagram-transport view of a [`quinn::Connection`], usable with +/// [`crate::SecureDatagramChannel`]. +impl crate::DatagramTransport for quinn::Connection { + type Error = QuinnDatagramTransportError; + + async fn send_datagram(&self, datagram: Vec) -> Result<(), Self::Error> { + quinn::Connection::send_datagram(self, Bytes::from(datagram))?; + Ok(()) + } + + async fn recv_datagram(&self) -> Result, Self::Error> { + let bytes = quinn::Connection::read_datagram(self).await?; + Ok(bytes.to_vec()) + } + + fn max_datagram_size(&self) -> Option { + quinn::Connection::max_datagram_size(self) + } +} + +/// Error returned by [`QuinnDatagramChannel`] operations. +#[derive(Debug, Error)] +pub enum QuinnDatagramError { + /// Foctet datagram seal/open failed. + #[error(transparent)] + Core(#[from] CoreError), + /// Quinn refused to send the datagram (too large, disabled, or closed). + #[error("quinn send datagram error: {0}")] + Send(#[from] quinn::SendDatagramError), + /// The Quinn connection failed while receiving a datagram. + #[error("quinn connection error: {0}")] + Connection(#[from] quinn::ConnectionError), +} + +/// A Foctet datagram channel over a QUIC connection. +/// +/// Each call seals one Foctet frame into exactly one QUIC datagram (and opens +/// one per received datagram). QUIC datagrams are unreliable and unordered; +/// Foctet's replay window tolerates loss and reordering and rejects duplicates. +/// +/// Negotiate keys with a normal Foctet handshake over a QUIC stream first (for +/// example via [`open_secure_channel_with_handshake_and_auth_config`]), then +/// build this channel from the resulting [`Session`]. +/// +/// # MTU and anti-amplification +/// +/// Set [`DatagramConfig::max_datagram_size`] at or below the connection's +/// [`quinn::Connection::max_datagram_size`]. As with any datagram protocol, do +/// not send a large volume of datagrams to a peer whose address has not been +/// validated. +#[derive(Debug)] +pub struct QuinnDatagramChannel { + connection: quinn::Connection, + endpoint: DatagramEndpoint, +} + +impl QuinnDatagramChannel { + /// Builds a datagram channel from a connection and an active [`Session`], + /// clamping the datagram size to the connection's current maximum. + pub fn from_active_session( + connection: quinn::Connection, + session: &Session, + ) -> Result { + let mut config = DatagramConfig::default(); + if let Some(max) = connection.max_datagram_size() { + config.max_datagram_size = config.max_datagram_size.min(max); + } + Self::from_active_session_with_config(connection, session, config) + } + + /// Builds a datagram channel from a connection, an active [`Session`], and + /// an explicit datagram configuration. + pub fn from_active_session_with_config( + connection: quinn::Connection, + session: &Session, + config: DatagramConfig, + ) -> Result { + let keys = session + .active_keys() + .ok_or(CoreError::InvalidSessionState)?; + let endpoint = DatagramEndpoint::with_config( + keys, + session.inbound_direction(), + session.outbound_direction(), + config, + ); + Ok(Self { + connection, + endpoint, + }) + } + + /// Returns the maximum plaintext bytes that fit in one datagram. + pub fn max_plaintext_len(&self) -> usize { + self.endpoint.max_plaintext_len() + } + + /// Installs a freshly rotated set of traffic keys (after a rekey). + pub fn install_active_keys(&mut self, keys: foctet_core::KeyHandle) { + self.endpoint.install_active_keys(keys); + } + + /// Returns a reference to the underlying QUIC connection. + pub fn connection(&self) -> &quinn::Connection { + &self.connection + } + + /// Seals `plaintext` into one Foctet frame and sends it as a QUIC datagram. + pub fn send_datagram( + &mut self, + stream_id: u32, + flags: u8, + plaintext: &[u8], + ) -> Result<(), QuinnDatagramError> { + let bytes = self.endpoint.seal(stream_id, flags, plaintext)?; + self.connection.send_datagram(Bytes::from(bytes))?; + Ok(()) + } + + /// Receives one QUIC datagram and opens it into a decrypted payload. + pub async fn recv_datagram(&mut self) -> Result { + let bytes = self.connection.read_datagram().await?; + let decoded = self.endpoint.open(&bytes)?; + Ok(decoded) + } +} + /// Opens a bidirectional Quinn stream and wraps it as a Foctet secure channel. pub async fn open_secure_channel( connection: &quinn::Connection, @@ -48,7 +192,7 @@ pub async fn open_secure_channel_with_handshake( open_secure_channel_with_handshake_and_auth_config( connection, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), TransportConfig::default(), ) .await @@ -66,7 +210,7 @@ pub async fn open_secure_channel_with_handshake_and_config( open_secure_channel_with_handshake_and_auth_config( connection, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), config, ) .await @@ -134,7 +278,7 @@ pub async fn accept_secure_channel_with_handshake( accept_secure_channel_with_handshake_and_auth_config( connection, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), TransportConfig::default(), ) .await @@ -152,7 +296,7 @@ pub async fn accept_secure_channel_with_handshake_and_config( accept_secure_channel_with_handshake_and_auth_config( connection, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), config, ) .await @@ -178,3 +322,123 @@ pub async fn accept_secure_channel_with_handshake_and_auth_config( .await .map_err(TransportChannelError::core) } + +#[cfg(all(test, feature = "runtime-tokio"))] +mod datagram_tests { + use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; + use std::sync::Arc; + + use foctet_core::{RekeyThresholds, Session}; + use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; + + use super::QuinnDatagramChannel; + use crate::{SecureDatagramChannel, TokioTransportBuilder}; + + fn server_endpoint() -> (quinn::Endpoint, Vec>) { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]) + .expect("self-signed cert"); + let cert_der = CertificateDer::from(cert.cert); + let key_der = + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der())); + let server_config = + quinn::ServerConfig::with_single_cert(vec![cert_der.clone()], key_der).expect("config"); + let addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)); + let endpoint = quinn::Endpoint::server(server_config, addr).expect("server endpoint"); + (endpoint, vec![cert_der]) + } + + fn client_endpoint(cert_chain: &[CertificateDer<'static>]) -> quinn::Endpoint { + let mut roots = rustls::RootCertStore::empty(); + for cert in cert_chain { + roots.add(cert.clone()).expect("add root"); + } + let client_config = + quinn::ClientConfig::with_root_certificates(Arc::new(roots)).expect("client config"); + let addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)); + let mut endpoint = quinn::Endpoint::client(addr).expect("client endpoint"); + endpoint.set_default_client_config(client_config); + endpoint + } + + /// Establishes a QUIC connection pair and runs the Foctet handshake over a + /// bi-directional stream, returning both connections and their sessions. + async fn establish() -> (quinn::Connection, Session, quinn::Connection, Session) { + let (server_ep, cert_chain) = server_endpoint(); + let server_addr = server_ep.local_addr().expect("server addr"); + let client_ep = client_endpoint(&cert_chain); + + let server_task = tokio::spawn(async move { + let incoming = server_ep.accept().await.expect("incoming"); + incoming.await.expect("server connection") + }); + let client_conn = client_ep + .connect(server_addr, "localhost") + .expect("connect") + .await + .expect("client connection"); + let server_conn = server_task.await.expect("server join"); + + let client_conn_hs = client_conn.clone(); + let client_hs = tokio::spawn(async move { + let (send, recv) = client_conn_hs.open_bi().await.expect("open_bi"); + TokioTransportBuilder::new() + .establish_initiator_from_split(recv, send, RekeyThresholds::default()) + .await + .expect("client handshake") + .into_transport_and_session() + }); + + let (server_send, server_recv) = server_conn.accept_bi().await.expect("accept_bi"); + let server_channel = TokioTransportBuilder::new() + .establish_responder_from_split(server_recv, server_send, RekeyThresholds::default()) + .await + .expect("server handshake"); + let (_server_io, server_session) = server_channel.into_transport_and_session(); + let (_client_io, client_session) = client_hs.await.expect("client hs join"); + + (client_conn, client_session, server_conn, server_session) + } + + #[tokio::test] + async fn quinn_datagram_roundtrip_over_real_connection() { + let (client_conn, client_session, server_conn, server_session) = establish().await; + + let mut client_dgram = + QuinnDatagramChannel::from_active_session(client_conn, &client_session) + .expect("client datagram channel"); + let mut server_dgram = + QuinnDatagramChannel::from_active_session(server_conn, &server_session) + .expect("server datagram channel"); + + client_dgram + .send_datagram(0, 0, b"datagram payload") + .expect("send datagram"); + let received = server_dgram.recv_datagram().await.expect("recv datagram"); + assert_eq!(received.plaintext, b"datagram payload"); + + server_dgram + .send_datagram(0, 0, b"reply payload") + .expect("send reply"); + let reply = client_dgram.recv_datagram().await.expect("recv reply"); + assert_eq!(reply.plaintext, b"reply payload"); + } + + #[tokio::test] + async fn generic_secure_datagram_channel_over_quinn() { + // The same QUIC connection works through the backend-agnostic + // `DatagramTransport` / `SecureDatagramChannel` interface (§3.2). + let (client_conn, client_session, server_conn, server_session) = establish().await; + + let mut client = SecureDatagramChannel::from_active_session(client_conn, &client_session) + .expect("client secure datagram channel"); + let mut server = SecureDatagramChannel::from_active_session(server_conn, &server_session) + .expect("server secure datagram channel"); + + client + .send_datagram(0, 0, b"generic datagram") + .await + .expect("send"); + let received = server.recv_datagram().await.expect("recv"); + assert_eq!(received.plaintext, b"generic datagram"); + } +} diff --git a/foctet-transport/src/rate_limit.rs b/foctet-transport/src/rate_limit.rs new file mode 100644 index 0000000..b972b57 --- /dev/null +++ b/foctet-transport/src/rate_limit.rs @@ -0,0 +1,164 @@ +//! Connection-level handshake admission control. +//! +//! A native Foctet handshake costs the responder an X25519 exchange, an HKDF +//! run, and (when identity auth is on) an Ed25519 verification before the peer +//! has proven anything. [`HandshakeRateLimiter`] bounds how fast a listener +//! accepts new handshakes so a hostile client (or a stampede of well-meaning +//! ones) cannot pin the accept loop's CPU: admission is a token bucket with a +//! sustained rate and a burst capacity, shared across connections by cloning +//! the limiter (clones share one bucket). +//! +//! ```rust,ignore +//! use foctet_transport::HandshakeRateLimiter; +//! +//! // Sustained 100 handshakes/second, bursts up to 200. +//! let limiter = HandshakeRateLimiter::new(100.0, 200); +//! loop { +//! let (stream, _addr) = listener.accept().await?; +//! limiter.admit()?; // fails fast with HandshakeRateLimited when saturated +//! let limiter_task = builder.establish_responder_with_auth_and_timeout( +//! stream, thresholds, auth.clone(), timeout); +//! // ... +//! } +//! ``` +//! +//! # Cancellation +//! +//! All `establish_*` handshake futures in this crate are **drop-cancellable**: +//! dropping the future (e.g. via `tokio::select!`, a surrounding +//! `tokio::time::timeout`, or task abort) abandons the handshake without +//! leaking protocol state — all session state lives inside the future, and the +//! underlying I/O object is simply dropped or returned to the caller. A +//! rejected or cancelled handshake consumes nothing beyond the admission token +//! already taken. + +use std::{ + sync::{Arc, Mutex}, + time::Instant, +}; + +use foctet_core::CoreError; + +/// Token-bucket admission control for inbound (or outbound) handshakes. +/// +/// Cheap to clone; all clones share the same bucket, so one limiter can guard +/// every connection a listener accepts. Thread-safe. +#[derive(Clone, Debug)] +pub struct HandshakeRateLimiter { + inner: Arc>, +} + +#[derive(Debug)] +struct Bucket { + /// Tokens currently available (fractional to keep refill precise). + tokens: f64, + /// Sustained refill rate, tokens per second. + rate_per_sec: f64, + /// Maximum tokens the bucket holds (burst capacity). + burst: f64, + /// Last refill timestamp. + last_refill: Instant, +} + +impl HandshakeRateLimiter { + /// Creates a limiter allowing `rate_per_sec` sustained handshakes per + /// second with bursts of up to `burst` (clamped to at least 1). The bucket + /// starts full, so the first `burst` admissions succeed immediately. + pub fn new(rate_per_sec: f64, burst: u32) -> Self { + let burst = f64::from(burst.max(1)); + let rate_per_sec = if rate_per_sec.is_finite() && rate_per_sec > 0.0 { + rate_per_sec + } else { + 1.0 + }; + Self { + inner: Arc::new(Mutex::new(Bucket { + tokens: burst, + rate_per_sec, + burst, + last_refill: Instant::now(), + })), + } + } + + /// Attempts to admit one handshake now. + /// + /// Consumes one token on success; fails with + /// [`CoreError::HandshakeRateLimited`] when the bucket is empty. This + /// never blocks or sleeps — callers decide whether to drop the connection, + /// queue it, or back off. + pub fn admit(&self) -> Result<(), CoreError> { + if self.try_admit() { + Ok(()) + } else { + Err(CoreError::HandshakeRateLimited) + } + } + + /// Non-erroring form of [`Self::admit`]: `true` if a token was consumed. + pub fn try_admit(&self) -> bool { + let mut bucket = self.inner.lock().expect("rate-limiter mutex poisoned"); + let now = Instant::now(); + let elapsed = now.duration_since(bucket.last_refill).as_secs_f64(); + bucket.tokens = (bucket.tokens + elapsed * bucket.rate_per_sec).min(bucket.burst); + bucket.last_refill = now; + if bucket.tokens >= 1.0 { + bucket.tokens -= 1.0; + true + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn burst_is_admitted_then_saturates() { + let limiter = HandshakeRateLimiter::new(1000.0, 3); + assert!(limiter.try_admit()); + assert!(limiter.try_admit()); + assert!(limiter.try_admit()); + // The bucket refills at 1000/s, so a tiny amount may trickle back in + // between calls; drain whatever fraction accrued and then expect + // saturation. + let mut extra = 0; + while limiter.try_admit() { + extra += 1; + assert!(extra < 100, "bucket must saturate near its burst size"); + } + assert!(matches!( + limiter.admit(), + Err(CoreError::HandshakeRateLimited) + )); + } + + #[test] + fn tokens_refill_over_time() { + let limiter = HandshakeRateLimiter::new(1000.0, 1); + assert!(limiter.try_admit()); + assert!(!limiter.try_admit()); + std::thread::sleep(std::time::Duration::from_millis(5)); + assert!(limiter.try_admit(), "5ms at 1000/s must refill a token"); + } + + #[test] + fn clones_share_one_bucket() { + let limiter = HandshakeRateLimiter::new(0.001, 1); + let clone = limiter.clone(); + assert!(limiter.try_admit()); + assert!( + !clone.try_admit(), + "a clone must observe the shared bucket as drained" + ); + } + + #[test] + fn degenerate_parameters_are_clamped() { + let limiter = HandshakeRateLimiter::new(f64::NAN, 0); + assert!(limiter.try_admit(), "burst clamps to 1"); + assert!(!limiter.try_admit()); + } +} diff --git a/foctet-transport/src/shape.rs b/foctet-transport/src/shape.rs new file mode 100644 index 0000000..c3e34b8 --- /dev/null +++ b/foctet-transport/src/shape.rs @@ -0,0 +1,109 @@ +//! Transport shapes and a unified secure-channel abstraction. +//! +//! Foctet protects three transport shapes, each with its own raw transport +//! trait: +//! +//! - **Byte stream** — a reliable, ordered byte stream +//! ([`ByteStreamTransport`], i.e. `AsyncRead + AsyncWrite`), driven by +//! [`crate::TokioTransportBuilder`] / [`crate::FuturesTransportBuilder`]. +//! - **Message** — reliable, ordered, message-bounded units +//! ([`crate::MessageTransport`]), via [`crate::SecureMessageChannel`]. +//! - **Datagram** — MTU-bounded, loss/reorder-tolerant units +//! ([`crate::DatagramTransport`]), via [`crate::SecureDatagramChannel`]. +//! +//! Although the shapes differ on the wire, every Foctet secure channel exposes +//! the same application contract: send and receive whole **payloads**. The +//! [`SecureChannel`] trait captures that contract so application code (and the +//! shared conformance suite) can be written once and run over any shape. + +/// Marker for a reliable, ordered **byte-stream** transport — the byte-stream +/// counterpart to [`crate::MessageTransport`] and [`crate::DatagramTransport`]. +/// +/// Any runtime-agnostic `futures_io` read/write stream is a byte-stream +/// transport; pair it with [`crate::FuturesTransportBuilder`] (or, for Tokio +/// streams, [`crate::TokioTransportBuilder`]) to obtain a [`SecureChannel`]. +#[cfg(feature = "runtime-futures")] +pub trait ByteStreamTransport: futures_io::AsyncRead + futures_io::AsyncWrite + Unpin {} + +#[cfg(feature = "runtime-futures")] +impl ByteStreamTransport for T where T: futures_io::AsyncRead + futures_io::AsyncWrite + Unpin {} + +/// A Foctet secure channel, abstracted over the three transport shapes. +/// +/// Each method moves one application payload, sealing/opening it with the +/// channel's traffic keys. The shapes' extra wire semantics (message vs datagram +/// boundaries, per-frame `stream_id`/`flags`) are not exposed here; reach for the +/// concrete channel type when you need them. +#[allow(async_fn_in_trait)] +pub trait SecureChannel { + /// Error type returned by send/receive. + type Error: std::error::Error; + + /// Seals and sends one application payload. + async fn send_payload(&mut self, payload: &[u8]) -> Result<(), Self::Error>; + + /// Receives and opens one application payload. + async fn recv_payload(&mut self) -> Result, Self::Error>; +} + +#[cfg(feature = "runtime-tokio")] +impl SecureChannel for crate::TokioTransportChannel +where + T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + type Error = foctet_core::CoreError; + + async fn send_payload(&mut self, payload: &[u8]) -> Result<(), Self::Error> { + self.send_application(payload).await + } + + async fn recv_payload(&mut self) -> Result, Self::Error> { + self.recv_application().await + } +} + +#[cfg(feature = "runtime-futures")] +impl SecureChannel for crate::FuturesTransportChannel +where + T: futures_io::AsyncRead + futures_io::AsyncWrite + Unpin, +{ + type Error = foctet_core::CoreError; + + async fn send_payload(&mut self, payload: &[u8]) -> Result<(), Self::Error> { + self.send_application(payload).await + } + + async fn recv_payload(&mut self) -> Result, Self::Error> { + self.recv_application().await + } +} + +impl SecureChannel for crate::message::SecureMessageChannel +where + T: crate::message::MessageTransport, +{ + type Error = crate::message::MessageChannelError; + + async fn send_payload(&mut self, payload: &[u8]) -> Result<(), Self::Error> { + self.send_message(0, 0, payload).await + } + + async fn recv_payload(&mut self) -> Result, Self::Error> { + Ok(self.recv_message().await?.plaintext) + } +} + +impl SecureChannel for crate::datagram::SecureDatagramChannel +where + T: crate::datagram::DatagramTransport, +{ + type Error = crate::datagram::DatagramChannelError; + + async fn send_payload(&mut self, payload: &[u8]) -> Result<(), Self::Error> { + self.send_datagram(0, 0, payload).await + } + + async fn recv_payload(&mut self) -> Result, Self::Error> { + Ok(self.recv_datagram().await?.plaintext) + } +} diff --git a/foctet-transport/src/tokio.rs b/foctet-transport/src/tokio.rs index c05ff06..3a59fbf 100644 --- a/foctet-transport/src/tokio.rs +++ b/foctet-transport/src/tokio.rs @@ -1,4 +1,4 @@ -use std::{future::poll_fn, pin::Pin}; +use std::{future::poll_fn, pin::Pin, time::Duration}; use foctet_core::{ AsyncSecureChannel, ControlMessage, CoreError, FoctetFramed, RekeyThresholds, Session, @@ -11,6 +11,14 @@ use crate::{TransportConfig, adapter::SplitIo}; const HANDSHAKE_CONTROL_MAX_LEN: usize = 1024; +/// Default deadline for [`TokioTransportBuilder::establish_initiator_with_timeout`] +/// and the responder/auth variants: enough for a two-message round trip over a +/// slow network, short enough to bound a stalled or hostile peer's hold on +/// server resources. This is the same value as +/// [`foctet_core::ProtocolLimits::handshake_timeout`]'s default, so the +/// centralized limits struct remains the single documented source. +pub const DEFAULT_HANDSHAKE_TIMEOUT: Duration = foctet_core::DEFAULT_HANDSHAKE_TIMEOUT; + /// Builder for the recommended Tokio-based transport integration path. #[derive(Clone, Copy, Debug, Default)] pub struct TokioTransportBuilder { @@ -84,8 +92,12 @@ impl TokioTransportBuilder { where T: AsyncRead + AsyncWrite + Unpin, { - let session = - run_initiator_handshake(&mut io, thresholds, SessionAuthConfig::default()).await?; + let session = run_initiator_handshake( + &mut io, + thresholds, + SessionAuthConfig::unauthenticated_for_testing(), + ) + .await?; self.build(io, session) } @@ -112,8 +124,12 @@ impl TokioTransportBuilder { where T: AsyncRead + AsyncWrite + Unpin, { - let session = - run_responder_handshake(&mut io, thresholds, SessionAuthConfig::default()).await?; + let session = run_responder_handshake( + &mut io, + thresholds, + SessionAuthConfig::unauthenticated_for_testing(), + ) + .await?; self.build(io, session) } @@ -131,6 +147,139 @@ impl TokioTransportBuilder { self.build(io, session) } + /// Runs the native Foctet handshake as initiator with an explicit deadline, + /// failing with [`CoreError::HandshakeTimeout`] if the peer does not + /// complete it in time. Bounds how long a stalled or hostile peer can hold + /// a connection open before authentication completes. + pub async fn establish_initiator_with_auth_and_timeout( + self, + mut io: T, + thresholds: RekeyThresholds, + auth: SessionAuthConfig, + timeout: Duration, + ) -> Result, CoreError> + where + T: AsyncRead + AsyncWrite + Unpin, + { + let session = + tokio::time::timeout(timeout, run_initiator_handshake(&mut io, thresholds, auth)) + .await + .map_err(|_| CoreError::HandshakeTimeout)??; + self.build(io, session) + } + + /// Convenience wrapper using [`DEFAULT_HANDSHAKE_TIMEOUT`] and the + /// unauthenticated-for-testing auth config; see + /// [`Self::establish_initiator_with_auth_and_timeout`]. + pub async fn establish_initiator_with_timeout( + self, + io: T, + thresholds: RekeyThresholds, + timeout: Duration, + ) -> Result, CoreError> + where + T: AsyncRead + AsyncWrite + Unpin, + { + self.establish_initiator_with_auth_and_timeout( + io, + thresholds, + SessionAuthConfig::unauthenticated_for_testing(), + timeout, + ) + .await + } + + /// Runs the native Foctet handshake as responder with an explicit + /// deadline; see [`Self::establish_initiator_with_auth_and_timeout`]. + pub async fn establish_responder_with_auth_and_timeout( + self, + mut io: T, + thresholds: RekeyThresholds, + auth: SessionAuthConfig, + timeout: Duration, + ) -> Result, CoreError> + where + T: AsyncRead + AsyncWrite + Unpin, + { + let session = + tokio::time::timeout(timeout, run_responder_handshake(&mut io, thresholds, auth)) + .await + .map_err(|_| CoreError::HandshakeTimeout)??; + self.build(io, session) + } + + /// Rate-limited responder handshake: consults `limiter` **before** doing + /// any handshake work, failing fast with + /// [`CoreError::HandshakeRateLimited`] when admission control is + /// saturated, then runs + /// [`Self::establish_responder_with_auth_and_timeout`]. Intended for + /// accept loops that share one [`crate::HandshakeRateLimiter`] across all + /// inbound connections. + pub async fn establish_responder_with_auth_timeout_and_limiter( + self, + io: T, + thresholds: RekeyThresholds, + auth: SessionAuthConfig, + timeout: Duration, + limiter: &crate::HandshakeRateLimiter, + ) -> Result, CoreError> + where + T: AsyncRead + AsyncWrite + Unpin, + { + limiter.admit()?; + self.establish_responder_with_auth_and_timeout(io, thresholds, auth, timeout) + .await + } + + /// Convenience wrapper using [`DEFAULT_HANDSHAKE_TIMEOUT`] and the + /// unauthenticated-for-testing auth config; see + /// [`Self::establish_responder_with_auth_and_timeout`]. + pub async fn establish_responder_with_timeout( + self, + io: T, + thresholds: RekeyThresholds, + timeout: Duration, + ) -> Result, CoreError> + where + T: AsyncRead + AsyncWrite + Unpin, + { + self.establish_responder_with_auth_and_timeout( + io, + thresholds, + SessionAuthConfig::unauthenticated_for_testing(), + timeout, + ) + .await + } + + /// Convenience wrapper using [`DEFAULT_HANDSHAKE_TIMEOUT`]; see + /// [`Self::establish_initiator_with_timeout`]. + pub async fn establish_initiator_with_default_timeout( + self, + io: T, + thresholds: RekeyThresholds, + ) -> Result, CoreError> + where + T: AsyncRead + AsyncWrite + Unpin, + { + self.establish_initiator_with_timeout(io, thresholds, DEFAULT_HANDSHAKE_TIMEOUT) + .await + } + + /// Convenience wrapper using [`DEFAULT_HANDSHAKE_TIMEOUT`]; see + /// [`Self::establish_responder_with_timeout`]. + pub async fn establish_responder_with_default_timeout( + self, + io: T, + thresholds: RekeyThresholds, + ) -> Result, CoreError> + where + T: AsyncRead + AsyncWrite + Unpin, + { + self.establish_responder_with_timeout(io, thresholds, DEFAULT_HANDSHAKE_TIMEOUT) + .await + } + /// Runs the native Foctet handshake as initiator on split transport halves, then builds a secure channel. pub async fn establish_initiator_from_split( self, @@ -298,15 +447,21 @@ where #[cfg(all(test, feature = "runtime-tokio"))] mod tests { - use foctet_core::{RekeyThresholds, Session}; + use foctet_core::{RekeyThresholds, Session, SessionAuthConfig}; use super::TokioTransportBuilder; use crate::TransportConfig; fn make_session_pair() -> Result<(Session, Session), foctet_core::CoreError> { let thresholds = RekeyThresholds::default(); - let (mut initiator, hello) = Session::new_initiator(thresholds.clone()); - let mut responder = Session::new_responder(thresholds); + let (mut initiator, hello) = Session::new_initiator_with_auth( + thresholds.clone(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut responder = Session::new_responder_with_auth( + thresholds, + SessionAuthConfig::unauthenticated_for_testing(), + ); let server_hello = responder .handle_control(&hello)? .expect("responder returns server hello"); @@ -371,4 +526,61 @@ mod tests { assert_eq!(msg, b"hello"); assert_eq!(client.config().app_stream_id(), 9); } + + #[tokio::test] + async fn establish_with_timeout_succeeds_when_peer_responds_in_time() { + let thresholds = RekeyThresholds::default(); + let (client_recv, server_send) = tokio::io::duplex(1024); + let (server_recv, client_send) = tokio::io::duplex(1024); + + let builder = TokioTransportBuilder::new(); + let client_task = tokio::spawn({ + let thresholds = thresholds.clone(); + async move { + builder + .establish_initiator_with_timeout( + crate::adapter::SplitIo::from_split(client_recv, client_send), + thresholds, + std::time::Duration::from_secs(5), + ) + .await + } + }); + + let server = builder + .establish_responder_with_timeout( + crate::adapter::SplitIo::from_split(server_recv, server_send), + thresholds, + std::time::Duration::from_secs(5), + ) + .await; + assert!(server.is_ok()); + let client = client_task.await.expect("client join"); + assert!(client.is_ok()); + } + + #[tokio::test] + async fn establish_with_timeout_fails_closed_when_peer_never_responds() { + let thresholds = RekeyThresholds::default(); + // The responder side is never driven, so the initiator's "wait for + // server hello" read never resolves; the handshake must time out + // rather than hang forever. + let (client_recv, _server_send) = tokio::io::duplex(1024); + let (_server_recv, client_send) = tokio::io::duplex(1024); + + let builder = TokioTransportBuilder::new(); + let result = builder + .establish_initiator_with_timeout( + crate::adapter::SplitIo::from_split(client_recv, client_send), + thresholds, + std::time::Duration::from_millis(50), + ) + .await; + assert!(matches!( + result, + Err(foctet_core::CoreError::HandshakeTimeout) + | Err(foctet_core::CoreError::Io(_)) + | Err(foctet_core::CoreError::UnexpectedEof) + )); + } } diff --git a/foctet-transport/src/udp.rs b/foctet-transport/src/udp.rs new file mode 100644 index 0000000..62ce491 --- /dev/null +++ b/foctet-transport/src/udp.rs @@ -0,0 +1,290 @@ +//! Raw UDP [`DatagramTransport`] adapter. +//! +//! This wraps a connected `tokio::net::UdpSocket` so it implements +//! [`DatagramTransport`] and can be used with [`crate::SecureDatagramChannel`]. +//! +//! Unlike QUIC/WebTransport datagrams, raw UDP has no built-in connection or +//! peer authentication. This adapter only moves bytes; *you* are responsible +//! for: +//! +//! - **Session setup.** Negotiate Foctet traffic keys out of band (e.g. a +//! Foctet handshake over a TCP/TLS control connection, or any other secure +//! channel) before constructing the [`crate::SecureDatagramChannel`], exactly as +//! for the QUIC datagram adapter. +//! - **Peer discovery / pinning.** Call [`tokio::net::UdpSocket::connect`] on +//! the socket before wrapping it here: this trait carries no destination +//! address, so an unconnected socket would accept datagrams from (and only +//! report errors for, not actually block) any source. A connected socket +//! only sends to and receives from the one peer address passed to +//! `connect`, which is the raw-UDP equivalent of the implicit peer binding +//! QUIC/WebTransport give you for free. +//! - **Path/MTU changes and fragmentation.** Not handled here; oversized +//! plaintext fails closed with `CoreError::FrameTooLarge` (see +//! `foctet_core::datagram`). +//! - **Anti-amplification.** If you accept first datagrams from +//! not-yet-validated peers (e.g. a rendezvous/listener socket spawning a +//! connected socket per peer), bound how much you send before the peer has +//! proven they can receive at that address, the same concern QUIC's +//! handshake amplification limits address. This adapter can enforce that for +//! you — see [`UdpDatagramTransport::with_anti_amplification`]. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +use foctet_core::DEFAULT_MAX_DATAGRAM_SIZE; +use tokio::net::UdpSocket; + +use crate::datagram::DatagramTransport; + +/// The standard QUIC anti-amplification factor: a server may send at most this +/// many times the bytes it has received from an unvalidated peer. +pub const DEFAULT_AMPLIFICATION_FACTOR: u64 = 3; + +/// Tracks the anti-amplification budget for one peer. +#[derive(Debug)] +struct AmplificationLimiter { + factor: u64, + received: AtomicU64, + sent: AtomicU64, + validated: AtomicBool, +} + +/// [`DatagramTransport`] over a connected `tokio::net::UdpSocket`. +#[derive(Clone, Debug)] +pub struct UdpDatagramTransport { + socket: Arc, + max_datagram_size: usize, + limiter: Option>, +} + +impl UdpDatagramTransport { + /// Wraps a socket, defaulting the reported max datagram size to + /// [`DEFAULT_MAX_DATAGRAM_SIZE`] (a conservative bound safe for typical + /// Internet paths without path-MTU discovery). + /// + /// The socket must already be connected to the single intended peer (see + /// the module docs); this constructor does not call `connect` for you. + /// Anti-amplification is **off** by default; opt in with + /// [`UdpDatagramTransport::with_anti_amplification`]. + pub fn new(socket: UdpSocket) -> Self { + Self { + socket: Arc::new(socket), + max_datagram_size: DEFAULT_MAX_DATAGRAM_SIZE, + limiter: None, + } + } + + /// Overrides the reported max datagram size, e.g. after path-MTU + /// discovery or for a known-constrained network. + pub fn with_max_datagram_size(mut self, max_datagram_size: usize) -> Self { + self.max_datagram_size = max_datagram_size; + self + } + + /// Enforces an anti-amplification limit until the peer is validated. + /// + /// While the peer is **not** validated, [`Self::send_datagram`] refuses to + /// send once the cumulative bytes sent would exceed `factor` times the + /// cumulative bytes received (returning a [`std::io::ErrorKind::WouldBlock`] + /// error), so a spoofed source address cannot turn this endpoint into a + /// reflector/amplifier. Call [`Self::mark_peer_validated`] once the peer has + /// proven it can receive at its claimed address (typically when the Foctet + /// handshake over this path completes) to lift the limit. + /// + /// `factor` is clamped to at least 1; [`DEFAULT_AMPLIFICATION_FACTOR`] (3) + /// matches QUIC. Counters are shared across clones of this transport. + pub fn with_anti_amplification(mut self, factor: u64) -> Self { + self.limiter = Some(Arc::new(AmplificationLimiter { + factor: factor.max(1), + received: AtomicU64::new(0), + sent: AtomicU64::new(0), + validated: AtomicBool::new(false), + })); + self + } + + /// Marks the peer as address-validated, lifting any anti-amplification + /// limit. No-op if anti-amplification was not enabled. + pub fn mark_peer_validated(&self) { + if let Some(limiter) = &self.limiter { + limiter.validated.store(true, Ordering::Release); + } + } + + /// Returns whether the peer has been marked validated (always `true` when + /// anti-amplification is not enabled). + pub fn is_peer_validated(&self) -> bool { + match &self.limiter { + Some(limiter) => limiter.validated.load(Ordering::Acquire), + None => true, + } + } + + /// Returns the underlying socket. + pub fn socket(&self) -> &UdpSocket { + &self.socket + } +} + +impl DatagramTransport for UdpDatagramTransport { + type Error = std::io::Error; + + async fn send_datagram(&self, datagram: Vec) -> Result<(), Self::Error> { + if let Some(limiter) = &self.limiter + && !limiter.validated.load(Ordering::Acquire) + { + let budget = limiter + .received + .load(Ordering::Acquire) + .saturating_mul(limiter.factor); + let projected = limiter + .sent + .load(Ordering::Acquire) + .saturating_add(datagram.len() as u64); + if projected > budget { + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "anti-amplification limit reached: cannot send more until the peer is \ + validated or has sent more", + )); + } + } + + self.socket.send(&datagram).await?; + + if let Some(limiter) = &self.limiter { + limiter + .sent + .fetch_add(datagram.len() as u64, Ordering::AcqRel); + } + Ok(()) + } + + async fn recv_datagram(&self) -> Result, Self::Error> { + let mut buf = vec![0u8; self.max_datagram_size]; + let n = self.socket.recv(&mut buf).await?; + buf.truncate(n); + if let Some(limiter) = &self.limiter { + limiter.received.fetch_add(n as u64, Ordering::AcqRel); + } + Ok(buf) + } + + fn max_datagram_size(&self) -> Option { + Some(self.max_datagram_size) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SecureDatagramChannel; + use foctet_core::{RekeyThresholds, Session, SessionAuthConfig}; + + async fn connected_pair() -> (UdpSocket, UdpSocket) { + let a = UdpSocket::bind("127.0.0.1:0").await.expect("bind a"); + let b = UdpSocket::bind("127.0.0.1:0").await.expect("bind b"); + a.connect(b.local_addr().expect("b addr")) + .await + .expect("connect a->b"); + b.connect(a.local_addr().expect("a addr")) + .await + .expect("connect b->a"); + (a, b) + } + + fn session_pair() -> (Session, Session) { + let thresholds = RekeyThresholds::default(); + let (mut initiator, hello) = Session::new_initiator_with_auth( + thresholds.clone(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut responder = Session::new_responder_with_auth( + thresholds, + SessionAuthConfig::unauthenticated_for_testing(), + ); + let server_hello = responder + .handle_control(&hello) + .expect("responder handles hello") + .expect("server hello"); + initiator + .handle_control(&server_hello) + .expect("initiator handles server hello"); + (initiator, responder) + } + + #[tokio::test] + async fn roundtrip_over_real_udp_sockets() { + let (sock_a, sock_b) = connected_pair().await; + let (session_a, session_b) = session_pair(); + + // Directions mirror the QUIC datagram adapter test: each side seals + // with its own outbound direction and opens with the peer's. + let mut channel_a = SecureDatagramChannel::from_active_session( + UdpDatagramTransport::new(sock_a), + &session_a, + ) + .expect("channel a"); + let mut channel_b = SecureDatagramChannel::from_active_session( + UdpDatagramTransport::new(sock_b), + &session_b, + ) + .expect("channel b"); + + channel_a + .send_datagram(0, 0, b"hello over udp") + .await + .expect("send"); + let decoded = channel_b.recv_datagram().await.expect("recv"); + assert_eq!(decoded.plaintext, b"hello over udp"); + assert_eq!(decoded.header.stream_id, 0); + } + + #[tokio::test] + async fn max_datagram_size_override_is_reported() { + let (sock_a, _sock_b) = connected_pair().await; + let transport = UdpDatagramTransport::new(sock_a).with_max_datagram_size(500); + assert_eq!(transport.max_datagram_size(), Some(500)); + } + + #[tokio::test] + async fn anti_amplification_caps_sends_until_validated() { + use std::io::ErrorKind; + + let (sock_a, sock_b) = connected_pair().await; + // `a` is the responder enforcing a 3x anti-amplification budget. + let a = UdpDatagramTransport::new(sock_a).with_anti_amplification(3); + let b = UdpDatagramTransport::new(sock_b); + assert!(!a.is_peer_validated()); + + // With nothing received yet, the budget is zero: any send is refused. + let err = a + .send_datagram(vec![0u8; 32]) + .await + .expect_err("send before any receipt must be blocked"); + assert_eq!(err.kind(), ErrorKind::WouldBlock); + + // The peer sends 100 bytes; the budget becomes 300. + b.send_datagram(vec![0u8; 100]).await.expect("peer sends"); + let got = a.recv_datagram().await.expect("recv"); + assert_eq!(got.len(), 100); + + // 300 bytes are now allowed; the next 100-byte send (total 400) is not. + a.send_datagram(vec![0u8; 200]) + .await + .expect("within budget"); + a.send_datagram(vec![0u8; 100]).await.expect("at budget"); + let err = a + .send_datagram(vec![0u8; 100]) + .await + .expect_err("over budget must be blocked"); + assert_eq!(err.kind(), ErrorKind::WouldBlock); + + // Once the peer is validated the limit no longer applies. + a.mark_peer_validated(); + assert!(a.is_peer_validated()); + a.send_datagram(vec![0u8; 4096]) + .await + .expect("validated peer is unlimited"); + } +} diff --git a/foctet-transport/src/websock.rs b/foctet-transport/src/websock.rs index e9557e2..496d06e 100644 --- a/foctet-transport/src/websock.rs +++ b/foctet-transport/src/websock.rs @@ -1,14 +1,124 @@ -//! High-level Foctet integration helpers for multiplexed WebSocket transports. +//! High-level Foctet integration helpers for WebSocket transports. +//! +//! Two shapes are supported: +//! +//! - **Byte stream over a multiplexed WebSocket** (the `*_secure_channel*` +//! helpers below): a Foctet [`crate::TokioTransportChannel`] runs over a +//! `websock-tungstenite-mux` bidirectional stream, treating the connection as +//! an opaque byte stream. +//! - **Discrete messages over a raw WebSocket** ([`WebsockMessageTransport`]): +//! each Foctet frame is one binary WebSocket message, preserving message +//! boundaries. Pair it with [`crate::SecureMessageChannel`]. +//! +//! # Multiplexing and backpressure (normative for these adapters) +//! +//! - **Raw (message) shape:** one WebSocket connection carries one Foctet +//! session. Foctet's `stream_id` provides *logical* multiplexing inside that +//! session (independent replay windows per `(key_id, stream_id)`), but all +//! streams share the connection's ordering and flow control — a slow +//! consumer stalls every logical stream (head-of-line blocking, as with any +//! single WebSocket). Do not share one connection between independent +//! sessions: frames from two sessions would be indistinguishable at the +//! transport layer. +//! - **Mux shape:** `websock-tungstenite-mux` provides real per-stream +//! multiplexing; each mux stream carries exactly one Foctet channel, and the +//! mux layer owns fairness between streams. +//! - **Backpressure** is delegated to the WebSocket implementation: sends +//! await the underlying socket's readiness (TCP flow control), and receives +//! are pulled one message at a time — this adapter never buffers more than +//! the single in-flight message per direction. Sender-side queueing above +//! the socket (e.g. an unbounded channel feeding this adapter) reintroduces +//! unbounded memory; if you add a queue, bound it. Oversized inbound +//! messages are rejected by [`foctet_core::MessageConfig`]'s +//! `max_message_size` before allocation, so a hostile peer cannot force +//! unbounded buffering at the Foctet layer. -use foctet_core::{RekeyThresholds, Session, SessionAuthConfig}; -use websock_tungstenite_mux as websock_mux; +use futures_util::lock::Mutex; +use websock::{Error as WebsockError, Message, WebSocketConnection}; + +use crate::message::MessageTransport; +#[cfg(feature = "transport-websock-mux")] use crate::{ TokioTransportBuilder, TokioTransportChannel, TransportChannelError, TransportConfig, adapter::SplitIo, }; +#[cfg(feature = "transport-websock-mux")] +use foctet_core::{RekeyThresholds, Session, SessionAuthConfig}; +#[cfg(feature = "transport-websock-mux")] +use websock_tungstenite_mux as websock_mux; + +/// A [`MessageTransport`] over a raw (non-multiplexed) WebSocket connection. +/// +/// Each Foctet frame travels as exactly one **binary** WebSocket message, so the +/// message-bounded shape of [`crate::SecureMessageChannel`] maps directly onto +/// WebSocket framing (unlike the byte-stream mux helpers in this module, which +/// treat the socket as an opaque stream). +/// +/// It is generic over the `websock` crate's cross-platform +/// [`WebSocketConnection`] trait, so the same adapter works over the native +/// (`websock-tungstenite`) connection **and** the browser +/// (`websock-wasm`) WebSocket connection — a Rust/wasm front-end can run a +/// `SecureMessageChannel` over a browser `WebSocket` with no JavaScript glue. +/// +/// Negotiate a [`foctet_core::Session`] first (for example over a Foctet control +/// stream or an out-of-band handshake), then wrap the connection: +/// +/// ```rust,ignore +/// let transport = WebsockMessageTransport::new(connection); +/// let mut channel = SecureMessageChannel::from_active_session(transport, &session)?; +/// channel.send_message(0, 0, b"hello").await?; +/// ``` +/// +/// Sends and receives are serialized through an internal async lock, matching +/// the sequential `&mut self` API of `SecureMessageChannel`. Incoming **text** +/// frames are rejected — Foctet messages are always binary. +pub struct WebsockMessageTransport { + conn: Mutex, +} + +impl WebsockMessageTransport { + /// Wraps an established WebSocket connection as a message transport. + pub fn new(connection: C) -> Self { + Self { + conn: Mutex::new(connection), + } + } + + /// Consumes the transport and returns the underlying connection. + pub fn into_inner(self) -> C { + self.conn.into_inner() + } +} + +impl MessageTransport for WebsockMessageTransport +where + C: WebSocketConnection, +{ + type Error = WebsockError; + + async fn send_message(&self, message: Vec) -> Result<(), Self::Error> { + let mut conn = self.conn.lock().await; + WebSocketConnection::send(&mut *conn, Message::Binary(message.into())).await + } + + async fn recv_message(&self) -> Result, Self::Error> { + let mut conn = self.conn.lock().await; + match WebSocketConnection::recv(&mut *conn).await? { + Message::Binary(bytes) => Ok(bytes.to_vec()), + Message::Text(_) => Err(WebsockError::Protocol( + "expected a binary Foctet message but received a text frame".into(), + )), + } + } + + fn max_message_size(&self) -> Option { + None + } +} /// Opens a bidirectional WebSocket-mux stream and wraps it as a Foctet secure channel. +#[cfg(feature = "transport-websock-mux")] pub async fn open_secure_channel( session_handle: &websock_mux::Session, session: Session, @@ -20,6 +130,7 @@ pub async fn open_secure_channel( } /// Opens a bidirectional WebSocket-mux stream and applies a custom transport config. +#[cfg(feature = "transport-websock-mux")] pub async fn open_secure_channel_with( session_handle: &websock_mux::Session, session: Session, @@ -39,6 +150,7 @@ pub async fn open_secure_channel_with( } /// Opens a bidirectional WebSocket-mux stream, runs the native Foctet handshake, and wraps it as a secure channel. +#[cfg(feature = "transport-websock-mux")] pub async fn open_secure_channel_with_handshake( session_handle: &websock_mux::Session, thresholds: RekeyThresholds, @@ -49,13 +161,14 @@ pub async fn open_secure_channel_with_handshake( open_secure_channel_with_handshake_and_auth_config( session_handle, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), TransportConfig::default(), ) .await } /// Opens a bidirectional WebSocket-mux stream, runs the native Foctet handshake, and applies a custom transport config. +#[cfg(feature = "transport-websock-mux")] pub async fn open_secure_channel_with_handshake_and_config( session_handle: &websock_mux::Session, thresholds: RekeyThresholds, @@ -67,13 +180,14 @@ pub async fn open_secure_channel_with_handshake_and_config( open_secure_channel_with_handshake_and_auth_config( session_handle, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), config, ) .await } /// Opens a bidirectional WebSocket-mux stream, runs the native Foctet handshake with explicit auth config, and applies a custom transport config. +#[cfg(feature = "transport-websock-mux")] pub async fn open_secure_channel_with_handshake_and_auth_config( session_handle: &websock_mux::Session, thresholds: RekeyThresholds, @@ -95,6 +209,7 @@ pub async fn open_secure_channel_with_handshake_and_auth_config( } /// Accepts a bidirectional WebSocket-mux stream and wraps it as a Foctet secure channel. +#[cfg(feature = "transport-websock-mux")] pub async fn accept_secure_channel( session_handle: &websock_mux::Session, session: Session, @@ -106,6 +221,7 @@ pub async fn accept_secure_channel( } /// Accepts a bidirectional WebSocket-mux stream and applies a custom transport config. +#[cfg(feature = "transport-websock-mux")] pub async fn accept_secure_channel_with( session_handle: &websock_mux::Session, session: Session, @@ -125,6 +241,7 @@ pub async fn accept_secure_channel_with( } /// Accepts a bidirectional WebSocket-mux stream, runs the native Foctet handshake, and wraps it as a secure channel. +#[cfg(feature = "transport-websock-mux")] pub async fn accept_secure_channel_with_handshake( session_handle: &websock_mux::Session, thresholds: RekeyThresholds, @@ -135,13 +252,14 @@ pub async fn accept_secure_channel_with_handshake( accept_secure_channel_with_handshake_and_auth_config( session_handle, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), TransportConfig::default(), ) .await } /// Accepts a bidirectional WebSocket-mux stream, runs the native Foctet handshake, and applies a custom transport config. +#[cfg(feature = "transport-websock-mux")] pub async fn accept_secure_channel_with_handshake_and_config( session_handle: &websock_mux::Session, thresholds: RekeyThresholds, @@ -153,13 +271,14 @@ pub async fn accept_secure_channel_with_handshake_and_config( accept_secure_channel_with_handshake_and_auth_config( session_handle, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), config, ) .await } /// Accepts a bidirectional WebSocket-mux stream, runs the native Foctet handshake with explicit auth config, and applies a custom transport config. +#[cfg(feature = "transport-websock-mux")] pub async fn accept_secure_channel_with_handshake_and_auth_config( session_handle: &websock_mux::Session, thresholds: RekeyThresholds, @@ -179,3 +298,82 @@ pub async fn accept_secure_channel_with_handshake_and_auth_config( .await .map_err(TransportChannelError::core) } + +// Native loopback integration test (real TCP/WebSocket); not built for wasm. +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + use crate::SecureMessageChannel; + use foctet_core::{RekeyThresholds, Session, SessionAuthConfig}; + use websock::{ClientBuilder, ServerBuilder}; + + /// Drives a real native Foctet handshake so both sides share traffic keys. + fn shared_session_keys() -> (Session, Session) { + let (mut initiator, client_hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut responder = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let server_hello = responder + .handle_control(&client_hello) + .expect("responder handles client hello") + .expect("responder returns a server hello"); + let none = initiator + .handle_control(&server_hello) + .expect("initiator finalizes"); + assert!(none.is_none(), "initiator must not reply to server hello"); + (initiator, responder) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn secure_message_channel_over_raw_websocket() { + let (initiator, responder) = shared_session_keys(); + + // Plain (non-TLS) WebSocket loopback on an ephemeral port. + let bind_addr: std::net::SocketAddr = "127.0.0.1:0".parse().expect("valid loopback addr"); + let server = ServerBuilder::new() + .with_addr(bind_addr) + .build() + .await + .expect("build ws server"); + let addr = server.local_addr().expect("server addr"); + let accept = tokio::spawn(async move { server.accept().await }); + + let client = ClientBuilder::new().build(); + let client_conn = client + .connect(&format!("ws://{addr}/")) + .await + .expect("client connects"); + let server_conn = accept.await.expect("accept task joins").expect("accept"); + + let mut client = SecureMessageChannel::from_active_session( + WebsockMessageTransport::new(client_conn), + &initiator, + ) + .expect("client channel"); + let mut server = SecureMessageChannel::from_active_session( + WebsockMessageTransport::new(server_conn), + &responder, + ) + .expect("server channel"); + + // Client → server, one frame per WebSocket message. + client + .send_message(0, 0, b"hello over a raw websocket") + .await + .expect("client send"); + let opened = server.recv_message().await.expect("server recv"); + assert_eq!(opened.plaintext, b"hello over a raw websocket"); + + // Server → client, exercising the reverse direction. + server + .send_message(0, 0, b"reply over a raw websocket") + .await + .expect("server send"); + let back = client.recv_message().await.expect("client recv"); + assert_eq!(back.plaintext, b"reply over a raw websocket"); + } +} diff --git a/foctet-transport/src/webtrans.rs b/foctet-transport/src/webtrans.rs index e03135d..3857f87 100644 --- a/foctet-transport/src/webtrans.rs +++ b/foctet-transport/src/webtrans.rs @@ -48,7 +48,7 @@ pub async fn open_secure_channel_with_handshake( open_secure_channel_with_handshake_and_auth_config( session_handle, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), TransportConfig::default(), ) .await @@ -66,7 +66,7 @@ pub async fn open_secure_channel_with_handshake_and_config( open_secure_channel_with_handshake_and_auth_config( session_handle, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), config, ) .await @@ -134,7 +134,7 @@ pub async fn accept_secure_channel_with_handshake( accept_secure_channel_with_handshake_and_auth_config( session_handle, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), TransportConfig::default(), ) .await @@ -152,7 +152,7 @@ pub async fn accept_secure_channel_with_handshake_and_config( accept_secure_channel_with_handshake_and_auth_config( session_handle, thresholds, - SessionAuthConfig::default(), + SessionAuthConfig::unauthenticated_for_testing(), config, ) .await diff --git a/foctet-transport/src/webtrans_browser.rs b/foctet-transport/src/webtrans_browser.rs new file mode 100644 index 0000000..08496c5 --- /dev/null +++ b/foctet-transport/src/webtrans_browser.rs @@ -0,0 +1,169 @@ +//! Browser WebTransport **datagram** adapter (wasm32). +//! +//! [`BrowserWebTransportDatagrams`] implements [`crate::DatagramTransport`] +//! over the browser's `WebTransport.datagrams` duplex, so a Rust/wasm +//! application can run a [`crate::SecureDatagramChannel`] over real browser +//! WebTransport datagrams with no JavaScript glue beyond handing over the +//! datagram duplex object: +//! +//! ```javascript +//! const wt = new WebTransport("https://example.com:4433/foctet"); +//! await wt.ready; +//! // pass `wt.datagrams` to the wasm side +//! ``` +//! +//! ```rust,ignore +//! let transport = BrowserWebTransportDatagrams::new(datagrams_js_value)?; +//! let mut channel = SecureDatagramChannel::from_active_session(transport, &session)?; +//! channel.send_datagram(0, 0, b"hello").await?; +//! ``` +//! +//! The handshake that produces the [`foctet_core::Session`] must run over a +//! **reliable** channel first (e.g. a WebTransport bidirectional stream or the +//! wasm `FoctetSession` message mode); see the rekey-over-datagram notes in +//! [`crate::datagram`]. +//! +//! # Binding strategy +//! +//! The adapter binds the WebTransport datagram duplex **duck-typed** through +//! `js-sys` reflection (`readable`/`writable`/`maxDatagramSize`, standard +//! WHATWG stream reader/writer methods) instead of `web-sys`'s `WebTransport` +//! type, which is still gated behind the unstable-APIs cfg flag. Anything +//! shaped like `{ readable, writable, maxDatagramSize? }` works, which also +//! makes the adapter testable against in-page mock streams. + +use js_sys::{Promise, Reflect, Uint8Array}; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; + +use crate::datagram::DatagramTransport; + +/// Error from the browser WebTransport datagram adapter. +/// +/// JavaScript error values are stringified: `JsValue` is neither `Send` nor +/// `Sync`, and the message is all the caller can act on anyway. +#[derive(Debug, thiserror::Error)] +#[error("browser webtransport datagram error: {0}")] +pub struct BrowserWebTransportError(String); + +impl BrowserWebTransportError { + fn from_js(context: &str, value: JsValue) -> Self { + let detail = value + .dyn_ref::() + .map(|e| String::from(e.message())) + .or_else(|| value.as_string()) + .unwrap_or_else(|| format!("{value:?}")); + Self(format!("{context}: {detail}")) + } + + fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +fn get(target: &JsValue, key: &str) -> Result { + Reflect::get(target, &JsValue::from_str(key)) + .map_err(|e| BrowserWebTransportError::from_js(key, e)) +} + +fn call0(target: &JsValue, method: &str) -> Result { + let f: js_sys::Function = get(target, method)? + .dyn_into() + .map_err(|_| BrowserWebTransportError::new(format!("`{method}` is not a function")))?; + f.call0(target) + .map_err(|e| BrowserWebTransportError::from_js(method, e)) +} + +fn call1( + target: &JsValue, + method: &str, + arg: &JsValue, +) -> Result { + let f: js_sys::Function = get(target, method)? + .dyn_into() + .map_err(|_| BrowserWebTransportError::new(format!("`{method}` is not a function")))?; + f.call1(target, arg) + .map_err(|e| BrowserWebTransportError::from_js(method, e)) +} + +async fn await_promise(value: JsValue, context: &str) -> Result { + let promise: Promise = value + .dyn_into() + .map_err(|_| BrowserWebTransportError::new(format!("`{context}` is not a promise")))?; + JsFuture::from(promise) + .await + .map_err(|e| BrowserWebTransportError::from_js(context, e)) +} + +/// A [`DatagramTransport`] over a browser `WebTransport.datagrams` duplex. +/// +/// Construct with the JS `WebTransport.datagrams` object (or anything with the +/// same `{ readable, writable, maxDatagramSize? }` shape). The adapter locks +/// the readable's reader and the writable's writer for its lifetime. +#[derive(Debug)] +pub struct BrowserWebTransportDatagrams { + reader: JsValue, + writer: JsValue, + max_datagram_size: Option, +} + +impl BrowserWebTransportDatagrams { + /// Wraps a `WebTransport.datagrams` duplex object. + /// + /// Reads `maxDatagramSize` once at construction (the browser may lower it + /// later on path changes; Foctet fails closed with `FrameTooLarge` rather + /// than fragmenting if a sealed datagram no longer fits — see the + /// MTU/fragmentation policy in `SPEC.md`). + pub fn new(datagrams: JsValue) -> Result { + let readable = get(&datagrams, "readable")?; + let writable = get(&datagrams, "writable")?; + if readable.is_undefined() || writable.is_undefined() { + return Err(BrowserWebTransportError::new( + "expected a WebTransport datagram duplex with `readable` and `writable`", + )); + } + let reader = call0(&readable, "getReader")?; + let writer = call0(&writable, "getWriter")?; + let max_datagram_size = get(&datagrams, "maxDatagramSize") + .ok() + .and_then(|v| v.as_f64()) + .filter(|v| v.is_finite() && *v >= 1.0) + .map(|v| v as usize); + Ok(Self { + reader, + writer, + max_datagram_size, + }) + } +} + +impl DatagramTransport for BrowserWebTransportDatagrams { + type Error = BrowserWebTransportError; + + async fn send_datagram(&self, datagram: Vec) -> Result<(), Self::Error> { + let chunk = Uint8Array::from(datagram.as_slice()); + let pending = call1(&self.writer, "write", &chunk.into())?; + await_promise(pending, "writer.write").await?; + Ok(()) + } + + async fn recv_datagram(&self) -> Result, Self::Error> { + let pending = call0(&self.reader, "read")?; + let result = await_promise(pending, "reader.read").await?; + let done = get(&result, "done")?.as_bool().unwrap_or(false); + if done { + return Err(BrowserWebTransportError::new( + "datagram readable stream closed", + )); + } + let value = get(&result, "value")?; + let bytes: Uint8Array = value + .dyn_into() + .map_err(|_| BrowserWebTransportError::new("datagram chunk is not a Uint8Array"))?; + Ok(bytes.to_vec()) + } + + fn max_datagram_size(&self) -> Option { + self.max_datagram_size + } +} diff --git a/foctet-transport/tests/conformance.rs b/foctet-transport/tests/conformance.rs new file mode 100644 index 0000000..75b0172 --- /dev/null +++ b/foctet-transport/tests/conformance.rs @@ -0,0 +1,410 @@ +//! Shared conformance suite: the same application-level checks run against all +//! three Foctet transport shapes (message, datagram, byte stream) through the +//! unified [`SecureChannel`] trait, so the shapes stay behaviourally consistent. + +use std::cell::RefCell; +use std::collections::VecDeque; +use std::rc::Rc; + +use foctet_core::{RekeyThresholds, Session, SessionAuthConfig}; +use foctet_transport::{ + DatagramTransport, MessageTransport, SecureChannel, SecureDatagramChannel, SecureMessageChannel, +}; + +/// Drives the shared checks over any pair of connected secure channels. +async fn run_conformance(a: &mut A, b: &mut B) +where + A: SecureChannel, + B: SecureChannel, +{ + // Round trip in both directions. + a.send_payload(b"ping").await.expect("a -> b send"); + assert_eq!(b.recv_payload().await.expect("b recv"), b"ping"); + b.send_payload(b"pong").await.expect("b -> a send"); + assert_eq!(a.recv_payload().await.expect("a recv"), b"pong"); + + // Ordering: several payloads arrive in the order sent. + let payloads: [&[u8]; 3] = [b"one", b"two", b"three"]; + for p in payloads { + a.send_payload(p).await.expect("a send seq"); + } + for p in payloads { + assert_eq!(b.recv_payload().await.expect("b recv seq"), p); + } + + // A larger payload survives a single round trip (well under the datagram MTU + // so every shape can carry it). + let big = vec![0x5Au8; 1000]; + a.send_payload(&big).await.expect("a send big"); + assert_eq!(b.recv_payload().await.expect("b recv big"), big); +} + +/// Drives a real native handshake so both sides share traffic keys. +fn session_pair() -> (Session, Session) { + let (mut initiator, hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut responder = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let server_hello = responder + .handle_control(&hello) + .expect("responder handles hello") + .expect("server hello"); + initiator + .handle_control(&server_hello) + .expect("initiator finalizes"); + (initiator, responder) +} + +// ---- In-memory transports for the message and datagram shapes ---- + +#[derive(Debug)] +struct MemoryError; + +impl std::fmt::Display for MemoryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("in-memory transport closed") + } +} + +impl std::error::Error for MemoryError {} + +#[derive(Default)] +struct MemoryQueueTransport { + inbox: Rc>>>, + outbox: Rc>>>, +} + +fn linked_pair() -> (MemoryQueueTransport, MemoryQueueTransport) { + let a_to_b: Rc>>> = Rc::default(); + let b_to_a: Rc>>> = Rc::default(); + let a = MemoryQueueTransport { + inbox: b_to_a.clone(), + outbox: a_to_b.clone(), + }; + let b = MemoryQueueTransport { + inbox: a_to_b, + outbox: b_to_a, + }; + (a, b) +} + +impl MessageTransport for MemoryQueueTransport { + type Error = MemoryError; + + async fn send_message(&self, message: Vec) -> Result<(), Self::Error> { + self.outbox.borrow_mut().push_back(message); + Ok(()) + } + + async fn recv_message(&self) -> Result, Self::Error> { + self.inbox.borrow_mut().pop_front().ok_or(MemoryError) + } + + fn max_message_size(&self) -> Option { + None + } +} + +impl DatagramTransport for MemoryQueueTransport { + type Error = MemoryError; + + async fn send_datagram(&self, datagram: Vec) -> Result<(), Self::Error> { + self.outbox.borrow_mut().push_back(datagram); + Ok(()) + } + + async fn recv_datagram(&self) -> Result, Self::Error> { + self.inbox.borrow_mut().pop_front().ok_or(MemoryError) + } + + fn max_datagram_size(&self) -> Option { + None + } +} + +#[tokio::test] +async fn message_shape_conformance() { + let (init, resp) = session_pair(); + let (ta, tb) = linked_pair(); + let mut a = SecureMessageChannel::from_active_session(ta, &init).expect("a"); + let mut b = SecureMessageChannel::from_active_session(tb, &resp).expect("b"); + run_conformance(&mut a, &mut b).await; +} + +#[tokio::test] +async fn datagram_shape_conformance() { + let (init, resp) = session_pair(); + let (ta, tb) = linked_pair(); + let mut a = SecureDatagramChannel::from_active_session(ta, &init).expect("a"); + let mut b = SecureDatagramChannel::from_active_session(tb, &resp).expect("b"); + run_conformance(&mut a, &mut b).await; +} + +#[cfg(feature = "runtime-tokio")] +#[tokio::test] +async fn byte_stream_shape_conformance() { + use foctet_transport::TokioTransportBuilder; + + let (init, resp) = session_pair(); + let (a_io, b_io) = tokio::io::duplex(64 * 1024); + let mut a = TokioTransportBuilder::new() + .build(a_io, init) + .expect("a channel"); + let mut b = TokioTransportBuilder::new() + .build(b_io, resp) + .expect("b channel"); + run_conformance(&mut a, &mut b).await; +} + +// ---- Real-backend byte-stream conformance ---- +// +// The same suite runs over a real loopback connection for every advertised +// byte-stream backend: each test brings up the outer transport with a +// self-signed localhost certificate, runs the native Foctet handshake over one +// bidirectional stream, and then drives `run_conformance` end to end. + +#[cfg(all(feature = "runtime-tokio", feature = "transport-quinn"))] +mod quinn_byte_stream { + use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; + use std::sync::Arc; + + use foctet_core::RekeyThresholds; + use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; + + use super::run_conformance; + + #[tokio::test] + async fn quinn_byte_stream_conformance() { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]) + .expect("self-signed cert"); + let cert_der = CertificateDer::from(cert.cert); + let key_der = + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der())); + let server_config = quinn::ServerConfig::with_single_cert(vec![cert_der.clone()], key_der) + .expect("server config"); + let bind = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)); + let server_ep = quinn::Endpoint::server(server_config, bind).expect("server endpoint"); + let server_addr = server_ep.local_addr().expect("server addr"); + + let mut roots = rustls::RootCertStore::empty(); + roots.add(cert_der).expect("add root"); + let client_config = + quinn::ClientConfig::with_root_certificates(Arc::new(roots)).expect("client config"); + let mut client_ep = quinn::Endpoint::client(bind).expect("client endpoint"); + client_ep.set_default_client_config(client_config); + + let server_task = tokio::spawn(async move { + let incoming = server_ep.accept().await.expect("incoming"); + let conn = incoming.await.expect("server connection"); + (server_ep, conn) + }); + let client_conn = client_ep + .connect(server_addr, "localhost") + .expect("connect") + .await + .expect("client connection"); + let (_server_ep, server_conn) = server_task.await.expect("server join"); + + let (client, server) = tokio::join!( + foctet_transport::quinn::open_secure_channel_with_handshake( + &client_conn, + RekeyThresholds::default(), + ), + foctet_transport::quinn::accept_secure_channel_with_handshake( + &server_conn, + RekeyThresholds::default(), + ), + ); + let mut a = client.expect("client channel"); + let mut b = server.expect("server channel"); + run_conformance(&mut a, &mut b).await; + } +} + +#[cfg(all(feature = "runtime-tokio", feature = "transport-muxtls"))] +mod muxtls_byte_stream { + use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; + + use foctet_core::RekeyThresholds; + + use super::run_conformance; + + #[tokio::test] + async fn muxtls_byte_stream_conformance() { + let (server_config, cert) = + muxtls::ServerConfig::self_signed_for_localhost().expect("self-signed cert"); + let client_config = + muxtls::ClientConfig::with_custom_roots(vec![cert]).expect("client config"); + + let bind = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)); + let server_ep = muxtls::Endpoint::server(bind, server_config) + .await + .expect("server endpoint"); + let server_addr = server_ep.local_addr().expect("server addr"); + let client_ep = muxtls::Endpoint::client(client_config); + + let server_task = tokio::spawn(async move { + let conn = server_ep.accept().await.expect("server connection"); + (server_ep, conn) + }); + let client_conn = client_ep + .connect(server_addr, "localhost") + .expect("connect") + .await + .expect("client connection"); + let (_server_ep, server_conn) = server_task.await.expect("server join"); + + let (client, server) = tokio::join!( + foctet_transport::muxtls::open_secure_channel_with_handshake( + &client_conn, + RekeyThresholds::default(), + ), + foctet_transport::muxtls::accept_secure_channel_with_handshake( + &server_conn, + RekeyThresholds::default(), + ), + ); + let mut a = client.expect("client channel"); + let mut b = server.expect("server channel"); + run_conformance(&mut a, &mut b).await; + } +} + +#[cfg(all(feature = "runtime-tokio", feature = "transport-webtrans"))] +mod webtrans_byte_stream { + use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; + + use foctet_core::RekeyThresholds; + + use super::run_conformance; + + #[tokio::test] + async fn webtransport_byte_stream_conformance() { + let (cert_chain, key) = webtrans::tls::generate_self_signed_pair_der(vec![ + "localhost".to_owned(), + "127.0.0.1".to_owned(), + ]) + .expect("self-signed cert"); + + // Reserve a free UDP port for the server (bind-and-release; the tiny + // race is acceptable for a loopback test). + let addr = { + let sock = std::net::UdpSocket::bind(SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::LOCALHOST, + 0, + ))) + .expect("probe socket"); + sock.local_addr().expect("probe addr") + }; + + let mut server = webtrans::ServerBuilder::new() + .with_addr(addr) + .with_certificate(cert_chain.clone(), key) + .expect("server"); + let server_task = tokio::spawn(async move { + let request = server.accept().await.expect("server closed"); + let session = request.ok().await.expect("server session"); + (server, session) + }); + + let client = webtrans::ClientBuilder::new() + .with_server_certificates(cert_chain) + .expect("client"); + let url = + url::Url::parse(&format!("https://127.0.0.1:{}", addr.port())).expect("server url"); + let client_session = client.connect(url).await.expect("client session"); + let (_server, server_session) = server_task.await.expect("server join"); + + let (client, server) = tokio::join!( + foctet_transport::webtrans::open_secure_channel_with_handshake( + &client_session, + RekeyThresholds::default(), + ), + foctet_transport::webtrans::accept_secure_channel_with_handshake( + &server_session, + RekeyThresholds::default(), + ), + ); + let mut a = client.expect("client channel"); + let mut b = server.expect("server channel"); + run_conformance(&mut a, &mut b).await; + } +} + +#[cfg(feature = "transport-websock-mux")] +mod websock_mux_byte_stream { + use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; + + use foctet_core::RekeyThresholds; + + use super::run_conformance; + + #[tokio::test] + async fn websocket_mux_byte_stream_conformance() { + let (cert_chain, key) = websock_tungstenite_mux::tls::generate_self_signed_pair_der(vec![ + "localhost".to_owned(), + "127.0.0.1".to_owned(), + ]) + .expect("self-signed cert"); + + let mut roots = rustls::RootCertStore::empty(); + for cert in &cert_chain { + roots.add(cert.clone()).expect("add root"); + } + let client_tls = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + let server_tls = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(cert_chain, key) + .expect("server tls"); + + // Reserve a free TCP port (bind-and-release, same as the UDP probe). + let addr = { + let sock = std::net::TcpListener::bind(SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::LOCALHOST, + 0, + ))) + .expect("probe socket"); + sock.local_addr().expect("probe addr") + }; + + let server = websock_tungstenite_mux::ServerBuilder::new() + .with_addr(addr) + .with_default_alpn() + .with_tls_config(server_tls) + .build() + .await + .expect("server"); + let server_task = tokio::spawn(async move { + let session = server.accept().await.expect("server session"); + (server, session) + }); + + let client = websock_tungstenite_mux::ClientBuilder::new() + .with_default_alpn() + .with_tls_config(client_tls) + .build(); + let url = format!("wss://127.0.0.1:{}", addr.port()); + let client_session = client.connect(&url).await.expect("client session"); + let (_server, server_session) = server_task.await.expect("server join"); + + let (client, server) = tokio::join!( + foctet_transport::websock::open_secure_channel_with_handshake( + &client_session, + RekeyThresholds::default(), + ), + foctet_transport::websock::accept_secure_channel_with_handshake( + &server_session, + RekeyThresholds::default(), + ), + ); + let mut a = client.expect("client channel"); + let mut b = server.expect("server channel"); + run_conformance(&mut a, &mut b).await; + } +} diff --git a/foctet-wasm/Cargo.toml b/foctet-wasm/Cargo.toml new file mode 100644 index 0000000..458c178 --- /dev/null +++ b/foctet-wasm/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "foctet-wasm" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "WebAssembly bindings for Foctet end-to-end encryption (body envelopes)" +readme = "README.md" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +foctet-core = { path = "../foctet-core", version = "0.3.0", default-features = false } +foctet-http = { path = "../foctet-http", version = "0.3.0", default-features = false } +wasm-bindgen = "0.2" +x25519-dalek = { workspace = true } +rand_core = { workspace = true } +zeroize = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +# `OsRng` flows through `rand_core -> getrandom`; wasm32 needs explicit JS support. +getrandom = { version = "0.2", features = ["js"] } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +# In-browser integration tests: `wasm-pack test --headless --chrome foctet-wasm`. +wasm-bindgen-test = "0.3" +# Exercises the browser WebTransport datagram adapter against in-page mock +# streams inside real headless Chrome (see tests/browser.rs). +foctet-transport = { path = "../foctet-transport", version = "0.3.0", default-features = false, features = ["transport-webtrans-browser"] } +js-sys = "0.3" +wasm-bindgen-futures = "0.4" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/foctet-wasm/README.md b/foctet-wasm/README.md new file mode 100644 index 0000000..9f60938 --- /dev/null +++ b/foctet-wasm/README.md @@ -0,0 +1,299 @@ +# foctet-wasm + +WebAssembly / TypeScript bindings for [Foctet](../README.md) end-to-end +encryption. Exposes the `application/foctet` body envelope so browsers, Node.js, +Deno, Bun, and Cloudflare Workers can seal and open the **same wire format** as +the Rust implementation. + +> **Status: experimental (Draft v0).** Provides the one-shot body envelope **and** +> a full framed session (authenticated handshake, ordered messages, +> MTU-bounded datagrams, and in-session DH-ratchet rekey). See +> [`SECURITY.md`](../SECURITY.md) for the security posture and limitations. + +## API + +Generated TypeScript declarations (`foctet_wasm.d.ts`) accompany every build. + +```ts +function version(): string; + +class KeyPair { + constructor(); // generate a fresh X25519 key pair + static fromSecretKey(secretKey: Uint8Array): KeyPair; + readonly publicKey: Uint8Array; // 32 bytes + readonly secretKey: Uint8Array; // 32 bytes — handle with care +} + +function sealBody(plaintext: Uint8Array, recipientPublicKey: Uint8Array, keyId: Uint8Array): Uint8Array; +function openBody(envelope: Uint8Array, recipientSecretKey: Uint8Array): Uint8Array; + +// Bind an application context (e.g. HTTP method/path/message-id) into the AEAD. +function sealBodyWithContext(plaintext: Uint8Array, recipientPublicKey: Uint8Array, keyId: Uint8Array, context: Uint8Array): Uint8Array; +function openBodyWithContext(envelope: Uint8Array, recipientSecretKey: Uint8Array, context: Uint8Array): Uint8Array; +``` + +Fallible functions throw a JavaScript `Error` on malformed input; they never +abort the WASM instance. + +### HTTP protected context + +For HTTP request/response protection, let JavaScript adapt the runtime's +`Request` / `Response` objects into normalized parts, then use these classes to +produce the same canonical associated-data bytes as `foctet-http`. + +```ts +function httpContentType(): string; // "application/foctet" +function httpContentTypeHeader(): string; // "content-type" +function httpScopeHeader(): string; // "x-foctet-scope" +function httpBodyOnlyScope(): string; // "body-only" +function httpMessageIdHeader(): string; // "x-foctet-msg-id" +function httpTimestampHeader(): string; // "x-foctet-timestamp" +function httpExpiryHeader(): string; // "x-foctet-expiry" +function httpIdempotencyHeader(): string; // "x-foctet-idempotency-key" +function httpRequestMessageIdHeader(): string; // "x-foctet-req-msg-id" +function defaultHttpContextTtlSecs(): bigint; // 300 +function defaultHttpMaxClockSkewSecs(): bigint; // 30 + +class HttpContextCarrier { + constructor(messageId: Uint8Array, timestampSecs: bigint, expirySecs: bigint); + static generate(nowSecs: bigint, ttlSecs: bigint): HttpContextCarrier; + static fromHeaderValues( + messageIdHex: string, + timestampSecs: string, + expirySecs: string, + idempotencyKey?: string, + requestMessageIdHex?: string, + ): HttpContextCarrier; + setIdempotencyKey(key?: string): void; + setRequestMessageId(messageId?: Uint8Array): void; + readonly messageId: Uint8Array; + readonly timestampSecs: bigint; + readonly expirySecs: bigint; + readonly idempotencyKey?: string; + readonly requestMessageId?: Uint8Array; + readonly messageIdHeaderValue: string; + readonly timestampHeaderValue: string; + readonly expiryHeaderValue: string; + readonly requestMessageIdHeaderValue?: string; +} + +class HttpRequestContext { + constructor(method: string, uri: string, carrier: HttpContextCarrier); + setHeader(name: string, value: string): void; // add headers used for Host or bound-header AAD + setBindAuthority(value: boolean): void; // off by default + bindHeader(name: string): void; // opt-in selected-header binding + aadBytes(): Uint8Array; + validateFreshness(nowSecs: bigint, maxSkewSecs: bigint): void; + sealBody(plaintext: Uint8Array, recipientPublicKey: Uint8Array, keyId: Uint8Array): Uint8Array; + openBody(envelope: Uint8Array, recipientSecretKey: Uint8Array, nowSecs: bigint, maxSkewSecs: bigint): Uint8Array; +} + +class HttpResponseContext { + constructor(status: number, carrier: HttpContextCarrier); + aadBytes(): Uint8Array; + validateFreshness(nowSecs: bigint, maxSkewSecs: bigint): void; + sealBody(plaintext: Uint8Array, recipientPublicKey: Uint8Array, keyId: Uint8Array): Uint8Array; + openBody(envelope: Uint8Array, recipientSecretKey: Uint8Array, nowSecs: bigint, maxSkewSecs: bigint): Uint8Array; +} +``` + +These classes do not implement replay storage. A TypeScript Worker SDK should +open/authenticate with this API first, then perform an atomic replay +check-and-insert in a runtime store such as a Cloudflare Durable Object. + +### Framed session (handshake + messages) + +For a full secure channel — not just one-shot envelopes — drive a `FoctetSession`. +WebAssembly performs the authenticated handshake and per-message seal/open; **your +JS owns the transport** (a browser `WebSocket`, a `WebTransport` stream or +datagram channel, etc.) and moves the `Uint8Array` blobs in order. + +```ts +class IdentityKeyPair { + constructor(); // generate an Ed25519 identity + static fromSecretKey(secretKey: Uint8Array): IdentityKeyPair; + readonly publicKey: Uint8Array; // 32 bytes — share to let the peer pin you + readonly secretKey: Uint8Array; // 32 bytes — handle with care +} + +class AuthConfig { + // Pin the peer's identity and prove your own (recommended). + static authenticated(localIdentity: IdentityKeyPair, peerPublicKey: Uint8Array): AuthConfig; + // No Foctet identity: bind to an authenticated outer channel (e.g. a TLS + // exporter). Both peers must pass the same value; a relay across a different + // channel fails closed. + static boundToChannel(channelBinding: Uint8Array): AuthConfig; + // Tests, or use only inside an already-authenticated outer channel (e.g. mTLS). + static unauthenticatedForTesting(): AuthConfig; + // Additionally bind any config to an outer-channel value (returns a new config). + withChannelBinding(channelBinding: Uint8Array): AuthConfig; +} + +class DecodedMessage { + readonly streamId: number; + readonly flags: number; + readonly keyId: number; + readonly seq: bigint; + readonly plaintext: Uint8Array; +} + +class FoctetSession { + // Message mode: reliable/ordered (raw WebSocket, WebTransport stream). + static newInitiator(auth: AuthConfig): FoctetSession; + static newResponder(auth: AuthConfig): FoctetSession; + // Datagram mode: MTU-bounded, loss-tolerant (WebTransport datagrams). + // maxDatagramSize = 0 uses the default (1200). + static newDatagramInitiator(auth: AuthConfig, maxDatagramSize: number): FoctetSession; + static newDatagramResponder(auth: AuthConfig, maxDatagramSize: number): FoctetSession; + + initialHandshakeMessage(): Uint8Array | undefined; // initiator: send this first + handleHandshakeMessage(message: Uint8Array): Uint8Array | undefined; // returns a reply to send, if any + handleControlMessage(message: Uint8Array): Uint8Array | undefined; // handshake or rekey control + isEstablished(): boolean; + peerAuthenticated(): boolean; + canRekey(): boolean; + forceRekey(): Uint8Array; + readonly activeKeyId: number | undefined; + + // Message-mode sessions: + sealMessage(streamId: number, flags: number, plaintext: Uint8Array): Uint8Array; + openMessage(message: Uint8Array): DecodedMessage; + // Datagram-mode sessions: + sealDatagram(streamId: number, flags: number, plaintext: Uint8Array): Uint8Array; + openDatagram(datagram: Uint8Array): DecodedMessage; +} +``` + +A session commits to one framing mode; the methods for the other mode throw. For +WebTransport datagrams, run the (reliable) handshake messages over a stream, then +send each `sealDatagram` result as a datagram. Rekey control messages must also +travel over that reliable channel; call `forceRekey()` only when +`canRekey() === true`, and feed the peer's rekey bytes to +`handleControlMessage()`. + +Example over a browser `WebSocket` (binary frames), as the initiator: + +```ts +const ws = new WebSocket(url); +ws.binaryType = "arraybuffer"; + +const auth = AuthConfig.authenticated(myIdentity, serverPublicKey); +const session = FoctetSession.newInitiator(auth); + +ws.onopen = () => ws.send(session.initialHandshakeMessage()!); // send ClientHello +ws.onmessage = (ev) => { + const bytes = new Uint8Array(ev.data as ArrayBuffer); + if (!session.isEstablished()) { + const reply = session.handleHandshakeMessage(bytes); // finish handshake + if (reply) ws.send(reply); + if (session.isEstablished()) { + ws.send(session.sealMessage(0, 0, new TextEncoder().encode("hello"))); + } + } else { + const msg = session.openMessage(bytes); // application data + console.log(new TextDecoder().decode(msg.plaintext)); + } +}; +``` + +The same pattern works over `WebTransport` streams. For WebTransport datagrams, +construct the session with `newDatagramInitiator` / `newDatagramResponder`, run +the handshake over a reliable stream, send each `sealDatagram` result as one +datagram, and feed each received datagram to `openDatagram`. In-session rekey is +available in both modes; rekey control messages still have to travel over the +reliable channel. + +## Build + +Requires [`wasm-pack`](https://drager.github.io/wasm-pack/). + +```sh +# Node.js / Cloudflare Workers +npm run build:node # -> pkg-node/ + +# Browser (ES modules, no bundler) +npm run build:web # -> pkg-web/ + +# Bundler (webpack/Vite/Rollup) +npm run build:bundler # -> pkg/ +``` + +## Test + +```sh +npm test # builds for Node and runs the interop test +``` + +The interop test (`tests/node_interop.cjs`) verifies a JS seal→open roundtrip, +context binding, and — crucially — that Node opens **Rust-produced** envelopes +from `tests/interop_vector.json`, proving cross-language wire compatibility. +Regenerate the fixture with: + +```sh +cargo run -p foctet-wasm --example gen_interop_fixture > foctet-wasm/tests/interop_vector.json +``` + +### Headless browser tests + +`tests/browser.rs` runs `wasm-bindgen-test` tests in a real headless Chrome +(body envelope, context binding, authenticated `FoctetSession` handshake + +messages + replay rejection, datagram mode). CI runs them on every push/PR; +locally: + +```sh +wasm-pack test --headless --chrome foctet-wasm # from the workspace root +``` + +If wasm-pack's auto-downloaded chromedriver is a major version ahead of your +installed Chrome (symptom: `Error: http status: 404`), download the matching +driver from [Chrome for Testing](https://googlechromelabs.github.io/chrome-for-testing/) +and set `CHROMEDRIVER=/path/to/chromedriver`. + +### Browser harness + +To exercise the SDK in a **real browser engine** (body envelope, context +binding, Rust→JS interop, and a full `FoctetSession` handshake + message +roundtrip), run the harness page: + +```sh +./examples/browser/serve.sh # Unix/macOS (serve.ps1 on Windows) +# or: npm run browser +# then open http://localhost:8011/examples/browser/index.html +``` + +This builds `pkg-web/` and serves it with a dependency-free Node static server +(`examples/browser/serve.mjs`) — no Python or editor-specific config required. +The page reports `passed`/`failed` on screen and as `window.__FOCTET_RESULT__` +for a headless runner. See [`../tests.md`](../tests.md) for the full +real-environment test plan. + +A second page, `examples/browser/websocket.html`, drives the SDK as the +handshake initiator over a real browser `WebSocket` against the native +`websock_message_server` example (raw-message shape). Start that server first; +see `tests.md` (§3.3). + +A third page, `examples/browser/webtransport.html`, drives the SDK (datagram +mode) over a real browser `WebTransport` against the native +`webtrans_datagram_split` example — handshake over a stream, data over +datagrams, with the dev cert pinned via `serverCertificateHashes`. See +`tests.md` (§3.5). + +## Scope and security + +The **body envelope** functions provide body-only protection: they encrypt and +authenticate the payload (and an optional associated `context`), not the outer +HTTP metadata — carry that over an authenticated outer channel such as HTTPS. For +HTTP replay protection, use `HttpRequestContext` / `HttpResponseContext` so the +associated data is generated by the same canonical implementation as +`foctet-http`, and pair request opening with an atomic host-side replay store. + +The **framed session** (`FoctetSession`) authenticates and protects the message +stream end to end once the handshake completes; prefer `AuthConfig.authenticated` +with a pinned peer identity so the handshake fails closed against an unexpected +peer. The JS transport it runs over should still be carried by an authenticated +outer channel (`wss://`, `https://`) unless you pin identities. + +`KeyPair` and `IdentityKeyPair` expose raw key bytes because WebCrypto has no +portable non-extractable X25519/Ed25519 type; store secret keys in a platform +keystore or Worker secret and never log them. Host-backed (non-extractable) key +handling is not yet available across the WASM boundary. diff --git a/foctet-wasm/examples/browser/index.html b/foctet-wasm/examples/browser/index.html new file mode 100644 index 0000000..aa37655 --- /dev/null +++ b/foctet-wasm/examples/browser/index.html @@ -0,0 +1,186 @@ + + + + + + foctet-wasm — browser runtime harness + + + +

foctet-wasm — browser runtime harness

+

+ Exercises the WASM SDK inside a real browser engine: body-envelope + roundtrip, Rust→JS wire compatibility (the same fixture used by the Node + interop test), and a full in-page FoctetSession handshake + + message exchange. Run ./examples/browser/serve.sh (or + npm run browser) from the foctet-wasm directory, + then open this page. +

+
Running…
+
    + + + + diff --git a/foctet-wasm/examples/browser/serve.mjs b/foctet-wasm/examples/browser/serve.mjs new file mode 100644 index 0000000..5a1af4e --- /dev/null +++ b/foctet-wasm/examples/browser/serve.mjs @@ -0,0 +1,72 @@ +// Minimal dependency-free static server for the foctet-wasm browser harness. +// +// Serves the `foctet-wasm` crate directory so the harness at +// `examples/browser/index.html` can reach `../../pkg-web` and `../../tests`. +// Node is already required to build the SDK, so this avoids a Python dependency. +// +// Usage: +// node examples/browser/serve.mjs [port] +// then open the printed URL. Set FOCTET_OPEN=1 to launch the default browser. + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { extname, join, normalize, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const port = Number(process.argv[2] ?? process.env.PORT ?? 8011); +// examples/browser/serve.mjs -> crate root is two levels up. +const root = resolve(fileURLToPath(new URL("../../", import.meta.url))); +const harnessPath = "/examples/browser/index.html"; + +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".wasm": "application/wasm", + ".css": "text/css; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".ts": "text/plain; charset=utf-8", +}; + +const server = createServer(async (req, res) => { + try { + const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]); + const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, ""); + let filePath = join(root, rel); + // Block path traversal outside the served root. + if (filePath !== root && !filePath.startsWith(root + sep)) { + res.writeHead(403).end("Forbidden"); + return; + } + if (urlPath === "/" || urlPath.endsWith("/")) { + filePath = join(root, harnessPath); + } + const body = await readFile(filePath); + res.writeHead(200, { + "content-type": MIME[extname(filePath)] ?? "application/octet-stream", + "cache-control": "no-store", + }); + res.end(body); + } catch { + res.writeHead(404).end("Not Found"); + } +}); + +server.listen(port, () => { + const url = `http://localhost:${port}${harnessPath}`; + console.log(`foctet-wasm browser harness: ${url}`); + console.log(`serving ${root}`); + console.log("Ctrl+C to stop."); + if (process.env.FOCTET_OPEN === "1") { + const opener = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "cmd" + : "xdg-open"; + const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; + spawn(opener, args, { stdio: "ignore", detached: true }).unref(); + } +}); diff --git a/foctet-wasm/examples/browser/serve.ps1 b/foctet-wasm/examples/browser/serve.ps1 new file mode 100644 index 0000000..047e9be --- /dev/null +++ b/foctet-wasm/examples/browser/serve.ps1 @@ -0,0 +1,22 @@ +# Build the foctet-wasm web target and serve the in-browser harness. +# No Python required (uses a tiny Node static server). +# +# Usage: ./examples/browser/serve.ps1 [-Port 8011] +# $env:FOCTET_OPEN=1; ./examples/browser/serve.ps1 # also open the default browser +param( + [int]$Port = 8011 +) + +$ErrorActionPreference = "Stop" + +# Resolve the foctet-wasm crate root (this script lives in examples/browser/). +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$CrateDir = Resolve-Path (Join-Path $ScriptDir "..\..") + +Set-Location $CrateDir + +Write-Host "Building pkg-web (wasm-pack)..." +wasm-pack build --target web --out-dir pkg-web + +Write-Host "Starting static server on port $Port..." +node examples/browser/serve.mjs $Port diff --git a/foctet-wasm/examples/browser/serve.sh b/foctet-wasm/examples/browser/serve.sh new file mode 100755 index 0000000..2ab7821 --- /dev/null +++ b/foctet-wasm/examples/browser/serve.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Build the foctet-wasm web target and serve the in-browser harness. +# No Python required (uses a tiny Node static server). +# +# Usage: ./examples/browser/serve.sh [port] +# FOCTET_OPEN=1 ./examples/browser/serve.sh # also open the default browser +set -euo pipefail + +# Resolve the foctet-wasm crate root (this script lives in examples/browser/). +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CRATE_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +PORT="${1:-8011}" + +cd "${CRATE_DIR}" + +echo "Building pkg-web (wasm-pack)..." +wasm-pack build --target web --out-dir pkg-web + +echo "Starting static server on port ${PORT}..." +exec node examples/browser/serve.mjs "${PORT}" diff --git a/foctet-wasm/examples/browser/websocket.html b/foctet-wasm/examples/browser/websocket.html new file mode 100644 index 0000000..c9fb8e4 --- /dev/null +++ b/foctet-wasm/examples/browser/websocket.html @@ -0,0 +1,195 @@ + + + + + + foctet-wasm — browser WebSocket interop + + + +

    foctet-wasm — browser WebSocket interop

    +

    + Drives the WASM FoctetSession as the handshake + initiator over a real browser WebSocket + against the native websock_message_server responder (tests.md + §3.3). Each binary WebSocket message carries one Foctet frame: the + handshake control messages, then sealed application messages. +

    +

    + Start the native server first (from the repo root): +
    + cargo run -p foctet-transport --example websock_message_server + --features "runtime-tokio transport-websock" -- --role server --addr + 127.0.0.1:4460 +

    + + + + + +
    Idle — press “Run test”.
    +
      + + + + diff --git a/foctet-wasm/examples/browser/webtransport.html b/foctet-wasm/examples/browser/webtransport.html new file mode 100644 index 0000000..c1647ea --- /dev/null +++ b/foctet-wasm/examples/browser/webtransport.html @@ -0,0 +1,215 @@ + + + + + + foctet-wasm — browser WebTransport interop + + + +

      foctet-wasm — browser WebTransport interop

      +

      + Drives the WASM FoctetSession (datagram mode) as the handshake + initiator over a real browser WebTransport + against the native webtrans_datagram_split responder (tests.md + §3.5). The authenticated handshake runs over a reliable bidi stream + (length-prefixed control frames); the sealed application data then flows as + WebTransport datagrams (one Foctet datagram frame each). +

      +

      + Start the native server first with the dev cert (from the repo root): +
      + cargo run -p foctet-transport --example webtrans_datagram_split + --features "runtime-tokio transport-webtrans" -- --role server --addr + 127.0.0.1:4470 --tls-cert devcert/localhost.crt --tls-key + devcert/localhost.key +

      + + + + + + + +
      Paste the cert hash, then press “Run test”.
      +
        + + + + diff --git a/foctet-wasm/examples/gen_interop_fixture.rs b/foctet-wasm/examples/gen_interop_fixture.rs new file mode 100644 index 0000000..fb3ee50 --- /dev/null +++ b/foctet-wasm/examples/gen_interop_fixture.rs @@ -0,0 +1,58 @@ +//! Generates the cross-language interop fixture consumed by the Node test. +//! +//! Run with `cargo run -p foctet-wasm --example gen_interop_fixture` and save the +//! JSON output to `foctet-wasm/tests/interop_vector.json`. The Node interop test +//! opens these Rust-produced envelopes to prove wire compatibility across the +//! Rust/WASM boundary. + +use foctet_core::{BodyEnvelopeLimits, seal_body, seal_body_with_context}; +use rand_core::OsRng; +use x25519_dalek::{PublicKey, StaticSecret}; + +fn hex(bytes: &[u8]) -> String { + use std::fmt::Write; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(out, "{byte:02x}"); + } + out +} + +fn main() { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret).to_bytes(); + let key_id = b"interop-kid"; + let plaintext = b"hello from rust"; + let context = b"foctet-http-ctx-v1|POST|/pay"; + + let envelope = seal_body(plaintext, public, key_id).expect("seal"); + let context_envelope = seal_body_with_context( + plaintext, + public, + key_id, + context, + &BodyEnvelopeLimits::default(), + ) + .expect("seal with context"); + + println!( + concat!( + "{{\n", + " \"secret\": \"{}\",\n", + " \"public\": \"{}\",\n", + " \"key_id\": \"{}\",\n", + " \"plaintext\": \"{}\",\n", + " \"envelope\": \"{}\",\n", + " \"context\": \"{}\",\n", + " \"context_envelope\": \"{}\"\n", + "}}" + ), + hex(&secret.to_bytes()), + hex(&public), + String::from_utf8_lossy(key_id), + String::from_utf8_lossy(plaintext), + hex(&envelope), + String::from_utf8_lossy(context), + hex(&context_envelope), + ); +} diff --git a/foctet-wasm/package.json b/foctet-wasm/package.json new file mode 100644 index 0000000..b511118 --- /dev/null +++ b/foctet-wasm/package.json @@ -0,0 +1,13 @@ +{ + "name": "@foctet/wasm-dev", + "private": true, + "version": "0.3.0", + "description": "Development harness for building and testing the Foctet WASM SDK", + "scripts": { + "build:node": "wasm-pack build --target nodejs --out-dir pkg-node", + "build:web": "wasm-pack build --target web --out-dir pkg-web", + "build:bundler": "wasm-pack build --target bundler --out-dir pkg", + "test": "npm run build:node && node tests/node_interop.cjs", + "browser": "npm run build:web && node examples/browser/serve.mjs" + } +} diff --git a/foctet-wasm/src/http_context.rs b/foctet-wasm/src/http_context.rs new file mode 100644 index 0000000..f259201 --- /dev/null +++ b/foctet-wasm/src/http_context.rs @@ -0,0 +1,665 @@ +use foctet_core::{ + BodyEnvelopeError, BodyEnvelopeLimits, open_body_with_context, seal_body_with_context, +}; +use foctet_http::{ + ContextCarrier, DEFAULT_CONTEXT_TTL_SECS, DEFAULT_MAX_CLOCK_SKEW_SECS, HttpError, + MESSAGE_ID_LEN, ProtectedContext, http, +}; +use http::{HeaderMap, HeaderName, HeaderValue}; +use wasm_bindgen::prelude::*; + +use crate::{WasmError, to_key}; + +const CONTENT_TYPE_HEADER: &str = "content-type"; +const CONTENT_TYPE_VALUE: &str = "application/foctet"; +const SCOPE_HEADER: &str = "x-foctet-scope"; +const BODY_ONLY_SCOPE: &str = "body-only"; +const MSG_ID_HEADER: &str = "x-foctet-msg-id"; +const TIMESTAMP_HEADER: &str = "x-foctet-timestamp"; +const EXPIRY_HEADER: &str = "x-foctet-expiry"; +const IDEMPOTENCY_HEADER: &str = "x-foctet-idempotency-key"; +const REQUEST_MSG_ID_HEADER: &str = "x-foctet-req-msg-id"; + +#[derive(Debug)] +pub(crate) enum WasmHttpError { + BadMessageIdLength, + InvalidMessageId, + InvalidTimestamp, + InvalidExpiry, + BadHeaderName(String), + BadHeaderValue(String), + InvalidMethod(String), + InvalidUri(String), + InvalidStatus(u16), + Envelope(BodyEnvelopeError), + Http(HttpError), + Key(WasmError), +} + +impl core::fmt::Display for WasmHttpError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + WasmHttpError::BadMessageIdLength => write!(f, "expected a 16-byte Foctet message id"), + WasmHttpError::InvalidMessageId => write!(f, "invalid Foctet message id"), + WasmHttpError::InvalidTimestamp => write!(f, "invalid Foctet context timestamp"), + WasmHttpError::InvalidExpiry => write!(f, "invalid Foctet context expiry"), + WasmHttpError::BadHeaderName(name) => write!(f, "invalid HTTP header name: {name}"), + WasmHttpError::BadHeaderValue(name) => { + write!(f, "invalid HTTP header value for header: {name}") + } + WasmHttpError::InvalidMethod(method) => write!(f, "invalid HTTP method: {method}"), + WasmHttpError::InvalidUri(uri) => write!(f, "invalid HTTP URI: {uri}"), + WasmHttpError::InvalidStatus(status) => write!(f, "invalid HTTP status: {status}"), + WasmHttpError::Envelope(err) => write!(f, "{err}"), + WasmHttpError::Http(err) => write!(f, "{err}"), + WasmHttpError::Key(err) => write!(f, "{err}"), + } + } +} + +impl From for WasmHttpError { + fn from(err: BodyEnvelopeError) -> Self { + WasmHttpError::Envelope(err) + } +} + +impl From for WasmHttpError { + fn from(err: HttpError) -> Self { + WasmHttpError::Http(err) + } +} + +impl From for WasmHttpError { + fn from(err: WasmError) -> Self { + WasmHttpError::Key(err) + } +} + +fn to_js(err: WasmHttpError) -> JsError { + JsError::new(&err.to_string()) +} + +fn to_message_id(bytes: &[u8]) -> Result<[u8; MESSAGE_ID_LEN], WasmHttpError> { + <[u8; MESSAGE_ID_LEN]>::try_from(bytes).map_err(|_| WasmHttpError::BadMessageIdLength) +} + +fn parse_message_id_hex(value: &str) -> Result<[u8; MESSAGE_ID_LEN], WasmHttpError> { + if value.len() != MESSAGE_ID_LEN * 2 { + return Err(WasmHttpError::InvalidMessageId); + } + let bytes = value.as_bytes(); + let mut out = [0u8; MESSAGE_ID_LEN]; + for (i, slot) in out.iter_mut().enumerate() { + let hi = hex_val(bytes[2 * i]).ok_or(WasmHttpError::InvalidMessageId)?; + let lo = hex_val(bytes[2 * i + 1]).ok_or(WasmHttpError::InvalidMessageId)?; + *slot = (hi << 4) | lo; + } + Ok(out) +} + +fn header_map(headers: &[(String, String)]) -> Result { + let mut out = HeaderMap::new(); + for (name, value) in headers { + let header_name = HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| WasmHttpError::BadHeaderName(name.clone()))?; + let header_value = HeaderValue::from_str(value) + .map_err(|_| WasmHttpError::BadHeaderValue(name.clone()))?; + out.append(header_name, header_value); + } + Ok(out) +} + +fn request_parts( + method: &str, + uri: &str, + headers: &[(String, String)], +) -> Result { + let method = http::Method::from_bytes(method.as_bytes()) + .map_err(|_| WasmHttpError::InvalidMethod(method.to_string()))?; + let uri = uri + .parse::() + .map_err(|_| WasmHttpError::InvalidUri(uri.to_string()))?; + let mut builder = http::Request::builder().method(method).uri(uri); + for (name, value) in header_map(headers)?.iter() { + builder = builder.header(name, value); + } + let (parts, _) = builder + .body(()) + .expect("method, URI, and headers were validated before building") + .into_parts(); + Ok(parts) +} + +fn response_parts(status: u16) -> Result { + let response = http::Response::builder() + .status(status) + .body(()) + .map_err(|_| WasmHttpError::InvalidStatus(status))?; + let (parts, _) = response.into_parts(); + Ok(parts) +} + +fn seal_with_aad( + plaintext: &[u8], + recipient_public_key: &[u8], + recipient_key_id: &[u8], + aad: &[u8], +) -> Result, WasmHttpError> { + let key = to_key(recipient_public_key)?; + Ok(seal_body_with_context( + plaintext, + key, + recipient_key_id, + aad, + &BodyEnvelopeLimits::default(), + )?) +} + +fn open_with_aad( + envelope: &[u8], + recipient_secret_key: &[u8], + aad: &[u8], +) -> Result, WasmHttpError> { + let key = to_key(recipient_secret_key)?; + Ok(open_body_with_context( + envelope, + key, + aad, + &BodyEnvelopeLimits::default(), + )?) +} + +/// Sender-chosen HTTP protected-context values carried in `x-foctet-*` headers. +#[wasm_bindgen(js_name = HttpContextCarrier)] +#[derive(Clone, Debug)] +pub struct WasmHttpContextCarrier { + inner: ContextCarrier, +} + +#[wasm_bindgen(js_class = HttpContextCarrier)] +impl WasmHttpContextCarrier { + /// Generates a fresh carrier with a random message ID and `now + ttl` expiry. + #[wasm_bindgen(js_name = generate)] + pub fn generate(now_secs: u64, ttl_secs: u64) -> Self { + Self { + inner: ContextCarrier::generate(now_secs, ttl_secs), + } + } + + /// Builds a carrier from received `x-foctet-*` header values. + #[wasm_bindgen(js_name = fromHeaderValues)] + pub fn from_header_values( + message_id_hex: &str, + timestamp_secs: &str, + expiry_secs: &str, + idempotency_key: Option, + request_message_id_hex: Option, + ) -> Result { + let message_id = parse_message_id_hex(message_id_hex).map_err(to_js)?; + let timestamp_secs = timestamp_secs + .parse::() + .map_err(|_| to_js(WasmHttpError::InvalidTimestamp))?; + let expiry_secs = expiry_secs + .parse::() + .map_err(|_| to_js(WasmHttpError::InvalidExpiry))?; + let request_message_id = match request_message_id_hex { + Some(value) => Some(parse_message_id_hex(&value).map_err(to_js)?), + None => None, + }; + Ok(Self { + inner: ContextCarrier { + message_id, + timestamp_secs, + expiry_secs, + idempotency_key, + request_message_id, + }, + }) + } + + /// Builds a carrier from explicit field values. + #[wasm_bindgen(constructor)] + pub fn new(message_id: &[u8], timestamp_secs: u64, expiry_secs: u64) -> Result { + Ok(Self { + inner: ContextCarrier { + message_id: to_message_id(message_id).map_err(to_js)?, + timestamp_secs, + expiry_secs, + idempotency_key: None, + request_message_id: None, + }, + }) + } + + /// Sets the optional application idempotency key. + #[wasm_bindgen(js_name = setIdempotencyKey)] + pub fn set_idempotency_key(&mut self, key: Option) { + self.inner.idempotency_key = key; + } + + /// Sets the request message ID answered by a response. + #[wasm_bindgen(js_name = setRequestMessageId)] + pub fn set_request_message_id(&mut self, message_id: Option>) -> Result<(), JsError> { + self.inner.request_message_id = match message_id { + Some(id) => Some(to_message_id(&id).map_err(to_js)?), + None => None, + }; + Ok(()) + } + + /// Returns the raw 16-byte message ID. + #[wasm_bindgen(getter, js_name = messageId)] + pub fn message_id(&self) -> Vec { + self.inner.message_id.to_vec() + } + + /// Returns the Unix-seconds timestamp. + #[wasm_bindgen(getter, js_name = timestampSecs)] + pub fn timestamp_secs(&self) -> u64 { + self.inner.timestamp_secs + } + + /// Returns the Unix-seconds absolute expiry. + #[wasm_bindgen(getter, js_name = expirySecs)] + pub fn expiry_secs(&self) -> u64 { + self.inner.expiry_secs + } + + /// Returns the optional application idempotency key. + #[wasm_bindgen(getter, js_name = idempotencyKey)] + pub fn idempotency_key(&self) -> Option { + self.inner.idempotency_key.clone() + } + + /// Returns the optional answered request message ID. + #[wasm_bindgen(getter, js_name = requestMessageId)] + pub fn request_message_id(&self) -> Option> { + self.inner.request_message_id.map(|id| id.to_vec()) + } + + /// Returns the `x-foctet-msg-id` header value. + #[wasm_bindgen(getter, js_name = messageIdHeaderValue)] + pub fn message_id_header_value(&self) -> String { + hex(&self.inner.message_id) + } + + /// Returns the `x-foctet-timestamp` header value. + #[wasm_bindgen(getter, js_name = timestampHeaderValue)] + pub fn timestamp_header_value(&self) -> String { + self.inner.timestamp_secs.to_string() + } + + /// Returns the `x-foctet-expiry` header value. + #[wasm_bindgen(getter, js_name = expiryHeaderValue)] + pub fn expiry_header_value(&self) -> String { + self.inner.expiry_secs.to_string() + } + + /// Returns the `x-foctet-req-msg-id` header value, if set. + #[wasm_bindgen(getter, js_name = requestMessageIdHeaderValue)] + pub fn request_message_id_header_value(&self) -> Option { + self.inner.request_message_id.map(|id| hex(&id)) + } +} + +/// Request-side HTTP protected context. +#[wasm_bindgen(js_name = HttpRequestContext)] +#[derive(Clone, Debug)] +pub struct WasmHttpRequestContext { + method: String, + uri: String, + headers: Vec<(String, String)>, + carrier: WasmHttpContextCarrier, + bind_authority: bool, + bound_header_names: Vec, +} + +#[wasm_bindgen(js_class = HttpRequestContext)] +impl WasmHttpRequestContext { + /// Creates a request context from normalized HTTP request metadata. + #[wasm_bindgen(constructor)] + pub fn new(method: String, uri: String, carrier: &WasmHttpContextCarrier) -> Self { + Self { + method, + uri, + headers: Vec::new(), + carrier: carrier.clone(), + bind_authority: false, + bound_header_names: Vec::new(), + } + } + + /// Adds one request header visible to context binding. + #[wasm_bindgen(js_name = setHeader)] + pub fn set_header(&mut self, name: String, value: String) { + self.headers.push((name, value)); + } + + /// Controls whether request authority is bound into the context. + #[wasm_bindgen(js_name = setBindAuthority)] + pub fn set_bind_authority(&mut self, value: bool) { + self.bind_authority = value; + } + + /// Adds a request header name to the protected-context binding policy. + #[wasm_bindgen(js_name = bindHeader)] + pub fn bind_header(&mut self, name: String) { + self.bound_header_names.push(name); + } + + /// Produces the canonical associated-data bytes for this request context. + #[wasm_bindgen(js_name = aadBytes)] + pub fn aad_bytes(&self) -> Result, JsError> { + self.aad_inner().map_err(to_js) + } + + /// Validates the timestamp/expiry for this request context. + #[wasm_bindgen(js_name = validateFreshness)] + pub fn validate_freshness(&self, now_secs: u64, max_skew_secs: u64) -> Result<(), JsError> { + let context = self.protected_context().map_err(to_js)?; + context + .validate_freshness(now_secs, max_skew_secs) + .map_err(WasmHttpError::from) + .map_err(to_js) + } + + /// Seals a request body bound to this HTTP protected context. + #[wasm_bindgen(js_name = sealBody)] + pub fn seal_body( + &self, + plaintext: &[u8], + recipient_public_key: &[u8], + recipient_key_id: &[u8], + ) -> Result, JsError> { + let aad = self.aad_inner().map_err(to_js)?; + seal_with_aad(plaintext, recipient_public_key, recipient_key_id, &aad).map_err(to_js) + } + + /// Opens a request body bound to this HTTP protected context. + #[wasm_bindgen(js_name = openBody)] + pub fn open_body( + &self, + envelope: &[u8], + recipient_secret_key: &[u8], + now_secs: u64, + max_skew_secs: u64, + ) -> Result, JsError> { + self.validate_freshness(now_secs, max_skew_secs)?; + let aad = self.aad_inner().map_err(to_js)?; + open_with_aad(envelope, recipient_secret_key, &aad).map_err(to_js) + } +} + +impl WasmHttpRequestContext { + fn protected_context(&self) -> Result { + let parts = request_parts(&self.method, &self.uri, &self.headers)?; + Ok(ProtectedContext::for_request_with_header_binding( + &parts, + self.carrier.inner.clone(), + self.bind_authority, + self.bound_header_names.iter(), + )) + } + + fn aad_inner(&self) -> Result, WasmHttpError> { + Ok(self.protected_context()?.to_aad_bytes()) + } +} + +/// Response-side HTTP protected context. +#[wasm_bindgen(js_name = HttpResponseContext)] +#[derive(Clone, Debug)] +pub struct WasmHttpResponseContext { + status: u16, + carrier: WasmHttpContextCarrier, +} + +#[wasm_bindgen(js_class = HttpResponseContext)] +impl WasmHttpResponseContext { + /// Creates a response context from the HTTP status and carrier fields. + #[wasm_bindgen(constructor)] + pub fn new(status: u16, carrier: &WasmHttpContextCarrier) -> Self { + Self { + status, + carrier: carrier.clone(), + } + } + + /// Produces the canonical associated-data bytes for this response context. + #[wasm_bindgen(js_name = aadBytes)] + pub fn aad_bytes(&self) -> Result, JsError> { + self.aad_inner().map_err(to_js) + } + + /// Validates the timestamp/expiry for this response context. + #[wasm_bindgen(js_name = validateFreshness)] + pub fn validate_freshness(&self, now_secs: u64, max_skew_secs: u64) -> Result<(), JsError> { + let context = self.protected_context().map_err(to_js)?; + context + .validate_freshness(now_secs, max_skew_secs) + .map_err(WasmHttpError::from) + .map_err(to_js) + } + + /// Seals a response body bound to this HTTP protected context. + #[wasm_bindgen(js_name = sealBody)] + pub fn seal_body( + &self, + plaintext: &[u8], + recipient_public_key: &[u8], + recipient_key_id: &[u8], + ) -> Result, JsError> { + let aad = self.aad_inner().map_err(to_js)?; + seal_with_aad(plaintext, recipient_public_key, recipient_key_id, &aad).map_err(to_js) + } + + /// Opens a response body bound to this HTTP protected context. + #[wasm_bindgen(js_name = openBody)] + pub fn open_body( + &self, + envelope: &[u8], + recipient_secret_key: &[u8], + now_secs: u64, + max_skew_secs: u64, + ) -> Result, JsError> { + self.validate_freshness(now_secs, max_skew_secs)?; + let aad = self.aad_inner().map_err(to_js)?; + open_with_aad(envelope, recipient_secret_key, &aad).map_err(to_js) + } +} + +impl WasmHttpResponseContext { + fn protected_context(&self) -> Result { + let parts = response_parts(self.status)?; + Ok(ProtectedContext::for_response( + &parts, + self.carrier.inner.clone(), + )) + } + + fn aad_inner(&self) -> Result, WasmHttpError> { + Ok(self.protected_context()?.to_aad_bytes()) + } +} + +/// Foctet HTTP media type. +#[wasm_bindgen(js_name = httpContentType)] +pub fn http_content_type() -> String { + CONTENT_TYPE_VALUE.to_string() +} + +/// Advisory Foctet protection-scope header name. +#[wasm_bindgen(js_name = httpScopeHeader)] +pub fn http_scope_header() -> String { + SCOPE_HEADER.to_string() +} + +/// Advisory Foctet body-only protection-scope value. +#[wasm_bindgen(js_name = httpBodyOnlyScope)] +pub fn http_body_only_scope() -> String { + BODY_ONLY_SCOPE.to_string() +} + +/// HTTP `Content-Type` header name. +#[wasm_bindgen(js_name = httpContentTypeHeader)] +pub fn http_content_type_header() -> String { + CONTENT_TYPE_HEADER.to_string() +} + +/// Header carrying the hex-encoded Foctet message ID. +#[wasm_bindgen(js_name = httpMessageIdHeader)] +pub fn http_message_id_header() -> String { + MSG_ID_HEADER.to_string() +} + +/// Header carrying the Unix-seconds timestamp. +#[wasm_bindgen(js_name = httpTimestampHeader)] +pub fn http_timestamp_header() -> String { + TIMESTAMP_HEADER.to_string() +} + +/// Header carrying the Unix-seconds absolute expiry. +#[wasm_bindgen(js_name = httpExpiryHeader)] +pub fn http_expiry_header() -> String { + EXPIRY_HEADER.to_string() +} + +/// Header carrying the optional application idempotency key. +#[wasm_bindgen(js_name = httpIdempotencyHeader)] +pub fn http_idempotency_header() -> String { + IDEMPOTENCY_HEADER.to_string() +} + +/// Response-only header carrying the answered request message ID. +#[wasm_bindgen(js_name = httpRequestMessageIdHeader)] +pub fn http_request_message_id_header() -> String { + REQUEST_MSG_ID_HEADER.to_string() +} + +/// Suggested default protected-context TTL in seconds. +#[wasm_bindgen(js_name = defaultHttpContextTtlSecs)] +pub fn default_http_context_ttl_secs() -> u64 { + DEFAULT_CONTEXT_TTL_SECS +} + +/// Suggested default protected-context clock-skew tolerance in seconds. +#[wasm_bindgen(js_name = defaultHttpMaxClockSkewSecs)] +pub fn default_http_max_clock_skew_secs() -> u64 { + DEFAULT_MAX_CLOCK_SKEW_SECS +} + +fn hex(bytes: &[u8]) -> String { + use core::fmt::Write; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(out, "{byte:02x}"); + } + out +} + +fn hex_val(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::KEY_LEN; + use foctet_http::ContextBinding; + use x25519_dalek::{PublicKey, StaticSecret}; + + #[test] + fn request_aad_matches_foctet_http() { + let carrier = WasmHttpContextCarrier { + inner: ContextCarrier::generate(1_000, 60).with_idempotency_key("idem-1"), + }; + let mut wasm_ctx = WasmHttpRequestContext::new( + "post".to_string(), + "https://api.example.test/pay?currency=USD".to_string(), + &carrier, + ); + wasm_ctx.set_header("x-tenant-id".to_string(), "tenant-a".to_string()); + wasm_ctx.bind_header("x-tenant-id".to_string()); + + let request = http::Request::builder() + .method("post") + .uri("https://api.example.test/pay?currency=USD") + .header("x-tenant-id", "tenant-a") + .body(()) + .expect("request"); + let (parts, _) = request.into_parts(); + let rust_aad = ProtectedContext::for_request( + &parts, + carrier.inner.clone(), + ContextBinding::default().with_bound_headers(&["x-tenant-id"]), + ) + .to_aad_bytes(); + + assert_eq!(wasm_ctx.aad_inner().expect("wasm aad"), rust_aad); + } + + #[test] + fn request_context_seals_body_compatible_with_foctet_http() { + let secret = StaticSecret::from([7u8; KEY_LEN]); + let public = PublicKey::from(&secret).to_bytes(); + let carrier = WasmHttpContextCarrier { + inner: ContextCarrier::generate(2_000, 60), + }; + let wasm_ctx = WasmHttpRequestContext::new( + "POST".to_string(), + "https://example.test/x".to_string(), + &carrier, + ); + let envelope = wasm_ctx + .seal_body(b"payload", &public, b"kid") + .expect("seal"); + let opened = wasm_ctx + .open_body(&envelope, &secret.to_bytes(), 2_000, 5) + .expect("open"); + assert_eq!(opened, b"payload"); + + let mut headers = HeaderMap::new(); + carrier + .inner + .apply_to_headers(&mut headers) + .expect("carrier headers"); + let mut builder = http::Request::builder() + .method("POST") + .uri("https://example.test/x") + .header(http::header::CONTENT_TYPE, CONTENT_TYPE_VALUE); + for (name, value) in headers.iter() { + builder = builder.header(name, value); + } + let request = builder.body(envelope).expect("request"); + let opener = + foctet_http::HttpOpener::new(foctet_http::HttpOpenOptions::new(secret.to_bytes())); + let store = foctet_http::InMemoryReplayStore::new(); + let opened_by_rust = opener + .open_request_with_context(request, &store, 2_000, 5, ContextBinding::default()) + .expect("rust open"); + assert_eq!(opened_by_rust.body(), b"payload"); + } + + #[test] + fn response_aad_requires_response_direction() { + let mut carrier = ContextCarrier::generate(1_000, 60); + carrier.request_message_id = Some([3u8; MESSAGE_ID_LEN]); + let carrier = WasmHttpContextCarrier { inner: carrier }; + let response_ctx = WasmHttpResponseContext::new(201, &carrier); + let request_ctx = WasmHttpRequestContext::new( + "POST".to_string(), + "https://example.test/x".to_string(), + &carrier, + ); + + assert_ne!( + response_ctx.aad_inner().expect("response aad"), + request_ctx.aad_inner().expect("request aad") + ); + } +} diff --git a/foctet-wasm/src/lib.rs b/foctet-wasm/src/lib.rs new file mode 100644 index 0000000..829795c --- /dev/null +++ b/foctet-wasm/src/lib.rs @@ -0,0 +1,251 @@ +//! WebAssembly bindings for Foctet end-to-end encryption. +//! +//! This crate exposes a small JavaScript/TypeScript API for the +//! `application/foctet` body envelope and the framed `FoctetSession`, so +//! browsers and JS runtimes can interoperate with the Rust implementation. +//! +//! Byte values cross the boundary as `Uint8Array`, and fallible operations +//! throw a JavaScript `Error` rather than aborting the WASM instance. +//! +//! [`KeyPair`] exposes raw X25519 key bytes, so callers are responsible for +//! storing secret keys safely. +//! +//! The body-envelope APIs protect payload bytes and optional associated context, +//! not outer HTTP metadata. Pair them with an authenticated outer channel when +//! used over HTTP. + +use std::fmt; + +use foctet_core::{ + BodyEnvelopeError, BodyEnvelopeLimits, open_body, open_body_with_context, seal_body, + seal_body_with_context, +}; +use rand_core::OsRng; +use wasm_bindgen::prelude::*; +use x25519_dalek::{PublicKey, StaticSecret}; + +mod http_context; +mod session; +pub use http_context::{WasmHttpContextCarrier, WasmHttpRequestContext, WasmHttpResponseContext}; +pub use session::{FoctetSession, WasmAuthConfig, WasmDecodedMessage, WasmIdentityKeyPair}; + +/// X25519 public/secret key length in bytes. +pub const KEY_LEN: usize = 32; + +/// Error returned by the inner (native-testable) functions. +#[derive(Debug)] +enum WasmError { + /// A key argument was not exactly [`KEY_LEN`] bytes. + BadKeyLength, + /// A body-envelope seal/open operation failed. + Envelope(BodyEnvelopeError), +} + +impl fmt::Display for WasmError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WasmError::BadKeyLength => write!(f, "expected a 32-byte X25519 key"), + WasmError::Envelope(err) => write!(f, "{err}"), + } + } +} + +impl From for WasmError { + fn from(err: BodyEnvelopeError) -> Self { + WasmError::Envelope(err) + } +} + +fn to_key(bytes: &[u8]) -> Result<[u8; KEY_LEN], WasmError> { + <[u8; KEY_LEN]>::try_from(bytes).map_err(|_| WasmError::BadKeyLength) +} + +fn seal_inner( + plaintext: &[u8], + recipient_public_key: &[u8], + recipient_key_id: &[u8], +) -> Result, WasmError> { + let rpk = to_key(recipient_public_key)?; + Ok(seal_body(plaintext, rpk, recipient_key_id)?) +} + +fn open_inner(envelope: &[u8], recipient_secret_key: &[u8]) -> Result, WasmError> { + let rsk = to_key(recipient_secret_key)?; + Ok(open_body(envelope, rsk)?) +} + +fn seal_ctx_inner( + plaintext: &[u8], + recipient_public_key: &[u8], + recipient_key_id: &[u8], + context: &[u8], +) -> Result, WasmError> { + let rpk = to_key(recipient_public_key)?; + Ok(seal_body_with_context( + plaintext, + rpk, + recipient_key_id, + context, + &BodyEnvelopeLimits::default(), + )?) +} + +fn open_ctx_inner( + envelope: &[u8], + recipient_secret_key: &[u8], + context: &[u8], +) -> Result, WasmError> { + let rsk = to_key(recipient_secret_key)?; + Ok(open_body_with_context( + envelope, + rsk, + context, + &BodyEnvelopeLimits::default(), + )?) +} + +fn to_js(err: WasmError) -> JsError { + JsError::new(&err.to_string()) +} + +/// Returns the SDK version string (the crate version). +#[wasm_bindgen] +pub fn version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +/// An X25519 recipient key pair. +#[wasm_bindgen] +pub struct KeyPair { + secret: StaticSecret, + public: [u8; KEY_LEN], +} + +#[wasm_bindgen] +impl KeyPair { + /// Generates a fresh random X25519 key pair. + #[wasm_bindgen(constructor)] + pub fn generate() -> KeyPair { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret).to_bytes(); + KeyPair { secret, public } + } + + /// Reconstructs a key pair from 32 secret-key bytes. + #[wasm_bindgen(js_name = fromSecretKey)] + pub fn from_secret_key(secret_key: &[u8]) -> Result { + let bytes = to_key(secret_key).map_err(to_js)?; + let secret = StaticSecret::from(bytes); + let public = PublicKey::from(&secret).to_bytes(); + Ok(KeyPair { secret, public }) + } + + /// The 32-byte public key. + #[wasm_bindgen(getter, js_name = publicKey)] + pub fn public_key(&self) -> Vec { + self.public.to_vec() + } + + /// The 32-byte secret key. Handle with care. + #[wasm_bindgen(getter, js_name = secretKey)] + pub fn secret_key(&self) -> Vec { + self.secret.to_bytes().to_vec() + } +} + +/// Seals `plaintext` into an `application/foctet` body envelope for the holder +/// of the secret key matching `recipient_public_key`. +#[wasm_bindgen(js_name = sealBody)] +pub fn seal_body_js( + plaintext: &[u8], + recipient_public_key: &[u8], + recipient_key_id: &[u8], +) -> Result, JsError> { + seal_inner(plaintext, recipient_public_key, recipient_key_id).map_err(to_js) +} + +/// Opens an `application/foctet` body envelope using `recipient_secret_key`. +#[wasm_bindgen(js_name = openBody)] +pub fn open_body_js(envelope: &[u8], recipient_secret_key: &[u8]) -> Result, JsError> { + open_inner(envelope, recipient_secret_key).map_err(to_js) +} + +/// Seals `plaintext`, additionally authenticating `context` as associated data. +/// +/// The opener must supply byte-identical `context` or the open fails. An empty +/// `context` is byte-identical to [`seal_body_js`]. +#[wasm_bindgen(js_name = sealBodyWithContext)] +pub fn seal_body_with_context_js( + plaintext: &[u8], + recipient_public_key: &[u8], + recipient_key_id: &[u8], + context: &[u8], +) -> Result, JsError> { + seal_ctx_inner(plaintext, recipient_public_key, recipient_key_id, context).map_err(to_js) +} + +/// Opens a context-bound envelope, requiring the same `context` used to seal it. +#[wasm_bindgen(js_name = openBodyWithContext)] +pub fn open_body_with_context_js( + envelope: &[u8], + recipient_secret_key: &[u8], + context: &[u8], +) -> Result, JsError> { + open_ctx_inner(envelope, recipient_secret_key, context).map_err(to_js) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn keypair_bytes() -> ([u8; KEY_LEN], [u8; KEY_LEN]) { + let kp = KeyPair::generate(); + let public = <[u8; KEY_LEN]>::try_from(kp.public_key().as_slice()).expect("public"); + let secret = <[u8; KEY_LEN]>::try_from(kp.secret_key().as_slice()).expect("secret"); + (public, secret) + } + + #[test] + fn body_roundtrip() { + let (public, secret) = keypair_bytes(); + let plaintext = b"wasm body payload"; + let envelope = seal_inner(plaintext, &public, b"kid").expect("seal"); + let opened = open_inner(&envelope, &secret).expect("open"); + assert_eq!(opened, plaintext); + } + + #[test] + fn wrong_key_length_is_error_not_panic() { + assert!(matches!( + seal_inner(b"x", &[0u8; 31], b"kid"), + Err(WasmError::BadKeyLength) + )); + assert!(matches!( + open_inner(&[0u8; 10], &[0u8; 33]), + Err(WasmError::BadKeyLength) + )); + } + + #[test] + fn context_binding_roundtrip_and_mismatch() { + let (public, secret) = keypair_bytes(); + let plaintext = b"context payload"; + let ctx = b"foctet-http-ctx-v1|POST|/pay"; + let envelope = seal_ctx_inner(plaintext, &public, b"kid", ctx).expect("seal"); + + let opened = open_ctx_inner(&envelope, &secret, ctx).expect("open"); + assert_eq!(opened, plaintext); + + assert!(open_ctx_inner(&envelope, &secret, b"other").is_err()); + // Opening a context-bound envelope without context must also fail. + assert!(open_inner(&envelope, &secret).is_err()); + } + + #[test] + fn from_secret_key_recovers_public() { + let kp = KeyPair::generate(); + let rebuilt = + KeyPair::from_secret_key(&kp.secret_key()).unwrap_or_else(|_| panic!("from secret")); + assert_eq!(rebuilt.public_key(), kp.public_key()); + } +} diff --git a/foctet-wasm/src/session.rs b/foctet-wasm/src/session.rs new file mode 100644 index 0000000..11610fb --- /dev/null +++ b/foctet-wasm/src/session.rs @@ -0,0 +1,936 @@ +//! Framed Foctet session over WebAssembly. +//! +//! This module exposes the native Foctet handshake and framed session API to +//! JavaScript, so a browser or JS runtime can run an authenticated, +//! replay-protected Foctet session instead of only the one-shot body envelope. +//! +//! WebAssembly owns the cryptography and session state; JavaScript owns the +//! transport. Handshake/control messages and sealed payloads cross the boundary +//! as `Uint8Array`. +//! +//! A session is created in one framing mode and stays there: +//! +//! - `newInitiator` / `newResponder` for reliable ordered messages +//! - `newDatagramInitiator` / `newDatagramResponder` for MTU-bounded datagrams +//! +//! Datagram sessions still require a reliable channel for the handshake and +//! rekey control messages. In-session rekey is available through `canRekey`, +//! `forceRekey`, and `handleControlMessage`. + +use foctet_core::{ + ChannelBinding, ControlMessage, CoreError, DatagramConfig, DatagramEndpoint, DecodedDatagram, + DecodedMessage, IdentityKeyPair, MessageEndpoint, PeerIdentity, RekeyThresholds, Session, + SessionAuthConfig, SessionState, +}; +use wasm_bindgen::prelude::*; +use zeroize::Zeroizing; + +use crate::KEY_LEN; + +fn core_to_js(err: CoreError) -> JsError { + JsError::new(&err.to_string()) +} + +fn to_key32_js(bytes: &[u8]) -> Result<[u8; KEY_LEN], JsError> { + <[u8; KEY_LEN]>::try_from(bytes).map_err(|_| JsError::new("expected a 32-byte key")) +} + +/// An Ed25519 long-term identity key pair used to authenticate a handshake. +#[wasm_bindgen(js_name = IdentityKeyPair)] +pub struct WasmIdentityKeyPair { + inner: IdentityKeyPair, +} + +#[wasm_bindgen(js_class = IdentityKeyPair)] +impl WasmIdentityKeyPair { + /// Generates a fresh random identity key pair. + #[wasm_bindgen(constructor)] + pub fn generate() -> WasmIdentityKeyPair { + WasmIdentityKeyPair { + inner: IdentityKeyPair::generate(), + } + } + + /// Reconstructs an identity key pair from 32 secret-key bytes. + #[wasm_bindgen(js_name = fromSecretKey)] + pub fn from_secret_key(secret_key: &[u8]) -> Result { + let bytes = to_key32_js(secret_key)?; + Ok(WasmIdentityKeyPair { + inner: IdentityKeyPair::from_secret_key_bytes(bytes), + }) + } + + /// The 32-byte Ed25519 public identity key (share this with the peer to pin). + #[wasm_bindgen(getter, js_name = publicKey)] + pub fn public_key(&self) -> Vec { + self.inner.public_key().to_vec() + } + + /// The 32-byte secret identity key. Handle with care. + #[wasm_bindgen(getter, js_name = secretKey)] + pub fn secret_key(&self) -> Vec { + self.inner.expose_secret_key_bytes().to_vec() + } +} + +#[derive(Clone)] +enum AuthMode { + UnauthenticatedForTesting, + Authenticated { + local_secret: Zeroizing<[u8; KEY_LEN]>, + peer_public: [u8; KEY_LEN], + }, +} + +/// Handshake authentication policy for a [`FoctetSession`]. +/// +/// Mirrors the fail-closed native default: prefer [`WasmAuthConfig::authenticated`] +/// with a pinned peer identity for production. [`WasmAuthConfig::bound_to_channel`] +/// substitutes an authenticated outer channel for a Foctet identity, while +/// [`WasmAuthConfig::unauthenticated_for_testing`] is only for tests or use +/// inside an already-authenticated outer channel. +/// +/// Any config can additionally carry an outer-channel binding via +/// [`WasmAuthConfig::with_channel_binding`]. +#[wasm_bindgen(js_name = AuthConfig)] +pub struct WasmAuthConfig { + mode: AuthMode, + channel_binding: Option>, +} + +#[wasm_bindgen(js_class = AuthConfig)] +impl WasmAuthConfig { + /// Authenticates the local side with `local_identity` and pins the peer to + /// `peer_public_key`, requiring the peer to prove that identity. + pub fn authenticated( + local_identity: &WasmIdentityKeyPair, + peer_public_key: &[u8], + ) -> Result { + let peer_public = to_key32_js(peer_public_key)?; + let local_secret = Zeroizing::new(*local_identity.inner.expose_secret_key_bytes()); + Ok(WasmAuthConfig { + mode: AuthMode::Authenticated { + local_secret, + peer_public, + }, + channel_binding: None, + }) + } + + /// Builds a config whose man-in-the-middle resistance comes from an + /// authenticated outer channel (e.g. a TLS exporter value) rather than a + /// Foctet identity. + /// + /// Both peers must supply the same `channel_binding`; a relay across a + /// different outer channel fails closed. This is the production-oriented + /// alternative to [`WasmAuthConfig::unauthenticated_for_testing`]. + #[wasm_bindgen(js_name = boundToChannel)] + pub fn bound_to_channel(channel_binding: &[u8]) -> WasmAuthConfig { + WasmAuthConfig { + mode: AuthMode::UnauthenticatedForTesting, + channel_binding: Some(channel_binding.to_vec()), + } + } + + /// Builds an unauthenticated config. Use only for tests or inside an + /// already-authenticated outer channel (e.g. mutually authenticated TLS). + #[wasm_bindgen(js_name = unauthenticatedForTesting)] + pub fn unauthenticated_for_testing() -> WasmAuthConfig { + WasmAuthConfig { + mode: AuthMode::UnauthenticatedForTesting, + channel_binding: None, + } + } + + /// Returns a copy of this config additionally bound to `channel_binding`. + /// + /// Additive to any mode: both peers must supply the same binding or the + /// handshake fails. An empty binding leaves the transcript unchanged. + #[wasm_bindgen(js_name = withChannelBinding)] + pub fn with_channel_binding(&self, channel_binding: &[u8]) -> WasmAuthConfig { + WasmAuthConfig { + mode: self.mode.clone(), + channel_binding: Some(channel_binding.to_vec()), + } + } +} + +impl WasmAuthConfig { + fn build(&self) -> SessionAuthConfig { + let mut config = match &self.mode { + AuthMode::UnauthenticatedForTesting => SessionAuthConfig::unauthenticated_for_testing(), + AuthMode::Authenticated { + local_secret, + peer_public, + } => SessionAuthConfig::new() + .with_local_identity(IdentityKeyPair::from_secret_key_bytes(**local_secret)) + .with_peer_identity(PeerIdentity::new(*peer_public)) + .require_peer_authentication(true), + }; + if let Some(binding) = &self.channel_binding { + config = config.with_channel_binding(ChannelBinding::new(binding.clone())); + } + config + } +} + +/// A decrypted message returned by [`FoctetSession::open_message`]. +#[wasm_bindgen(js_name = DecodedMessage)] +pub struct WasmDecodedMessage { + stream_id: u32, + flags: u8, + key_id: u8, + seq: u64, + plaintext: Vec, +} + +#[wasm_bindgen(js_class = DecodedMessage)] +impl WasmDecodedMessage { + /// Logical stream identifier the frame was sealed on. + #[wasm_bindgen(getter, js_name = streamId)] + pub fn stream_id(&self) -> u32 { + self.stream_id + } + + /// Frame flags bitfield. + #[wasm_bindgen(getter)] + pub fn flags(&self) -> u8 { + self.flags + } + + /// Traffic-key identifier that opened the frame. + #[wasm_bindgen(getter, js_name = keyId)] + pub fn key_id(&self) -> u8 { + self.key_id + } + + /// Per-stream sequence number. + #[wasm_bindgen(getter)] + pub fn seq(&self) -> u64 { + self.seq + } + + /// Decrypted payload bytes. + #[wasm_bindgen(getter)] + pub fn plaintext(&self) -> Vec { + self.plaintext.clone() + } +} + +impl From for WasmDecodedMessage { + fn from(decoded: DecodedMessage) -> Self { + WasmDecodedMessage { + stream_id: decoded.header.stream_id, + flags: decoded.header.flags, + key_id: decoded.header.key_id, + seq: decoded.header.seq, + plaintext: decoded.plaintext, + } + } +} + +impl From for WasmDecodedMessage { + fn from(decoded: DecodedDatagram) -> Self { + WasmDecodedMessage { + stream_id: decoded.header.stream_id, + flags: decoded.header.flags, + key_id: decoded.header.key_id, + seq: decoded.header.seq, + plaintext: decoded.plaintext, + } + } +} + +/// The data-framing shape a session uses after the handshake. A session commits +/// to exactly one so message and datagram framing can never share a +/// `(key_id, stream_id)` sequence space (which would reuse a nonce). +enum SessionEndpoint { + Message(MessageEndpoint), + Datagram(DatagramEndpoint), +} + +#[derive(Clone, Copy)] +enum TransportKind { + /// Reliable, ordered, message-bounded framing (raw WebSocket, WebTransport + /// stream). Not MTU-capped. + Message, + /// MTU-bounded, loss/reorder-tolerant framing (WebTransport datagrams). + Datagram { max_datagram_size: usize }, +} + +/// A full Foctet session: an authenticated handshake followed by +/// replay-protected per-message (or per-datagram) seal/open. +/// +/// A session is created in one framing mode and stays in it: the `*Message` +/// methods work on a message-mode session (reliable, ordered — raw WebSocket or +/// a WebTransport stream) and the `*Datagram` methods on a datagram-mode session +/// (MTU-bounded, loss-tolerant — WebTransport datagrams). The handshake messages +/// themselves are reliable and must be exchanged over a reliable channel even +/// when data later flows as datagrams. +#[wasm_bindgen] +pub struct FoctetSession { + session: Session, + kind: TransportKind, + endpoint: Option, + pending_handshake: Option>, +} + +#[wasm_bindgen] +impl FoctetSession { + /// Starts a session as the handshake initiator. + /// + /// Call [`Self::initial_handshake_message`] next to obtain the first message + /// to send to the peer. + #[wasm_bindgen(js_name = newInitiator)] + pub fn new_initiator(auth: &WasmAuthConfig) -> FoctetSession { + FoctetSession::initiator(auth.build()) + } + + /// Starts a session as the handshake responder. + /// + /// Feed the initiator's first message to [`Self::handle_handshake_message`]. + #[wasm_bindgen(js_name = newResponder)] + pub fn new_responder(auth: &WasmAuthConfig) -> FoctetSession { + FoctetSession::responder(auth.build()) + } + + /// Starts a datagram-mode session as the initiator (for WebTransport + /// datagrams). Exchange the handshake messages over a reliable channel, then + /// use [`Self::seal_datagram`] / [`Self::open_datagram`] for data. + /// + /// `max_datagram_size` caps each sealed datagram; pass `0` for the default + /// (`foctet_core::DEFAULT_MAX_DATAGRAM_SIZE`). + #[wasm_bindgen(js_name = newDatagramInitiator)] + pub fn new_datagram_initiator( + auth: &WasmAuthConfig, + max_datagram_size: usize, + ) -> FoctetSession { + FoctetSession::initiator_with_kind(auth.build(), datagram_kind(max_datagram_size)) + } + + /// Starts a datagram-mode session as the responder. See + /// [`Self::new_datagram_initiator`]. + #[wasm_bindgen(js_name = newDatagramResponder)] + pub fn new_datagram_responder( + auth: &WasmAuthConfig, + max_datagram_size: usize, + ) -> FoctetSession { + FoctetSession::responder_with_kind(auth.build(), datagram_kind(max_datagram_size)) + } + + /// Returns the initiator's first handshake message to send, exactly once. + /// + /// Returns `undefined` for a responder or after the message was already taken. + #[wasm_bindgen(js_name = initialHandshakeMessage)] + pub fn initial_handshake_message(&mut self) -> Option> { + self.pending_handshake.take() + } + + /// Feeds a received handshake message, returning an optional reply to send. + #[wasm_bindgen(js_name = handleHandshakeMessage)] + pub fn handle_handshake_message(&mut self, message: &[u8]) -> Result>, JsError> { + self.handle_handshake_inner(message).map_err(core_to_js) + } + + /// Feeds a received control message (handshake *or* in-session rekey), + /// returning an optional reply to send. Alias of + /// [`Self::handle_handshake_message`] with a name that matches its full + /// role: after the handshake, feed the peer's rekey messages here so this + /// side rotates to the new traffic keys. + #[wasm_bindgen(js_name = handleControlMessage)] + pub fn handle_control_message(&mut self, message: &[u8]) -> Result>, JsError> { + self.handle_handshake_inner(message).map_err(core_to_js) + } + + /// Whether it is this side's turn to initiate the next rekey (the DH + /// ratchet alternates between peers; the initiator holds the first turn). + #[wasm_bindgen(js_name = canRekey)] + pub fn can_rekey(&self) -> bool { + self.session.can_rekey() + } + + /// Performs one DH-ratchet rekey step and returns the control message to + /// send to the peer **over the reliable channel**. This side's keys rotate + /// immediately; the peer rotates when it feeds the message to + /// [`Self::handle_control_message`]. + /// + /// Throws when it is the peer's turn to rekey (`canRekey() === false`). + #[wasm_bindgen(js_name = forceRekey)] + pub fn force_rekey(&mut self) -> Result, JsError> { + self.force_rekey_inner().map_err(core_to_js) + } + + /// The identifier of the traffic key currently used for sealing, or + /// `undefined` before the handshake completes. + #[wasm_bindgen(getter, js_name = activeKeyId)] + pub fn active_key_id(&self) -> Option { + self.session.active_keys().map(|k| k.key_id) + } + + /// Whether the handshake has completed and traffic keys are available. + #[wasm_bindgen(js_name = isEstablished)] + pub fn is_established(&self) -> bool { + self.session.state() == SessionState::Active + } + + /// Whether the peer proved a pinned identity during the handshake. + #[wasm_bindgen(js_name = peerAuthenticated)] + pub fn peer_authenticated(&self) -> bool { + self.session.peer_authenticated() + } + + /// Seals `plaintext` into one frame to send as a single transport message. + #[wasm_bindgen(js_name = sealMessage)] + pub fn seal_message( + &mut self, + stream_id: u32, + flags: u8, + plaintext: &[u8], + ) -> Result, JsError> { + self.seal_message_inner(stream_id, flags, plaintext) + .map_err(core_to_js) + } + + /// Opens one received transport message into its decrypted payload. + #[wasm_bindgen(js_name = openMessage)] + pub fn open_message(&mut self, message: &[u8]) -> Result { + self.open_message_inner(message) + .map(WasmDecodedMessage::from) + .map_err(core_to_js) + } + + /// Seals `plaintext` into one datagram (datagram-mode sessions only). + /// + /// Fails if the sealed datagram would exceed the configured maximum size, or + /// if this is a message-mode session. + #[wasm_bindgen(js_name = sealDatagram)] + pub fn seal_datagram( + &mut self, + stream_id: u32, + flags: u8, + plaintext: &[u8], + ) -> Result, JsError> { + self.seal_datagram_inner(stream_id, flags, plaintext) + .map_err(core_to_js) + } + + /// Opens one received datagram into its decrypted payload (datagram-mode + /// sessions only). + #[wasm_bindgen(js_name = openDatagram)] + pub fn open_datagram(&mut self, datagram: &[u8]) -> Result { + self.open_datagram_inner(datagram) + .map(WasmDecodedMessage::from) + .map_err(core_to_js) + } +} + +fn datagram_kind(max_datagram_size: usize) -> TransportKind { + TransportKind::Datagram { max_datagram_size } +} + +// Inner, native-testable logic (no `JsError`), shared by the wasm wrappers above. +impl FoctetSession { + fn initiator(auth: SessionAuthConfig) -> Self { + Self::initiator_with_kind(auth, TransportKind::Message) + } + + fn responder(auth: SessionAuthConfig) -> Self { + Self::responder_with_kind(auth, TransportKind::Message) + } + + fn initiator_with_kind(auth: SessionAuthConfig, kind: TransportKind) -> Self { + let (session, hello) = Session::new_initiator_with_auth(RekeyThresholds::default(), auth); + FoctetSession { + session, + kind, + endpoint: None, + pending_handshake: Some(hello.encode()), + } + } + + fn responder_with_kind(auth: SessionAuthConfig, kind: TransportKind) -> Self { + FoctetSession { + session: Session::new_responder_with_auth(RekeyThresholds::default(), auth), + kind, + endpoint: None, + pending_handshake: None, + } + } + + fn handle_handshake_inner(&mut self, message: &[u8]) -> Result>, CoreError> { + let control = ControlMessage::decode(message)?; + let reply = self.session.handle_control(&control)?; + self.ensure_endpoint(); + // A rekey control message rotates the session's active key; adopt it + // on the framing endpoint so subsequent seals use the new key while + // retained previous keys still open in-flight frames. + self.sync_endpoint_keys(); + Ok(reply.map(|msg| msg.encode())) + } + + fn force_rekey_inner(&mut self) -> Result, CoreError> { + let msg = self.session.force_rekey()?; + self.sync_endpoint_keys(); + Ok(msg.encode()) + } + + /// Installs the session's current active key on the framing endpoint + /// (no-op before the endpoint exists; the endpoint keeps previous key + /// generations for frames still in flight across the rotation). + fn sync_endpoint_keys(&mut self) { + if let (Some(endpoint), Some(keys)) = (self.endpoint.as_mut(), self.session.active_keys()) { + match endpoint { + SessionEndpoint::Message(e) => { + if e.active_key_id() != keys.key_id { + e.install_active_keys(keys); + } + } + SessionEndpoint::Datagram(e) => { + if e.active_key_id() != keys.key_id { + e.install_active_keys(keys); + } + } + } + } + } + + /// Builds the framing endpoint (matching the session's mode) once the + /// handshake reaches `Active`. + fn ensure_endpoint(&mut self) { + if self.endpoint.is_none() + && self.session.state() == SessionState::Active + && let Some(keys) = self.session.active_keys() + { + let inbound = self.session.inbound_direction(); + let outbound = self.session.outbound_direction(); + self.endpoint = Some(match self.kind { + TransportKind::Message => { + SessionEndpoint::Message(MessageEndpoint::new(keys, inbound, outbound)) + } + TransportKind::Datagram { max_datagram_size } => { + let mut config = DatagramConfig::default(); + if max_datagram_size > 0 { + config.max_datagram_size = max_datagram_size; + } + SessionEndpoint::Datagram(DatagramEndpoint::with_config( + keys, inbound, outbound, config, + )) + } + }); + } + } + + fn seal_message_inner( + &mut self, + stream_id: u32, + flags: u8, + plaintext: &[u8], + ) -> Result, CoreError> { + self.ensure_endpoint(); + match self.endpoint.as_mut() { + Some(SessionEndpoint::Message(endpoint)) => endpoint.seal(stream_id, flags, plaintext), + _ => Err(CoreError::InvalidSessionState), + } + } + + fn open_message_inner(&mut self, message: &[u8]) -> Result { + self.ensure_endpoint(); + match self.endpoint.as_mut() { + Some(SessionEndpoint::Message(endpoint)) => endpoint.open(message), + _ => Err(CoreError::InvalidSessionState), + } + } + + fn seal_datagram_inner( + &mut self, + stream_id: u32, + flags: u8, + plaintext: &[u8], + ) -> Result, CoreError> { + self.ensure_endpoint(); + match self.endpoint.as_mut() { + Some(SessionEndpoint::Datagram(endpoint)) => endpoint.seal(stream_id, flags, plaintext), + _ => Err(CoreError::InvalidSessionState), + } + } + + fn open_datagram_inner(&mut self, datagram: &[u8]) -> Result { + self.ensure_endpoint(); + match self.endpoint.as_mut() { + Some(SessionEndpoint::Datagram(endpoint)) => endpoint.open(datagram), + _ => Err(CoreError::InvalidSessionState), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn drive_handshake(initiator: &mut FoctetSession, responder: &mut FoctetSession) { + let client_hello = initiator + .initial_handshake_message() + .expect("initiator emits a client hello"); + let server_hello = responder + .handle_handshake_inner(&client_hello) + .expect("responder handles client hello") + .expect("responder replies with a server hello"); + let none = initiator + .handle_handshake_inner(&server_hello) + .expect("initiator finalizes"); + assert!( + none.is_none(), + "initiator must not reply to the server hello" + ); + } + + #[test] + fn unauthenticated_handshake_then_message_roundtrip_and_replay() { + let mut initiator = + FoctetSession::initiator(SessionAuthConfig::unauthenticated_for_testing()); + let mut responder = + FoctetSession::responder(SessionAuthConfig::unauthenticated_for_testing()); + + drive_handshake(&mut initiator, &mut responder); + assert!(initiator.is_established() && responder.is_established()); + + let frame = initiator + .seal_message_inner(7, 0, b"hello over a wasm session") + .expect("seal"); + let opened = responder.open_message_inner(&frame).expect("open"); + assert_eq!(opened.plaintext, b"hello over a wasm session"); + assert_eq!(opened.header.stream_id, 7); + + // A duplicate frame must be rejected as a replay. + assert!(responder.open_message_inner(&frame).is_err()); + + // Reverse direction works too. + let back = responder + .seal_message_inner(7, 0, b"reply") + .expect("seal back"); + assert_eq!( + initiator + .open_message_inner(&back) + .expect("open back") + .plaintext, + b"reply" + ); + } + + #[test] + fn authenticated_handshake_pins_peer_identity() { + let initiator_id = IdentityKeyPair::generate(); + let responder_id = IdentityKeyPair::generate(); + + // Build configs through the WASM-facing builder to exercise `build()`. + let initiator_auth = WasmAuthConfig::authenticated( + &WasmIdentityKeyPair { + inner: IdentityKeyPair::from_secret_key_bytes( + *initiator_id.expose_secret_key_bytes(), + ), + }, + &responder_id.public_key(), + ) + .expect("initiator auth"); + let responder_auth = WasmAuthConfig::authenticated( + &WasmIdentityKeyPair { + inner: IdentityKeyPair::from_secret_key_bytes( + *responder_id.expose_secret_key_bytes(), + ), + }, + &initiator_id.public_key(), + ) + .expect("responder auth"); + + let mut initiator = FoctetSession::initiator(initiator_auth.build()); + let mut responder = FoctetSession::responder(responder_auth.build()); + + drive_handshake(&mut initiator, &mut responder); + assert!(initiator.is_established() && responder.is_established()); + assert!( + initiator.peer_authenticated() && responder.peer_authenticated(), + "both peers must be authenticated when identities are pinned" + ); + + let frame = initiator + .seal_message_inner(0, 0, b"authenticated payload") + .expect("seal"); + assert_eq!( + responder + .open_message_inner(&frame) + .expect("open") + .plaintext, + b"authenticated payload" + ); + } + + #[test] + fn channel_bound_auth_config_completes_handshake() { + // No Foctet identity: MITM resistance comes from a shared channel binding. + let binding = b"tls-exporter:wasm-channel".to_vec(); + let initiator_auth = WasmAuthConfig::bound_to_channel(&binding); + let responder_auth = WasmAuthConfig::bound_to_channel(&binding); + + let mut initiator = FoctetSession::initiator(initiator_auth.build()); + let mut responder = FoctetSession::responder(responder_auth.build()); + + drive_handshake(&mut initiator, &mut responder); + assert!(initiator.is_established() && responder.is_established()); + + let frame = initiator.seal_message_inner(0, 0, b"hi").expect("seal"); + assert_eq!( + responder + .open_message_inner(&frame) + .expect("open") + .plaintext, + b"hi" + ); + } + + #[test] + fn mismatched_channel_binding_fails_wasm_handshake() { + let initiator_auth = WasmAuthConfig::bound_to_channel(b"channel-A"); + let responder_auth = WasmAuthConfig::bound_to_channel(b"channel-B"); + let mut initiator = FoctetSession::initiator(initiator_auth.build()); + let mut responder = FoctetSession::responder(responder_auth.build()); + + let client_hello = initiator.initial_handshake_message().expect("client hello"); + assert!(responder.handle_handshake_inner(&client_hello).is_err()); + } + + #[test] + fn with_channel_binding_strengthens_authenticated_config() { + // Identity auth plus a channel binding: both must match. + let client_id = IdentityKeyPair::generate(); + let server_id = IdentityKeyPair::generate(); + let binding = b"bound".to_vec(); + + let client_auth = WasmAuthConfig::authenticated( + &WasmIdentityKeyPair { + inner: IdentityKeyPair::from_secret_key_bytes(*client_id.expose_secret_key_bytes()), + }, + &server_id.public_key(), + ) + .expect("client auth") + .with_channel_binding(&binding); + let server_auth = WasmAuthConfig::authenticated( + &WasmIdentityKeyPair { + inner: IdentityKeyPair::from_secret_key_bytes(*server_id.expose_secret_key_bytes()), + }, + &client_id.public_key(), + ) + .expect("server auth") + .with_channel_binding(&binding); + + let mut initiator = FoctetSession::initiator(client_auth.build()); + let mut responder = FoctetSession::responder(server_auth.build()); + drive_handshake(&mut initiator, &mut responder); + assert!(initiator.peer_authenticated() && responder.peer_authenticated()); + } + + #[test] + fn sealing_before_handshake_fails_closed() { + let mut initiator = + FoctetSession::initiator(SessionAuthConfig::unauthenticated_for_testing()); + assert!( + !initiator.is_established(), + "session must not be active before the handshake completes" + ); + assert!( + initiator.seal_message_inner(0, 0, b"too early").is_err(), + "sealing before the session is active must fail" + ); + } + + #[test] + fn authenticated_handshake_rejects_unexpected_peer() { + let initiator_id = IdentityKeyPair::generate(); + let responder_id = IdentityKeyPair::generate(); + let attacker_id = IdentityKeyPair::generate(); + + // The initiator pins the attacker's key, but the real responder uses its + // own identity: the handshake must fail rather than silently accept it. + let initiator_auth = SessionAuthConfig::new() + .with_local_identity(IdentityKeyPair::from_secret_key_bytes( + *initiator_id.expose_secret_key_bytes(), + )) + .with_peer_identity(PeerIdentity::new(attacker_id.public_key())) + .require_peer_authentication(true); + let responder_auth = SessionAuthConfig::new() + .with_local_identity(IdentityKeyPair::from_secret_key_bytes( + *responder_id.expose_secret_key_bytes(), + )) + .with_peer_identity(PeerIdentity::new(initiator_id.public_key())) + .require_peer_authentication(true); + + let mut initiator = FoctetSession::initiator(initiator_auth); + let mut responder = FoctetSession::responder(responder_auth); + + let client_hello = initiator.initial_handshake_message().expect("client hello"); + let server_hello = responder + .handle_handshake_inner(&client_hello) + .expect("responder handles client hello") + .expect("server hello"); + // The initiator must reject the responder whose identity it did not pin. + assert!(initiator.handle_handshake_inner(&server_hello).is_err()); + } + + #[test] + fn in_session_rekey_rotates_keys_and_traffic_continues() { + let mut initiator = + FoctetSession::initiator(SessionAuthConfig::unauthenticated_for_testing()); + let mut responder = + FoctetSession::responder(SessionAuthConfig::unauthenticated_for_testing()); + drive_handshake(&mut initiator, &mut responder); + + let key_before = initiator.session.active_keys().expect("key").key_id; + + // The initiator holds the first ratchet turn; the responder does not. + assert!(initiator.session.can_rekey()); + assert!(!responder.session.can_rekey()); + assert!(responder.force_rekey_inner().is_err()); + + // A frame sealed under the old key, delivered after the rekey below, + // must still open (previous key generations are retained). + let old_key_frame = initiator + .seal_message_inner(1, 0, b"sealed before rekey") + .expect("seal under old key"); + + let rekey = initiator.force_rekey_inner().expect("initiator rekeys"); + assert!( + !initiator.session.can_rekey(), + "after rekeying, the turn passes to the peer" + ); + responder + .handle_handshake_inner(&rekey) + .expect("responder applies rekey"); + assert!(responder.session.can_rekey(), "turn handed to responder"); + + let key_after = initiator.session.active_keys().expect("key").key_id; + assert_eq!(key_after, key_before + 1, "active key must rotate"); + + // Traffic continues under the new key in both directions. + let frame = initiator + .seal_message_inner(1, 0, b"after rekey") + .expect("seal under new key"); + let opened = responder.open_message_inner(&frame).expect("open"); + assert_eq!(opened.plaintext, b"after rekey"); + assert_eq!(opened.header.key_id, key_after); + + let back = responder.seal_message_inner(1, 0, b"reply").expect("seal"); + assert_eq!( + initiator.open_message_inner(&back).expect("open").plaintext, + b"reply" + ); + + // The pre-rekey frame still opens under the retained previous key. + assert_eq!( + responder + .open_message_inner(&old_key_frame) + .expect("old-key frame still opens") + .plaintext, + b"sealed before rekey" + ); + + // And the responder can now take its turn. + let rekey_back = responder.force_rekey_inner().expect("responder rekeys"); + initiator + .handle_handshake_inner(&rekey_back) + .expect("initiator applies the responder's rekey"); + assert_eq!( + initiator.session.active_keys().expect("key").key_id, + key_after + 1 + ); + let frame = initiator + .seal_message_inner(1, 0, b"third key") + .expect("seal"); + assert_eq!( + responder + .open_message_inner(&frame) + .expect("open") + .plaintext, + b"third key" + ); + } + + #[test] + fn in_session_rekey_works_in_datagram_mode() { + let mut initiator = FoctetSession::initiator_with_kind( + SessionAuthConfig::unauthenticated_for_testing(), + TransportKind::Datagram { + max_datagram_size: 0, + }, + ); + let mut responder = FoctetSession::responder_with_kind( + SessionAuthConfig::unauthenticated_for_testing(), + TransportKind::Datagram { + max_datagram_size: 0, + }, + ); + drive_handshake(&mut initiator, &mut responder); + + // Seal a datagram under the old key, deliver it *after* the rekey — + // the loss/reorder-tolerant shape must still open it. + let old_key_datagram = initiator + .seal_datagram_inner(2, 0, b"reordered across rekey") + .expect("seal under old key"); + + // The rekey control message itself travels over the reliable channel. + let rekey = initiator.force_rekey_inner().expect("initiator rekeys"); + responder + .handle_handshake_inner(&rekey) + .expect("responder applies rekey"); + + let fresh = initiator + .seal_datagram_inner(2, 0, b"after rekey") + .expect("seal under new key"); + assert_eq!( + responder + .open_datagram_inner(&fresh) + .expect("open new-key datagram") + .plaintext, + b"after rekey" + ); + assert_eq!( + responder + .open_datagram_inner(&old_key_datagram) + .expect("open reordered old-key datagram") + .plaintext, + b"reordered across rekey" + ); + } + + #[test] + fn datagram_mode_handshake_then_datagram_roundtrip_and_replay() { + let mut initiator = FoctetSession::initiator_with_kind( + SessionAuthConfig::unauthenticated_for_testing(), + TransportKind::Datagram { + max_datagram_size: 0, + }, + ); + let mut responder = FoctetSession::responder_with_kind( + SessionAuthConfig::unauthenticated_for_testing(), + TransportKind::Datagram { + max_datagram_size: 0, + }, + ); + + drive_handshake(&mut initiator, &mut responder); + assert!(initiator.is_established() && responder.is_established()); + + let datagram = initiator + .seal_datagram_inner(3, 0, b"hello over a wasm datagram") + .expect("seal datagram"); + let opened = responder + .open_datagram_inner(&datagram) + .expect("open datagram"); + assert_eq!(opened.plaintext, b"hello over a wasm datagram"); + assert_eq!(opened.header.stream_id, 3); + + // A duplicate datagram must be rejected as a replay. + assert!(responder.open_datagram_inner(&datagram).is_err()); + + // Message-framing methods must fail on a datagram-mode session. + assert!(initiator.seal_message_inner(3, 0, b"wrong shape").is_err()); + } +} diff --git a/foctet-wasm/tests/browser.rs b/foctet-wasm/tests/browser.rs new file mode 100644 index 0000000..b430ebc --- /dev/null +++ b/foctet-wasm/tests/browser.rs @@ -0,0 +1,246 @@ +//! In-browser integration tests for the WASM SDK. +//! +//! These run inside a real browser engine (not Node, not native), so they +//! catch wasm-runtime-only failures that native unit tests cannot — e.g. the +//! `std::time::Instant::now()` abort that once crashed the `FoctetSession` +//! handshake at runtime while every native test stayed green. +//! +//! Run headlessly from the workspace root: +//! +//! ```bash +//! wasm-pack test --headless --chrome foctet-wasm +//! ``` + +#![cfg(target_arch = "wasm32")] + +use foctet_wasm::{ + FoctetSession, KeyPair, WasmAuthConfig, WasmIdentityKeyPair, open_body_js, + open_body_with_context_js, seal_body_js, seal_body_with_context_js, +}; +use wasm_bindgen_test::*; + +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +fn body_envelope_roundtrip() { + let kp = KeyPair::generate(); + let plaintext = b"browser body envelope roundtrip"; + + let Ok(envelope) = seal_body_js(plaintext, &kp.public_key(), b"browser-key") else { + panic!("seal_body failed"); + }; + let Ok(opened) = open_body_js(&envelope, &kp.secret_key()) else { + panic!("open_body failed"); + }; + assert_eq!(opened, plaintext); +} + +#[wasm_bindgen_test] +fn context_binding_is_enforced() { + let kp = KeyPair::generate(); + let plaintext = b"browser context binding"; + + let Ok(envelope) = + seal_body_with_context_js(plaintext, &kp.public_key(), b"browser-key", b"ctx-a") + else { + panic!("seal_body_with_context failed"); + }; + + // Wrong context must fail authentication. + assert!(open_body_with_context_js(&envelope, &kp.secret_key(), b"ctx-b").is_err()); + + // The sealing context opens. + let Ok(opened) = open_body_with_context_js(&envelope, &kp.secret_key(), b"ctx-a") else { + panic!("open with matching context failed"); + }; + assert_eq!(opened, plaintext); +} + +/// Drives the full authenticated handshake between two in-page sessions. +fn establish( + mut initiator: FoctetSession, + mut responder: FoctetSession, +) -> (FoctetSession, FoctetSession) { + let hello = initiator + .initial_handshake_message() + .expect("initiator hello"); + let Ok(Some(server_hello)) = responder.handle_handshake_message(&hello) else { + panic!("responder handshake failed"); + }; + let Ok(none) = initiator.handle_handshake_message(&server_hello) else { + panic!("initiator handshake failed"); + }; + assert!(none.is_none()); + assert!(initiator.is_established()); + assert!(responder.is_established()); + (initiator, responder) +} + +#[wasm_bindgen_test] +fn authenticated_session_handshake_and_messages() { + let client_id = WasmIdentityKeyPair::generate(); + let server_id = WasmIdentityKeyPair::generate(); + + let Ok(client_auth) = WasmAuthConfig::authenticated(&client_id, &server_id.public_key()) else { + panic!("client auth config"); + }; + let Ok(server_auth) = WasmAuthConfig::authenticated(&server_id, &client_id.public_key()) else { + panic!("server auth config"); + }; + + let (mut client, mut server) = establish( + FoctetSession::new_initiator(&client_auth), + FoctetSession::new_responder(&server_auth), + ); + assert!(client.peer_authenticated()); + assert!(server.peer_authenticated()); + + // Messages both ways. + let Ok(sealed) = client.seal_message(0, 0, b"hello from the browser client") else { + panic!("client seal failed"); + }; + let Ok(decoded) = server.open_message(&sealed) else { + panic!("server open failed"); + }; + assert_eq!(decoded.plaintext(), b"hello from the browser client"); + + let Ok(reply) = server.seal_message(0, 0, b"hello from the browser server") else { + panic!("server seal failed"); + }; + let Ok(decoded) = client.open_message(&reply) else { + panic!("client open failed"); + }; + assert_eq!(decoded.plaintext(), b"hello from the browser server"); + + // A replayed message must be rejected. + assert!(server.open_message(&sealed).is_err()); +} + +#[wasm_bindgen_test] +fn in_session_rekey_in_the_browser() { + let auth = WasmAuthConfig::unauthenticated_for_testing(); + let (mut client, mut server) = establish( + FoctetSession::new_initiator(&auth), + FoctetSession::new_responder(&auth), + ); + + let key_before = client.active_key_id().expect("active key id"); + assert!(client.can_rekey()); + assert!(!server.can_rekey()); + + let Ok(rekey) = client.force_rekey() else { + panic!("client forceRekey failed"); + }; + let Ok(none) = server.handle_control_message(&rekey) else { + panic!("server rekey handling failed"); + }; + assert!(none.is_none()); + assert_eq!(client.active_key_id(), Some(key_before + 1)); + assert_eq!(server.active_key_id(), Some(key_before + 1)); + + // Traffic continues under the rotated key. + let Ok(sealed) = client.seal_message(0, 0, b"post-rekey browser message") else { + panic!("seal after rekey failed"); + }; + let Ok(decoded) = server.open_message(&sealed) else { + panic!("open after rekey failed"); + }; + assert_eq!(decoded.plaintext(), b"post-rekey browser message"); + assert_eq!(decoded.key_id(), key_before + 1); + + // The turn alternates: now the server may rekey. + assert!(server.can_rekey()); + assert!(!client.can_rekey()); +} + +/// The browser WebTransport datagram adapter, driven end to end inside the +/// browser against in-page WHATWG streams (`TransformStream`) that stand in +/// for a real `WebTransport.datagrams` duplex — the adapter is duck-typed +/// over exactly that `{ readable, writable, maxDatagramSize }` shape. +#[wasm_bindgen_test] +async fn secure_datagram_channel_over_browser_webtransport_shape() { + use foctet_core::{RekeyThresholds, Session, SessionAuthConfig}; + use foctet_transport::{ + BrowserWebTransportDatagrams, DatagramTransport, SecureDatagramChannel, + }; + + // Two cross-wired TransformStreams emulate the bidirectional datagram + // duplex a WebTransport session exposes. + // A readable high-water mark keeps writes from exerting backpressure while + // nobody reads yet — matching real WebTransport datagram semantics, where + // `writable` accepts datagrams immediately (dropping under pressure) + // instead of blocking the writer. + let pair = js_sys::eval( + r#"(() => { + const ab = new TransformStream({}, { highWaterMark: 16 }, { highWaterMark: 16 }); + const ba = new TransformStream({}, { highWaterMark: 16 }, { highWaterMark: 16 }); + return { + a: { readable: ba.readable, writable: ab.writable, maxDatagramSize: 1200 }, + b: { readable: ab.readable, writable: ba.writable, maxDatagramSize: 1200 }, + }; + })()"#, + ) + .expect("build mock datagram duplex pair"); + let side = |key: &str| js_sys::Reflect::get(&pair, &key.into()).expect("side"); + + // Native Foctet handshake (as it would run over a reliable channel). + let (mut initiator, hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut responder = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let server_hello = responder + .handle_control(&hello) + .expect("responder handles hello") + .expect("server hello"); + initiator + .handle_control(&server_hello) + .expect("initiator finalizes"); + + let transport_a = BrowserWebTransportDatagrams::new(side("a")).expect("adapter a"); + let transport_b = BrowserWebTransportDatagrams::new(side("b")).expect("adapter b"); + assert_eq!(transport_a.max_datagram_size(), Some(1200)); + + let mut a = + SecureDatagramChannel::from_active_session(transport_a, &initiator).expect("channel a"); + let mut b = + SecureDatagramChannel::from_active_session(transport_b, &responder).expect("channel b"); + + a.send_datagram(0, 0, b"browser webtransport datagram") + .await + .expect("a sends"); + let decoded = b.recv_datagram().await.expect("b receives"); + assert_eq!(decoded.plaintext, b"browser webtransport datagram"); + + b.send_datagram(0, 0, b"reply").await.expect("b sends"); + let decoded = a.recv_datagram().await.expect("a receives"); + assert_eq!(decoded.plaintext, b"reply"); + + // A datagram above the clamped maximum fails closed instead of + // fragmenting (see the MTU policy in SPEC.md). + let oversized = vec![0u8; 1300]; + assert!(a.send_datagram(0, 0, &oversized).await.is_err()); +} + +#[wasm_bindgen_test] +fn datagram_session_roundtrip() { + let auth = WasmAuthConfig::unauthenticated_for_testing(); + let (mut client, mut server) = establish( + FoctetSession::new_datagram_initiator(&auth, 1200), + FoctetSession::new_datagram_responder(&auth, 1200), + ); + + let Ok(datagram) = client.seal_datagram(0, 0, b"browser datagram payload") else { + panic!("seal_datagram failed"); + }; + let Ok(decoded) = server.open_datagram(&datagram) else { + panic!("open_datagram failed"); + }; + assert_eq!(decoded.plaintext(), b"browser datagram payload"); + + // Wrong-mode call fails: a datagram session cannot seal messages. + assert!(client.seal_message(0, 0, b"nope").is_err()); +} diff --git a/foctet-wasm/tests/interop_vector.json b/foctet-wasm/tests/interop_vector.json new file mode 100644 index 0000000..3bdaea8 --- /dev/null +++ b/foctet-wasm/tests/interop_vector.json @@ -0,0 +1,9 @@ +{ + "secret": "2e16ee4f318d1765f4ad15efa03115b1b4b9a0e9845aa792d68442b8dbb467c1", + "public": "9cc84fd37ff001910829b7818f68e7142eae20d70efbbdc9f0258459c3f8e865", + "key_id": "interop-kid", + "plaintext": "hello from rust", + "envelope": "464f435445544842010100208501011f1681456c75f9c590120ed57e7702414cb2b792c9902a42d28cc130a0e310401f0bbada164b2f60398ac667c0e819d84367f00caea52175b80b30696e7465726f702d6b6964c525afb0b3e8278859839957ec4f024bcbe838737956930f9580ddafe0dde4b748bedaf6fb4dab8caac366631761ccfada54cf7c76f2814b397c22f777342f806662bce120bbd0cef222f7750b6562", + "context": "foctet-http-ctx-v1|POST|/pay", + "context_envelope": "464f435445544842010100208501011f1be8bcd8807b3c7ff522a383becd52085773fdbfa9b386ff2e00c0f20213b17fb3565d5a86f738f3a6a476cf9258eb6afde413ccb5813ac10b30696e7465726f702d6b69646964f038693dcf5fd228f97d1d9a2deafb55f8fb8366b86cbaa174219b71f0e3978ed01b96daaac1c3b2a2ea9813427f18055e87367d29de9d3692b72f3546630f7a688f9654f39463757376ac0d95" +} diff --git a/foctet-wasm/tests/node_interop.cjs b/foctet-wasm/tests/node_interop.cjs new file mode 100644 index 0000000..7c7c6ea --- /dev/null +++ b/foctet-wasm/tests/node_interop.cjs @@ -0,0 +1,145 @@ +// Node.js interop test for the Foctet WASM SDK. +// +// Build the package first: +// wasm-pack build --target nodejs --out-dir pkg-node +// Then run: +// node foctet-wasm/tests/node_interop.cjs +// +// Verifies: +// 1. JS seal -> JS open roundtrip (the WASM API works end to end in Node). +// 2. JS opens a Rust-produced envelope (Rust -> JS wire compatibility). +// 3. Context binding: matching context opens; wrong/absent context fails. + +const assert = require("node:assert"); +const fs = require("node:fs"); +const path = require("node:path"); + +const wasm = require("../pkg-node/foctet_wasm.js"); + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +function fromHex(hex) { + return Uint8Array.from(Buffer.from(hex, "hex")); +} + +let passed = 0; +function check(name, fn) { + fn(); + passed += 1; + console.log(` ok - ${name}`); +} + +console.log(`foctet-wasm version ${wasm.version()}`); + +check("js seal -> js open roundtrip", () => { + const kp = new wasm.KeyPair(); + const plaintext = enc.encode("hello from node"); + const envelope = wasm.sealBody(plaintext, kp.publicKey, enc.encode("node-kid")); + const opened = wasm.openBody(envelope, kp.secretKey); + assert.strictEqual(dec.decode(opened), "hello from node"); +}); + +check("wrong recipient cannot open", () => { + const kp = new wasm.KeyPair(); + const other = new wasm.KeyPair(); + const envelope = wasm.sealBody(enc.encode("secret"), kp.publicKey, enc.encode("kid")); + assert.throws(() => wasm.openBody(envelope, other.secretKey)); +}); + +check("context binding roundtrip and mismatch", () => { + const kp = new wasm.KeyPair(); + const ctx = enc.encode("foctet-http-ctx-v1|POST|/pay"); + const plaintext = enc.encode("charge"); + const envelope = wasm.sealBodyWithContext(plaintext, kp.publicKey, enc.encode("kid"), ctx); + + const opened = wasm.openBodyWithContext(envelope, kp.secretKey, ctx); + assert.strictEqual(dec.decode(opened), "charge"); + + assert.throws(() => wasm.openBodyWithContext(envelope, kp.secretKey, enc.encode("other"))); + assert.throws(() => wasm.openBody(envelope, kp.secretKey)); +}); + +check("HTTP request protected context roundtrip and mismatch", () => { + const kp = new wasm.KeyPair(); + const now = 1_700_000_000n; + const carrier = wasm.HttpContextCarrier.generate(now, 300n); + carrier.setIdempotencyKey("idem-node"); + + const ctx = new wasm.HttpRequestContext( + "POST", + "https://api.example.test/pay?currency=USD", + carrier, + ); + ctx.setHeader("x-tenant-id", "tenant-a"); + ctx.bindHeader("x-tenant-id"); + + const plaintext = enc.encode("charge over protected HTTP context"); + const envelope = ctx.sealBody(plaintext, kp.publicKey, enc.encode("http-kid")); + const opened = ctx.openBody(envelope, kp.secretKey, now, 30n); + assert.strictEqual(dec.decode(opened), "charge over protected HTTP context"); + assert.strictEqual(carrier.messageIdHeaderValue.length, 32); + assert.strictEqual(carrier.timestampHeaderValue, now.toString()); + assert.strictEqual(carrier.expiryHeaderValue, (now + 300n).toString()); + + const parsed = wasm.HttpContextCarrier.fromHeaderValues( + carrier.messageIdHeaderValue, + carrier.timestampHeaderValue, + carrier.expiryHeaderValue, + carrier.idempotencyKey, + undefined, + ); + assert.deepStrictEqual(Array.from(parsed.messageId), Array.from(carrier.messageId)); + assert.strictEqual(parsed.timestampSecs, carrier.timestampSecs); + assert.strictEqual(parsed.expirySecs, carrier.expirySecs); + + const wrongRoute = new wasm.HttpRequestContext( + "POST", + "https://api.example.test/refund?currency=USD", + carrier, + ); + wrongRoute.setHeader("x-tenant-id", "tenant-a"); + wrongRoute.bindHeader("x-tenant-id"); + assert.throws(() => wrongRoute.openBody(envelope, kp.secretKey, now, 30n)); +}); + +check("HTTP response protected context answers a request id", () => { + const kp = new wasm.KeyPair(); + const now = 1_700_000_500n; + const requestCarrier = wasm.HttpContextCarrier.generate(now, 300n); + const responseCarrier = wasm.HttpContextCarrier.generate(now, 300n); + responseCarrier.setRequestMessageId(requestCarrier.messageId); + + const ctx = new wasm.HttpResponseContext(201, responseCarrier); + const envelope = ctx.sealBody(enc.encode("created"), kp.publicKey, enc.encode("http-kid")); + const opened = ctx.openBody(envelope, kp.secretKey, now, 30n); + assert.strictEqual(dec.decode(opened), "created"); + assert.strictEqual(responseCarrier.requestMessageIdHeaderValue, requestCarrier.messageIdHeaderValue); + + const wrongStatus = new wasm.HttpResponseContext(200, responseCarrier); + assert.throws(() => wrongStatus.openBody(envelope, kp.secretKey, now, 30n)); +}); + +check("fromSecretKey recovers the public key", () => { + const kp = new wasm.KeyPair(); + const rebuilt = wasm.KeyPair.fromSecretKey(kp.secretKey); + assert.deepStrictEqual(Array.from(rebuilt.publicKey), Array.from(kp.publicKey)); +}); + +check("opens Rust-produced envelopes (Rust -> JS wire compat)", () => { + const fixturePath = path.join(__dirname, "interop_vector.json"); + const v = JSON.parse(fs.readFileSync(fixturePath, "utf8")); + const secret = fromHex(v.secret); + + const opened = wasm.openBody(fromHex(v.envelope), secret); + assert.strictEqual(dec.decode(opened), v.plaintext); + + const openedCtx = wasm.openBodyWithContext( + fromHex(v.context_envelope), + secret, + enc.encode(v.context), + ); + assert.strictEqual(dec.decode(openedCtx), v.plaintext); +}); + +console.log(`\nAll ${passed} interop checks passed.`); diff --git a/foctet-wasm/webdriver.json b/foctet-wasm/webdriver.json new file mode 100644 index 0000000..de1e89f --- /dev/null +++ b/foctet-wasm/webdriver.json @@ -0,0 +1,13 @@ +{ + "goog:chromeOptions": { + "args": [ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu" + ] + }, + "moz:firefoxOptions": { + "args": ["-headless"] + } +} diff --git a/foctet/Cargo.toml b/foctet/Cargo.toml index e15eb88..8c672b1 100644 --- a/foctet/Cargo.toml +++ b/foctet/Cargo.toml @@ -2,6 +2,7 @@ name = "foctet" version.workspace = true edition.workspace = true +rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true @@ -21,7 +22,7 @@ transport-quinn = ["transport", "foctet-transport/transport-quinn"] [dependencies] foctet-core = { workspace = true } foctet-archive = { workspace = true } -foctet-transport = { path = "../foctet-transport", version = "0.2.0", optional = true, default-features = false } +foctet-transport = { path = "../foctet-transport", version = "0.3.0", optional = true, default-features = false } ed25519-dalek.workspace = true sha2.workspace = true x25519-dalek.workspace = true diff --git a/foctet/examples/e2ee_tcp_relay_sync.rs b/foctet/examples/e2ee_tcp_relay_sync.rs index 249d959..5374256 100644 --- a/foctet/examples/e2ee_tcp_relay_sync.rs +++ b/foctet/examples/e2ee_tcp_relay_sync.rs @@ -6,12 +6,16 @@ use std::{ thread, }; -use foctet::core::{Direction, derive_traffic_keys, io::SyncIo}; +use foctet::core::{Direction, KeyHandle, derive_traffic_keys, io::SyncIo}; -fn derive_demo_keys() -> Result { +fn derive_demo_keys() -> Result { let shared_secret = [0x11u8; 32]; let session_salt = [0x22u8; 32]; - derive_traffic_keys(&shared_secret, &session_salt, 1) + Ok(KeyHandle::new(derive_traffic_keys( + &shared_secret, + &session_salt, + 1, + )?)) } fn hex_prefix(bytes: &[u8], n: usize) -> String { @@ -80,7 +84,7 @@ fn run_relay( fn run_server( server_listener: TcpListener, - keys: foctet::core::TrafficKeys, + keys: KeyHandle, ) -> Result<(), Box> { let (stream, peer) = server_listener.accept()?; stream.set_nodelay(true)?; @@ -96,10 +100,7 @@ fn run_server( Ok(()) } -fn run_client( - relay_addr: SocketAddr, - keys: foctet::core::TrafficKeys, -) -> Result<(), Box> { +fn run_client(relay_addr: SocketAddr, keys: KeyHandle) -> Result<(), Box> { let stream = TcpStream::connect(relay_addr)?; stream.set_nodelay(true)?; diff --git a/foctet/examples/e2ee_tcp_relay_tokio.rs b/foctet/examples/e2ee_tcp_relay_tokio.rs index 44359dc..67b9766 100644 --- a/foctet/examples/e2ee_tcp_relay_tokio.rs +++ b/foctet/examples/e2ee_tcp_relay_tokio.rs @@ -1,6 +1,6 @@ use std::{env, error::Error}; -use foctet::core::{Direction, FoctetStream, derive_traffic_keys}; +use foctet::core::{Direction, FoctetStream, KeyHandle, derive_traffic_keys}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::{ @@ -9,10 +9,14 @@ use tokio::{ }, }; -fn derive_demo_keys() -> Result { +fn derive_demo_keys() -> Result { let shared_secret = [0x11u8; 32]; let session_salt = [0x22u8; 32]; - derive_traffic_keys(&shared_secret, &session_salt, 1) + Ok(KeyHandle::new(derive_traffic_keys( + &shared_secret, + &session_salt, + 1, + )?)) } fn hex_prefix(bytes: &[u8], n: usize) -> String { @@ -86,7 +90,7 @@ async fn run_relay( async fn run_server( server_listener: TcpListener, - keys: foctet::core::TrafficKeys, + keys: KeyHandle, ) -> Result<(), Box> { let (stream, peer) = server_listener.accept().await?; stream.set_nodelay(true)?; @@ -112,7 +116,7 @@ async fn run_server( async fn run_client( relay_addr: std::net::SocketAddr, - keys: foctet::core::TrafficKeys, + keys: KeyHandle, ) -> Result<(), Box> { let stream = TcpStream::connect(relay_addr).await?; stream.set_nodelay(true)?; diff --git a/foctet/examples/e2ee_tcp_sync.rs b/foctet/examples/e2ee_tcp_sync.rs index e23f44b..8f89538 100644 --- a/foctet/examples/e2ee_tcp_sync.rs +++ b/foctet/examples/e2ee_tcp_sync.rs @@ -5,10 +5,10 @@ use std::{ thread, }; -use foctet::core::{Direction, derive_traffic_keys, io::SyncIo}; +use foctet::core::{Direction, KeyHandle, derive_traffic_keys, io::SyncIo}; use x25519_dalek::{PublicKey, StaticSecret}; -fn build_shared_keys() -> Result { +fn build_shared_keys() -> Result { let client_private = StaticSecret::from([0x31; 32]); let server_private = StaticSecret::from([0x52; 32]); let server_public = PublicKey::from(&server_private).to_bytes(); @@ -16,13 +16,14 @@ fn build_shared_keys() -> Result Result<(), Box> { +fn run_server(listener: TcpListener, keys: KeyHandle) -> Result<(), Box> { let (stream, peer) = listener.accept()?; stream.set_nodelay(true)?; @@ -37,10 +38,7 @@ fn run_server( Ok(()) } -fn run_client( - addr: std::net::SocketAddr, - keys: foctet::core::TrafficKeys, -) -> Result<(), Box> { +fn run_client(addr: std::net::SocketAddr, keys: KeyHandle) -> Result<(), Box> { let stream = TcpStream::connect(addr)?; stream.set_nodelay(true)?; diff --git a/foctet/examples/e2ee_tokio_stream.rs b/foctet/examples/e2ee_tokio_stream.rs index 9451b55..c84a9c8 100644 --- a/foctet/examples/e2ee_tokio_stream.rs +++ b/foctet/examples/e2ee_tokio_stream.rs @@ -1,13 +1,13 @@ use std::{env, error::Error}; -use foctet::core::{Direction, FoctetStream, TrafficKeys, derive_traffic_keys}; +use foctet::core::{Direction, FoctetStream, KeyHandle, derive_traffic_keys}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::{TcpListener, TcpStream}, }; use x25519_dalek::{PublicKey, StaticSecret}; -fn build_shared_keys() -> Result { +fn build_shared_keys() -> Result { let client_private = StaticSecret::from([0x31; 32]); let server_private = StaticSecret::from([0x52; 32]); let server_public = PublicKey::from(&server_private).to_bytes(); @@ -15,12 +15,16 @@ fn build_shared_keys() -> Result { .diffie_hellman(&PublicKey::from(server_public)) .to_bytes(); let session_salt = [0xA5; 32]; - derive_traffic_keys(&shared_secret, &session_salt, 1) + Ok(KeyHandle::new(derive_traffic_keys( + &shared_secret, + &session_salt, + 1, + )?)) } async fn run_server( listener: TcpListener, - keys: TrafficKeys, + keys: KeyHandle, ) -> Result<(), Box> { let (stream, peer) = listener.accept().await?; stream.set_nodelay(true)?; @@ -44,7 +48,7 @@ async fn run_server( Ok(()) } -async fn run_client(addr: std::net::SocketAddr, keys: TrafficKeys) -> Result<(), Box> { +async fn run_client(addr: std::net::SocketAddr, keys: KeyHandle) -> Result<(), Box> { let stream = TcpStream::connect(addr).await?; stream.set_nodelay(true)?; diff --git a/foctet/examples/gen_fuzz_seeds.rs b/foctet/examples/gen_fuzz_seeds.rs new file mode 100644 index 0000000..b8e3340 --- /dev/null +++ b/foctet/examples/gen_fuzz_seeds.rs @@ -0,0 +1,149 @@ +//! Generates seed inputs for the fuzz targets in `fuzz/fuzz_targets/`. +//! +//! Each seed is a *valid* input for its target (well-formed frames, envelopes, +//! archives, control messages) so the fuzzer starts from deep, structurally +//! meaningful states instead of discovering the wire formats from scratch. +//! The recipient/traffic keys match the fixed keys hard-coded in the fuzz +//! targets, so AEAD-open and key-unwrap paths succeed on the seeds themselves. +//! +//! Output goes to `fuzz/seeds//*.bin` (committed; CI copies them into +//! the working corpus before each run). Sealing uses random ephemerals, so +//! regenerated seeds differ byte-for-byte — that is fine; they only need to be +//! valid, not reproducible. +//! +//! Run from the workspace root: +//! +//! ```bash +//! cargo run -p foctet --example gen_fuzz_seeds +//! ``` + +use std::{error::Error, fs, path::PathBuf}; + +use foctet::{archive, core}; +use x25519_dalek::{PublicKey, StaticSecret}; + +fn seed_dir(target: &str) -> Result> { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../fuzz/seeds") + .join(target); + fs::create_dir_all(&dir)?; + Ok(dir) +} + +fn write_seed(target: &str, name: &str, bytes: &[u8]) -> Result<(), Box> { + let path = seed_dir(target)?.join(format!("{name}.bin")); + fs::write(&path, bytes)?; + println!("wrote {} ({} bytes)", path.display(), bytes.len()); + Ok(()) +} + +fn main() -> Result<(), Box> { + // Fixed keys matching the fuzz targets. + let body_recipient_secret = [0x42u8; 32]; // body_envelope / stream_body + let archive_recipient_secret = [0x00u8; 32]; // archive_parser + let traffic_ikm = [0x11u8; 32]; // datagram_message + let traffic_salt = [0x22u8; 32]; + + let body_recipient_public = + PublicKey::from(&StaticSecret::from(body_recipient_secret)).to_bytes(); + let archive_recipient_public = + PublicKey::from(&StaticSecret::from(archive_recipient_secret)).to_bytes(); + + // ---- frame_parser: one valid encrypted data frame ---- + let keys = core::derive_traffic_keys(&traffic_ikm, &traffic_salt, 0)?; + let data_frame = core::encrypt_frame( + &keys, + core::Direction::C2S, + 0, + 0, + 0, + b"fuzz seed data frame payload", + )?; + write_seed("frame_parser", "data_frame", &data_frame.to_bytes())?; + + // ---- control_message + handshake: real handshake control messages ---- + let (mut initiator, client_hello) = core::Session::new_initiator_with_auth( + core::RekeyThresholds::default(), + core::SessionAuthConfig::unauthenticated_for_testing(), + ); + let mut responder = core::Session::new_responder_with_auth( + core::RekeyThresholds::default(), + core::SessionAuthConfig::unauthenticated_for_testing(), + ); + let server_hello = responder + .handle_control(&client_hello)? + .ok_or("responder must produce a server hello")?; + initiator.handle_control(&server_hello)?; + let rekey = initiator.force_rekey()?; + + for (name, msg) in [ + ("client_hello", &client_hello), + ("server_hello", &server_hello), + ("rekey", &rekey), + ] { + let encoded = msg.encode(); + write_seed("control_message", name, &encoded)?; + write_seed("handshake", name, &encoded)?; + } + + // ---- body_envelope: envelope sealed to the target's fixed recipient ---- + let envelope = core::seal_body( + b"fuzz seed body envelope plaintext", + body_recipient_public, + b"fuzz-seed-key-id", + )?; + write_seed("body_envelope", "sealed_body", &envelope)?; + + // ---- stream_body: header || chunk0 || final chunk, one wire blob ---- + let limits = core::BodyEnvelopeLimits::default(); + let (mut sealer, header) = + core::StreamSealer::new(body_recipient_public, b"fuzz-seed-key-id", b"", &limits)?; + let mut stream_wire = header; + stream_wire.extend_from_slice(&sealer.seal_chunk(b"fuzz seed stream chunk zero", false)?); + stream_wire.extend_from_slice(&sealer.seal_chunk(b"fuzz seed final chunk", true)?); + write_seed("stream_body", "sealed_stream", &stream_wire)?; + + // ---- datagram_message: sealed datagram + message frames ---- + // The fuzz target opens with inbound = S2C, so seal with outbound = S2C. + let dgram_keys = + core::KeyHandle::new(core::derive_traffic_keys(&traffic_ikm, &traffic_salt, 0)?); + let mut datagram_sealer = core::DatagramEndpoint::new( + dgram_keys.clone(), + core::Direction::C2S, + core::Direction::S2C, + ); + write_seed( + "datagram_message", + "sealed_datagram", + &datagram_sealer.seal(0, 0, b"fuzz seed datagram payload")?, + )?; + let mut message_sealer = + core::MessageEndpoint::new(dgram_keys, core::Direction::C2S, core::Direction::S2C); + write_seed( + "datagram_message", + "sealed_message", + &message_sealer.seal(0, 0, b"fuzz seed message payload")?, + )?; + + // ---- archive_parser: single archive + split-archive manifest and part ---- + let (single, _meta) = archive::create_archive_from_bytes( + b"fuzz seed archive plaintext", + &[archive_recipient_public], + archive::ArchiveOptions::default(), + )?; + write_seed("archive_parser", "single_archive", &single)?; + + let split = archive::create_split_archive_from_bytes( + b"fuzz seed split archive plaintext", + &[archive_recipient_public], + archive::ArchiveOptions::default(), + 16, + )?; + write_seed("archive_parser", "split_manifest", &split.manifest)?; + if let Some(part) = split.parts.first() { + write_seed("archive_parser", "split_part", part)?; + } + + println!("fuzz seeds generated under fuzz/seeds/"); + Ok(()) +} diff --git a/foctet/examples/gen_vectors.rs b/foctet/examples/gen_vectors.rs index 609596e..41434c4 100644 --- a/foctet/examples/gen_vectors.rs +++ b/foctet/examples/gen_vectors.rs @@ -142,12 +142,12 @@ fn main() { hs_json.push_str(&json_line("server_public_hex", &hex(&server_pub), true)); hs_json.push_str(&json_line( "client_identity_private_hex", - &hex(&client_identity.secret_key_bytes()), + &hex(&client_identity.expose_secret_key_bytes()[..]), true, )); hs_json.push_str(&json_line( "server_identity_private_hex", - &hex(&server_identity.secret_key_bytes()), + &hex(&server_identity.expose_secret_key_bytes()[..]), true, )); hs_json.push_str(&json_line( @@ -236,5 +236,59 @@ fn main() { archive_json.push_str("}\n"); fs::write(out_dir.join("archive-v0.json"), archive_json).expect("write archive vector"); + // DH-ratchet rekey vector: one deterministic ratchet step. + // + // Locks in `derive_ratchet_root` and `dh_ratchet_step` (their HKDF labels and + // wiring) so the in-session rekey key schedule cannot silently change. + let rk_session_salt = [0xA5u8; 32]; + let rk_shared_secret = [0x7Bu8; 32]; + // The rekeying side's fresh ephemeral and the peer's current ratchet public. + let rk_eph_private = [0x33u8; 32]; + let rk_eph_secret = StaticSecret::from(rk_eph_private); + let rk_eph_public = PublicKey::from(&rk_eph_secret).to_bytes(); + let peer_ratchet_private = [0x52u8; 32]; + let peer_ratchet_public = PublicKey::from(&StaticSecret::from(peer_ratchet_private)).to_bytes(); + + let rk_root = + core::derive_ratchet_root(&rk_session_salt, &rk_shared_secret).expect("ratchet root"); + let rk_dh = rk_eph_secret + .diffie_hellman(&PublicKey::from(peer_ratchet_public)) + .to_bytes(); + let new_key_id: u8 = 1; + let (rk_new_root, rk_keys) = + core::dh_ratchet_step(&rk_root, &rk_dh, new_key_id).expect("dh ratchet step"); + + let mut rk_json = String::new(); + rk_json.push_str("{\n"); + rk_json.push_str(&json_line("session_salt_hex", &hex(&rk_session_salt), true)); + rk_json.push_str(&json_line( + "shared_secret_hex", + &hex(&rk_shared_secret), + true, + )); + rk_json.push_str(&json_line("ratchet_root_hex", &hex(&rk_root), true)); + rk_json.push_str(&json_line( + "rekey_eph_private_hex", + &hex(&rk_eph_private), + true, + )); + rk_json.push_str(&json_line( + "rekey_eph_public_hex", + &hex(&rk_eph_public), + true, + )); + rk_json.push_str(&json_line( + "peer_ratchet_public_hex", + &hex(&peer_ratchet_public), + true, + )); + rk_json.push_str(&json_line("rekey_dh_hex", &hex(&rk_dh), true)); + rk_json.push_str(&format!(" \"new_key_id\": {new_key_id},\n")); + rk_json.push_str(&json_line("new_ratchet_root_hex", &hex(&rk_new_root), true)); + rk_json.push_str(&json_line("rekey_key_c2s_hex", &hex(&rk_keys.c2s), true)); + rk_json.push_str(&json_line("rekey_key_s2c_hex", &hex(&rk_keys.s2c), false)); + rk_json.push_str("}\n"); + fs::write(out_dir.join("rekey-v0.json"), rk_json).expect("write rekey vector"); + println!("wrote vectors to {}", out_dir.display()); } diff --git a/foctet/src/lib.rs b/foctet/src/lib.rs index 7bd11bb..c035d6b 100644 --- a/foctet/src/lib.rs +++ b/foctet/src/lib.rs @@ -2,39 +2,14 @@ //! //! This crate re-exports: //! -//! - `core` for framing, key schedule, handshake/rekey, and replay protection. -//! - `archive` for encrypted single-file and split archive containers. -//! - `transport` for optional split-stream adapters when the `transport` -//! feature is enabled. -//! -//! # Quick Start -//! -//! ```rust -//! use foctet::{archive, core}; -//! -//! let _wire = core::WIRE_VERSION_V0; -//! let _chunk_size = archive::DEFAULT_CHUNK_SIZE; -//! ``` -//! -//! # Which Crate To Reach For -//! -//! - Use `foctet::transport` when you want the easiest authenticated E2EE path -//! over split stream transports such as QUIC, WebTransport, or multiplexed -//! WebSocket streams. -//! - Use `foctet::core` when you need direct control over handshake messages, -//! framing, rekey policy, or the `application/foctet` body envelope. -//! - Use `foctet::archive` for encrypted files, manifests, and deterministic -//! interoperability fixtures. -//! -//! # Production Guidance -//! -//! - Prefer authenticated native handshakes with pinned peer identities. -//! - Pair `foctet::core::body` or `foctet-http` usage with an authenticated -//! outer HTTP transport because body envelopes protect body bytes only. -//! - Reserve deterministic archive build secrets for test fixtures and vectors, -//! not for live user data. -//! -//! For wire-format details, see `SPEC.md` in the workspace root. +//! - `core` for framing, handshake/rekey, and replay protection +//! - `archive` for encrypted single-file and split archives +//! - `transport` for optional authenticated channel builders over stream +//! transports +//! +//! Use `foctet::transport` for the easiest transport E2EE path, `foctet::core` +//! for low-level protocol control, and `foctet::archive` for encrypted file +//! packaging. pub use foctet_archive as archive; pub use foctet_core as core; diff --git a/foctet/tests/test_vector_schema.rs b/foctet/tests/test_vector_schema.rs index a3adfa8..079a008 100644 --- a/foctet/tests/test_vector_schema.rs +++ b/foctet/tests/test_vector_schema.rs @@ -86,3 +86,26 @@ fn archive_vector_schema_is_valid() { assert!(is_hex(s), "each parts_hex item must be hex"); } } + +#[test] +fn rekey_vector_schema_is_valid() { + let v = load_json("rekey-v0.json"); + for key in [ + "session_salt_hex", + "shared_secret_hex", + "ratchet_root_hex", + "rekey_eph_private_hex", + "rekey_eph_public_hex", + "peer_ratchet_public_hex", + "rekey_dh_hex", + "new_ratchet_root_hex", + "rekey_key_c2s_hex", + "rekey_key_s2c_hex", + ] { + assert_hex_len(&v, key, 32); + } + assert!( + v["new_key_id"].as_u64().is_some(), + "new_key_id must be an integer" + ); +} diff --git a/foctet/tests/test_vectors.rs b/foctet/tests/test_vectors.rs index 82c229f..00ff663 100644 --- a/foctet/tests/test_vectors.rs +++ b/foctet/tests/test_vectors.rs @@ -287,3 +287,49 @@ fn archive_vectors_match() { archive::decrypt_split_archive_to_bytes(&tampered_manifest, &part_refs, recipient_priv) .expect_err("tampered manifest must fail"); } + +#[test] +fn rekey_ratchet_vector_matches() { + // Locks in the DH-ratchet key schedule: `derive_ratchet_root` then + // `dh_ratchet_step`. A change to either HKDF label or the wiring breaks this. + let v = load_json("rekey-v0.json"); + + let session_salt = hex32(v["session_salt_hex"].as_str().expect("session_salt_hex")); + let shared_secret = hex32(v["shared_secret_hex"].as_str().expect("shared_secret_hex")); + let expected_root = hex32(v["ratchet_root_hex"].as_str().expect("ratchet_root_hex")); + let eph_private = hex32( + v["rekey_eph_private_hex"] + .as_str() + .expect("rekey_eph_private_hex"), + ); + let peer_public = hex32( + v["peer_ratchet_public_hex"] + .as_str() + .expect("peer_ratchet_public_hex"), + ); + let expected_dh = hex32(v["rekey_dh_hex"].as_str().expect("rekey_dh_hex")); + let new_key_id = v["new_key_id"].as_u64().expect("new_key_id") as u8; + let expected_new_root = hex32( + v["new_ratchet_root_hex"] + .as_str() + .expect("new_ratchet_root_hex"), + ); + let expected_c2s = hex32(v["rekey_key_c2s_hex"].as_str().expect("rekey_key_c2s_hex")); + let expected_s2c = hex32(v["rekey_key_s2c_hex"].as_str().expect("rekey_key_s2c_hex")); + + // 1. Initial ratchet root from the handshake shared secret. + let root = core::derive_ratchet_root(&session_salt, &shared_secret).expect("ratchet root"); + assert_eq!(root, expected_root, "ratchet root mismatch"); + + // 2. The ratchet Diffie-Hellman output. + let dh = StaticSecret::from(eph_private) + .diffie_hellman(&PublicKey::from(peer_public)) + .to_bytes(); + assert_eq!(dh, expected_dh, "ratchet DH mismatch"); + + // 3. One ratchet step → new root and traffic keys. + let (new_root, keys) = core::dh_ratchet_step(&root, &dh, new_key_id).expect("dh ratchet step"); + assert_eq!(new_root, expected_new_root, "ratcheted root mismatch"); + assert_eq!(keys.c2s, expected_c2s, "rekey c2s mismatch"); + assert_eq!(keys.s2c, expected_s2c, "rekey s2c mismatch"); +} diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index c675a37..6d35c71 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -25,3 +25,38 @@ path = "fuzz_targets/archive_parser.rs" test = false doc = false bench = false + +[[bin]] +name = "control_message" +path = "fuzz_targets/control_message.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "handshake" +path = "fuzz_targets/handshake.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "body_envelope" +path = "fuzz_targets/body_envelope.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "stream_body" +path = "fuzz_targets/stream_body.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "datagram_message" +path = "fuzz_targets/datagram_message.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md index e2b22d9..14d9a9c 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -1,24 +1,49 @@ # Fuzzing -This directory contains `cargo-fuzz` targets for parser hardening. +This directory contains `cargo-fuzz` targets for hardening every parser and +AEAD-open path that consumes attacker-controlled bytes. ## Prerequisites -Install `cargo-fuzz` once: +Install `cargo-fuzz` once (requires a nightly toolchain): ```bash -cargo install cargo-fuzz +cargo install cargo-fuzz --locked ``` ## Targets -- `frame_parser`: fuzzes `foctet_core::Frame::from_bytes` -- `archive_parser`: fuzzes archive decryption entry points +- `frame_parser`: `foctet_core::Frame::from_bytes` (stream frame header/wire) +- `archive_parser`: single- and split-archive decryption entry points +- `control_message`: control-plane (`ControlMessage`) decoder +- `handshake`: handshake/rekey state machine — any decodable control message is + fed to a fresh responder and initiator +- `body_envelope`: one-shot body-envelope parse + key-unwrap + AEAD open +- `stream_body`: streaming-body header parser + incremental `StreamFrameDecoder` +- `datagram_message`: datagram and message frame open paths (header + AEAD + + replay handling) -## Run +## Seeds + +`seeds//` holds committed seed inputs — *valid* wire blobs (frames, +envelopes, archives, control messages) sealed with the same fixed keys the +targets hard-code, so the fuzzer starts from deep parser states. Regenerate +with: + +```bash +cargo run -p foctet --example gen_fuzz_seeds +``` + +Sealing uses random ephemerals, so regenerated seeds differ byte-for-byte; +they only need to be valid, not reproducible. + +The working corpus (`corpus/`, git-ignored) grows locally + +## Run locally ```bash -cd fuzz -cargo fuzz run frame_parser -cargo fuzz run archive_parser +# From the workspace root; copy seeds into the working corpus first. +mkdir -p fuzz/corpus/frame_parser +cp fuzz/seeds/frame_parser/* fuzz/corpus/frame_parser/ +cargo +nightly fuzz run frame_parser -- -max_total_time=300 ``` diff --git a/fuzz/fuzz_targets/body_envelope.rs b/fuzz/fuzz_targets/body_envelope.rs new file mode 100644 index 0000000..46c7c24 --- /dev/null +++ b/fuzz/fuzz_targets/body_envelope.rs @@ -0,0 +1,11 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +// Fuzzes the one-shot body-envelope parser/AEAD path with a fixed recipient key. +// Authentication will fail, but the header parsing and key-unwrap paths run over +// attacker-controlled bytes. +fuzz_target!(|data: &[u8]| { + let recipient_secret_key = [0x42u8; 32]; + let _ = foctet_core::open_body(data, recipient_secret_key); +}); diff --git a/fuzz/fuzz_targets/control_message.rs b/fuzz/fuzz_targets/control_message.rs new file mode 100644 index 0000000..47a6552 --- /dev/null +++ b/fuzz/fuzz_targets/control_message.rs @@ -0,0 +1,9 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +// Fuzzes the control-plane message parser, which decodes attacker-controlled +// bytes from the wire (ClientHello / ServerHello / Rekey / Error). +fuzz_target!(|data: &[u8]| { + let _ = foctet_core::ControlMessage::decode(data); +}); diff --git a/fuzz/fuzz_targets/datagram_message.rs b/fuzz/fuzz_targets/datagram_message.rs new file mode 100644 index 0000000..8aa6b68 --- /dev/null +++ b/fuzz/fuzz_targets/datagram_message.rs @@ -0,0 +1,20 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +use foctet_core::{DatagramEndpoint, Direction, KeyHandle, MessageEndpoint, derive_traffic_keys}; + +// Fuzzes the datagram and message frame decoders (header parse + AEAD open) with +// fixed traffic keys. +fuzz_target!(|data: &[u8]| { + let Ok(keys) = derive_traffic_keys(&[0x11u8; 32], &[0x22u8; 32], 0) else { + return; + }; + let keys = KeyHandle::new(keys); + + let mut datagram = DatagramEndpoint::new(keys.clone(), Direction::S2C, Direction::C2S); + let _ = datagram.open(data); + + let mut message = MessageEndpoint::new(keys, Direction::S2C, Direction::C2S); + let _ = message.open(data); +}); diff --git a/fuzz/fuzz_targets/handshake.rs b/fuzz/fuzz_targets/handshake.rs new file mode 100644 index 0000000..d7f1c8d --- /dev/null +++ b/fuzz/fuzz_targets/handshake.rs @@ -0,0 +1,25 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +use foctet_core::{ControlMessage, RekeyThresholds, Session, SessionAuthConfig}; + +// Fuzzes the handshake/rekey state machine: any decodable control message is fed +// to a fresh responder and initiator, exercising state transitions and the +// transcript/auth checks against hostile input. +fuzz_target!(|data: &[u8]| { + let Ok(msg) = ControlMessage::decode(data) else { + return; + }; + let mut responder = Session::new_responder_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let _ = responder.handle_control(&msg); + + let (mut initiator, _hello) = Session::new_initiator_with_auth( + RekeyThresholds::default(), + SessionAuthConfig::unauthenticated_for_testing(), + ); + let _ = initiator.handle_control(&msg); +}); diff --git a/fuzz/fuzz_targets/stream_body.rs b/fuzz/fuzz_targets/stream_body.rs new file mode 100644 index 0000000..3892cdb --- /dev/null +++ b/fuzz/fuzz_targets/stream_body.rs @@ -0,0 +1,19 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +use foctet_core::{BodyEnvelopeLimits, StreamFrameDecoder, StreamOpener}; + +// Fuzzes the streaming-body header parser and the incremental frame decoder. +fuzz_target!(|data: &[u8]| { + let limits = BodyEnvelopeLimits::default(); + let recipient_secret_key = [0x42u8; 32]; + + // Stream-header parse + content-key unwrap. + let _ = StreamOpener::new(recipient_secret_key, data, b"", &limits); + + // Incremental frame reassembly over the same bytes. + let mut decoder = StreamFrameDecoder::new(&limits); + decoder.push(data); + while let Ok(Some(_)) = decoder.decode_next() {} +}); diff --git a/fuzz/seeds/archive_parser/single_archive.bin b/fuzz/seeds/archive_parser/single_archive.bin new file mode 100644 index 0000000..ea3eddc Binary files /dev/null and b/fuzz/seeds/archive_parser/single_archive.bin differ diff --git a/fuzz/seeds/archive_parser/split_manifest.bin b/fuzz/seeds/archive_parser/split_manifest.bin new file mode 100644 index 0000000..a66329a Binary files /dev/null and b/fuzz/seeds/archive_parser/split_manifest.bin differ diff --git a/fuzz/seeds/archive_parser/split_part.bin b/fuzz/seeds/archive_parser/split_part.bin new file mode 100644 index 0000000..fb2e37f Binary files /dev/null and b/fuzz/seeds/archive_parser/split_part.bin differ diff --git a/fuzz/seeds/body_envelope/sealed_body.bin b/fuzz/seeds/body_envelope/sealed_body.bin new file mode 100644 index 0000000..977dda9 Binary files /dev/null and b/fuzz/seeds/body_envelope/sealed_body.bin differ diff --git a/fuzz/seeds/control_message/client_hello.bin b/fuzz/seeds/control_message/client_hello.bin new file mode 100644 index 0000000..b176d5f Binary files /dev/null and b/fuzz/seeds/control_message/client_hello.bin differ diff --git a/fuzz/seeds/control_message/rekey.bin b/fuzz/seeds/control_message/rekey.bin new file mode 100644 index 0000000..013b1d3 Binary files /dev/null and b/fuzz/seeds/control_message/rekey.bin differ diff --git a/fuzz/seeds/control_message/server_hello.bin b/fuzz/seeds/control_message/server_hello.bin new file mode 100644 index 0000000..398b040 Binary files /dev/null and b/fuzz/seeds/control_message/server_hello.bin differ diff --git a/fuzz/seeds/datagram_message/sealed_datagram.bin b/fuzz/seeds/datagram_message/sealed_datagram.bin new file mode 100644 index 0000000..206515a Binary files /dev/null and b/fuzz/seeds/datagram_message/sealed_datagram.bin differ diff --git a/fuzz/seeds/datagram_message/sealed_message.bin b/fuzz/seeds/datagram_message/sealed_message.bin new file mode 100644 index 0000000..209bdbb Binary files /dev/null and b/fuzz/seeds/datagram_message/sealed_message.bin differ diff --git a/fuzz/seeds/frame_parser/data_frame.bin b/fuzz/seeds/frame_parser/data_frame.bin new file mode 100644 index 0000000..47d3810 Binary files /dev/null and b/fuzz/seeds/frame_parser/data_frame.bin differ diff --git a/fuzz/seeds/handshake/client_hello.bin b/fuzz/seeds/handshake/client_hello.bin new file mode 100644 index 0000000..b176d5f Binary files /dev/null and b/fuzz/seeds/handshake/client_hello.bin differ diff --git a/fuzz/seeds/handshake/rekey.bin b/fuzz/seeds/handshake/rekey.bin new file mode 100644 index 0000000..013b1d3 Binary files /dev/null and b/fuzz/seeds/handshake/rekey.bin differ diff --git a/fuzz/seeds/handshake/server_hello.bin b/fuzz/seeds/handshake/server_hello.bin new file mode 100644 index 0000000..398b040 Binary files /dev/null and b/fuzz/seeds/handshake/server_hello.bin differ diff --git a/fuzz/seeds/stream_body/sealed_stream.bin b/fuzz/seeds/stream_body/sealed_stream.bin new file mode 100644 index 0000000..7a71a18 Binary files /dev/null and b/fuzz/seeds/stream_body/sealed_stream.bin differ diff --git a/interop/README.md b/interop/README.md index cda29ff..4974e2f 100644 --- a/interop/README.md +++ b/interop/README.md @@ -1,10 +1,60 @@ # Interop Reference -This directory contains minimal non-Rust helpers for interoperability bring-up. +This directory contains non-Rust tooling for interoperability verification. + +## Which tool for which job + +- **Full seal/open from JavaScript/TypeScript — use the WASM SDK + (`foctet-wasm/`)**, not this directory. It exposes the body envelope + (`sealBody`/`openBody`, context-bound variants) and the framed session + (`FoctetSession` handshake + `sealMessage`/`openMessage`), with generated + `.d.ts` typings. Its Node interop test (`foctet-wasm/tests/node_interop.cjs`) + opens **Rust-produced** envelopes from `tests/interop_vector.json`, proving + cross-language wire compatibility, and `foctet-wasm/tests/browser.rs` runs + the same surface in a real headless browser. +- **Independent verification of the canonical vectors — use + `verify_vectors.mjs`** (below). Unlike the WASM SDK, it is *not generated + from the Rust implementation*, so it provides a genuinely independent check + of the Draft v0 wire format and key schedule against the committed test + vectors. That independence is the point: it catches a systematic + encode/derive bug that a Rust-derived artifact would faithfully reproduce. + +## `verify_vectors.mjs` — independent vector verification + +A from-spec re-implementation of the Draft v0 primitives on top of the +[@noble](https://paulmillr.com/noble/) cryptography libraries (pure-JS, zero +shared code with this workspace). It verifies every canonical +vector in `test-vectors/` end to end: + +- **`frame-v0.json`** — HKDF-SHA-256 traffic-key derivation, frame-header + decoding, nonce construction, and a **full XChaCha20-Poly1305 AEAD open** + with the header as AAD (plus negative controls: tampered ciphertext and + tampered header must fail). +- **`handshake-v0.json`** — X25519 public-key and shared-secret derivation on + both sides, the handshake key schedule, `ClientHello`/`ServerHello` wire + decoding, transcript-binding recomputation, and **Ed25519 identity signature + verification** for both hellos. +- **`rekey-v0.json`** — DH-ratchet root seeding, the rekey ephemeral DH, and + one full `dh_ratchet_step` (advanced root + both direction keys). + +Run it (Node 18+): + +```bash +cd interop +npm ci +npm test +``` + +CI runs this on every push/PR (`interop-verify` job in +`.github/workflows/rust.yml`), so the vectors and the Rust implementation +cannot drift from the spec without an independent implementation noticing. ## `minimal_decoder.js` / `minimal_decoder.ts` -A tiny Node.js decoder, with an equivalent TypeScript source, for Draft v0 frame headers from hex bytes. +A tiny, dependency-free Node.js decoder (with an equivalent TypeScript +source) for Draft v0 frame headers from hex bytes. Kept as the smallest +possible reference for the header layout; `verify_vectors.mjs` supersedes it +for actual verification. Usage: @@ -17,8 +67,3 @@ Example with the repository vector: ```bash node interop/minimal_decoder.js $(jq -r .frame_hex test-vectors/frame-v0.json) ``` - -Notes: - -- This script is intentionally minimal and performs header-level decoding only. -- It does not implement AEAD decryption. diff --git a/interop/package-lock.json b/interop/package-lock.json new file mode 100644 index 0000000..fa34248 --- /dev/null +++ b/interop/package-lock.json @@ -0,0 +1,57 @@ +{ + "name": "foctet-interop-verify", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "foctet-interop-verify", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "2.0.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.0.1.tgz", + "integrity": "sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + } + } +} diff --git a/interop/package.json b/interop/package.json new file mode 100644 index 0000000..0908f18 --- /dev/null +++ b/interop/package.json @@ -0,0 +1,16 @@ +{ + "name": "foctet-interop-verify", + "version": "0.1.0", + "private": true, + "description": "Independent (non-Rust) verification of the canonical Foctet Draft v0 test vectors", + "type": "commonjs", + "scripts": { + "test": "node verify_vectors.mjs" + }, + "license": "MIT", + "dependencies": { + "@noble/ciphers": "2.0.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1" + } +} diff --git a/interop/verify_vectors.mjs b/interop/verify_vectors.mjs new file mode 100644 index 0000000..f258669 --- /dev/null +++ b/interop/verify_vectors.mjs @@ -0,0 +1,336 @@ +#!/usr/bin/env node +// Independent verification of the canonical Foctet Draft v0 test vectors. +// +// This script re-implements the Draft v0 key schedule, frame AEAD, handshake +// transcript binding, identity authentication, and DH-ratchet rekey step from +// SPEC.md, on top of the @noble crypto libraries. It shares no code with the +// Rust implementation and is not generated from it (unlike the WASM SDK), so +// it provides a genuinely independent check of the committed vectors in +// `test-vectors/`: a systematic encode/derive bug in the Rust workspace that +// its own tests would faithfully reproduce fails here instead. +// +// Usage (from the repository root or interop/): +// +// cd interop && npm ci && npm test + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { xchacha20poly1305 } from "@noble/ciphers/chacha.js"; +import { hkdf } from "@noble/hashes/hkdf.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { x25519, ed25519 } from "@noble/curves/ed25519.js"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const vectors = (name) => + JSON.parse(readFileSync(join(root, "test-vectors", name), "utf8")); + +const fromHex = (hex) => { + if (hex.length % 2 !== 0) throw new Error("odd-length hex"); + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) + out[i] = parseInt(hex.slice(2 * i, 2 * i + 2), 16); + return out; +}; +const toHex = (bytes) => + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + +let checks = 0; +const assertEq = (actual, expected, what) => { + const a = typeof actual === "string" ? actual : toHex(actual); + const e = typeof expected === "string" ? expected : toHex(expected); + if (a !== e) { + console.error(`FAIL: ${what}\n expected ${e}\n actual ${a}`); + process.exit(1); + } + checks++; +}; +const assertTrue = (cond, what) => { + if (!cond) { + console.error(`FAIL: ${what}`); + process.exit(1); + } + checks++; +}; + +const concat = (...parts) => { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { + out.set(p, off); + off += p.length; + } + return out; +}; +const ascii = (s) => new TextEncoder().encode(s); +const u32be = (n) => + new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]); +const u64be = (n) => { + const big = BigInt(n); + const out = new Uint8Array(8); + for (let i = 0; i < 8; i++) + out[7 - i] = Number((big >> BigInt(8 * i)) & 0xffn); + return out; +}; + +// --- Draft v0 primitives, per SPEC.md ------------------------------------- + +// HKDF-SHA-256(salt = session_salt, ikm = shared_secret) expanded with the +// direction labels. +const deriveTrafficKeys = (sharedSecret, sessionSalt) => ({ + c2s: hkdf(sha256, sharedSecret, sessionSalt, ascii("foctet c2s"), 32), + s2c: hkdf(sha256, sharedSecret, sessionSalt, ascii("foctet s2c"), 32), +}); + +const deriveRatchetRoot = (sessionSalt, sharedSecret) => + hkdf(sha256, sharedSecret, sessionSalt, ascii("foctet ratchet init"), 32); + +const dhRatchetStep = (rootKey, dh, keyId) => ({ + newRoot: hkdf(sha256, dh, rootKey, ascii("foctet ratchet root"), 32), + c2s: hkdf( + sha256, + dh, + rootKey, + concat(ascii("foctet ratchet c2s"), new Uint8Array([keyId])), + 32, + ), + s2c: hkdf( + sha256, + dh, + rootKey, + concat(ascii("foctet ratchet s2c"), new Uint8Array([keyId])), + 32, + ), +}); + +// 24-byte XChaCha nonce: key_id || stream_id(be32) || seq(be64) || zeros. +const makeNonce = (keyId, streamId, seq) => + concat(new Uint8Array([keyId]), u32be(streamId), u64be(seq), new Uint8Array(11)); + +const FRAME_HEADER_LEN = 22; +const decodeFrameHeader = (bytes) => { + assertTrue(bytes.length >= FRAME_HEADER_LEN, "frame has a full header"); + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { + magic: [bytes[0], bytes[1]], + version: bytes[2], + flags: bytes[3], + profileId: bytes[4], + keyId: bytes[5], + streamId: dv.getUint32(6), + seq: dv.getBigUint64(10), + ctLen: dv.getUint32(18), + raw: bytes.slice(0, FRAME_HEADER_LEN), + }; +}; + +// --- 1. Frame vector: full AEAD open --------------------------------------- + +const frameVec = vectors("frame-v0.json"); +{ + const sharedSecret = fromHex(frameVec.shared_secret_hex); + const sessionSalt = fromHex(frameVec.session_salt_hex); + const frame = fromHex(frameVec.frame_hex); + const header = decodeFrameHeader(frame); + + assertEq(toHex(new Uint8Array(header.magic)), "f0c7", "frame magic"); + assertTrue(header.version === 0, "frame version is v0"); + assertTrue(header.profileId === 0x01, "frame profile is 0x01"); + const ciphertext = frame.slice(FRAME_HEADER_LEN); + assertTrue(ciphertext.length === header.ctLen, "ct_len matches body length"); + + const keys = deriveTrafficKeys(sharedSecret, sessionSalt); + const nonce = makeNonce(header.keyId, header.streamId, header.seq); + + // The header (with its final ct_len) is the AAD; decrypt under the + // direction key that authenticates. + let plaintext = null; + for (const key of [keys.c2s, keys.s2c]) { + try { + plaintext = xchacha20poly1305(key, nonce, header.raw).decrypt(ciphertext); + break; + } catch { + /* try the other direction */ + } + } + assertTrue(plaintext !== null, "frame decrypts under a derived traffic key"); + assertEq(plaintext, frameVec.plaintext_hex, "frame plaintext"); +} + +// --- 2. Handshake vector: X25519, key schedule, hellos, identity auth ------ + +const hsVec = vectors("handshake-v0.json"); +{ + const clientPriv = fromHex(hsVec.client_private_hex); + const serverPriv = fromHex(hsVec.server_private_hex); + const clientPub = fromHex(hsVec.client_public_hex); + const serverPub = fromHex(hsVec.server_public_hex); + const sessionSalt = fromHex(hsVec.session_salt_hex); + + assertEq(x25519.getPublicKey(clientPriv), clientPub, "client X25519 public"); + assertEq(x25519.getPublicKey(serverPriv), serverPub, "server X25519 public"); + assertEq( + x25519.getSharedSecret(clientPriv, serverPub), + hsVec.shared_secret_hex, + "X25519 shared secret (client side)", + ); + assertEq( + x25519.getSharedSecret(serverPriv, clientPub), + hsVec.shared_secret_hex, + "X25519 shared secret (server side)", + ); + + const keys = deriveTrafficKeys(fromHex(hsVec.shared_secret_hex), sessionSalt); + assertEq(keys.c2s, hsVec.key_c2s_hex, "handshake-derived c2s key"); + assertEq(keys.s2c, hsVec.key_s2c_hex, "handshake-derived s2c key"); + + // ClientHello wire layout: "FCTL" ver(0) kind(1) eph(32) salt(32) + // binding(32) auth_kind(1) [identity(32) signature(64)]. + const hello = fromHex(hsVec.client_hello_hex); + assertEq(hello.slice(0, 4), toHex(ascii("FCTL")), "client hello prefix"); + assertTrue(hello[4] === 0 && hello[5] === 1, "client hello version/kind"); + const chEph = hello.slice(6, 38); + const chSalt = hello.slice(38, 70); + const chBinding = hello.slice(70, 102); + assertEq(chEph, clientPub, "client hello ephemeral public"); + assertEq(chSalt, sessionSalt, "client hello session salt"); + const expectedClientBinding = sha256( + concat(ascii("foctet hs client"), chEph, chSalt), + ); + assertEq(chBinding, expectedClientBinding, "client transcript binding"); + assertTrue(hello[102] === 1, "client hello carries Ed25519 auth"); + const chIdentity = hello.slice(103, 135); + const chSignature = hello.slice(135, 199); + assertEq( + chIdentity, + hsVec.client_identity_public_hex, + "client identity public key", + ); + assertEq( + ed25519.getPublicKey(fromHex(hsVec.client_identity_private_hex)), + hsVec.client_identity_public_hex, + "client Ed25519 public derivation", + ); + const clientAuthMsg = concat( + ascii("foctet auth client"), + chEph, + chSalt, + chBinding, + ); + assertTrue( + ed25519.verify(chSignature, clientAuthMsg, chIdentity), + "client identity signature verifies", + ); + + // ServerHello wire layout: "FCTL" ver(0) kind(2) eph(32) binding(32) + // auth_kind(1) [identity(32) signature(64)]. + const serverHello = fromHex(hsVec.server_hello_hex); + assertEq(serverHello.slice(0, 4), toHex(ascii("FCTL")), "server hello prefix"); + assertTrue( + serverHello[4] === 0 && serverHello[5] === 2, + "server hello version/kind", + ); + const shEph = serverHello.slice(6, 38); + const shBinding = serverHello.slice(38, 70); + assertEq(shEph, serverPub, "server hello ephemeral public"); + const expectedServerBinding = sha256( + concat(ascii("foctet hs server"), chEph, shEph, sessionSalt), + ); + assertEq(shBinding, expectedServerBinding, "server transcript binding"); + assertTrue(serverHello[70] === 1, "server hello carries Ed25519 auth"); + const shIdentity = serverHello.slice(71, 103); + const shSignature = serverHello.slice(103, 167); + assertEq( + shIdentity, + hsVec.server_identity_public_hex, + "server identity public key", + ); + const serverAuthMsg = concat( + ascii("foctet auth server"), + chEph, + shEph, + sessionSalt, + shBinding, + ); + assertTrue( + ed25519.verify(shSignature, serverAuthMsg, shIdentity), + "server identity signature verifies", + ); +} + +// --- 3. Rekey vector: DH-ratchet root seeding and one ratchet step --------- + +const rkVec = vectors("rekey-v0.json"); +{ + const sessionSalt = fromHex(rkVec.session_salt_hex); + const sharedSecret = fromHex(rkVec.shared_secret_hex); + assertEq( + deriveRatchetRoot(sessionSalt, sharedSecret), + rkVec.ratchet_root_hex, + "ratchet root seeding", + ); + + const ephPriv = fromHex(rkVec.rekey_eph_private_hex); + assertEq( + x25519.getPublicKey(ephPriv), + rkVec.rekey_eph_public_hex, + "rekey ephemeral public", + ); + assertEq( + x25519.getSharedSecret(ephPriv, fromHex(rkVec.peer_ratchet_public_hex)), + rkVec.rekey_dh_hex, + "rekey DH output", + ); + + const step = dhRatchetStep( + fromHex(rkVec.ratchet_root_hex), + fromHex(rkVec.rekey_dh_hex), + rkVec.new_key_id, + ); + assertEq(step.newRoot, rkVec.new_ratchet_root_hex, "advanced ratchet root"); + assertEq(step.c2s, rkVec.rekey_key_c2s_hex, "post-rekey c2s key"); + assertEq(step.s2c, rkVec.rekey_key_s2c_hex, "post-rekey s2c key"); +} + +// --- 4. Negative controls: tampering must fail ------------------------------ + +{ + const sharedSecret = fromHex(frameVec.shared_secret_hex); + const sessionSalt = fromHex(frameVec.session_salt_hex); + const keys = deriveTrafficKeys(sharedSecret, sessionSalt); + const frame = fromHex(frameVec.frame_hex); + const header = decodeFrameHeader(frame); + const nonce = makeNonce(header.keyId, header.streamId, header.seq); + + const opensWith = (aad, body) => { + for (const key of [keys.c2s, keys.s2c]) { + try { + xchacha20poly1305(key, nonce, aad).decrypt(body); + return true; + } catch { + /* keep trying */ + } + } + return false; + }; + + const tamperedBody = frame.slice(FRAME_HEADER_LEN); + tamperedBody[0] ^= 0xff; + assertTrue( + !opensWith(header.raw, tamperedBody), + "tampered ciphertext must not authenticate", + ); + + const tamperedAad = header.raw.slice(); + tamperedAad[3] ^= 0x01; // flip a frame-flag bit: the header is AAD + assertTrue( + !opensWith(tamperedAad, frame.slice(FRAME_HEADER_LEN)), + "tampered header (AAD) must not authenticate", + ); +} + +console.log( + `ok: ${checks} independent checks passed (frame AEAD, handshake, identity auth, rekey ratchet)`, +); diff --git a/test-vectors/README.md b/test-vectors/README.md index 2407799..f48bfcd 100644 --- a/test-vectors/README.md +++ b/test-vectors/README.md @@ -30,6 +30,17 @@ This directory contains deterministic vectors for interoperability and regressio - `single_archive_hex`: hex - `manifest_hex`: hex - `parts_hex`: array of hex strings +- `rekey-v0.json` — one deterministic DH-ratchet rekey step (locks the in-session + rekey key schedule: `derive_ratchet_root` then `dh_ratchet_step`) + - `session_salt_hex`, `shared_secret_hex`: 32-byte hex (ratchet-root inputs) + - `ratchet_root_hex`: 32-byte hex (`derive_ratchet_root` output) + - `rekey_eph_private_hex`, `rekey_eph_public_hex`: 32-byte hex (the rekeying + side's fresh ephemeral) + - `peer_ratchet_public_hex`: 32-byte hex (the peer's current ratchet public) + - `rekey_dh_hex`: 32-byte hex (`X25519(rekey_eph_private, peer_ratchet_public)`) + - `new_key_id`: integer + - `new_ratchet_root_hex`, `rekey_key_c2s_hex`, `rekey_key_s2c_hex`: 32-byte hex + (`dh_ratchet_step` outputs) The archive vector is generated with fixed `ArchiveBuildSecrets` so repeated regeneration is byte-for-byte stable across runs. diff --git a/test-vectors/rekey-v0.json b/test-vectors/rekey-v0.json new file mode 100644 index 0000000..43df8f3 --- /dev/null +++ b/test-vectors/rekey-v0.json @@ -0,0 +1,13 @@ +{ + "session_salt_hex": "a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5", + "shared_secret_hex": "7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b", + "ratchet_root_hex": "d891aa7cdabdd5be71c1a33d98e8763b3a0c899cf1617120a669f7d15faea096", + "rekey_eph_private_hex": "3333333333333333333333333333333333333333333333333333333333333333", + "rekey_eph_public_hex": "7b0d47d93427f8311160781c7c733fd89f88970aef490d8aa0ee19a4cb8a1b14", + "peer_ratchet_public_hex": "f68b05ba03f7185e1ba88878682f8dd0b15158f6050889c9481d79c2d7d2fa07", + "rekey_dh_hex": "dc0b3acc4698709848a9f6f403ad52ca35fff02d2f15b662348748d1d43de74a", + "new_key_id": 1, + "new_ratchet_root_hex": "aa191edf7274874e37207a9dd57c3ebfebb2f5bfc081022cc277b8034e661c0f", + "rekey_key_c2s_hex": "fdccd5f64c9916bf563ea4c0ddb7b95d4b98e09bfd87551ae9913e19294d2a91", + "rekey_key_s2c_hex": "afccdadf9d88d60119fa9b8a2b7152d225bdc71039ea7c11f03081a8317f1f62" +}