mtp/wasm/src/client/authentication.rs
Alex Emmet 2b0bdc3257
Some checks failed
CI / checks (push) Failing after 3m14s
[Fix] Connections
2026-08-20 20:11:08 +02:00

671 lines
26 KiB
Rust

use wasm_bindgen::prelude::*;
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, PROTOCOL_VERSION};
use crate::auth;
use crate::client::{ConnectionState, WasmClient};
use crate::config::ConnectionConfig;
use crate::error::js_error;
use crate::transport::WasmTransport;
fn server_rejection_message(outcome: &CommunicationValue) -> Option<&str> {
(outcome.get_data(DataType::Connected) == Some(&DataValue::BoolFalse)).then(|| {
outcome
.get_str(DataType::ErrorMessage)
.unwrap_or("host rejected the connection")
})
}
#[wasm_bindgen]
#[allow(deprecated)]
impl WasmClient {
pub async fn connect(&self, config: &ConnectionConfig) -> Result<(), JsValue> {
self.connect_owned(config.clone()).await
}
#[wasm_bindgen(js_name = connectOwned)]
pub async fn connect_owned(&self, config: ConnectionConfig) -> Result<(), JsValue> {
let generation = self.begin_connection();
let transport = match WasmTransport::connect_with_limits(
&config.url,
config.server_certificate_hashes.clone(),
config.max_message_size,
self.receive_decode_limits(),
)
.await
{
Ok(transport) => transport,
Err(error) => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(error);
}
};
if !self.install_attempt_transport(&transport, generation) {
return Err(js_error("connection attempt superseded"));
}
let result = async {
let version_str = format!("{}", PROTOCOL_VERSION);
let opening_codec = mtp_codec::registry::VersionedCodec::for_version(
mtp_codec::registry::Registry::builtin(),
PROTOCOL_VERSION,
)
.ok_or_else(|| js_error("client protocol version is not registered"))?;
transport.set_type_map(opening_codec.type_map());
let mut ident = CommunicationValue::new_with_type_map(
CommunicationType::Identification,
opening_codec.type_map(),
)
.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)))?;
transport.send_frame(&ident_bytes).await?;
let outcome_bytes = transport.read_one_frame().await?;
let outcome = CommunicationValue::try_from_bytes_with_type_map_and_limits(
&outcome_bytes,
opening_codec.type_map(),
transport.decode_limits(),
)
.map_err(|e| js_error(format!("parse handshake outcome: {e}")))?;
if Some(outcome.get_type())
== CommunicationType::ErrorBadVersion.try_to_id(opening_codec.type_map())
{
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error(
outcome
.get_str(DataType::ErrorMessage)
.unwrap_or("host does not support this protocol version"),
));
}
// Generic host rejections are IdentificationResponse frames with
// Connected=false. They intentionally do not carry a negotiated
// Version because negotiation never completed. Check this before
// reading Version, otherwise a useful server error such as an
// authentication timeout is reported as the misleading
// "host omitted a valid negotiated protocol version".
if let Some(message) = server_rejection_message(&outcome) {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error(message));
}
let negotiated_version = match outcome.get_data(DataType::Version) {
Some(DataValue::Str(version)) => mtp_codec::Version::parse(version)
.ok_or_else(|| js_error("host omitted a valid negotiated protocol version"))?,
_ => return Err(js_error("host omitted a valid negotiated protocol version")),
};
if negotiated_version != PROTOCOL_VERSION {
return Err(js_error(
"host selected a protocol version the client did not offer",
));
}
let codec = mtp_codec::registry::VersionedCodec::for_version(
mtp_codec::registry::Registry::builtin(),
negotiated_version,
)
.ok_or_else(|| js_error("host returned an unsupported negotiated protocol version"))?;
transport.set_type_map(codec.type_map());
let outcome = CommunicationValue::try_from_bytes_with_type_map_and_limits(
&outcome_bytes,
codec.type_map(),
transport.decode_limits(),
)
.map_err(|e| js_error(format!("parse negotiated handshake outcome: {e}")))?;
let tm = codec.type_map();
let expected = CommunicationType::IdentificationResponse
.try_to_id(&tm)
.ok_or_else(|| js_error("IdentificationResponse is absent from the type map"))?;
if outcome.get_type() != expected
|| outcome.get_data(DataType::Connected) != Some(&DataValue::BoolTrue)
{
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error(
outcome
.get_str(DataType::ErrorMessage)
.unwrap_or("host rejected the connection"),
));
}
let assigned_id = match outcome.get_data(DataType::Id) {
Some(DataValue::UnsignedNumber(id)) => {
u64::try_from(*id).map_err(|_| js_error("assigned ID is out of range"))?
}
_ => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error("host omitted the assigned client ID"));
}
};
if !self.start_receive_loop(transport.clone(), generation, assigned_id) {
return Err(js_error("connection attempt superseded"));
}
Ok(())
}
.await;
if let Err(error) = &result {
self.abort_attempt(&transport, generation);
let _ = error;
}
result
}
#[wasm_bindgen]
#[deprecated(
note = "use the SDK authentication methods; this raw method remains for compatibility"
)]
pub async fn auth_connect(
&self,
config: &ConnectionConfig,
host_public_key_bytes: &[u8],
keyring_bytes: &[u8],
client_id: u64,
) -> Result<u64, JsValue> {
self.auth_connect_owned(
config.clone(),
host_public_key_bytes.to_vec(),
keyring_bytes.to_vec(),
client_id,
)
.await
}
#[wasm_bindgen(js_name = authConnectOwned)]
pub async fn auth_connect_owned(
&self,
config: ConnectionConfig,
host_public_key_bytes: Vec<u8>,
keyring_bytes: Vec<u8>,
client_id: u64,
) -> Result<u64, JsValue> {
let generation = self.begin_connection();
let host_pk = match mtp_crypto::PublicKeyBundle::from_bytes(&host_public_key_bytes) {
Ok(value) => value,
Err(error) => {
let error = js_error(format!("invalid host public key: {}", error));
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(error);
}
};
let keyring = match mtp_crypto::Keyring::from_bytes(&keyring_bytes) {
Ok(value) => value,
Err(error) => {
let error = js_error(format!("invalid keyring: {}", error));
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(error);
}
};
let handshake_codec = mtp_codec::registry::VersionedCodec::for_version(
mtp_codec::registry::Registry::builtin(),
PROTOCOL_VERSION,
)
.ok_or_else(|| js_error("client protocol version is not registered"))?;
let tm = handshake_codec.type_map().clone();
let version_str = format!("{}", PROTOCOL_VERSION);
let public_key_bytes = keyring
.public_key_bundle()
.try_as_bytes()
.map_err(|error| js_error(format!("public key serialization failed: {error}")))?;
let transport = match WasmTransport::connect_with_limits(
&config.url,
config.server_certificate_hashes.clone(),
config.max_message_size,
self.receive_decode_limits(),
)
.await
{
Ok(transport) => transport,
Err(error) => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(error);
}
};
transport.set_type_map(&tm);
if !self.install_attempt_transport(&transport, generation) {
return Err(js_error("connection attempt superseded"));
}
let result = async {
let mut hello =
CommunicationValue::new_with_type_map(CommunicationType::Identification, &tm)
.add_typed_default(DataType::Version, DataValue::Str(version_str.clone()))
.add_typed_default(DataType::Id, DataValue::UnsignedNumber(client_id as u128))
// Mark this as an authentication-capable opening so a
// non-crypto host can reject it explicitly.
.add_typed_default(
DataType::PublicKeys,
DataValue::Bytes(public_key_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_bytes).await?;
let server_challenge = self
.read_verified_challenge(
&transport,
&tm,
&host_pk,
client_id,
"auth_connect challenge",
config.require_pq,
!keyring.sig_pq_secret_key.as_bytes().is_empty(),
generation,
)
.await?;
let client_nonce = auth::random_nonce()?;
let proof_payload = mtp_crypto::auth::login_proof_payload(
&version_str,
client_id,
server_challenge,
client_nonce,
);
let proof =
auth::signed_challenge_response_bytes(&keyring, &proof_payload, client_nonce, &tm)?;
transport.send_frame(&proof).await?;
let response = transport.read_one_frame().await?;
let resp_comm = CommunicationValue::try_from_bytes_with_type_map_and_limits(
&response,
&tm,
transport.decode_limits(),
)
.map_err(|e| js_error(format!("parse response: {}", e)))?;
let negotiated_version = match resp_comm.get_data(DataType::Version) {
Some(DataValue::Str(version)) => mtp_codec::Version::parse(version)
.ok_or_else(|| js_error("host returned an invalid negotiated version"))?,
_ => return Err(js_error("host omitted the negotiated version")),
};
if negotiated_version != PROTOCOL_VERSION {
return Err(js_error(
"host selected a protocol version the client did not offer",
));
}
let codec = mtp_codec::registry::VersionedCodec::for_version(
mtp_codec::registry::Registry::builtin(),
negotiated_version,
)
.ok_or_else(|| js_error("host returned an unsupported negotiated version"))?;
transport.set_type_map(codec.type_map());
let resp_comm = CommunicationValue::try_from_bytes_with_type_map_and_limits(
&response,
codec.type_map(),
transport.decode_limits(),
)
.map_err(|e| js_error(format!("parse negotiated response: {}", e)))?;
let tm = codec.type_map();
let resp_type = resp_comm.get_type();
let expected_type = CommunicationType::IdentificationResponse
.try_to_id(&tm)
.ok_or_else(|| js_error("IdentificationResponse is absent from the type map"))?;
if resp_type != expected_type {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(auth::unexpected_response_type_error(
"auth_connect",
expected_type,
resp_type,
&response,
&resp_comm,
));
}
if resp_comm.get_data(DataType::Connected) != Some(&DataValue::BoolTrue) {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error(
resp_comm
.get_str(DataType::ErrorMessage)
.unwrap_or("host rejected authentication"),
));
}
if let Err(e) = auth::verify_host_final(
&resp_comm,
&tm,
&host_pk,
client_id,
client_nonce,
server_challenge,
config.require_pq,
) {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(e);
}
let assigned_id = match resp_comm.get_data(DataType::Id) {
Some(DataValue::UnsignedNumber(n)) => match u64::try_from(*n) {
Ok(id) => id,
Err(_) => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error("assigned ID is out of range"));
}
},
_ => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error("missing assigned ID"));
}
};
if !self.start_receive_loop(transport.clone(), generation, assigned_id) {
return Err(js_error("connection attempt superseded"));
}
Ok(assigned_id)
}
.await;
if let Err(error) = &result {
self.abort_attempt(&transport, generation);
let _ = error;
}
result
}
#[wasm_bindgen]
#[deprecated(
note = "use the SDK registration methods; this raw method remains for compatibility"
)]
pub async fn auth_register(
&self,
config: &ConnectionConfig,
host_public_key_bytes: &[u8],
keyring_bytes: &[u8],
) -> Result<u64, JsValue> {
self.auth_register_owned(
config.clone(),
host_public_key_bytes.to_vec(),
keyring_bytes.to_vec(),
)
.await
}
#[wasm_bindgen(js_name = authRegisterOwned)]
pub async fn auth_register_owned(
&self,
config: ConnectionConfig,
host_public_key_bytes: Vec<u8>,
keyring_bytes: Vec<u8>,
) -> Result<u64, JsValue> {
let generation = self.begin_connection();
let host_pk = match mtp_crypto::PublicKeyBundle::from_bytes(&host_public_key_bytes) {
Ok(value) => value,
Err(error) => {
let error = js_error(format!("invalid host public key: {}", error));
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(error);
}
};
let keyring = match mtp_crypto::Keyring::from_bytes(&keyring_bytes) {
Ok(value) => value,
Err(error) => {
let error = js_error(format!("invalid keyring: {}", error));
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(error);
}
};
let handshake_codec = mtp_codec::registry::VersionedCodec::for_version(
mtp_codec::registry::Registry::builtin(),
PROTOCOL_VERSION,
)
.ok_or_else(|| js_error("client protocol version is not registered"))?;
let tm = handshake_codec.type_map().clone();
let version_str = format!("{}", PROTOCOL_VERSION);
let pk_bytes = keyring
.public_key_bundle()
.try_as_bytes()
.map_err(|error| js_error(format!("public key serialization failed: {error}")))?;
let transport = match WasmTransport::connect_with_limits(
&config.url,
config.server_certificate_hashes.clone(),
config.max_message_size,
self.receive_decode_limits(),
)
.await
{
Ok(transport) => transport,
Err(error) => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(error);
}
};
transport.set_type_map(&tm);
if !self.install_attempt_transport(&transport, generation) {
return Err(js_error("connection attempt superseded"));
}
let result = async {
let mut hello = CommunicationValue::new_with_type_map(CommunicationType::Register, &tm)
.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 {
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_bytes).await?;
let server_challenge = self
.read_verified_challenge(
&transport,
&tm,
&host_pk,
0,
"auth_register challenge",
config.require_pq,
!keyring.sig_pq_secret_key.as_bytes().is_empty(),
generation,
)
.await?;
let client_nonce = auth::random_nonce()?;
let proof_payload = mtp_crypto::auth::register_proof_payload(
&version_str,
&pk_bytes,
server_challenge,
client_nonce,
);
let proof =
auth::signed_challenge_response_bytes(&keyring, &proof_payload, client_nonce, &tm)?;
transport.send_frame(&proof).await?;
let response = transport.read_one_frame().await?;
let resp_comm = CommunicationValue::try_from_bytes_with_type_map_and_limits(
&response,
&tm,
transport.decode_limits(),
)
.map_err(|e| js_error(format!("parse response: {}", e)))?;
let negotiated_version = match resp_comm.get_data(DataType::Version) {
Some(DataValue::Str(version)) => mtp_codec::Version::parse(version)
.ok_or_else(|| js_error("host returned an invalid negotiated version"))?,
_ => return Err(js_error("host omitted the negotiated version")),
};
if negotiated_version != PROTOCOL_VERSION {
return Err(js_error(
"host selected a protocol version the client did not offer",
));
}
let codec = mtp_codec::registry::VersionedCodec::for_version(
mtp_codec::registry::Registry::builtin(),
negotiated_version,
)
.ok_or_else(|| js_error("host returned an unsupported negotiated version"))?;
transport.set_type_map(codec.type_map());
let resp_comm = CommunicationValue::try_from_bytes_with_type_map_and_limits(
&response,
codec.type_map(),
transport.decode_limits(),
)
.map_err(|e| js_error(format!("parse negotiated response: {}", e)))?;
let tm = codec.type_map();
let resp_type = resp_comm.get_type();
let expected_type = CommunicationType::RegisterResponse
.try_to_id(&tm)
.ok_or_else(|| js_error("RegisterResponse is absent from the type map"))?;
if resp_type != expected_type {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(auth::unexpected_response_type_error(
"auth_register",
expected_type,
resp_type,
&response,
&resp_comm,
));
}
if resp_comm.get_data(DataType::Connected) != Some(&DataValue::BoolTrue) {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error(
resp_comm
.get_str(DataType::ErrorMessage)
.unwrap_or("host rejected registration"),
));
}
let assigned_id = match resp_comm.get_data(DataType::Id) {
Some(DataValue::UnsignedNumber(n)) => match u64::try_from(*n) {
Ok(id) => id,
Err(_) => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error("assigned ID is out of range"));
}
},
_ => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error("missing assigned ID"));
}
};
if let Err(e) = auth::verify_host_final(
&resp_comm,
&tm,
&host_pk,
assigned_id,
client_nonce,
server_challenge,
config.require_pq,
) {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(e);
}
if !self.start_receive_loop(transport.clone(), generation, assigned_id) {
return Err(js_error("connection attempt superseded"));
}
Ok(assigned_id)
}
.await;
if let Err(error) = &result {
self.abort_attempt(&transport, generation);
let _ = error;
}
result
}
async fn read_verified_challenge(
&self,
transport: &WasmTransport,
tm: &mtp_codec::TypeMap,
host_pk: &mtp_crypto::PublicKeyBundle,
bound_id: u64,
context: &str,
require_pq: bool,
client_has_pq_key: bool,
generation: u32,
) -> Result<u128, JsValue> {
let challenge_bytes = transport.read_one_frame().await?;
let challenge = CommunicationValue::try_from_bytes_with_type_map_and_limits(
&challenge_bytes,
tm,
transport.decode_limits(),
)
.map_err(|e| js_error(format!("parse challenge: {}", e)))?;
let expected = CommunicationType::Challenge
.try_to_id(tm)
.ok_or_else(|| js_error("Challenge is absent from the type map"))?;
if challenge.get_type() != expected {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(auth::unexpected_response_type_error(
context,
expected,
challenge.get_type(),
&challenge_bytes,
&challenge,
));
}
let server_challenge = match challenge.get_data(DataType::ServerNonce) {
Some(DataValue::UnsignedNumber(n)) => *n,
_ => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error("missing server challenge"));
}
};
if challenge.get_data(DataType::RequirePq) == Some(&DataValue::BoolTrue)
&& !client_has_pq_key
{
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error(
"host requires post-quantum authentication but the client PQ key is absent",
));
}
if let Err(e) = auth::verify_host_challenge(
&challenge,
tm,
host_pk,
bound_id,
server_challenge,
require_pq,
) {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(e);
}
Ok(server_challenge)
}
}
#[cfg(test)]
mod tests {
use super::server_rejection_message;
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue};
#[test]
fn reports_rejection_reason_without_a_negotiated_version() {
let response = CommunicationValue::new(CommunicationType::IdentificationResponse)
.add_typed_default(DataType::Connected, DataValue::BoolFalse)
.add_typed_default(
DataType::ErrorMessage,
DataValue::Str("authentication handshake timed out".into()),
);
assert_eq!(
server_rejection_message(&response),
Some("authentication handshake timed out")
);
}
}