Blockers 4 & 5: browser getUserMedia → JPEG IPC → Rust I420 pipeline; remote video strip renders decoded frames via canvas; EncryptingTransport wraps QuinnTransport so WZP AEAD is applied to all media (C2 fix). Test fixes: HandshakeResult.session destructuring across relay/client/crypto integration tests; video_codecs field added to all CallOffer/CallAnswer structs; wzp-video pipeline_roundtrip integration tests added. PRD docs: five Kimi-ready specs for E2E encryption, Android NDK 0.9 migration, quality upgrade flow, wire-format hardening, and clippy debt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7.9 KiB
PRD: E2E Media Encryption — Wire EncryptingTransport on Relay Path
Status: proposed Resolves: Security gap — relay-path media travels in QUIC TLS only; WZP application-layer ChaCha20-Poly1305 is negotiated but never applied. Depends on:
wzp_client::encrypted_transport::EncryptingTransport(already implemented).
Problem
CallEngine::start (both the Android path and the desktop path) calls
wzp_client::handshake::perform_handshake, which returns a HandshakeResult
containing a session: Box<dyn CryptoSession> (a keyed ChaChaSession).
Both call sites discard the session — only hs.video_codec is retained.
All subsequent send_media / recv_media calls go directly through
Arc<wzp_transport::QuinnTransport>, which provides QUIC TLS (relay sees
plaintext application data after TLS termination at the relay). The WZP
application-level AEAD — ChaCha20-Poly1305, keyed per-call, relay-never-sees
— is never applied.
wzp_client::encrypted_transport::EncryptingTransport exists
(crates/wzp-client/src/encrypted_transport.rs) and is fully tested.
It wraps any Arc<dyn MediaTransport> and intercepts every send_media /
recv_media call with session.encrypt() / session.decrypt().
Goals
- The relay-path
HandshakeResult::sessionis used to construct anEncryptingTransportthat wraps the rawQuinnTransport. - All
send_mediaandrecv_mediacalls in the relay path go through the wrapper, not the raw transport. - The direct P2P path (
is_direct_p2p == true) is left unchanged — QUIC TLS is the encryption layer there. cargo check --manifest-path desktop/src-tauri/Cargo.tomlpasses.- A
#[cfg(test)]test verifies that the relay path usesEncryptingTransport.
Non-goals
- Rekeying (
SignalMessage::Rekey) — tracked separately. - Video transport encryption (same mechanism; apply after audio is confirmed working).
- Changes to the P2P path, the relay binary, or any crate outside
desktop/src-tauri.
Design
EncryptingTransport API (read crates/wzp-client/src/encrypted_transport.rs)
pub struct EncryptingTransport { ... }
impl EncryptingTransport {
pub fn new(inner: Arc<dyn MediaTransport>, session: Box<dyn CryptoSession>) -> Self;
}
// Implements MediaTransport:
// send_media → session.encrypt(header_bytes, payload) → inner.send_media
// recv_media → inner.recv_media → session.decrypt(header_bytes, ciphertext)
// send_signal / recv_signal / path_quality / close → forwarded unchanged
EncryptingTransport is NOT Arc-wrapped by the constructor; wrap it in
Arc::new(...) when storing as Arc<dyn MediaTransport>.
Two call sites in desktop/src-tauri/src/engine.rs
Call site 1 — Android path (CallEngine::start around line 575):
if !is_direct_p2p {
let _hs = match wzp_client::handshake::perform_handshake(...).await { Ok(hs) => hs, ... };
// hs.session is discarded here — fix this
}
Change: capture hs, then build a wrapped transport:
if !is_direct_p2p {
let hs = match wzp_client::handshake::perform_handshake(...).await { Ok(hs) => hs, ... };
info!(video_codec = ?hs.video_codec, "handshake complete");
let transport: Arc<dyn wzp_proto::MediaTransport> =
Arc::new(wzp_client::encrypted_transport::EncryptingTransport::new(
transport.clone(),
hs.session,
));
// use `transport` (the wrapped version) for all subsequent send_t / recv_t clones
}
The variable transport must shadow the raw Arc<QuinnTransport> so that
every subsequent clone of transport picks up the encrypted wrapper.
Call site 2 — Desktop path (CallEngine::start around line 1551):
let _negotiated_video_codec = if !is_direct_p2p {
let hs = wzp_client::handshake::perform_handshake(...).await?;
info!(video_codec = ?hs.video_codec, "handshake complete");
hs.video_codec // session dropped here — fix this
} else { None };
Change: extract session before returning video_codec, then shadow
transport with the wrapped version. Because transport is used after this
block (cloned into send_t, recv_t, etc.), the shadow must happen inside
the same scope or immediately after:
let (_negotiated_video_codec, transport): (_, Arc<dyn wzp_proto::MediaTransport>) =
if !is_direct_p2p {
let hs = wzp_client::handshake::perform_handshake(...).await?;
info!(video_codec = ?hs.video_codec, "handshake complete");
let enc = Arc::new(wzp_client::encrypted_transport::EncryptingTransport::new(
transport.clone(),
hs.session,
));
(hs.video_codec, enc)
} else {
info!("direct P2P — skipping relay handshake");
(None, transport.clone())
};
All subsequent transport.clone() calls then operate on the encrypted wrapper.
Import
Add to the top of engine.rs if not already present:
use wzp_client::encrypted_transport::EncryptingTransport;
Or use the fully-qualified path inline (already shown above).
Type compatibility
EncryptingTransportimplementswzp_proto::MediaTransport(confirmed in the source).- The existing
send_t/recv_tvariables are already typed asArc<dyn MediaTransport>(or coerced on first use) — the shadow is a drop-in replacement. - The
vid_transportfor the video path (line ~2090) is also cloned fromtransport; it will automatically use the encrypted wrapper if the shadow is placed before those clones.
Implementation steps
- Read
desktop/src-tauri/src/engine.rslines 570–620 (Android path) and 1547–1570 (desktop path) to see the exact variable names in each branch. - Android path fix (line ~585): rename
_hstohs, extracths.session, wraptransportwithEncryptingTransport::new, re-bindtransportasArc<dyn MediaTransport>. - Desktop path fix (line ~1551): restructure the
if !is_direct_p2pblock to return(video_codec, wrapped_transport)and shadowtransport. - Confirm that
vid_transport(line ~2090) is cloned after the shadow — if it is, no further changes are needed for video. - Run
cargo check --manifest-path desktop/src-tauri/Cargo.toml. Fix any type-mismatch errors (usually a missingas Arc<dyn MediaTransport>cast or a moved value). - Add a
#[cfg(test)]module toengine.rs(or to a newengine_tests.rsincluded via#[cfg(test)] mod engine_tests) with a test that constructs aLoopbackTransport, callsperform_handshakeagainst a mock relay fixture, and verifies that a received payload is decrypted before returning fromrecv_media. A simpler alternative that avoids a full handshake: assertis::<EncryptingTransport>()on thetransportvariable at the test call site usingstd::any::Any.
Files to read before implementing
desktop/src-tauri/src/engine.rslines 475–625 (Android path) and 1480–1570 (desktop path)crates/wzp-client/src/encrypted_transport.rs(full — for the exact constructor signature and trait impl)crates/wzp-client/src/handshake.rs(forHandshakeResultstruct definition — confirm thesessionfield name and type)
Verify
cargo check --manifest-path desktop/src-tauri/Cargo.toml
Expected: 0 errors.
Manual smoke check: both perform_handshake call sites in engine.rs must
use hs.session (grep: hs\.session should appear twice, once per call site).
The string _hs must not remain on the relay path (only on the _hs = binding if the variable is intentionally unused before wrapping).
Done when
cargo check --manifest-path desktop/src-tauri/Cargo.tomlexits 0.- Both relay-path
perform_handshakecall sites build anEncryptingTransportfromhs.session. - The direct-P2P branch (
is_direct_p2p == true) is unchanged. - A
#[cfg(test)]test inengine.rsverifies thatEncryptingTransportis used on the relay path (construction proof or decrypt round-trip).