mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-03-22 08:41:57 +00:00
Bumps [secrecy](https://github.com/iqlusioninc/crates) from 0.8.0 to 0.10.3. <details> <summary>Commits</summary> <ul> <li>See full diff in <a href="https://github.com/iqlusioninc/crates/commits">compare view</a></li> </ul> </details> <br /> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Thomas Eizinger <thomas@eizinger.io>
387 lines
12 KiB
Rust
387 lines
12 KiB
Rust
//! The authentication scheme for the TURN server.
|
|
//!
|
|
//! TURN specifies two ways of authentication: long-term credentials & short-term credentials.
|
|
//! For details on those, please consult the RFC: <https://www.rfc-editor.org/rfc/rfc8489.html#section-9>.
|
|
//!
|
|
//! This implementation only supports long-term credentials.
|
|
//!
|
|
//! ## Client authentication
|
|
//!
|
|
//! On startup, the server generates a 32-byte secret (referred to as `relay_secret`) that is only ever stored in-memory.
|
|
//! This secret is shared with the Firezone portal upon connecting with the WebSocket.
|
|
//! The portal uses this secret to generate credentials for each TURN client.
|
|
//! The credentials take the form of:
|
|
//!
|
|
//! - username: `{unix_expiry_timestamp}:{salt}`
|
|
//! - password: `sha256({unix_expiry_timestamp}:{relay_secret}:{salt})`
|
|
//!
|
|
//! As such, a TURN client can never create a set of credentials themselves because they are missing the `relay_secret`.
|
|
//! In addition, a relay can validate such a username and password combination without having to store any state other than the `relay_secret`.
|
|
//!
|
|
//! All STUN messages other than `BINDING` requests MUST be authenticated by the client.
|
|
//!
|
|
//! ## Server authentication
|
|
//!
|
|
//! In addition to authenticating all messages from the client with the server, a server will authenticate its messages to the client.
|
|
//! This also uses the long-term credentials mechanism using the same username and password.
|
|
//! In other words, the server will authenticate the messages sent to the client with the client's username and password.
|
|
//!
|
|
//! ## Security considerations
|
|
//!
|
|
//! The password is a shared secret and thus ensures message integrity and authenticity to the client.
|
|
//! An observer on the network path does not have knowledge of the `relay_secret` and thus cannot fake a relay's identity.
|
|
//!
|
|
//! Each client will receive a different pair of username and password.
|
|
//! Thus, even with valid credentials, an attacker cannot reuse those credentials to fake responses for a different client.
|
|
|
|
use base64::Engine;
|
|
use base64::prelude::BASE64_STANDARD_NO_PAD;
|
|
use bytecodec::Encode;
|
|
use once_cell::sync::Lazy;
|
|
use secrecy::{ExposeSecret, SecretString};
|
|
use sha2::Sha256;
|
|
use sha2::digest::FixedOutput;
|
|
use std::collections::HashMap;
|
|
use std::collections::hash_map::Entry;
|
|
use std::time::{Duration, SystemTime};
|
|
use stun_codec::Message;
|
|
use stun_codec::rfc5389::attributes::{MessageIntegrity, Realm, Username};
|
|
use uuid::Uuid;
|
|
|
|
use crate::Attribute;
|
|
|
|
// TODO: Upstream a const constructor to `stun-codec`.
|
|
pub static FIREZONE: Lazy<Realm> =
|
|
Lazy::new(|| Realm::new("firezone".to_owned()).expect("static realm is less than 128 chars"));
|
|
|
|
pub(crate) trait MessageIntegrityExt {
|
|
fn verify(
|
|
&self,
|
|
relay_secret: &SecretString,
|
|
username: &str,
|
|
now: SystemTime,
|
|
) -> Result<(), Error>;
|
|
}
|
|
|
|
impl MessageIntegrityExt for MessageIntegrity {
|
|
fn verify(
|
|
&self,
|
|
relay_secret: &SecretString,
|
|
username: &str,
|
|
now: SystemTime,
|
|
) -> Result<(), Error> {
|
|
let (expiry, salt) = split_username(username)?;
|
|
let expired = systemtime_from_unix(expiry);
|
|
|
|
if expired < now {
|
|
return Err(Error::Expired);
|
|
}
|
|
|
|
let password = generate_password(relay_secret, expiry, salt);
|
|
|
|
self.check_long_term_credential(
|
|
&Username::new(format!("{expiry}:{salt}")).map_err(|_| Error::InvalidUsername)?,
|
|
&FIREZONE,
|
|
&password,
|
|
)
|
|
.map_err(|_| Error::InvalidPassword)?;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub(crate) struct AuthenticatedMessage(Message<Attribute>);
|
|
|
|
impl AuthenticatedMessage {
|
|
/// Creates a new [`AuthenticatedMessage`] that isn't actually authenticated.
|
|
///
|
|
/// This should only be used in circumstances where we cannot authenticate the message because e.g. the original request wasn't authenticated either.
|
|
pub(crate) fn new_dangerous_unauthenticated(message: Message<Attribute>) -> Self {
|
|
Self(message)
|
|
}
|
|
|
|
pub(crate) fn new(
|
|
relay_secret: &SecretString,
|
|
username: &Username,
|
|
mut message: Message<Attribute>,
|
|
) -> Result<Self, Error> {
|
|
let (expiry, salt) = split_username(username.name())?;
|
|
let password = generate_password(relay_secret, expiry, salt);
|
|
|
|
let message_integrity =
|
|
MessageIntegrity::new_long_term_credential(&message, username, &FIREZONE, &password)?;
|
|
|
|
message.add_attribute(message_integrity);
|
|
|
|
Ok(Self(message))
|
|
}
|
|
|
|
pub fn class(&self) -> stun_codec::MessageClass {
|
|
self.0.class()
|
|
}
|
|
|
|
pub fn method(&self) -> stun_codec::Method {
|
|
self.0.method()
|
|
}
|
|
|
|
pub fn get_attribute<T>(&self) -> Option<&T>
|
|
where
|
|
T: stun_codec::Attribute,
|
|
Attribute: stun_codec::convert::TryAsRef<T>,
|
|
{
|
|
self.0.get_attribute()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub(crate) struct MessageEncoder(stun_codec::MessageEncoder<Attribute>);
|
|
|
|
impl Encode for MessageEncoder {
|
|
type Item = AuthenticatedMessage;
|
|
|
|
fn encode(&mut self, buf: &mut [u8], eos: bytecodec::Eos) -> bytecodec::Result<usize> {
|
|
self.0.encode(buf, eos)
|
|
}
|
|
|
|
fn start_encoding(&mut self, item: Self::Item) -> bytecodec::Result<()> {
|
|
self.0.start_encoding(item.0)
|
|
}
|
|
|
|
fn requiring_bytes(&self) -> bytecodec::ByteCount {
|
|
self.0.requiring_bytes()
|
|
}
|
|
}
|
|
|
|
/// Tracks valid nonces for the TURN relay.
|
|
///
|
|
/// The semantic nature of nonces is an implementation detail of the relay in TURN.
|
|
/// This could just as easily also be a time-based logic (i.e. nonces are valid for 10min).
|
|
///
|
|
/// For simplicity reasons, we use a count-based strategy.
|
|
/// Each nonce can be used for a certain number of requests before it is invalid.
|
|
#[derive(Default, Debug, Clone)]
|
|
pub(crate) struct Nonces {
|
|
inner: HashMap<Uuid, u64>,
|
|
}
|
|
|
|
impl Nonces {
|
|
/// How many requests a client can perform with the same nonce.
|
|
const NUM_REQUESTS: u64 = 10_000;
|
|
|
|
pub(crate) fn add_new(&mut self, nonce: Uuid) {
|
|
self.inner.insert(nonce, Self::NUM_REQUESTS);
|
|
}
|
|
|
|
/// Record the usage of a nonce in a request.
|
|
pub(crate) fn handle_nonce_used(&mut self, nonce: Uuid) -> Result<(), Error> {
|
|
let mut entry = match self.inner.entry(nonce) {
|
|
Entry::Vacant(_) => return Err(Error::UnknownNonce),
|
|
Entry::Occupied(entry) => entry,
|
|
};
|
|
|
|
let remaining_requests = entry.get_mut();
|
|
|
|
if *remaining_requests == 0 {
|
|
entry.remove();
|
|
|
|
return Err(Error::NonceUsedUp);
|
|
}
|
|
|
|
*remaining_requests -= 1;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub(crate) enum Error {
|
|
#[error("expired")]
|
|
Expired,
|
|
#[error("invalid password")]
|
|
InvalidPassword,
|
|
#[error("invalid username")]
|
|
InvalidUsername,
|
|
#[error("nonce has been used up")]
|
|
NonceUsedUp,
|
|
#[error("unknown nonce")]
|
|
UnknownNonce,
|
|
#[error("cannot authenticate message")]
|
|
CannotAuthenticate(#[from] bytecodec::Error),
|
|
}
|
|
|
|
pub(crate) fn split_username(username: &str) -> Result<(u64, &str), Error> {
|
|
let [expiry, username_salt]: [&str; 2] = username
|
|
.split(':')
|
|
.collect::<Vec<&str>>()
|
|
.try_into()
|
|
.map_err(|_| Error::InvalidUsername)?;
|
|
|
|
let expiry_unix_timestamp = expiry.parse::<u64>().map_err(|_| Error::InvalidUsername)?;
|
|
|
|
Ok((expiry_unix_timestamp, username_salt))
|
|
}
|
|
|
|
pub fn generate_password(relay_secret: &SecretString, expiry: u64, username_salt: &str) -> String {
|
|
use sha2::Digest as _;
|
|
|
|
let mut hasher = Sha256::default();
|
|
|
|
hasher.update(format!("{expiry}"));
|
|
hasher.update(":");
|
|
hasher.update(relay_secret.expose_secret());
|
|
hasher.update(":");
|
|
hasher.update(username_salt);
|
|
|
|
let array = hasher.finalize_fixed();
|
|
|
|
BASE64_STANDARD_NO_PAD.encode(array.as_slice())
|
|
}
|
|
|
|
pub(crate) fn systemtime_from_unix(seconds: u64) -> SystemTime {
|
|
SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::Attribute;
|
|
use stun_codec::rfc5389::methods::BINDING;
|
|
use stun_codec::{Message, MessageClass, TransactionId};
|
|
|
|
const RELAY_SECRET_1: &str = "4c98bf59c99b3e467ecd7cf9d6b3e5279645fca59be67bc5bb4af3cf653761ab";
|
|
const RELAY_SECRET_2: &str = "7e35e34801e766a6a29ecb9e22810ea4e3476c2b37bf75882edf94a68b1d9607";
|
|
const SAMPLE_USERNAME: &str = "n23JJ2wKKtt30oXi";
|
|
|
|
#[test]
|
|
fn generate_password_test_vector() {
|
|
let expiry = 60 * 60 * 24 * 365 * 60;
|
|
|
|
let password = generate_password(&RELAY_SECRET_1.into(), expiry, SAMPLE_USERNAME);
|
|
|
|
assert_eq!(password, "00hqldgk5xLeKKOB+xls9mHMVtgqzie9DulfgQwMv68")
|
|
}
|
|
|
|
#[test]
|
|
fn generate_password_test_vector_elixir() {
|
|
let expiry = 1685984278;
|
|
let password = generate_password(
|
|
&"1cab293a-4032-46f4-862a-40e5d174b0d2".into(),
|
|
expiry,
|
|
"uvdgKvS9GXYZ_vmv",
|
|
);
|
|
assert_eq!(password, "6xUIoZ+QvxKhRasLifwfRkMXl+ETLJUsFkHlXjlHAkg")
|
|
}
|
|
|
|
#[test]
|
|
fn smoke() {
|
|
let message_integrity =
|
|
message_integrity(&RELAY_SECRET_1.into(), 1685200000, "n23JJ2wKKtt30oXi");
|
|
|
|
let result = message_integrity.verify(
|
|
&RELAY_SECRET_1.into(),
|
|
"1685200000:n23JJ2wKKtt30oXi",
|
|
systemtime_from_unix(1685200000 - 1000),
|
|
);
|
|
|
|
result.expect("credentials to be valid");
|
|
}
|
|
|
|
#[test]
|
|
fn expired_is_not_valid() {
|
|
let message_integrity = message_integrity(
|
|
&RELAY_SECRET_1.into(),
|
|
1685200000 - 1000,
|
|
"n23JJ2wKKtt30oXi",
|
|
);
|
|
|
|
let result = message_integrity.verify(
|
|
&RELAY_SECRET_1.into(),
|
|
"1685199000:n23JJ2wKKtt30oXi",
|
|
systemtime_from_unix(1685200000),
|
|
);
|
|
|
|
assert!(matches!(result.unwrap_err(), Error::Expired))
|
|
}
|
|
|
|
#[test]
|
|
fn different_relay_secret_makes_password_invalid() {
|
|
let message_integrity =
|
|
message_integrity(&RELAY_SECRET_2.into(), 1685200000, "n23JJ2wKKtt30oXi");
|
|
|
|
let result = message_integrity.verify(
|
|
&RELAY_SECRET_1.into(),
|
|
"1685200000:n23JJ2wKKtt30oXi",
|
|
systemtime_from_unix(168520000 + 1000),
|
|
);
|
|
|
|
assert!(matches!(result.unwrap_err(), Error::InvalidPassword))
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_username_format_fails() {
|
|
let message_integrity =
|
|
message_integrity(&RELAY_SECRET_2.into(), 1685200000, "n23JJ2wKKtt30oXi");
|
|
|
|
let result = message_integrity.verify(
|
|
&RELAY_SECRET_1.into(),
|
|
"foobar",
|
|
systemtime_from_unix(168520000 + 1000),
|
|
);
|
|
|
|
assert!(matches!(result.unwrap_err(), Error::InvalidUsername))
|
|
}
|
|
|
|
#[test]
|
|
fn nonces_are_valid_for_100_requests() {
|
|
let mut nonces = Nonces::default();
|
|
let nonce = Uuid::new_v4();
|
|
|
|
nonces.add_new(nonce);
|
|
|
|
for _ in 0..10_000 {
|
|
nonces.handle_nonce_used(nonce).unwrap();
|
|
}
|
|
|
|
assert!(matches!(
|
|
nonces.handle_nonce_used(nonce).unwrap_err(),
|
|
Error::NonceUsedUp
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_nonces_are_invalid() {
|
|
let mut nonces = Nonces::default();
|
|
let nonce = Uuid::new_v4();
|
|
|
|
assert!(matches!(
|
|
nonces.handle_nonce_used(nonce).unwrap_err(),
|
|
Error::UnknownNonce
|
|
));
|
|
}
|
|
|
|
fn message_integrity(
|
|
relay_secret: &SecretString,
|
|
username_expiry: u64,
|
|
username_salt: &str,
|
|
) -> MessageIntegrity {
|
|
let username = Username::new(format!("{username_expiry}:{username_salt}")).unwrap();
|
|
let password = generate_password(relay_secret, username_expiry, username_salt);
|
|
|
|
MessageIntegrity::new_long_term_credential(
|
|
&sample_message(),
|
|
&username,
|
|
&FIREZONE,
|
|
&password,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn sample_message() -> Message<Attribute> {
|
|
Message::new(
|
|
MessageClass::Request,
|
|
BINDING,
|
|
TransactionId::new([0u8; 12]),
|
|
)
|
|
}
|
|
}
|