18 KiB
MTP Native Client
The native client is a Rust library (mtp-client) for connecting to an MTP host over QUIC. It uses wtransport under the hood and provides both unauthenticated and authenticated (crypto handshake) connection modes.
Cargo Dependency
Add the mtp umbrella crate with the client feature (and optionally crypto for authentication):
[dependencies]
mtp = { path = "/path/to/mtp", features = ["client"] }
# Add crypto for auth_connect / auth_register:
mtp = { path = "/path/to/mtp", features = ["client", "crypto"] }
# Add pipes for raw binary streams:
mtp = { path = "/path/to/mtp", features = ["client", "pipes"] }
ClientConfig
use mtp::client::{ClientConfig, ClientTlsConfig};
use std::time::Duration;
let config = ClientConfig::new("https://host.example.com:4433")
.with_tls(ClientTlsConfig::SystemRoots)
.with_client_id(0)
.with_ping_interval(Duration::from_secs(5))
.with_max_missed_pings(3)
.with_ping_timestamp(true);
| Field | Type | Default | Description |
|---|---|---|---|
url |
String |
required | Host URL (https://host:port) |
tls |
ClientTlsConfig |
SystemRoots |
SystemRoots or PinnedPem(Vec<u8>) |
client_id |
u64 |
0 |
Client identifier (for login) |
description |
Option<String> |
None |
Optional label sent to host |
policy |
Policy |
default | Transport policy (timeouts, send mode) |
ping_interval |
Duration |
Duration::ZERO |
Interval between protocol Ping frames |
ping_jitter |
Option<Duration> |
None |
Random jitter added to each interval |
max_missed_pings |
usize |
3 |
Disconnect after this many unanswered Pings |
ping_timestamp |
bool |
true |
Include a Timestamp data entry in Ping |
auth_timeout (crypto) |
Duration |
30s |
Max time for auth handshake |
TLS Certificate Handling
When tls is ClientTlsConfig::SystemRoots (the default), the client loads the system's
native root certificate store via rustls_native_certs. This works with
publicly-trusted CAs out of the box on Linux (using openssl-probe), macOS
(Keychain), and Windows (Root Store).
For development or self-signed certificates, provide one or more PEM-encoded certificates:
let pem = std::fs::read("my-server-cert.pem")?;
let config = ClientConfig::new("https://host.example.com:4433").with_pinned_pem(pem);
When pinned, only the given certificate(s) are trusted for the TLS handshake.
Connection Methods
All methods return a Result<MTPConnection, CommunicationError>.
MTPConnection
pub struct MTPConnection {
pub version: Version,
pub sender: Sender,
pub receiver: Receiver,
pub description: Option<String>,
#[cfg(feature = "crypto")]
pub auth_state: AuthState,
#[cfg(feature = "crypto")]
pub client_id: u64,
}
version-- the negotiated protocol versionsender/receiver-- for message I/Odescription-- the label sent during handshake (set viaClientConfig::with_description)client_id-- the confirmed/assigned client identifier (crypto only)
When ping_interval is non-zero, MTP sends Ping frames in the background and
consumes their Pong responses before application message handling. get_ping()
returns the round-trip duration of the latest matched Pong, or None until a
Pong arrives. A connection closes when the configured unanswered Ping limit is
reached.
Ping-Pong
Ping/Pong is part of the protocol, not just a transport keepalive. Each Ping
frame is matched against a Pong with the same frame id, and the client uses the
response to update get_ping(). If the host does not answer within the
configured limit, the connection closes.
Enable it in ClientConfig, then inspect the latest round-trip time on the
connection. Pings start after the connection has been established; None is
normal until the first matching Pong arrives.
use mtp::client::{ClientConfig, MTPClient};
use std::time::Duration;
let config = ClientConfig::new("https://host.example.com:4433")
.with_client_id(42)
.with_ping_interval(Duration::from_secs(5))
.with_max_missed_pings(3)
.with_ping_timestamp(true);
let conn = MTPClient::connect(config).await?;
if let Some(round_trip) = conn.get_ping() {
println!("latest MTP round trip: {round_trip:?}");
}
The client consumes the Pong frames used by this loop, so they are not returned
by conn.receiver.receive(). Set ping_interval to Duration::ZERO (the
default) to disable protocol pings. max_missed_pings is the number of
outstanding Ping frames allowed before the client closes the connection; use a
host with automatic Pong responses, or provide an equivalent responder.
Unauthenticated Connect
use mtp::client::{ClientConfig, MTPClient};
let config = ClientConfig::new("https://host.example.com:4433").with_client_id(42);
let conn = MTPClient::connect(config).await?;
Sends an Identification frame with the compiled-in protocol version and
client ID. No cryptographic handshake is performed.
Authenticated Login
use mtp::client::MTPClient;
use mtp::crypto::{Keyring, PublicKeyBundle};
let keys = Keyring::from_bytes(&saved_keyring_bytes)?;
let host_pk = PublicKeyBundle::from_bytes(&saved_host_pk_bytes)?;
let config = ClientConfig::new("https://host.example.com:4433")
.with_client_id(42); // must match the keyring's identity
let conn = MTPClient::auth_connect(config, &keys, &host_pk).await?;
Protocol (challenge-response, the host issues the freshness):
- Client sends an unsigned
Identificationhello (version, client ID) - Host replies with a
Challengecarrying a fresh randomserver_challengeand the host's signature over it; the client verifies that signature - Client generates a random
client_nonceand signsversion || client_id || server_challenge || client_noncewith Ed25519 (and optionally ML-DSA-65) - Client sends a
ChallengeResponseframe (nonce + signature(s)) - Host verifies the proof against
server_challengeand responds withIdentificationResponse(echoed nonce + host signature) - Client verifies the host signature and nonce echo
Because the client's signature covers the host-issued server_challenge, a
captured proof cannot be replayed on another connection (each connection gets a
different challenge).
Registration
let (ed_signer, sig_sk, sig_pk) = mtp::crypto::Ed25519Signer::generate();
let (pq_signer, sig_pq_sk, sig_pq_pk) = mtp::crypto::MlDsaSigner::generate();
let (kem_sk, kem_pk) = mtp::crypto::HybridKem::generate_keypair();
let keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk);
let conn = MTPClient::auth_register(config, &keyring, &host_pk).await?;
// Save for next session
let id = conn.client_id;
let keyring_bytes = keyring.to_bytes();
When callers already know whether a saved client id exists, the convenience helper chooses login or registration:
let conn = MTPClient::auth_connect_or_register(
config,
saved_client_id, // Option<u64>
&keyring,
&host_pk,
).await?;
Protocol (challenge-response):
- Client sends an unsigned
Registerhello (version, public key bundle) - Host replies with a
Challengecarrying a fresh randomserver_challenge(signed by the host); the client verifies that signature - Client generates a random
client_nonceand signsversion || server_challenge || client_nonce || public_key_byteswith Ed25519 (and optionally ML-DSA-65) - Client sends a
ChallengeResponseframe (nonce + signature(s)) - Host verifies the proof against
server_challenge, assigns a new client ID, and responds withRegisterResponse(the ID, echoed nonce, host signature) - Client verifies the host signature and nonce echo
Key Material
Keyring
A Keyring bundles all secret and public key material for one identity:
pub struct Keyring {
pub kem_public_key: KemPublicKey,
pub kem_secret_key: KemPrivateKey,
pub sig_pq_public_key: SignaturePqPublicKey, // ML-DSA-65
pub sig_pq_secret_key: SignaturePqPrivateKey,
pub sig_cl_public_key: SignaturePublicKey, // Ed25519
pub sig_cl_secret_key: SignaturePrivateKey,
}
- Serialise:
keyring.to_bytes()->Vec<u8> - Deserialise:
Keyring::from_bytes(&bytes)->Result<Keyring, CryptoError> - Get public half:
keyring.public_key_bundle()->PublicKeyBundle
PublicKeyBundle
The public half of a keyring, used by the host for signature verification and by the client for host signature verification:
pub struct PublicKeyBundle {
pub kem_public_key: KemPublicKey,
pub sig_cl_public_key: SignaturePublicKey,
pub sig_pq_public_key: SignaturePqPublicKey,
}
Obtain the host's PublicKeyBundle out of band (e.g. from files exported by
the host, or from a trusted directory).
Sending and Receiving Messages
CommunicationValue
Messages are CommunicationValue frames. Construct them with the builder API:
use mtp::codec::{CommunicationValue, CommunicationType, DataType, DataValue};
use mtp::type_map::TypeMap;
let msg = CommunicationValue::new(CommunicationType::Ping)
.with_sender(conn.client_id)
.add_typed_default(DataType::Description, DataValue::Str("hello".into()))
.add_typed_default(DataType::Timestamp, DataValue::UnsignedNumber(now))
.to_bytes();
When the registry feature is enabled (via the host feature), you can also
use add_typed with a TypeMap to resolve data type names from your project's
type-map configuration.
Send
conn.sender.send(&msg).await?;
For request/response flows, MTPConnection::request sends one frame and waits
for a response with the same non-zero frame id. An expected response type can be
provided for validation:
let response = conn
.request(&msg, Some(mtp::codec::CommunicationType::Pong))
.await?;
Frames with other ids are consumed by this helper. Applications that need subscriptions or broad routing should use one receive task and correlate there.
Two send modes (configured via mtp::transport::Policy):
PersistentStream(default) -- reuses one QUIC uni-directional streamSingleStreamPerMessage-- opens a new stream per message
Receive
match conn.receiver.receive().await {
Ok(msg) => { /* handle CommunicationValue */ }
Err(e) => { /* connection closed or error */ }
}
Inbound frames are queued internally. The receive() method returns the next
available message.
Close
conn.sender.close();
// or
conn.receiver.close();
Sends a close frame and signals the peer. The Sender::close() spawns an async
task that sends the frame, waits for force_close_delay (default 300ms), then
force-closes the QUIC connection if the peer has not already done so.
Pipes
With the pipes feature enabled, the client can open raw binary streams
to the host. A Pipe is a unidirectional QUIC stream that carries a lightweight
PipeRequest handshake frame, then transitions to raw bytes with zero per-frame
overhead.
Enabling Pipes
Add the pipes feature to your dependency:
[dependencies]
mtp = { path = "/path/to/mtp", features = ["client", "pipes"] }
Creating a Pipe
use mtp::client::MTPClient;
use tokio::io::AsyncWriteExt;
let conn = MTPClient::connect(config).await?;
// Initiate a pipe request
let handle = conn.create_pipe("file-transfer").await?;
// Wait for the host to accept or reject
match handle.wait().await? {
Some(mut writer) => {
writer.write_all(b"raw binary data").await?;
writer.finish().await?; // graceful close
}
None => {
println!("host rejected the pipe");
}
}
PipeHandle
pub struct PipeHandle {
pipe_id: u32,
description: String,
}
| Method | Returns | Description |
|---|---|---|
wait() |
Result<Option<PipeWriter>, PipeError> |
Block until the host responds. Some(writer) if accepted, None if rejected. |
PipeHandle consumes itself on wait(), so you cannot poll it multiple times.
PipeWriter
pub struct PipeWriter {
// wraps a QUIC SendStream
}
PipeWriter implements tokio::io::AsyncWrite. After the handshake succeeds,
writes go directly to the QUIC stream with no framing overhead.
| Method | Returns | Description |
|---|---|---|
finish() |
Result<(), CommunicationError> |
Gracefully close the stream (sends FIN) |
abort() |
Result<(), ClosedStream> |
Abruptly reset the stream |
use tokio::io::AsyncWriteExt;
let mut writer = handle.wait().await?.unwrap();
writer.write_all(b"chunk 1").await?;
writer.write_all(b"chunk 2").await?;
writer.finish().await?;
PipeError
pub enum PipeError {
Rejected, // pipe request was rejected
HandshakeTimeout, // pipe handshake timed out
StreamClosed, // pipe stream closed unexpectedly
IoError(String), // pipe I/O error
ConnectionClosed, // connection closed
}
PipeError implements std::error::Error and can be converted from
CommunicationError via PipeError::from().
Do Not Use receiver.receive() for Pipes
When the pipes feature is active, conn.receiver.receive() will skip
PipeResponse frames and may return them as ordinary messages if called from
the wrong task. Use the facade methods:
conn.receive()to receive normalCommunicationValuemessagesconn.create_pipe(description)to initiate a new pipe
These methods are internally synchronised and safe to call from separate tasks.
Crypto Containers
With the crypto feature, DataValue supports encrypted, signed, and
signed+encrypted containers. Encryption uses ML-KEM to encapsulate to a
recipient's KEM public key (from their PublicKeyBundle); only the holder of
the matching Keyring can decrypt. Signing uses the sender's Ed25519 key.
use mtp::crypto::{EncryptionType, Ed25519Signer, SigAlgorithm};
let enc_type = EncryptionType::MlKemChaCha20Poly1305;
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?;
// `recipient` is the PublicKeyBundle of whoever should be able to decrypt
// (e.g. the host's bundle, obtained out of band).
// Encrypted container
let mut enc = DataValue::Container(vec![
(DataTypeId(1), DataValue::Str("secret".into())),
]);
enc.encrypt_container(enc_type, &recipient, b"aad");
// Signed container
let mut sig = DataValue::Container(vec![
(DataTypeId(1), DataValue::Str("signed".into())),
]);
sig.sign_container(SigAlgorithm::ED25519, &signer);
// Signed + encrypted
let mut sec = DataValue::Container(vec![
(DataTypeId(1), DataValue::Str("both".into())),
]);
sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, enc_type, &recipient, b"aad");
On the receiving side, the recipient decrypts with its own Keyring (each blob
is self-describing: its leading byte selects the algorithm and the matching KEM
key from the keyring):
enc.decrypt_into_container(&keyring, b"aad"); // -> Container
sig.verify_into_container(&verifier); // verifier: impl SignatureScheme
sec.decrypt_signed_encrypted_container(&keyring, b"aad"); // -> SignedContainer, then verify_into_container
Policy Configuration
The Policy struct controls transport behaviour:
use mtp::transport::{Policy, SendMode};
let policy = Policy {
send_mode: SendMode::PersistentStream,
max_message_size: 1_000_000_000,
open_stream_timeout: Duration::from_millis(2000),
write_timeout: Duration::from_millis(2000),
read_timeout: Duration::from_millis(30_000),
keep_alive_interval: Some(Duration::from_secs(3)),
max_idle_timeout: Some(Duration::from_secs(30)),
..Default::default()
};
To apply a custom policy, call mtp_transport::connect() directly instead of
using MTPClient:
use mtp_transport::{connect, Policy};
let server_cert = match &config.tls {
ClientTlsConfig::SystemRoots => None,
ClientTlsConfig::PinnedPem(pem) => Some(pem.clone()),
};
let (sender, receiver) = connect(&config.url, server_cert, policy).await?;
Then build and send the initial Identification frame manually to complete
version negotiation.
Version
The client's protocol version is baked in at compile time via the
PROTOCOL_VERSION constant from mtp::codec. The version is set by the
protocol_version field in your type-maps.yaml.
The client never imports the registry module; it uses a single compiled-in
version and expects the host to negotiate a compatible version.
Error Handling
CommunicationError covers transport errors:
| Variant | Meaning |
|---|---|
StreamClosed |
Connection was closed by peer or timed out |
StreamError |
Transport-level I/O error |
MessageTooLarge |
Frame exceeds max_message_size |
ParseCommunicationValue |
Failed to deserialize incoming frame |
AuthenticationFailed |
Nonce mismatch or invalid host signature |
ConnectionError |
QUIC connection failure |
UseAfterClosed |
Attempted send/receive after close |