fix(core): normalize channel passwords before joins
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user