13 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 |
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.
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.