(feat): add package.json for easy wasm installs
Some checks failed
CI / rustfmt (push) Failing after 23s
CI / clippy (push) Successful in 1m51s
CI / wasm build (push) Successful in 1m33s
CI / test (push) Successful in 2m21s
CI / example usage (push) Successful in 1m42s
CI / duplicate code (push) Successful in 11s
CI / cargo-machete (push) Successful in 1m38s
CI / web client (push) Failing after 24s
CI / cargo-deny (push) Failing after 3m23s

(feat): add code quality control
(fix): ci rewritten for forgejo
(qol): move docs to dedicated docs/ folder
This commit is contained in:
Alois 2026-06-27 02:32:43 +02:00
commit 22245e673d
20 changed files with 354 additions and 112 deletions

144
docs/CONNECTOR.md Normal file
View file

@ -0,0 +1,144 @@
# Connector
This file documents the connection and version negotiation logic.
## Registry
The `registry` module provides a multi-version `Registry` used by the host for version negotiation. Accessed through the `mtp` facade (requires the `host` feature):
```rust
use mtp::codec::registry::Registry;
let registry = Registry::builtin(); // loads all TypeMaps from config
// Check if a version is supported
assert!(registry.supports(&Version(1, 0)));
// Find highest mutual version for a client
let client_versions = &[Version(0, 0), Version(1, 0)];
let negotiated = registry.negotiate(client_versions);
assert_eq!(negotiated, Some(Version(1, 0)));
// Look up a version's TypeMap
let tm = registry.get(&Version(2, 0)).unwrap();
```
The `Registry::builtin()` constructor uses the `TypeMap::vX_Y()` methods generated from the config.
---
## Host
The host creates a QUIC server, manages the registry, and handles version negotiation with each connecting client.
### Initialization
The host binds to the address from the `mtp_BIND` environment variable (defaults to `::`) on the specified port:
```rust
use mtp::host::{MTPHost, HostConfig};
let config = HostConfig {
port: 4433,
tls_fullchain: std::fs::read("cert.pem")?,
tls_key: std::fs::read("key.pem")?,
};
let mut host = MTPHost::new(config).await?;
```
### Accepting Connections with Version Negotiation
```rust
while let Some(conn) = host.accept().await {
// conn.version is the negotiated version
// conn.codec is a VersionedCodec scoped to that version
// conn.sender / conn.receiver for raw CommunicationValue I/O
let msg = conn.receiver.receive().await?;
}
```
The host's `accept()` method:
1. Accepts a QUIC connection
2. If authentication is required (crypto feature): performs login/register handshake
3. Reads the first `CommunicationValue` (always encoded with reserved type IDs)
4. Extracts the client's protocol version from `DataType::Version` (wire ID 3)
5. Calls `registry.negotiate(&[client_version])`
6. Returns `None` if the version is unsupported
7. Returns an `MTPConnection` with the negotiated version otherwise
### Login/Register Handshake
When `require_authentication` is set, the parties run a mutually-authenticated
**challenge-response**. The client speaks first with an *unsigned* hello:
- **Login** (`CommunicationType::Identification`, ID 15): version, client ID
- **Register** (`CommunicationType::Register`, ID 17): version, public keys
The host then issues a fresh random `server_challenge` in a signed `Challenge`
(`CommunicationType::Challenge`, ID 21, carrying `ServerNonce`). The client signs
that challenge, binding its id (login) or public keys (register), and returns a
`ChallengeResponse` (ID 22). The host verifies the proof against the challenge it
issued and sends a signed final response, which the client verifies.
Because the client's proof covers the host-issued `server_challenge` (a one-time
value held only on the accepting task's stack), a captured proof cannot be
replayed on another connection. All signed payloads are domain-separated; see
`mtp::crypto::auth`.
---
## Client
The client connects to a host and uses a single compiled-in protocol version.
```rust
use mtp::client::{MTPClient, ClientConfig};
let config = ClientConfig {
url: "https://host.example.com:4433".into(),
server_cert: None, // or Some(cert_pem_bytes)
};
// Connect (unauthenticated, existing client)
let conn = MTPClient::connect(config, 8765).await?;
// Authenticated login
let conn = MTPClient::auth_connect(config, 8765, keys, host_pk).await?;
// Registration (new client)
let conn = MTPClient::auth_register(config, keys, host_pk).await?;
```
The client's `PROTOCOL_VERSION` constant is set by `protocol_version` in `type-maps.yaml` and baked in at compile time. The client never imports the `registry` crate; it only uses `mtp::type_map` for enum types and `mtp::codec` for encoding.
---
## Version Negotiation Flow
```
Client (v2.0) Host (v0.0, v1.0, v2.0)
| |
| QUIC connect |
|----------------------->|
| |
| CommValue{ Ident. } |
| Version -> "2.0" |
| Id -> 8765 |
| (unsigned hello; auth |
| challenge follows) |
|----------------------->|
| | registry.negotiate(&[Version(2,0)])
| | -> Some(Version(2,0))
| |
| Response |
|<-----------------------| (uses v2.0 TypeMap for encoding)
| Status, Nonces, |
| Signature |
| |
| (subsequent messages |
| use v2.0 TypeMap) |
```
If the client sends an unsupported version (e.g. v3.0 when the host only knows up to v2.0), `negotiate` returns `None` and the connection is closed.

