fix(core): normalize channel passwords before joins

This commit is contained in:
Edison Jwa
2026-05-20 14:52:33 +09:00
parent 26b06ce786
commit 2861f5b010
+98 -35
View File
@@ -37,6 +37,7 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![warn(missing_docs)] #![warn(missing_docs)]
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -293,6 +294,18 @@ struct ConnectedState {
/// supervisor and the public API both see updates. /// supervisor and the public API both see updates.
sup_inner: Arc<Mutex<SupervisorInner>>, sup_inner: Arc<Mutex<SupervisorInner>>,
join_state: ChannelJoinState, join_state: ChannelJoinState,
/// Per-session channel password cache. Mirrors Qint's behavior at
/// the protocol boundary: if the user later joins the same channel
/// without typing a password, retry the last successful password
/// for that channel. Kept in memory only; durable secret storage is
/// a separate storage-schema decision.
channel_passwords: HashMap<u64, String>,
}
fn normalize_channel_password(password: Option<String>) -> Option<String> {
password
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
} }
/// The top-level Chanora session. Owns at most one active server /// The top-level Chanora session. Owns at most one active server
@@ -543,7 +556,10 @@ impl ChanoraSession {
let snap = client.snapshot().await?; let snap = client.snapshot().await?;
let epoch = self.allocate_connection_epoch().await; let epoch = self.allocate_connection_epoch().await;
let mut join_state = ChannelJoinState::new(epoch); let mut join_state = ChannelJoinState::new(epoch);
let initial_channel = self.find_own_in(&snap).await.map(|(_, channel)| JoinChannelId(channel)); let initial_channel = self
.find_own_in(&snap)
.await
.map(|(_, channel)| JoinChannelId(channel));
let _ = channel_join::reduce( let _ = channel_join::reduce(
&mut join_state, &mut join_state,
ChannelJoinEvent::SnapshotReady { ChannelJoinEvent::SnapshotReady {
@@ -593,6 +609,7 @@ impl ChanoraSession {
cfg, cfg,
sup_inner, sup_inner,
join_state, join_state,
channel_passwords: HashMap::new(),
}); });
// TS3 servers auto-place a newly-connected client into the // TS3 servers auto-place a newly-connected client into the
@@ -951,9 +968,19 @@ impl ChanoraSession {
channel_id: u64, channel_id: u64,
password: Option<String>, password: Option<String>,
) -> Result<(), CoreError> { ) -> Result<(), CoreError> {
let guard = self.inner.lock().await; let mut guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?; let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
state.protocol.move_to_channel(channel_id, password).await?; let requested_password = normalize_channel_password(password);
let password_to_send = requested_password
.clone()
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
state
.protocol
.move_to_channel(channel_id, password_to_send)
.await?;
if let Some(pw) = requested_password {
state.channel_passwords.insert(channel_id, pw);
}
Ok(()) Ok(())
} }
@@ -1038,7 +1065,10 @@ impl ChanoraSession {
/// iOS interruption-ended hook (SDD-101). Resumes only when /// iOS interruption-ended hook (SDD-101). Resumes only when
/// `should_resume` is true. /// `should_resume` is true.
pub async fn ios_handle_interruption_ended(&self, should_resume: bool) -> Result<(), CoreError> { pub async fn ios_handle_interruption_ended(
&self,
should_resume: bool,
) -> Result<(), CoreError> {
let _ = self.events_tx.send(SessionEvent::InterruptionState { let _ = self.events_tx.send(SessionEvent::InterruptionState {
began: false, began: false,
should_resume, should_resume,
@@ -1118,18 +1148,22 @@ impl ChanoraSession {
channel_join::JoinReduceStatus::Rejected( channel_join::JoinReduceStatus::Rejected(
channel_join::JoinIntentRejected::JoinAlreadyPendingDifferentTarget, channel_join::JoinIntentRejected::JoinAlreadyPendingDifferentTarget,
) => { ) => {
return Err(CoreError::Protocol(chanora_protocol::ProtocolError::ServerRejected { return Err(CoreError::Protocol(
code: 0x7001, chanora_protocol::ProtocolError::ServerRejected {
message: "join already pending for a different target".to_string(), code: 0x7001,
})); message: "join already pending for a different target".to_string(),
},
));
} }
channel_join::JoinReduceStatus::Rejected( channel_join::JoinReduceStatus::Rejected(
channel_join::JoinIntentRejected::CannotJoinWhileSynchronizing, channel_join::JoinIntentRejected::CannotJoinWhileSynchronizing,
) => { ) => {
return Err(CoreError::Protocol(chanora_protocol::ProtocolError::ServerRejected { return Err(CoreError::Protocol(
code: 0x7002, chanora_protocol::ProtocolError::ServerRejected {
message: "join cannot start while synchronizing".to_string(), code: 0x7002,
})); message: "join cannot start while synchronizing".to_string(),
},
));
} }
channel_join::JoinReduceStatus::CoalescedSameTarget => return Ok(()), channel_join::JoinReduceStatus::CoalescedSameTarget => return Ok(()),
_ => {} _ => {}
@@ -1138,7 +1172,9 @@ impl ChanoraSession {
let generation = requested let generation = requested
.projection .projection
.pending_generation .pending_generation
.ok_or(CoreError::Invariant("missing pending generation after join request"))?; .ok_or(CoreError::Invariant(
"missing pending generation after join request",
))?;
let pending_key = state let pending_key = state
.join_state .join_state
.pending .pending
@@ -1148,7 +1184,9 @@ impl ChanoraSession {
generation: pending.generation, generation: pending.generation,
request_id: pending.request_id, request_id: pending.request_id,
}) })
.ok_or(CoreError::Invariant("missing pending key after join request"))?; .ok_or(CoreError::Invariant(
"missing pending key after join request",
))?;
// 1. Send the move command. With send_with_result the // 1. Send the move command. With send_with_result the
// adapter now correlates against the server's typed // adapter now correlates against the server's typed
@@ -1156,7 +1194,15 @@ impl ChanoraSession {
// password, channel full) we get a concrete // password, channel full) we get a concrete
// `ProtocolError::ServerRejected` and don't need the // `ProtocolError::ServerRejected` and don't need the
// snapshot polling at all. // snapshot polling at all.
if let Err(e) = state.protocol.move_to_channel(channel_id, password).await { let requested_password = normalize_channel_password(password);
let password_to_send = requested_password
.clone()
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
if let Err(e) = state
.protocol
.move_to_channel(channel_id, password_to_send)
.await
{
// TS3 error 0x0302 = `channel_already_in`: we're already // TS3 error 0x0302 = `channel_already_in`: we're already
// in the target channel, so this is a no-op success. // in the target channel, so this is a no-op success.
// Rolling `in_channel` back to false would break PTT // Rolling `in_channel` back to false would break PTT
@@ -1165,10 +1211,7 @@ impl ChanoraSession {
// (SDD-094, SAD-081, SRS-204) // (SDD-094, SAD-081, SRS-204)
if matches!( if matches!(
&e, &e,
chanora_protocol::ProtocolError::ServerRejected { chanora_protocol::ProtocolError::ServerRejected { code: 0x0302, .. }
code: 0x0302,
..
}
) { ) {
info!( info!(
target: "chanora_core", target: "chanora_core",
@@ -1193,10 +1236,11 @@ impl ChanoraSession {
} }
let _ = channel_join::reduce( let _ = channel_join::reduce(
&mut state.join_state, &mut state.join_state,
ChannelJoinEvent::ProtocolJoinSucceeded { ChannelJoinEvent::ProtocolJoinSucceeded { key: pending_key },
key: pending_key,
},
); );
if let Some(pw) = requested_password {
state.channel_passwords.insert(channel_id, pw);
}
drop(guard); drop(guard);
// 2. Bring the audio engine up. Tolerate failure: the // 2. Bring the audio engine up. Tolerate failure: the
// server-side channel move has ALREADY succeeded (step // server-side channel move has ALREADY succeeded (step
@@ -1762,17 +1806,18 @@ async fn supervisor_loop(
ConnectionEpoch(epoch) ConnectionEpoch(epoch)
}; };
// Successful reconnect. Snapshot for the event. // Successful reconnect. Snapshot for the event.
let (snap_name, snap_current_channel) = match new_client.snapshot().await { let (snap_name, snap_current_channel) =
Ok(s) => { match new_client.snapshot().await {
let current_channel = s Ok(s) => {
.clients let current_channel = s
.iter() .clients
.find(|c| c.id.0 == s.own_client_id) .iter()
.map(|c| JoinChannelId(c.channel.0)); .find(|c| c.id.0 == s.own_client_id)
(s.server_name, current_channel) .map(|c| JoinChannelId(c.channel.0));
} (s.server_name, current_channel)
Err(_) => (String::new(), None), }
}; Err(_) => (String::new(), None),
};
let new_lost_rx = match new_client.take_loss_notifier() { let new_lost_rx = match new_client.take_loss_notifier() {
Some(rx) => rx, Some(rx) => rx,
None => { None => {
@@ -2057,7 +2102,7 @@ mod tests {
#[test] #[test]
fn signature_is_stable_under_input_reorder() { fn signature_is_stable_under_input_reorder() {
use chanora_protocol::{ChannelInfo, ClientInfo}; use chanora_protocol::ChannelInfo;
let a = ServerSnapshot { let a = ServerSnapshot {
server_name: "s".into(), server_name: "s".into(),
welcome_message: "".into(), welcome_message: "".into(),
@@ -2085,6 +2130,24 @@ mod tests {
assert_eq!(super::snapshot_signature(&a), super::snapshot_signature(&b)); assert_eq!(super::snapshot_signature(&a), super::snapshot_signature(&b));
} }
#[test]
fn normalizes_blank_channel_password_to_none() {
assert_eq!(super::normalize_channel_password(None), None);
assert_eq!(super::normalize_channel_password(Some("".into())), None);
assert_eq!(
super::normalize_channel_password(Some(" \t ".into())),
None
);
}
#[test]
fn trims_channel_password_before_send() {
assert_eq!(
super::normalize_channel_password(Some(" secret ".into())),
Some("secret".into())
);
}
#[tokio::test] #[tokio::test]
async fn empty_address_is_rejected() { async fn empty_address_is_rejected() {
let s = ChanoraSession::new(); let s = ChanoraSession::new();