14 KiB
MTP Native Host
The native host is a Rust library (mtp-host) that runs a QUIC server, accepts
MTP client connections, negotiates protocol versions, and optionally performs a
mutual-authentication handshake (login/register) using Ed25519 and ML-DSA-65
signatures.
Cargo Dependency
[dependencies]
mtp = { path = "/path/to/mtp", features = ["host"] }
# Add crypto for authenticated connections:
mtp = { path = "/path/to/mtp", features = ["host", "crypto"] }
HostConfig
use mtp::host::HostConfig;
use std::net::{IpAddr, Ipv4Addr};
let config = HostConfig::new(
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
4433,
std::fs::read("cert.pem")?,
std::fs::read("key.pem")?,
)
.with_authentication(
/* Keyring */,
|client_id: u64| {
let db = CLIENT_DB.clone();
Box::pin(async move { db.lock().unwrap().get(&client_id).cloned() })
},
|bundle: PublicKeyBundle| {
let mut db = CLIENT_DB.lock().unwrap();
let id = next_id();
db.insert(id, bundle);
Box::pin(async move { id })
},
);
| Field | Type | Description |
|---|---|---|
ip |
IpAddr |
Bind address |
port |
u16 |
Listen port |
tls_fullchain |
Vec<u8> |
PEM-encoded TLS certificate chain |
tls_key |
Vec<u8> |
PEM-encoded TLS private key |
send_pongs |
bool |
Sends a Pong for each received Ping (default true) |
authentication_policy |
AuthenticationPolicy (crypto) |
ForceAuthentication, AllowAuthentication, or Unauthenticated |
host_keyring |
Keyring (crypto) |
Host's signing and KEM keys |
get_existing_user |
Fn(u64) -> Pin<Box<dyn Future<Output = Option<PublicKeyBundle>> + Send>> + Send + Sync (crypto) |
Async lookup callback for login |
complete_register |
Fn(PublicKeyBundle) -> Pin<Box<dyn Future<Output = u64> + Send>> + Send + Sync (crypto) |
Async registration callback, returns new client ID |
AuthenticationPolicy
ForceAuthentication requires every client to complete the login/register handshake. AllowAuthentication accepts both authenticated and unauthenticated connections — unauthenticated clients get a random ID and AuthState::Unauthenticated. Unauthenticated rejects any client that tries to authenticate and is the default.
use mtp::host::AuthenticationPolicy;
// Force authentication (default was `require_authentication: true`):
let config = HostConfig::new(ip, port, cert, key)
.with_authentication(host_keyring, get_user, register);
// Allow both authenticated and unauthenticated:
let config = HostConfig::new(ip, port, cert, key)
.with_allow_authentication(host_keyring, get_user, register);
// Unauthenticated only (default):
let config = HostConfig::new(ip, port, cert, key);
TLS
The host requires a TLS certificate. For development, generate a self-signed
certificate using rcgen. For production, use a CA-signed certificate.
Ping-Pong
The host handles protocol Ping/Pong automatically unless you disable it with
with_pongs(false). Enable the default responder explicitly when constructing
the host if you want to make the choice visible in application configuration:
let config = HostConfig::new(ip, port, cert, key)
.with_pongs(true);
For every received Ping, the responder sends a Pong with the same frame id and
copies the optional Timestamp data entry. Ping and Pong frames handled this
way are not delivered by conn.receiver.receive(). This lets native clients
use ClientConfig::with_ping_interval and MTPConnection::get_ping() without
adding application-level handlers.
Disable it only when the application needs to handle Ping frames itself:
let config = HostConfig::new(ip, port, cert, key)
.with_pongs(false);
With automatic responses disabled, Ping frames are delivered through the normal
receiver and the application is responsible for sending a compatible Pong (the
same frame id, and normally the Ping's Timestamp) if it wants clients to
continue their protocol ping loop.
Accepting Connections
use mtp::host::MTPHost;
let mut host = MTPHost::new(config).await?;
println!("Listening on {}", host.local_addr());
while let Some(conn) = host.accept().await? {
// conn is an MTPConnection ready for I/O
}
MTPConnection
Returned by accept() after version negotiation (and authentication if
enabled):
pub struct MTPConnection {
pub version: Version,
pub codec: VersionedCodec,
pub sender: Sender,
pub receiver: Receiver,
pub description: Option<String>,
#[cfg(feature = "crypto")]
pub auth_state: AuthState,
#[cfg(feature = "crypto")]
pub client_id: u64,
#[cfg(feature = "crypto")]
pub client_public_key: Option<PublicKeyBundle>,
}
version-- the negotiated protocol versioncodec-- aVersionedCodecscoped to the negotiated version (use for version-aware encode/decode)sender/receiver-- for message I/Odescription-- optional client-provided label (e.g."phone","desktop")client_id-- the authenticated client's IDclient_public_key-- the client's public key bundle (for signature verification of subsequent messages)
Version Negotiation
When a client connects, accept() performs the following sequence:
- Accept the QUIC connection
- Read the client's first
CommunicationValue(always encoded with reserved type IDs) - Extract the protocol version from
DataType::Version(reserved data type ID 0) as aDataValue::Str("major.minor") - Call
registry.negotiate(&[client_version])to find the highest mutually supported version - Return an
AcceptError(closing the connection) if no compatible version exists - Return
Ok(Some(MTPConnection))with the negotiated version
The Registry is built automatically from all type maps defined in your
type-maps.yaml via Registry::builtin().
Registry
use mtp::codec::registry::Registry;
let registry = host.registry();
assert!(registry.supports(&Version(2, 0)));
let negotiated = registry.negotiate(&[Version(1, 0), Version(2, 0)]);
// -> Some(Version(2, 0)) if both versions are registered
Authentication Flow
When authentication_policy is ForceAuthentication, accept() runs a mutually-authenticated
challenge-response handshake before returning the connection. The host issues
a fresh, random server_challenge that the client must sign, which is what makes
the client's proof unreplayable: a captured proof is bound to a one-time challenge
the host generates per connection and will never reissue. The challenge lives only
on the accepting task's stack; there is no replay database or shared state.
All signed payloads begin with a one-byte domain-separation tag (see
mtp::crypto::auth) so a signature for one step can never be reused as another.
Login
Client Host
| |
| QUIC connect |
|---------------------------------------->|
| |
| Identification { Version, Id } | (unsigned hello)
|---------------------------------------->|
| | lookup get_existing_user(id)
| | generate random server_challenge
| Challenge { |
| ServerNonce(server_challenge), |
| Signature, [PqSignature] | host signs the challenge
| } |
|<----------------------------------------|
| ChallengeResponse { |
| ClientNonce, Signature, [PqSignature]| client signs the challenge
| } |
|---------------------------------------->|
| | verify proof over server_challenge
| IdentificationResponse { |
| Connected=true, Id, |
| ClientNonce(echoed), |
| Signature, [PqSignature] |
| } |
|<----------------------------------------|
Payloads (|| is concatenation, integers big-endian; DS_* are domain tags):
- Host challenge:
DS_CHALLENGE || id (8) || server_challenge (16) - Client proof:
DS_LOGIN_PROOF || version_string || id (8) || server_challenge (16) || client_nonce (16) - Host final:
DS_HOST_FINAL || assigned_id (8) || client_nonce (16) || server_challenge (16)
Register
Client Host
| |
| QUIC connect |
|---------------------------------------->|
| |
| Register { |
| Version, | (unsigned hello)
| PublicKeys (serialized PublicKeyBundle)
| } |
|---------------------------------------->|
| | generate random server_challenge
| Challenge { |
| ServerNonce(server_challenge), |
| Signature, [PqSignature] | (challenge binds id = 0)
| } |
|<----------------------------------------|
| ChallengeResponse { |
| ClientNonce, Signature, [PqSignature]|
| } |
|---------------------------------------->|
| | verify proof over server_challenge
| | call complete_register(bundle) -> new_id
| RegisterResponse { |
| Connected=true, Id(new_id), |
| ClientNonce(echoed), |
| Signature, [PqSignature] |
| } |
|<----------------------------------------|
The register client proof is:
DS_REGISTER_PROOF || version_string || server_challenge (16) || client_nonce (16) || public_key_bytes
After a successful handshake, accept() returns an MTPConnection with
auth_state = Authenticated, client_id set, and client_public_key
available for verifying subsequent signed messages from the client.
Rejection
If verification fails or the client is not found (login), the host sends a
rejection response with Connected=false and closes the send stream, returning
AcceptError::AuthenticationFailed from accept().
Handling Messages
Use conn.sender and conn.receiver for bidirectional message exchange:
while let Some(conn) = host.accept().await? {
tokio::spawn(async move {
loop {
match conn.receiver.receive().await {
Ok(msg) => {
let response = process_message(&msg, &conn);
conn.sender.send(&response).await.ok();
}
Err(_) => break,
}
}
});
}
Versioned Codec
The conn.codec is a VersionedCodec pre-configured with the negotiated
version. Use it to encode/decode with version-specific type maps:
let tm = conn.codec.registry().get(&conn.version).unwrap();
// Look up type IDs for the negotiated version
let desc_id = DataTypeId(tm.data_id_enum(DataType::Description).unwrap());
let value = msg.get_data(desc_id);
Host Callbacks
get_existing_user
Called during login to retrieve a client's public key bundle for signature
verification. Must return Some(PublicKeyBundle) if the client ID is known,
or None to reject.
let get_existing_user = |id: u64| {
let db = db.clone();
Box::pin(async move { db.lock().unwrap().get(&id).cloned() })
};
complete_register
Called during registration to persist a new client's public key bundle and
assign a client ID. The returned u64 becomes the client's permanent
identifier.
let complete_register = |bundle: PublicKeyBundle| {
let db = db.clone();
let id = next_id.fetch_add(1, Ordering::SeqCst);
Box::pin(async move {
db.lock().unwrap().insert(id, bundle);
id
})
};
Both callbacks are called from within accept() and must be Send + Sync. They
are async (returning Pin<Box<dyn Future<...>>) and are .awaited by the
host, so they can perform I/O or other async work as needed.
Host Key Generation
Generate a host keyring once and persist it:
use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner};
use mtp::crypto::kem::HybridKem;
let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate();
let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate();
let (kem_sk, kem_pk) = HybridKem::generate_keypair();
let host_keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk);
// Save to disk
let bytes = host_keyring.to_bytes();
std::fs::write("host_keys.bin", bytes)?;
Export the public key bundle so clients can verify the host identity:
let bundle = host_keyring.public_key_bundle();
std::fs::write("host_enc_kem_pk.bin", bundle.kem_public_key.as_bytes())?;
std::fs::write("host_sig_pk.bin", bundle.sig_cl_public_key.as_bytes())?;
std::fs::write("host_sig_pq_pk.bin", bundle.sig_pq_public_key.as_bytes())?;
Policy
The transport Policy is set to defaults internally. To customise (timeouts,
send mode, etc.), use mtp_transport::host() directly instead of MTPHost:
use mtp_transport::{host, Policy};
let transport = host(ip, port, cert, key, custom_policy).await?;
// Then build version negotiation on top:
// - accept transport.next()
// - read first frame
// - registry.negotiate()
// - return MTPConnection
Graceful Shutdown
Drop the MTPHost to stop accepting new connections. Active connections
continue until their Sender/Receiver are dropped or the peer disconnects.