345
docs/NATIVE-CLIENT.md Normal file
View file

@ -0,0 +1,345 @@
# 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):
```toml
[dependencies]
mtp = { path = "/path/to/mtp", features = ["client"] }
# Add crypto for auth_connect / auth_register:
mtp = { path = "/path/to/mtp", features = ["client", "crypto"] }
```
## ClientConfig
```rust
use mtp::client::ClientConfig;
let config = ClientConfig {
url: "https://host.example.com:4433".into(),
server_cert: None, // None = use system root certificates
client_id: 0, // previously assigned ID or 0
};
```
| Field | Type | Description |
|--------------|--------------------|-----------------------------------------------------|
| `url` | `String` | `https://host:port` address of the MTP host |
| `server_cert`| `Option<Vec<u8>>` | `None` to use system roots, `Some(pem_bytes)` to pin |
| `client_id` | `u64` | Client identifier (ignored during `auth_register`) |
### TLS Certificate Handling
When `server_cert` is `None` (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:
```rust
let pem = std::fs::read("my-server-cert.pem")?;
let config = ClientConfig {
server_cert: Some(pem),
// ...
};
```
When pinned, **only** the given certificate(s) are trusted for the TLS
handshake.
## Connection Methods
All methods return a `Result<MTPConnection, CommunicationError>`.
### MTPConnection
```rust
pub struct MTPConnection {
pub version: Version,
pub sender: Sender,
pub receiver: Receiver,
#[cfg(feature = "crypto")]
pub auth_state: AuthState,
#[cfg(feature = "crypto")]
pub client_id: u64,
}
```
- `version` -- the negotiated protocol version
- `sender` / `receiver` -- for message I/O
- `client_id` -- the confirmed/assigned client identifier (crypto only)
### Unauthenticated Connect
```rust
use mtp::client::{MTPClient, ClientConfig};
let config = ClientConfig {
url: "https://host.example.com:4433".into(),
server_cert: None,
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
```rust
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 {
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):
1. Client sends an unsigned `Identification` hello (version, client ID)
2. Host replies with a `Challenge` carrying a fresh random `server_challenge`
and the host's signature over it; the client verifies that signature
3. Client generates a random `client_nonce` and signs
`version || client_id || server_challenge || client_nonce` with Ed25519
(and optionally ML-DSA-65)
4. Client sends a `ChallengeResponse` frame (nonce + signature(s))
5. Host verifies the proof against `server_challenge` and responds with
`IdentificationResponse` (echoed nonce + host signature)
6. 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
```rust
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();
```
Protocol (challenge-response):
1. Client sends an unsigned `Register` hello (version, public key bundle)
2. Host replies with a `Challenge` carrying a fresh random `server_challenge`
(signed by the host); the client verifies that signature
3. Client generates a random `client_nonce` and signs
`version || server_challenge || client_nonce || public_key_bytes` with
Ed25519 (and optionally ML-DSA-65)
4. Client sends a `ChallengeResponse` frame (nonce + signature(s))
5. Host verifies the proof against `server_challenge`, assigns a new client ID,
and responds with `RegisterResponse` (the ID, echoed nonce, host signature)
6. Client verifies the host signature and nonce echo
## Key Material
### Keyring
A `Keyring` bundles all secret and public key material for one identity:
```rust
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:
```rust
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:
```rust
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
```rust
conn.sender.send(&msg).await?;
```
Two send modes (configured via `mtp::transport::Policy`):
- `PersistentStream` (default) -- reuses one QUIC uni-directional stream
- `SingleStreamPerMessage` -- opens a new stream per message
### Receive
```rust
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
```rust
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.
## 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.
```rust
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):
```rust
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:
```rust
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`:
```rust
use mtp_transport::{connect, Policy};
let (sender, receiver) = connect(&config.url, config.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 |

342
docs/NATIVE-HOST.md Normal file
View file

@ -0,0 +1,342 @@
# 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
```toml
[dependencies]
mtp = { path = "/path/to/mtp", features = ["host"] }
# Add crypto for authenticated connections:
mtp = { path = "/path/to/mtp", features = ["host", "crypto"] }
```
## HostConfig
```rust
use mtp::host::HostConfig;
use std::net::{IpAddr, Ipv4Addr};
let config = HostConfig {
ip: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
port: 4433,
tls_fullchain: std::fs::read("cert.pem")?,
tls_key: std::fs::read("key.pem")?,
// Crypto fields (required when feature = "crypto"):
require_authentication: true,
host_id: 1,
host_keyring: /* Keyring */,
get_existing_user: Box::new(|client_id: u64| -> Option<PublicKeyBundle> {
CLIENT_DB.lock().unwrap().get(&client_id).cloned()
}),
complete_register: Box::new(|bundle: PublicKeyBundle| -> u64 {
let id = next_id();
CLIENT_DB.lock().unwrap().insert(id, bundle);
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 |
| `require_authentication` | `bool` (crypto) | Enable login/register handshake |
| `host_id` | `u64` (crypto) | Host identifier |
| `host_keyring` | `Keyring` (crypto) | Host's signing and KEM keys |
| `get_existing_user` | `Box<dyn Fn(u64) -> Option<PublicKeyBundle> + Send>` (crypto) | Lookup callback for login |
| `complete_register` | `Box<dyn Fn(PublicKeyBundle) -> u64 + Send>` (crypto) | Registration callback, returns new client ID |
### 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
```rust
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):
```rust
pub struct MTPConnection {
pub version: Version,
pub codec: VersionedCodec,
pub sender: Sender,
pub receiver: Receiver,
#[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 version
- `codec` -- a `VersionedCodec` scoped to the negotiated version (use for
version-aware encode/decode)
- `sender` / `receiver` -- for message I/O
- `client_id` -- the authenticated client's ID
- `client_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:
1. Accept the QUIC connection
2. Read the client's first `CommunicationValue` (always encoded with reserved
type IDs)
3. Extract the protocol version from `DataType::Version` (wire ID 3) as a
`DataValue::Str("major.minor")`
4. Call `registry.negotiate(&[client_version])` to find the highest mutually
supported version
5. Return `None` (closing the connection) if no compatible version exists
6. Return an `MTPConnection` with the negotiated version
The `Registry` is built automatically from all type maps defined in your
`type-maps.yaml` via `Registry::builtin()`.
### Registry
```rust
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 `require_authentication` is `true`, `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
`None` from `accept()`.
## Handling Messages
Use `conn.sender` and `conn.receiver` for bidirectional message exchange:
```rust
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:
```rust
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.
```rust
let get_existing_user = Box::new(|id: u64| -> Option<PublicKeyBundle> {
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.
```rust
let complete_register = Box::new(|bundle: PublicKeyBundle| -> u64 {
let mut db = db.lock().unwrap();
let id = next_id;
next_id += 1;
db.insert(id, bundle);
id
});
```
Both callbacks are called from within `accept()` and must be `Send`. They are
invoked synchronously, so avoid long-running operations (or use `spawn_blocking`
if needed, though the callbacks are `Fn`, not `AsyncFn`).
## Host Key Generation
Generate a host keyring once and persist it:
```rust
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:
```rust
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`:
```rust
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.

96
docs/TYPE-MAP.md Normal file
View file

@ -0,0 +1,96 @@
# Type Map
This file documents the Type Map & Registry configuration used by the MTP protocol.
## TypeMap & Compile-Time Type Safety
A `TypeMap` maps Communication-Types and Data-Types to their wire IDs. Each protocol version has its own `TypeMap` because the same type name may use different wire IDs in different versions.
Type names are defined in a YAML config and turned into Rust enums at **compile time** by a `build.rs` in the `type-map` crate. This means invalid type names are caught by the compiler instead of failing at runtime.
### Defining Type Maps
An example `type-maps.yaml` is provided in the [`example-type-maps.yaml`](./example-type-maps.yaml) file. Place your own `type-maps.yaml` in your project root and set the `MTP_TYPE_MAPS` environment variable (see [Customizing Type Maps in Downstream Projects](#customizing-type-maps-in-downstream-projects)).
### Using Generated Enums
After editing the config and rebuilding, `CommunicationType` and `DataType` enums are generated automatically. Use them in code:
```rust
use mtp::type_map::{CommunicationType, DataType, TypeMap};
let tm = TypeMap::v2_0();
let id = tm.data_id_enum(DataType::SomeType).unwrap();
```
The enums are a **union across all versions**; every type name from every version is a variant. The version-specific `TypeMap` maps each variant to the correct wire ID for that version. Types not defined in a version return `None`:
Encoding/decoding uses a `TypeMap` to resolve type names to wire IDs:
```rust
use mtp::codec::{encode, decode, DataValue};
use mtp::type_map::TypeMap;
let tm = TypeMap::v2_0();
let value = DataValue::Str("hello".into());
let bytes = encode(&value, &tm).unwrap();
let decoded = decode(&bytes, &tm).unwrap();
```
```rust
let tm_v2 = TypeMap::v2_0();
assert!(tm_v2.data_id_enum(DataType::SomeType).is_some()); // defined in v2.0
assert!(tm_v2.data_id_enum(DataType::ExampleType).is_none()); // NOT in v2.0
let tm_v1 = TypeMap::v1_0();
assert!(tm_v1.data_id_enum(DataType::ExampleType).is_some()); // defined in v1.0
```
### Forward/Backward Compatibility Between Versions
Because enums are a union of all types across versions, a variant might exist that has no wire mapping in the *negotiated* version:
```
v2.0 client sends DataType::SomeType → host encodes with v2.0 TypeMap → wire ID 32
v2.0 host receives DataType::ExampleType (from v1.0 client) → not in v2.0 TypeMap → None → Error
```
This is by design: the host maps unknown types to `Error`, and the client should only send types that exist in its compiled-in version.
## Registry
The `registry` feature of the Codec crate adds `VersionedCodec` for version-aware encoding:
Requires the `host` feature (which enables `mtp-codec`'s `registry` feature):
```toml
[dependencies]
mtp = { path = "..", features = ["host"] }
```
```rust
use mtp::codec::registry::{Registry, VersionedCodec};
let registry = Registry::builtin();
let codec = VersionedCodec::new(registry);
// Encode with a specific version
let bytes = codec.encode(&value, Version(2, 0)).unwrap();
// Decode with a specific version
let decoded = codec.decode(&bytes, Version(2, 0)).unwrap();
```
## Customizing Type Maps in Downstream Projects
External projects must provide their own type map configuration via the `MTP_TYPE_MAPS` environment variable. There is no bundled default; the build script will error if the variable is not set or points to an invalid file.
1. Create a `type-maps.yaml` in your project root
2. Set the `MTP_TYPE_MAPS` environment variable in `.cargo/config.toml`:
```toml
# .cargo/config.toml
[env]
MTP_TYPE_MAPS = { value = "type-maps.yaml", relative = true }
```

291
docs/WASM-CLIENT.md Normal file
View file

@ -0,0 +1,291 @@
# MTP WASM Client
The WASM client is a browser-compatible MTP implementation that uses the **WebTransport** API to communicate with an MTP host over QUIC (HTTP/3). It compiles from Rust to WebAssembly via `wasm-bindgen` and exposes a JavaScript/TypeScript API through the `mtp-wasm` npm package.
## Package
The compiled package lives in `wasm/pkg/` and contains:
- `mtp_wasm.js` -- generated JS glue
- `mtp_wasm_bg.wasm` -- the WebAssembly binary
- `mtp_wasm.d.ts` -- TypeScript type declarations
- `package.json` -- npm package definition
Install or copy these files into your web project. Then initialise the module:
```typescript
import init, { WasmClient } from 'mtp-wasm';
await init();
```
## Browser Support
WebTransport is required. Check availability at runtime:
```typescript
if (!WasmClient.is_supported()) {
// fall back or show an error
}
```
## Connecting to a Host
### ConnectionConfig
```typescript
const config = new ConnectionConfig("https://host.example.com:4433");
config.client_id = 12345n; // optional, for re-authentication
config.server_certificate_hashes = [ // optional, for certificate pinning
"sha-256:abc123...",
];
```
`client_id` is only needed for authenticated login (`auth_connect`). For registration (`auth_register`) it is ignored.
### TLS Certificate Handling
By default, when `server_certificate_hashes` is not set, the browser uses its
**built-in root certificate store** to verify the server's TLS certificate,
just like any other HTTPS/WebSocket connection. This works with publicly-trusted
certificate authorities automatically.
For development or self-signed certificates, pin the server certificate by
providing its hash:
```typescript
config.server_certificate_hashes = [
"sha-256:abcd1234...", // hex-encoded hash value
];
```
The hash format is `"<algorithm>:<hex-encoded-hash>"`, where the only algorithm
the browser's WebTransport API currently accepts is `sha-256`. When hashes are
provided, the browser **only** trusts certificates matching one of the given
hashes and ignores its root store for this connection.
### Callbacks
The client uses three callbacks for state, messages, and errors:
```typescript
const client = new WasmClient(
(state: number) => console.log("state", state), // ConnectionState enum
(data: Uint8Array) => console.log("msg", data), // raw frame bytes
(err: any) => console.error("err", err), // error description
);
```
### Connection States
| Value | Name |
|-------|--------------|
| 0 | Disconnected |
| 1 | Connecting |
| 2 | Connected |
| 3 | Failed |
Poll `client.state` at any time.
## Connection Methods
### Unauthenticated Connect
```typescript
await client.connect(config);
```
Sends an `Identification` frame with the protocol version and client ID. The host may accept or reject. No cryptographic handshake occurs.
### Authenticated Login
```typescript
const confirmedId = await client.auth_connect(
config,
hostPublicKeyBytes, // Uint8Array: serialized PublicKeyBundle from the host
keyringBytes, // Uint8Array: serialized Keyring matching the client ID
clientId, // bigint: previously assigned client ID
);
```
Exchange (challenge-response): the client sends an unsigned `Identification`
hello, the host replies with a signed `Challenge` carrying a fresh
`server_challenge`, the client signs that challenge in a `ChallengeResponse`, and
the host verifies it and replies with a signed `IdentificationResponse`. Signing
over the host-issued challenge is what prevents a captured proof from being
replayed on another connection. Returns the confirmed client ID.
### Registration
```typescript
const newId = await client.auth_register(
config,
hostPublicKeyBytes, // Uint8Array: serialized PublicKeyBundle from the host
keyringBytes, // Uint8Array: serialized Keyring for the new identity
);
```
Exchange (challenge-response): the client sends an unsigned `Register` hello with
its public keys, the host replies with a signed `Challenge`, the client signs it
(binding the public-key bundle) in a `ChallengeResponse`, and the host verifies
it, assigns a new ID, and responds with a signed `RegisterResponse`. Returns the
newly assigned client ID.
## Sending and Receiving Messages
### Send
```typescript
const frame = build_ping_frame(clientId, "hello", timestamp, data);
await client.send(frame);
```
`send()` takes raw frame bytes (a serialized `CommunicationValue`). Build frames
with the provided helper functions or construct them manually.
### Receive
Incoming frames arrive on the `on_message` callback registered in the constructor.
The callback receives a `Uint8Array` of raw frame bytes. Parse with
`CommunicationValue.from_bytes()` on the Rust side or handle the bytes in JS.
### Disconnect
```typescript
client.disconnect();
```
Gracefully closes the WebTransport session.
## Building Frames
### `build_ping_frame`
```typescript
function build_ping_frame(
clientId: bigint,
description: string,
timestamp: bigint,
data: Uint8Array,
): Uint8Array;
```
Constructs a basic `Ping` message with description, timestamp, and optional
binary payload. Useful for health checks and simple messaging.
### `build_demo_message`
```typescript
function build_demo_message(
clientId: bigint,
keyringBytes: Uint8Array,
hostBundleBytes: Uint8Array,
): Uint8Array;
```
Constructs a `Ping` frame that demonstrates encrypted, signed, and
signed+encrypted containers. The containers are ML-KEM-encrypted to the host's
`PublicKeyBundle` (`hostBundleBytes`, the same bytes passed to `auth_connect` /
`auth_register`), so the host decrypts them with its own keyring; signatures use
the client keyring's Ed25519 key. The client keyring only needs its Ed25519
signing key for this demo.
### `parse_auth_response`
```typescript
function parse_auth_response(response: Uint8Array): any;
```
Parses an `IdentificationResponse` or `RegisterResponse` frame into a JS object:
```typescript
{
connected: boolean,
clientNonce?: Uint8Array,
assignedId?: number,
timestamp?: number,
signature?: Uint8Array,
}
```
## Crypto Primitives
### Key Generation
```typescript
const result = ed25519_generate();
// result.signer -> WasmEd25519Signer
// result.secretKey -> Uint8Array (32 bytes)
// result.publicKey -> Uint8Array (32 bytes)
```
### Keyring
A `Keyring` bundles all key material for an identity. For Ed25519-only setups:
```typescript
const keyringBytes = keyring_from_ed25519(secretKey, publicKey);
// keyringBytes is ready for WasmClient.auth_register or WasmClient.auth_connect
```
Full keyring with KEM + ML-DSA requires constructing on the Rust side. The
serialized bytes are portable:
```typescript
const keyring = WasmKeyring.from_bytes(keyringBytes);
const bundle = keyring.public_key_bundle();
// bundle.kem_public_key -> Uint8Array
// bundle.sig_cl_public_key -> Uint8Array
// bundle.sig_pq_public_key -> Uint8Array
```
### Signing and Verification
```typescript
const signer = new WasmEd25519Signer(secretKey);
const sig = signer.sign(message); // Uint8Array
signer.verify(message, sig); // throws on mismatch
// Standalone verification (no signer object needed):
ed25519_verify(publicKey, message, signature);
```
### Symmetric Encryption
```typescript
const cipher = new WasmChaCha20Poly1305(key); // 32-byte key
const encrypted = cipher.encrypt(plaintext, aad); // nonce || ciphertext
const decrypted = cipher.decrypt(encrypted, aad);
```
### Hashing and KDF
```typescript
const hash = wasm_sha256(data); // 32 bytes
const double = wasm_sha256_double(data); // SHA-256(SHA-256(data))
const derived = wasm_hkdf_expand(ikm, salt, info, len);
const encKey = wasm_derive_encryption_key(ikm, salt, context); // 32 bytes
```
## Lifecycle and Best Practices
1. **Key persistence** -- serialise keyring bytes after registration and store
them (e.g. in `localStorage`). On next visit, load the saved keyring and
call `auth_connect` instead of registering again.
2. **Ownership** -- call `config.free()` after connecting if the config object
is no longer needed. WASM objects (`WasmClient`, `WasmKeyring`, etc.) are
garbage-collected, but explicit `free()` or `dispose()` reclaims memory
sooner.
3. **Receive loop** -- once `connect`, `auth_connect`, or `auth_register`
resolves, the receive loop is running in the background. Incoming frames
arrive on the `on_message` callback. There is no need to poll.
4. **Single active client** -- a `WasmClient` manages one WebTransport session.
Create a new instance for each connection.
5. **State transitions** -- after `disconnect()` the client transitions to
`Disconnected`. The instance is reusable; call a connect method again to
open a new session.