diff --git a/client/src/lib.rs b/client/src/lib.rs index a60541c..e044997 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -22,6 +22,7 @@ pub struct ClientConfig { pub url: String, pub tls: ClientTlsConfig, pub client_id: u64, + pub description: Option, #[cfg(feature = "crypto")] pub auth_timeout: Duration, } @@ -38,6 +39,7 @@ impl ClientConfig { url: url.into(), tls: ClientTlsConfig::SystemRoots, client_id: 0, + description: None, #[cfg(feature = "crypto")] auth_timeout: Duration::from_secs(30), } @@ -57,6 +59,11 @@ impl ClientConfig { self } + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + #[cfg(feature = "crypto")] pub fn with_auth_timeout(mut self, timeout: Duration) -> Self { self.auth_timeout = timeout; @@ -76,6 +83,7 @@ pub struct MTPConnection { pub version: Version, pub sender: Sender, pub receiver: Receiver, + pub description: Option, #[cfg(feature = "crypto")] pub auth_state: AuthState, #[cfg(feature = "crypto")] @@ -150,12 +158,15 @@ impl MTPClient { mtp_transport::connect(&config.url, config.server_cert(), Policy::default()).await?; let version_str = format!("{}", PROTOCOL_VERSION); - let ident = CommunicationValue::new(mtp_codec::CommunicationType::Identification) + let mut ident = CommunicationValue::new(mtp_codec::CommunicationType::Identification) .add_typed_default(DataType::Version, DataValue::Str(version_str)) .add_typed_default( DataType::Id, DataValue::UnsignedNumber(config.client_id.into()), ); + if let Some(desc) = &config.description { + ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); + } sender.send(&ident).await?; @@ -163,6 +174,7 @@ impl MTPClient { version: PROTOCOL_VERSION, sender, receiver, + description: config.description, #[cfg(feature = "crypto")] auth_state: AuthState::Unauthenticated, #[cfg(feature = "crypto")] @@ -379,12 +391,15 @@ impl MTPClient { let version_str = format!("{}", PROTOCOL_VERSION); // 1. Send the unsigned Identification hello (version + claimed id). - let ident = CommunicationValue::new(mtp_codec::CommunicationType::Identification) + let mut ident = CommunicationValue::new(mtp_codec::CommunicationType::Identification) .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) .add_typed_default( DataType::Id, DataValue::UnsignedNumber(config.client_id as u128), ); + if let Some(desc) = &config.description { + ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); + } if let Err(e) = sender.send(&ident).await { sender.close(); return Err(e); @@ -464,6 +479,7 @@ impl MTPClient { version: PROTOCOL_VERSION, sender, receiver, + description: config.description, auth_state: AuthState::Authenticated, client_id: config.client_id, }) @@ -519,9 +535,12 @@ impl MTPClient { let pk_bytes = pk_bundle.as_bytes(); // 1. Send the unsigned Register hello (version + public-key bundle). - let register = CommunicationValue::new(mtp_codec::CommunicationType::Register) + let mut register = CommunicationValue::new(mtp_codec::CommunicationType::Register) .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) .add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes.clone())); + if let Some(desc) = &config.description { + register = register.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); + } if let Err(e) = sender.send(®ister).await { sender.close(); return Err(e); @@ -607,6 +626,7 @@ impl MTPClient { version: PROTOCOL_VERSION, sender, receiver, + description: config.description, auth_state: AuthState::Authenticated, client_id: assigned_id, }) diff --git a/host/src/lib.rs b/host/src/lib.rs index 389af78..294a537 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -29,6 +29,14 @@ type CompleteRegister = Box< + Sync, >; +#[cfg(feature = "crypto")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthenticationPolicy { + ForceAuthentication, + AllowAuthentication, + Unauthenticated, +} + /* Host configuration. */ pub struct HostConfig { pub ip: IpAddr, @@ -37,7 +45,7 @@ pub struct HostConfig { pub tls_key: Vec, #[cfg(feature = "crypto")] - pub require_authentication: bool, + pub authentication_policy: AuthenticationPolicy, #[cfg(feature = "crypto")] pub auth_timeout: Duration, #[cfg(feature = "crypto")] @@ -56,7 +64,7 @@ impl HostConfig { tls_fullchain, tls_key, #[cfg(feature = "crypto")] - require_authentication: false, + authentication_policy: AuthenticationPolicy::Unauthenticated, #[cfg(feature = "crypto")] auth_timeout: Duration::from_secs(30), #[cfg(feature = "crypto")] @@ -93,13 +101,19 @@ impl HostConfig { + Sync + 'static, ) -> Self { - self.require_authentication = true; + self.authentication_policy = AuthenticationPolicy::ForceAuthentication; self.host_keyring = host_keyring; self.get_existing_user = Box::new(get_existing_user); self.complete_register = Box::new(complete_register); self } + #[cfg(feature = "crypto")] + pub fn with_authentication_policy(mut self, policy: AuthenticationPolicy) -> Self { + self.authentication_policy = policy; + self + } + #[cfg(feature = "crypto")] pub fn with_auth_timeout(mut self, timeout: Duration) -> Self { self.auth_timeout = timeout; @@ -152,6 +166,7 @@ pub struct MTPConnection { pub codec: VersionedCodec, pub sender: Sender, pub receiver: Receiver, + pub description: Option, #[cfg(feature = "crypto")] pub auth_state: AuthState, #[cfg(feature = "crypto")] @@ -203,49 +218,94 @@ impl MTPHost { }; #[cfg(feature = "crypto")] - if self.config.require_authentication { - let timeout = self.config.auth_timeout; - return match tokio::time::timeout(timeout, self.accept_authenticated(sender, receiver)) + match self.config.authentication_policy { + AuthenticationPolicy::ForceAuthentication => { + let timeout = self.config.auth_timeout; + return match tokio::time::timeout( + timeout, + self.accept_authenticated(sender, receiver), + ) .await - { - Ok(result) => result, - Err(_) => Err(AcceptError::AuthenticationTimedOut), - }; + { + Ok(result) => result, + Err(_) => Err(AcceptError::AuthenticationTimedOut), + }; + } + AuthenticationPolicy::AllowAuthentication => { + return self.accept_allow_auth(sender, receiver).await; + } + AuthenticationPolicy::Unauthenticated => { + let first_msg = match receiver.receive().await { + Ok(m) => m, + Err(e) => return Err(AcceptError::Receive(e)), + }; + if first_msg.get_type() + == mtp_codec::CommunicationType::Register.to_id(&mtp_codec::TypeMap::latest()) + { + sender.close(); + return Err(AcceptError::AuthenticationFailed( + "authentication not allowed on this host".into(), + )); + } + let client_version = match extract_version(&first_msg) { + Some(v) => v, + None => return Err(AcceptError::MissingVersion), + }; + let negotiated = match self.registry.negotiate(std::slice::from_ref(&client_version)) + { + Some(v) => v, + None => return Err(AcceptError::UnsupportedVersion(client_version)), + }; + let codec = VersionedCodec::new(self.registry.clone()); + let description = match first_msg.get_data(DataType::Description) { + DataValue::Str(s) => Some(s.clone()), + _ => None, + }; + Ok(Some(MTPConnection { + version: negotiated, + codec, + sender, + receiver, + description, + #[cfg(feature = "crypto")] + auth_state: AuthState::Unauthenticated, + #[cfg(feature = "crypto")] + client_id: rand::random(), + #[cfg(feature = "crypto")] + client_public_key: None, + })) + } } - // Read the first message (always encoded with reserved types). - let first_msg = match receiver.receive().await { - Ok(m) => m, - Err(e) => return Err(AcceptError::Receive(e)), - }; - - let client_version = match extract_version(&first_msg) { - Some(v) => v, - None => return Err(AcceptError::MissingVersion), - }; - - let negotiated = match self - .registry - .negotiate(std::slice::from_ref(&client_version)) + // Non-crypto fallback: no authentication feature, just read and respond. + #[cfg(not(feature = "crypto"))] { - Some(v) => v, - None => return Err(AcceptError::UnsupportedVersion(client_version)), - }; - - let codec = VersionedCodec::new(self.registry.clone()); - - Ok(Some(MTPConnection { - version: negotiated, - codec, - sender, - receiver, - #[cfg(feature = "crypto")] - auth_state: AuthState::Unauthenticated, - #[cfg(feature = "crypto")] - client_id: 0, - #[cfg(feature = "crypto")] - client_public_key: None, - })) + let first_msg = match receiver.receive().await { + Ok(m) => m, + Err(e) => return Err(AcceptError::Receive(e)), + }; + let client_version = match extract_version(&first_msg) { + Some(v) => v, + None => return Err(AcceptError::MissingVersion), + }; + let negotiated = match self.registry.negotiate(std::slice::from_ref(&client_version)) + { + Some(v) => v, + None => return Err(AcceptError::UnsupportedVersion(client_version)), + }; + let codec = VersionedCodec::new(self.registry.clone()); + let description = match first_msg.get_data(DataType::Description) { + DataValue::Str(s) => Some(s.clone()), + _ => None, + }; + return Ok(Some(MTPConnection { + version: negotiated, + codec, + sender, + receiver, + description, + })); + } } pub fn local_addr(&self) -> std::net::SocketAddr { @@ -257,6 +317,18 @@ impl MTPHost { } } +#[cfg(feature = "crypto")] +enum Flow { + Login { + id: u64, + bundle: mtp_crypto::PublicKeyBundle, + }, + Register { + bundle: mtp_crypto::PublicKeyBundle, + pk_bytes: Vec, + }, +} + #[cfg(feature = "crypto")] impl MTPHost { /* @@ -278,51 +350,9 @@ impl MTPHost { sender: Sender, receiver: Receiver, ) -> Result, AcceptError> { - use mtp_crypto::{ - Ed25519Signer, MlDsaSigner, PublicKeyBundle, SignatureScheme, auth, verify_ed25519, - verify_ml_dsa, - }; - - // Flow-specific state resolved from the client's opening hello. - enum Flow { - Login { - id: u64, - bundle: PublicKeyBundle, - }, - Register { - bundle: PublicKeyBundle, - pk_bytes: Vec, - }, - } + use mtp_crypto::PublicKeyBundle; let tm = mtp_codec::TypeMap::latest(); - let pq_enabled = !self - .config - .host_keyring - .sig_pq_secret_key - .as_bytes() - .is_empty(); - - // Sign `payload` with the host keys (Ed25519 always, ML-DSA when configured). - let host_sign = |payload: &[u8]| -> Result<(Vec, Vec), AcceptError> { - let signer = Ed25519Signer::new(&self.config.host_keyring.sig_cl_secret_key) - .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?; - let sig = signer - .sign(payload) - .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?; - let pq_sig = if pq_enabled { - let pq = MlDsaSigner::new( - &self.config.host_keyring.sig_pq_secret_key, - &self.config.host_keyring.sig_pq_public_key, - ) - .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?; - pq.sign(payload) - .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))? - } else { - Vec::new() - }; - Ok((sig, pq_sig)) - }; // ===== Step 1: receive the client's unsigned hello ===== let hello = match receiver.receive().await { @@ -347,6 +377,11 @@ impl MTPHost { } }; + let description = match hello.get_data(DataType::Description) { + DataValue::Str(s) => Some(s.clone()), + _ => None, + }; + let (flow, response_type) = if hello.get_type() == mtp_codec::CommunicationType::Identification.to_id(&tm) { let cid = match hello.get_data(DataType::Id) { @@ -400,6 +435,57 @@ impl MTPHost { )); }; + self.complete_auth_handshake(sender, receiver, flow, response_type, &version_str, client_version, description).await + } + + /* + * Steps 2-4 of the authenticated handshake, shared by both ForceAuthentication + * and AllowAuthentication. Takes the already-parsed hello (step 1) via `flow`, + * `version_str`, and `client_version`. + */ + #[allow(clippy::too_many_arguments)] + async fn complete_auth_handshake( + &self, + sender: Sender, + receiver: Receiver, + flow: Flow, + response_type: mtp_codec::CommunicationType, + version_str: &str, + client_version: Version, + description: Option, + ) -> Result, AcceptError> { + use mtp_crypto::{ + auth, verify_ed25519, verify_ml_dsa, Ed25519Signer, MlDsaSigner, SignatureScheme, + }; + + let tm = mtp_codec::TypeMap::latest(); + let pq_enabled = !self + .config + .host_keyring + .sig_pq_secret_key + .as_bytes() + .is_empty(); + + let host_sign = |payload: &[u8]| -> Result<(Vec, Vec), AcceptError> { + let signer = Ed25519Signer::new(&self.config.host_keyring.sig_cl_secret_key) + .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?; + let sig = signer + .sign(payload) + .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?; + let pq_sig = if pq_enabled { + let pq = MlDsaSigner::new( + &self.config.host_keyring.sig_pq_secret_key, + &self.config.host_keyring.sig_pq_public_key, + ) + .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?; + pq.sign(payload) + .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))? + } else { + Vec::new() + }; + Ok((sig, pq_sig)) + }; + let challenge_id = match &flow { Flow::Login { id, .. } => *id, Flow::Register { .. } => 0, @@ -464,18 +550,13 @@ impl MTPHost { let (proof_payload, bundle) = match &flow { Flow::Login { id, bundle } => ( - auth::login_proof_payload(&version_str, *id, server_challenge, client_nonce), + auth::login_proof_payload(version_str, *id, server_challenge, client_nonce), bundle, ), Flow::Register { bundle, pk_bytes, .. } => ( - auth::register_proof_payload( - &version_str, - pk_bytes, - server_challenge, - client_nonce, - ), + auth::register_proof_payload(version_str, pk_bytes, server_challenge, client_nonce), bundle, ), }; @@ -551,11 +632,134 @@ impl MTPHost { codec, sender, receiver, + description, auth_state: AuthState::Authenticated, client_id: assigned_id, client_public_key: Some(client_bundle), })) } + + /* + * Allow-authentication accept: clients may connect with or without + * authentication. Register messages always trigger the auth handshake. + * Identification with a known client-id triggers login; otherwise the + * client is treated as unauthenticated with a random id. + */ + async fn accept_allow_auth( + &mut self, + sender: Sender, + receiver: Receiver, + ) -> Result, AcceptError> { + use mtp_crypto::PublicKeyBundle; + + let tm = mtp_codec::TypeMap::latest(); + + let hello = match receiver.receive().await { + Ok(m) => m, + Err(e) => return Err(AcceptError::Receive(e)), + }; + + let version_str = match hello.get_data(DataType::Version) { + DataValue::Str(s) => s.clone(), + _ => { + sender.close(); + return Err(AcceptError::MissingVersion); + } + }; + let client_version = match Version::parse(&version_str) { + Some(v) => v, + None => { + sender.close(); + return Err(AcceptError::MissingVersion); + } + }; + + let description = match hello.get_data(DataType::Description) { + DataValue::Str(s) => Some(s.clone()), + _ => None, + }; + + // Register → authenticated registration. + if hello.get_type() == mtp_codec::CommunicationType::Register.to_id(&tm) { + let bundle = match hello.get_data(DataType::PublicKeys) { + DataValue::Bytes(b) => PublicKeyBundle::from_bytes(b).map_err(|_| { + sender.close(); + AcceptError::AuthenticationFailed("invalid public key bundle".into()) + })?, + _ => { + sender.close(); + return Err(AcceptError::AuthenticationFailed("missing public keys".into())); + } + }; + let pk_bytes = bundle.as_bytes(); + return self + .complete_auth_handshake( + sender, + receiver, + Flow::Register { + bundle, + pk_bytes, + }, + mtp_codec::CommunicationType::RegisterResponse, + &version_str, + client_version, + description, + ) + .await; + } + + // Identification with a known client-id → login. + if hello.get_type() == mtp_codec::CommunicationType::Identification.to_id(&tm) { + let cid = match hello.get_data(DataType::Id) { + DataValue::UnsignedNumber(n) => *n as u64, + _ => 0, + }; + + if cid > 0 + && let Some(bundle) = (self.config.get_existing_user)(cid).await + { + return self + .complete_auth_handshake( + sender, + receiver, + Flow::Login { + id: cid, + bundle, + }, + mtp_codec::CommunicationType::IdentificationResponse, + &version_str, + client_version, + description, + ) + .await; + } + + // Unknown (or zero) client id → unauthenticated connection. + let negotiated = match self + .registry + .negotiate(std::slice::from_ref(&client_version)) + { + Some(v) => v, + None => return Err(AcceptError::UnsupportedVersion(client_version)), + }; + let codec = VersionedCodec::new(self.registry.clone()); + return Ok(Some(MTPConnection { + version: negotiated, + codec, + sender, + receiver, + description, + auth_state: AuthState::Unauthenticated, + client_id: rand::random(), + client_public_key: None, + })); + } + + sender.close(); + Err(AcceptError::AuthenticationFailed( + "unexpected message type".into(), + )) + } } /* diff --git a/wasm/src/client.rs b/wasm/src/client.rs index 8fdde48..7aef180 100644 --- a/wasm/src/client.rs +++ b/wasm/src/client.rs @@ -302,12 +302,15 @@ impl WasmClient { .await?; let version_str = format!("{}", PROTOCOL_VERSION); - let ident = CommunicationValue::new(CommunicationType::Identification) + let mut ident = CommunicationValue::new(CommunicationType::Identification) .add_typed_default(DataType::Version, DataValue::Str(version_str)) .add_typed_default( DataType::Id, DataValue::UnsignedNumber(config.client_id as u128), ); + if let Some(desc) = &config.description { + ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); + } let ident_bytes = ident .to_bytes() .map_err(|e| js_error(&format!("encode failed: {}", e)))?; @@ -351,12 +354,16 @@ impl WasmClient { .await?; // 1. Send the unsigned Identification hello. - let hello = CommunicationValue::new(CommunicationType::Identification) + let mut hello = CommunicationValue::new(CommunicationType::Identification) .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) - .add_typed_default(DataType::Id, DataValue::UnsignedNumber(client_id as u128)) + .add_typed_default(DataType::Id, DataValue::UnsignedNumber(client_id as u128)); + if let Some(desc) = &config.description { + hello = hello.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); + } + let hello_bytes = hello .to_bytes() .map_err(|e| js_error(&format!("encode failed: {}", e)))?; - transport.send_frame(&hello).await?; + transport.send_frame(&hello_bytes).await?; // 2. Receive and verify the host's challenge. let server_challenge = self @@ -463,12 +470,16 @@ impl WasmClient { .await?; // 1. Send the unsigned Register hello (version + public-key bundle). - let hello = CommunicationValue::new(CommunicationType::Register) + let mut hello = CommunicationValue::new(CommunicationType::Register) .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) - .add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes.clone())) + .add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes.clone())); + if let Some(desc) = &config.description { + hello = hello.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); + } + let hello_bytes = hello .to_bytes() .map_err(|e| js_error(&format!("encode failed: {}", e)))?; - transport.send_frame(&hello).await?; + transport.send_frame(&hello_bytes).await?; // 2. Receive and verify the host's challenge (register binds id = 0). let server_challenge = self diff --git a/wasm/src/config.rs b/wasm/src/config.rs index 381d76e..8ac13b5 100644 --- a/wasm/src/config.rs +++ b/wasm/src/config.rs @@ -6,6 +6,7 @@ pub struct ConnectionConfig { pub(crate) server_certificate_hashes: Option>, pub(crate) client_id: u64, pub(crate) max_message_size: u32, + pub(crate) description: Option, } #[wasm_bindgen] @@ -17,6 +18,7 @@ impl ConnectionConfig { server_certificate_hashes: None, client_id: 0, max_message_size: 1_000_000_000, + description: None, } } @@ -49,4 +51,14 @@ impl ConnectionConfig { pub fn max_message_size(&self) -> u32 { self.max_message_size } + + #[wasm_bindgen(setter)] + pub fn set_description(&mut self, description: String) { + self.description = Some(description); + } + + #[wasm_bindgen(getter)] + pub fn description(&self) -> Option { + self.description.clone() + } }