# MTP Pipes Pipes are unidirectional QUIC/WebTransport streams. The transport primitive is byte-oriented, but raw pipe bytes are not confidential or authenticated by MTP. The creator sends a `PipeRequest` communication value, the peer accepts or rejects it, and an application that carries sensitive data must place the encrypted record layer described below on top of the accepted stream. The request's `Description` and `PipeRequest` type remain clear transport metadata. Do not put identities, call details, file names, or other sensitive protocol information in them. The creator owns the writer. The accepting peer owns the reader. A writer finishes with a stream FIN or aborts with a stream reset. A reader returns EOF after FIN and reports a connection or stream error when the peer closes unexpectedly. ## Opening a Pipe The creator calls `create_pipe` or the corresponding SDK `createPipe` method with a description. MTP assigns a pipe ID and sends a `PipeRequest` frame. The creator receives a handle, not an active writer, because the peer must decide whether to accept the request. The request description is application metadata. It does not grant access to the stream, authenticate the creator, or negotiate an application protocol. Use the authenticated MTP connection and the host's admission policy when a pipe carries sensitive data. The browser SDK's `createEncryptedPipe` and `acceptEncryptedPipe` convenience methods derive the local identity, actual pipe ID, random session ID, and default application purpose from MTP state. Use the lower-level session functions only when integrating a custom pipe transport. The low-level API checks that a supplied pipe ID matches the actual pipe; it does not infer a caller-provided sender or recipient identity. The convenience methods intentionally require registered client credentials because their endpoint identity is the transport client's registered MTP identity. An application that needs a cryptographic identity independent from transport registration must use the lower-level session functions and provide the endpoint IDs and key material explicitly. ## Endpoint Encryption `initiate_pipe_session`/`accept_pipe_session` in the native transport, or `initiateMTPPipeSession`/`acceptMTPPipeSession` in the browser SDK, perform the pipe-establishment step. The initiator sends an `Encrypted(Signed(Array<...>))` offer containing a fresh 32-byte initial chain key, session ID, pipe ID, direction, purpose, and both endpoint IDs. The recipient decrypts it with its keyring, resolves the expected sender bundle, verifies the signature, and checks every expected field before returning the record reader. The offer is bounded and separately framed from application records. The helpers then return `EncryptedPipeWriter`/`EncryptedPipeReader` (or their browser equivalents) without changing the raw QUIC/WebTransport adapter. The context contains the unique pipe/session identity, endpoint identities, direction, and application protocol purpose. Do not derive the initial chain key from the clear description or pipe ID alone. The receiver's signature verification policy is explicit and independent from its decryption keyring. Configure `signaturePolicy` on the browser accept helper, or use the client's `defaultSignatureVerificationPolicy`. The initiator and responder signing `signatureSuite` remain separate from this receive policy. Both sides default to Ed25519; choose `signatureSuite: "dual"` and a matching `signaturePolicy: "dual"` explicitly when hybrid signatures are required. Each record is encoded as: ```text [4-byte big-endian ciphertext length] [1-byte record type: DATA=0, FINAL=1] [XChaCha20-Poly1305 nonce || ciphertext || tag] ``` The AEAD associated data is `MTP-PIPE-E2EE-1 || purpose || direction || transcript-hash || sequence || record length || record type`. The transcript hash binds the session ID, pipe ID, sender, recipient, purpose, and direction. The sequence starts at zero and advances only after successful authentication. A missing, duplicated, reordered, or modified record causes authentication to fail. Each record derives a one-use message key and the next chain key with HKDF using the authenticated context and sequence number; the bootstrap key is never used directly as an AEAD key. The record layer caps one encoded record at 16 MiB. `FINAL` is an authenticated empty record. A reader returns clean EOF only after validating it; transport EOF before `FINAL` is truncation. Authentication, framing, sequence, and I/O failures permanently poison the encrypted reader or writer and erase its current chain key. This is a one-way chain, not a Diffie-Hellman ratchet, so the ordinary offer does not provide forward secrecy. The wrapper exposes `writeRecord`/`readRecord`. Callers that already have an independently authenticated session may still construct it directly with a key and context; otherwise use the establishment helpers. For more than two members, native `initiate_group_pipe_session` and the browser `initiateMTPPipeSession` recipient-array form encrypt one fresh session key to each current member. Membership changes are rekeys: create a new session ID and offer with the new recipient set, and stop using the old record chain. A removed member must never receive a later session key; an added member must not receive historical records. When a live call needs forward secrecy, use the duplex handshake `initiate_forward_secure_pipe_session`/`accept_forward_secure_pipe_session` or the browser `initiateMTPForwardSecurePipeSession`/ `acceptMTPForwardSecurePipeSession`. The responder contributes a fresh ephemeral hybrid-KEM key, while long-term signing keys authenticate the exchange. These helpers require a bidirectional stream and bind the handshake transcript into the record context. ## Accepting or Rejecting a Pipe The receiving side reads pipe requests through `receive_pipe`, the host dispatcher, or the browser pipe callback. It calls `accept` to obtain a reader or `deny` to reject the request. A rejected request completes the creator's handle with `Rejected` and no raw byte stream becomes available. With native pipes enabled, do not read the underlying `receiver` directly. Normal messages and pipe requests share the transport and must pass through the connection facade so a dispatcher does not deliver one event to the wrong consumer. ## Closing a Pipe The creator closes a successful encrypted pipe with `EncryptedPipeWriter::finish` or the browser writer's `close`; this authenticates `FINAL` and then sends a QUIC FIN. Use `abort` when the peer should discard the stream immediately; this resets the stream and the reader receives an error instead of a clean EOF. Dropping the connection closes all active pipes. Raw pipe FIN is not an authenticated application completion signal. The accepting side closes its reader by consuming it or dropping it. A reader does not send an application-level acknowledgement for EOF. If the application needs completion metadata, send an ordinary MTP message before finishing the pipe. ## Pipe Errors | Error | Meaning | | --- | --- | | `Rejected` | The peer denied the request. | | `HandshakeTimeout` | The peer did not complete the pipe handshake in time. | | `StreamClosed` | The pipe stream ended unexpectedly. | | `IoError` | The underlying byte stream returned an I/O error. | | `ConnectionClosed` | The MTP connection closed while the pipe was active. | Native applications use the pipe APIs on `MTPConnection`; browser applications use the SDK methods in [WASM Client](WASM-CLIENT.md#pipes). With native pipes enabled, normal messages and pipe requests must be read through the connection facade so the dispatcher can route each event to the correct queue. ## Native File Upload and Processing The creator streams a file in encrypted records. The accepting side processes each decrypted chunk without buffering the complete file. The `session_key` below is obtained from the authenticated pipe-establishment protocol: ```rust // Client use mtp_transport::{PipeSessionParameters, initiate_pipe_session}; use tokio::io::AsyncReadExt; let handle = conn.create_pipe("file-upload").await?; let pipe_id = handle.pipe_id(); if let Some(writer) = handle.wait().await? { let params = PipeSessionParameters::new( format!("file-upload/{pipe_id}"), pipe_id, own_client_id, host_client_id, 0x40, 0, )?; let mut writer = initiate_pipe_session( writer.into_inner(), params, &own_keyring, &host_public_bundle, ).await?; let mut file = tokio::fs::File::open("input.bin").await?; let mut buffer = [0u8; 64 * 1024]; loop { let count = file.read(&mut buffer).await?; if count == 0 { break; } writer.write_record(&buffer[..count]).await?; } writer.finish().await?; } ``` ```rust // Host use mtp_transport::{PipeSessionParameters, accept_pipe_session}; while let Ok(request) = conn.receive_pipe().await { if request.description() != "file-upload" { request.deny().await?; continue; } let pipe_id = request.id(); let reader = request.accept().await?; let params = PipeSessionParameters::new( format!("file-upload/{pipe_id}"), pipe_id, client_id, own_client_id, 0x40, 0, )?; let mut reader = accept_pipe_session( reader.into_inner(), ¶ms, &own_keyring, &client_public_bundle, ).await?; let mut hasher = sha2::Sha256::new(); while let Some(chunk) = reader.read_record().await? { hasher.update(&chunk); process_chunk(&chunk).await?; } let digest = hasher.finalize(); println!("processed upload with digest {digest:x}"); } ``` Send completion metadata as an ordinary MTP message after the reader observes EOF. A stream FIN means the writer finished; it does not authenticate file contents or provide a digest.