Files
firezone/rust/gui-client/src-common/src/auth.rs
Thomas Eizinger 488c599d5b chore(telemetry): capture Firezone ID and account in user ctx (#7310)
Sentry has a feature called the "User context" which allows us to assign
events to individual users. This in turn will give us statistics in
Sentry, how many users are affected by a certain issue.

Unfortunately, Sentry's user context cannot be built-up step-by-step but
has to be set as a whole. To achieve this, we need to slightly refactor
`Telemetry` to not be `clone`d and instead passed around by mutable
reference.

Resolves: #7248.
Related: https://github.com/getsentry/sentry-rust/issues/706.
2024-11-11 19:50:14 +00:00

541 lines
18 KiB
Rust

//! Fulfills <https://github.com/firezone/firezone/issues/2823>
use anyhow::Result;
use firezone_headless_client::known_dirs;
use firezone_logging::std_dyn_err;
use rand::{thread_rng, RngCore};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use subtle::ConstantTimeEq;
use url::Url;
const NONCE_LENGTH: usize = 32;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("`known_dirs` failed")]
CantFindKnownDir,
#[error("`create_dir_all` failed while writing `actor_name_path`")]
CreateDirAll(std::io::Error),
#[error("Couldn't delete session file from disk: {0}")]
DeleteFile(std::io::Error),
#[error(transparent)]
Keyring(#[from] keyring::Error),
#[error("No in-flight request")]
NoInflightRequest,
#[error("session file path has no parent, this should be impossible")]
PathWrong,
#[error("Couldn't read session file: {0}")]
ReadFile(std::io::Error),
#[error("Could not (de)serialize session data")]
Serde,
#[error("State in server response doesn't match state in client request")]
StatesDontMatch,
#[error("Couldn't write session file: {0}")]
WriteFile(std::io::Error),
}
pub struct Auth {
/// Implementation details in case we need to disable `keyring-rs`
token_store: keyring::Entry,
state: State,
}
enum State {
SignedOut,
NeedResponse(Request),
SignedIn(Session),
}
pub struct Request {
nonce: SecretString,
state: SecretString,
}
impl Request {
pub fn to_url(&self, auth_base_url: &Url) -> SecretString {
let mut url = auth_base_url.clone();
url.query_pairs_mut()
.append_pair("as", "client")
.append_pair("nonce", self.nonce.expose_secret())
.append_pair("state", self.state.expose_secret());
SecretString::new(url.to_string())
}
}
pub(crate) struct Response {
pub(crate) account_slug: String,
pub(crate) actor_name: String,
pub(crate) fragment: SecretString,
pub(crate) state: SecretString,
}
#[derive(Default, Deserialize, Serialize)]
pub struct Session {
pub(crate) account_slug: String,
pub(crate) actor_name: String,
}
impl Session {
pub fn account_slug(&self) -> &str {
&self.account_slug
}
}
struct SessionAndToken {
session: Session,
token: SecretString,
}
impl Auth {
/// Creates a new Auth struct using the "dev.firezone.client/token" keyring key. If the token is stored on disk, the struct is automatically signed in.
///
/// Performs I/O.
pub fn new() -> Result<Self> {
Self::new_with_key("dev.firezone.client/token")
}
/// Creates a new Auth struct with a custom keyring key for testing.
///
/// `new` also just wraps this.
fn new_with_key(keyring_key: &'static str) -> Result<Self> {
// The 2nd and 3rd args are ignored on some platforms, so don't use them
let token_store = keyring::Entry::new_with_target(keyring_key, "", "")?;
let mut this = Self {
token_store,
state: State::SignedOut,
};
match this.get_token_from_disk() {
Err(error) => tracing::error!(
error = std_dyn_err(&error),
"Failed to load token from disk. Will start in signed-out state"
),
Ok(Some(SessionAndToken { session, token: _ })) => {
this.state = State::SignedIn(session);
tracing::debug!("Reloaded token from disk, starting in signed-in state.");
}
Ok(None) => tracing::debug!("No token on disk, starting in signed-out state."),
}
Ok(this)
}
/// Returns the session iff we are signed in.
pub fn session(&self) -> Option<&Session> {
match &self.state {
State::SignedIn(x) => Some(x),
State::NeedResponse(_) | State::SignedOut => None,
}
}
/// Mark the session as signed out, or cancel an ongoing sign-in flow
///
/// Performs I/O.
pub fn sign_out(&mut self) -> Result<(), Error> {
match self.token_store.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => {}
Err(error) => {
tracing::warn!(
error = std_dyn_err(&error),
"Couldn't delete token while signing out"
);
}
}
delete_if_exists(&actor_name_path()?)?;
delete_if_exists(&session_data_path()?)?;
self.state = State::SignedOut;
Ok(())
}
/// Start a new sign-in flow, replacing any ongoing flow
///
/// Returns parameters used to make a URL for the web browser to open
/// May return Ok(None) if we're already signed in
pub fn start_sign_in(&mut self) -> Result<Option<&Request>, Error> {
self.sign_out()?;
self.state = State::NeedResponse(Request {
nonce: generate_nonce(),
state: generate_nonce(),
});
Ok(Some(self.ongoing_request()?))
}
/// Complete an ongoing sign-in flow using parameters from a deep link
///
/// Returns a valid token.
/// Performs I/O.
///
/// Errors if we don't have any ongoing flow, or if the response is invalid
pub(crate) fn handle_response(&mut self, resp: Response) -> Result<SecretString, Error> {
let req = self.ongoing_request()?;
if !secure_equality(&resp.state, &req.state) {
self.sign_out()?;
return Err(Error::StatesDontMatch);
}
let token = format!(
"{}{}",
req.nonce.expose_secret(),
resp.fragment.expose_secret()
);
let token = SecretString::from(token);
let session = Session {
account_slug: resp.account_slug,
actor_name: resp.actor_name,
};
self.save_session(&session, &token)?;
self.state = State::SignedIn(session);
Ok(SecretString::from(token))
}
fn save_session(&self, session: &Session, token: &SecretString) -> Result<(), Error> {
// This MUST be the only place the GUI can call `set_password`, since
// the actor name is also saved here.
self.token_store.set_password(token.expose_secret())?;
save_file(&actor_name_path()?, session.actor_name.as_bytes())?;
save_file(
&session_data_path()?,
serde_json::to_string(session)
.map_err(|_| Error::Serde)?
.as_bytes(),
)?;
Ok(())
}
/// Returns the token if we are signed in
///
/// This will always make syscalls, but it should be fast enough for normal use.
pub fn token(&self) -> Result<Option<SecretString>, Error> {
match self.state {
State::SignedIn(_) => {}
State::NeedResponse(_) | State::SignedOut => return Ok(None),
}
Ok(self
.get_token_from_disk()?
.map(|session_and_token| session_and_token.token))
}
/// Retrieves the token from disk regardless of in-memory state
///
/// Performs I/O
fn get_token_from_disk(&self) -> Result<Option<SessionAndToken>, Error> {
// Read the actor_name file, then let the session file override it if present.
let mut session = Session::default();
match std::fs::read_to_string(actor_name_path()?) {
Ok(x) => session.actor_name = x,
// It can happen with dev systems that actor_name.txt doesn't exist
// even though the token is in the cred manager.
// In that case we just say the app is signed out
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(Error::ReadFile(e)),
};
match std::fs::read_to_string(session_data_path()?) {
Ok(x) => {
session = serde_json::from_str(&x).map_err(|_| Error::Serde)?;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(Error::ReadFile(e)),
}
// This MUST be the only place the GUI can call `get_password`, since the
// actor name is also loaded here.
let Ok(token) = self.token_store.get_password() else {
return Ok(None);
};
let token = SecretString::from(token);
Ok(Some(SessionAndToken { session, token }))
}
pub fn ongoing_request(&self) -> Result<&Request, Error> {
match &self.state {
State::NeedResponse(x) => Ok(x),
State::SignedIn(_) | State::SignedOut => Err(Error::NoInflightRequest),
}
}
}
fn delete_if_exists(path: &Path) -> Result<(), Error> {
if let Err(error) = std::fs::remove_file(path) {
// Ignore NotFound, since the file is gone anyway
if error.kind() != std::io::ErrorKind::NotFound {
return Err(Error::DeleteFile(error));
}
}
Ok(())
}
fn save_file(path: &Path, content: &[u8]) -> Result<(), Error> {
std::fs::create_dir_all(path.parent().ok_or(Error::PathWrong)?).map_err(Error::CreateDirAll)?;
std::fs::write(path, content).map_err(Error::WriteFile)?;
Ok(())
}
/// Returns a path to a file where we can save the actor name
///
/// Hopefully we don't need to save anything else, or there will be a migration step
fn actor_name_path() -> Result<PathBuf, Error> {
Ok(known_dirs::session()
.ok_or(Error::CantFindKnownDir)?
.join("actor_name.txt"))
}
fn session_data_path() -> Result<PathBuf, Error> {
Ok(known_dirs::session()
.ok_or(Error::CantFindKnownDir)?
.join("session_data.json"))
}
/// Generates a random nonce using a CSPRNG, then returns it as hexadecimal
fn generate_nonce() -> SecretString {
let mut buf = [0u8; NONCE_LENGTH];
// rand's thread-local RNG is said to be cryptographically secure here: https://docs.rs/rand/latest/rand/rngs/struct.ThreadRng.html
thread_rng().fill_bytes(&mut buf);
// Make sure it's not somehow all still zeroes.
assert_ne!(buf, [0u8; NONCE_LENGTH]);
hex::encode(buf).into()
}
/// Checks if two byte strings are equal in constant-time.
/// May not be constant-time if the lengths differ:
/// <https://docs.rs/subtle/2.5.0/subtle/trait.ConstantTimeEq.html#impl-ConstantTimeEq-for-%5BT%5D>
fn secure_equality(a: &SecretString, b: &SecretString) -> bool {
let a = a.expose_secret().as_bytes();
let b = b.expose_secret().as_bytes();
a.ct_eq(b).into()
}
pub fn replicate_6791() -> Result<()> {
tracing::warn!("Debugging issue #6791, pretending to be signed in with a bad token");
let this = Auth::new()?;
this.save_session(
&Session {
account_slug: "firezone".to_string(),
actor_name: "Jane Doe".to_string(),
},
&SecretString::from("obviously invalid token for testing #6791".to_string()),
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(target_os = "linux"))]
fn bogus_secret(x: &str) -> SecretString {
SecretString::new(x.into())
}
#[test]
fn actor_name() {
assert!(actor_name_path()
.expect("`actor_name_path` should return Ok")
.components()
.any(|x| x == std::path::Component::Normal("dev.firezone.client".as_ref())));
}
#[test]
fn keyring_is_persistent() {
assert!(matches!(
keyring::default::default_credential_builder().persistence(),
keyring::credential::CredentialPersistence::UntilDelete
));
}
/// Runs everything in one test so that `cargo test` can't multi-thread it
/// This should work around a bug we had <https://github.com/firezone/firezone/issues/3256>
#[test]
// The Linux CI is headless so it's hard to test keyrings in it
#[cfg(not(target_os = "linux"))]
fn everything() {
// Run `happy_path` first to make sure it reacts okay if our `data` dir is missing
// TODO: Re-enable happy path tests once `keyring-rs` is working in CI tests
happy_path("");
happy_path("Jane Doe");
utils();
no_inflight_request();
states_dont_match();
}
// The Linux CI is headless so it's hard to test keyrings in it
#[cfg(not(target_os = "linux"))]
#[test]
fn keyring_rs() {
// We used this test to find that `service` is not used on Windows - We have to namespace on our own.
let name_1 = "dev.firezone.client/test_1/token";
let name_2 = "dev.firezone.client/test_2/token";
let test_password_1 = "test_password_1";
let test_password_2 = "test_password_2";
let entry = keyring::Entry::new_with_target(name_1, "", "").unwrap();
entry.set_password("test_password_1").unwrap();
{
// In the middle of accessing one token, access another to make sure they don't interfere much
let entry = keyring::Entry::new_with_target(name_2, "", "").unwrap();
entry.set_password(test_password_2).unwrap();
assert_eq!(entry.get_password().unwrap(), test_password_2);
}
{
// Make sure that closing and re-opening the `Entry` on the same thread
// gives the correct result
let entry = keyring::Entry::new_with_target(name_2, "", "").unwrap();
assert_eq!(entry.get_password().unwrap(), test_password_2);
entry.delete_credential().unwrap();
assert!(entry.get_password().is_err());
}
assert_eq!(entry.get_password().unwrap(), test_password_1);
entry.delete_credential().unwrap();
assert!(entry.get_password().is_err());
}
#[cfg(not(target_os = "linux"))]
fn utils() {
// This doesn't test for constant-time properties, it just makes sure the function
// gives the right result
let f = |a: &str, b: &str| secure_equality(&bogus_secret(a), &bogus_secret(b));
assert!(f("1234", "1234"));
assert!(!f("1234", "123"));
assert!(!f("1234", "1235"));
let hex_string = generate_nonce();
let hex_string = hex_string.expose_secret();
assert_eq!(hex_string.len(), NONCE_LENGTH * 2);
let auth_base_url = Url::parse("https://app.firez.one").unwrap();
let req = Request {
nonce: bogus_secret("some_nonce"),
state: bogus_secret("some_state"),
};
assert_eq!(
req.to_url(&auth_base_url).expose_secret(),
"https://app.firez.one/?as=client&nonce=some_nonce&state=some_state"
);
}
#[cfg(not(target_os = "linux"))]
fn happy_path(actor_name: &str) {
// Key for credential manager. This is not what we use in production
let key = "dev.firezone.client/test_DMRCZ67A_happy_path/token";
{
// Start the program
let mut state = Auth::new_with_key(key).unwrap();
// Delete any token on disk from previous test runs
state.sign_out().unwrap();
assert!(state.token().unwrap().is_none());
// User clicks "Sign In", build a fake server response
let req = state.start_sign_in().unwrap().unwrap();
let resp = Response {
account_slug: "firezone".into(),
actor_name: actor_name.into(),
fragment: bogus_secret("fragment"),
state: req.state.clone(),
};
// Handle deep link from the server, now we are signed in and have a token
assert!(state.token().unwrap().is_none());
state.handle_response(resp).unwrap();
assert!(state.token().unwrap().is_some());
// Make sure we loaded the actor_name
assert_eq!(state.session().unwrap().actor_name, actor_name);
}
// Recreate the state to simulate closing and re-opening the app
{
let mut state = Auth::new_with_key(key).unwrap();
// Make sure we automatically got the token and actor_name back
assert!(state.token().unwrap().is_some());
assert_eq!(state.session().unwrap().actor_name, actor_name);
// Accidentally sign in again, this can happen if the user holds the systray menu open while a sign in is succeeding.
// For now, we treat that like signing out and back in immediately, so it wipes the old token.
// TODO: That sounds wrong.
assert!(state.start_sign_in().unwrap().is_some());
assert!(state.token().unwrap().is_none());
// Sign out again, now the token is gone
state.sign_out().unwrap();
assert!(state.token().unwrap().is_none());
}
}
#[cfg(not(target_os = "linux"))]
fn no_inflight_request() {
// Start the program
let mut state =
Auth::new_with_key("dev.firezone.client/test_DMRCZ67A_invalid_response/token").unwrap();
// Delete any token on disk from previous test runs
state.sign_out().unwrap();
assert!(state.token().unwrap().is_none());
// If we get a deep link with no in-flight request, it's invalid
let r = state.handle_response(Response {
account_slug: "firezone".into(),
actor_name: "Jane Doe".into(),
fragment: bogus_secret("fragment"),
state: bogus_secret("state"),
});
match r {
Err(Error::NoInflightRequest) => {}
_ => panic!("Expected NoInflightRequest error"),
}
// Clean up the test token
state.sign_out().unwrap();
}
#[cfg(not(target_os = "linux"))]
fn states_dont_match() {
// Start the program
let mut state =
Auth::new_with_key("dev.firezone.client/test_DMRCZ67A_states_dont_match/token")
.unwrap();
// Delete any token on disk from previous test runs
state.sign_out().unwrap();
assert!(state.token().unwrap().is_none());
// User clicks "Sign In", build a fake server response
state.start_sign_in().unwrap();
let resp = Response {
account_slug: "firezone".into(),
actor_name: "Jane Doe".into(),
fragment: bogus_secret("fragment"),
state: SecretString::from(
"bogus state from a replay attack or browser mis-click".to_string(),
),
};
assert!(state.token().unwrap().is_none());
// Handle deep link from the server, we should get an error
let r = state.handle_response(resp);
match r {
Err(Error::StatesDontMatch) => {}
_ => panic!("Expected StatesDontMatch error"),
}
assert!(state.token().unwrap().is_none());
}
}