Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,9 @@ Useful controls:
--pairing-issuer issue one-use Foregather live grants to devices
--pairing-relay-url <URL> Foregather API base for --pairing-issuer
--pairing-operator-token-file <PATH> private file holding its operator token
--turn-issuer issue short-lived TURN credentials to devices
--turn-issuer hand out direct-send ICE servers: STUN alone, or TURN with short-lived credentials
--turn-url <URL> stun:, turn: or turns: URL for --turn-issuer, 1 to 4
--turn-secret-file <PATH> private file holding the TURN static-auth-secret
--turn-secret-file <PATH> private file holding the TURN static-auth-secret (only with a turn: or turns: URL)
--service-profile <PATH> opt-in public service listing (docs/SERVICE-DISCOVERY.md)
--checkout-profile <PATH> opt-in paid checkout, needs --no-tor (docs/CHECKOUT.md)
--max-concurrent-writes <N> concurrent upload/mirror limit, default 4
Expand Down
49 changes: 47 additions & 2 deletions crates/wildbloomd/src/grants/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1161,7 +1161,10 @@ async fn tokens_and_authorisation_events_never_reach_the_logs() {
logs.contains("accepted an owner-signed device grant"),
"{logs}"
);
assert!(logs.contains("issued a pair of TURN credentials"), "{logs}");
assert!(
logs.contains("issued a pair of ICE configurations"),
"{logs}"
);
let mut secrets = vec![
URL_SAFE_NO_PAD.encode(OPERATOR_TOKEN_BYTES),
URL_SAFE_NO_PAD.encode([9_u8; 32]),
Expand Down Expand Up @@ -2066,7 +2069,7 @@ fn turn_app(node: &Node) -> Router {
TurnIssuer::new(
node.grants.clone(),
urls,
TurnSecret::parse(TURN_SECRET).unwrap(),
Some(TurnSecret::parse(TURN_SECRET).unwrap()),
)
.router()
}
Expand Down Expand Up @@ -2186,6 +2189,48 @@ async fn turn_issuer_returns_two_fresh_credentials_to_a_granted_device() {
assert_eq!(distinct.len(), labels.len());
}

#[tokio::test]
async fn a_stun_only_issuer_needs_no_secret_and_hands_out_empty_credentials() {
let (_dir, node) = node().await;
let urls = vec!["stun:stun.example.net:3478".to_owned()];
let urls = super::turn::ice_urls(&urls).unwrap();
assert!(!super::turn::has_turn_url(&urls));
let app = TurnIssuer::new(node.grants.clone(), urls, None).router();
let now = node.now();
node.call(post_grant(grant_event(&keys(1), &standard_grant(), now)))
.await;
let response = app
.oneshot(turn_request(&keys(10), now, b""))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
let bytes = to_bytes(response.into_body(), 1 << 20).await.unwrap();
let text = String::from_utf8(bytes.to_vec()).unwrap();
let body = serde_json::from_slice::<Value>(&bytes).unwrap();
let expires = body["expires"].as_u64().unwrap();
let entry = r#"{"credential":"","urls":["stun:stun.example.net:3478"],"username":""}"#;
assert_eq!(
text,
format!(r#"{{"expires":{expires},"own":{entry},"peer":{entry}}}"#)
);
}

#[tokio::test]
async fn a_turn_issuer_without_its_secret_is_unavailable_rather_than_unsigned() {
let (_dir, node) = node().await;
let urls = super::turn::ice_urls(&TURN_URLS.map(str::to_owned)).unwrap();
let app = TurnIssuer::new(node.grants.clone(), urls, None).router();
let now = node.now();
node.call(post_grant(grant_event(&keys(1), &standard_grant(), now)))
.await;
let response = app
.oneshot(turn_request(&keys(10), now, b""))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}

#[tokio::test]
async fn turn_issuer_refuses_unauthorised_requests() {
let (_dir, node) = node().await;
Expand Down
61 changes: 44 additions & 17 deletions crates/wildbloomd/src/grants/turn.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
//! Opt-in TURN credential issuer: a currently granted device exchanges one
//! fresh NIP-98 request for two short-lived TURN REST credentials, one for
//! itself and one for the verified person at the other end of a direct send.
//! Opt-in ICE configuration issuer: a currently granted device exchanges one
//! fresh NIP-98 request for two entries, one for itself and one for the
//! verified person at the other end of a direct send. If any configured URL
//! is TURN, each entry holds short-lived TURN REST credentials. If every URL
//! is STUN, nothing is minted and no secret is held: both entries carry the
//! URLs with an empty username and credential.
//!
//! Credentials use coturn's `use-auth-secret` form: the username is
//! `<expires>:<label>` and the credential is the padded base64 HMAC-SHA1 of the
Expand Down Expand Up @@ -119,8 +122,8 @@ impl TurnSecret {

/// Validate the configured ICE URLs against the direct-send `ice` entry: 1 to
/// 4 distinct `stun:`, `turn:` or `turns:` URLs of at most 256 bytes of
/// printable ASCII (no spaces, quotes or backslashes), at least one of them
/// TURN.
/// printable ASCII (no spaces, quotes or backslashes). STUN alone is valid;
/// see [`has_turn_url`] for when a secret is needed.
pub fn ice_urls(urls: &[String]) -> Result<Vec<String>, &'static str> {
if urls.is_empty() || urls.len() > MAX_URLS {
return Err("--turn-url must be given 1 to 4 times");
Expand All @@ -144,15 +147,16 @@ pub fn ice_urls(urls: &[String]) -> Result<Vec<String>, &'static str> {
if urls.iter().collect::<BTreeSet<_>>().len() != urls.len() {
return Err("--turn-url values must be distinct");
}
if !urls
.iter()
.any(|url| url.starts_with("turn:") || url.starts_with("turns:"))
{
return Err("at least one --turn-url must be turn: or turns:");
}
Ok(urls.to_vec())
}

/// Whether any URL is `turn:` or `turns:`, which is when credentials are
/// minted and the shared secret is required.
pub fn has_turn_url(urls: &[String]) -> bool {
urls.iter()
.any(|url| url.starts_with("turn:") || url.starts_with("turns:"))
}

/// One entry of the direct-send `ice` list, members in canonical order.
/// `Debug` hides the username and credential.
#[derive(Serialize)]
Expand Down Expand Up @@ -184,12 +188,15 @@ pub struct TurnCredentials {
pub struct TurnIssuer {
auth: DeviceAuth,
urls: Arc<[String]>,
secret: TurnSecret,
/// Present exactly when a URL is TURN.
secret: Option<TurnSecret>,
}

impl TurnIssuer {
/// `urls` must already have passed [`ice_urls`].
pub fn new(grants: Grants, urls: Vec<String>, secret: TurnSecret) -> Self {
/// `urls` must already have passed [`ice_urls`]. `secret` is needed if
/// and only if [`has_turn_url`] holds for them; a TURN issuer without one
/// answers 503.
pub fn new(grants: Grants, urls: Vec<String>, secret: Option<TurnSecret>) -> Self {
Self {
auth: DeviceAuth::new(grants, TURN_PATH, RATE_LIMIT, &REFUSALS),
urls: Arc::from(urls),
Expand All @@ -211,10 +218,19 @@ impl TurnIssuer {
"TURN credentials are unavailable",
)
};
// STUN alone: nothing to mint, so no label and no credential.
if !has_turn_url(&self.urls) {
return Ok(IceServer {
credential: String::new(),
urls: self.urls.to_vec(),
username: String::new(),
});
}
let secret = self.secret.as_ref().ok_or_else(unavailable)?;
let mut label = [0_u8; LABEL_BYTES];
getrandom::fill(&mut label).map_err(|_| unavailable())?;
let username = format!("{expires}:{}", URL_SAFE_NO_PAD.encode(label));
let credential = self.secret.credential(&username).ok_or_else(unavailable)?;
let credential = secret.credential(&username).ok_or_else(unavailable)?;
Ok(IceServer {
credential,
urls: self.urls.to_vec(),
Expand All @@ -236,7 +252,7 @@ async fn issue(State(issuer): State<TurnIssuer>, request: Request<Body>) -> Resp
.await;
match result {
Ok(credentials) => {
tracing::info!("issued a pair of TURN credentials");
tracing::info!("issued a pair of ICE configurations");
let mut response = (StatusCode::CREATED, Json(credentials)).into_response();
no_store(&mut response);
response
Expand Down Expand Up @@ -383,12 +399,24 @@ mod tests {
assert!(TurnSecret::from_sources(None, Some(OsString::new())).is_err());
}

#[test]
fn a_secret_is_needed_only_when_a_url_is_turn() {
let owned = |urls: &[&str]| urls.iter().map(|url| (*url).to_owned()).collect::<Vec<_>>();
assert!(!has_turn_url(&owned(&["stun:stun.example.net:3478"])));
assert!(has_turn_url(&owned(&[
"stun:turn.example.net:3478",
"turn:turn.example.net:3478"
])));
assert!(has_turn_url(&owned(&["turns:turn.example.net:5349"])));
}

#[test]
fn ice_urls_follow_the_direct_send_rules() {
let owned = |urls: &[&str]| urls.iter().map(|url| (*url).to_owned()).collect::<Vec<_>>();
for accepted in [
&["turn:turn.example.net:3478"][..],
&["turns:turn.example.net:5349?transport=tcp"][..],
&["stun:stun.example.net:3478"][..],
&[
"stun:turn.example.net:3478",
"turn:turn.example.net:3478?transport=udp",
Expand All @@ -410,7 +438,6 @@ mod tests {
"turn:d.example",
"turn:e.example",
]),
owned(&["stun:turn.example.net:3478"]),
owned(&["http://turn.example.net/"]),
owned(&["TURN:turn.example.net"]),
owned(&["turn:"]),
Expand Down
124 changes: 109 additions & 15 deletions crates/wildbloomd/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,17 +191,19 @@ struct Cli {
)]
pairing_operator_token_file: Option<PathBuf>,

/// Issue short-lived TURN credentials to granted devices for direct send.
/// The shared secret comes from --turn-secret-file or the
/// WILDBLOOM_TURN_SECRET environment variable, never an argument.
/// Hand out direct-send ICE servers to granted devices: STUN URLs alone,
/// or TURN with short-lived credentials (the secret is then required, from
/// --turn-secret-file or the WILDBLOOM_TURN_SECRET environment variable,
/// never an argument; with STUN alone it must be absent).
#[arg(
long,
env = "WILDBLOOM_TURN_ISSUER",
requires_all = ["device_grants", "turn_urls"]
)]
turn_issuer: bool,

/// stun:, turn: or turns: URL returned with every TURN credential (1 to 4; repeatable or comma-separated).
/// stun:, turn: or turns: URL handed out with every direct-send ICE entry (1 to 4; repeatable or comma-separated).
/// STUN URLs alone need no secret; any turn: or turns: URL does.
#[arg(
long = "turn-url",
env = "WILDBLOOM_TURN_URLS",
Expand All @@ -211,6 +213,7 @@ struct Cli {
turn_urls: Vec<String>,

/// Private (0600) file holding the TURN server's static-auth-secret.
/// Required if and only if a --turn-url is turn: or turns:.
#[arg(long, env = "WILDBLOOM_TURN_SECRET_FILE", requires = "turn_issuer")]
turn_secret_file: Option<PathBuf>,

Expand Down Expand Up @@ -289,6 +292,33 @@ enum Command {
Replicas(replicas::Cli),
}

/// The ICE URLs and, if any is TURN, the secret. STUN URLs alone take no
/// secret, and one given anyway stops the node rather than being ignored.
type TurnIssuerConfig = (Vec<String>, Option<grants::turn::TurnSecret>);

fn issuer_config(
enabled: bool,
turn_urls: &[String],
secret_file: Option<&std::path::Path>,
env_secret: Option<std::ffi::OsString>,
) -> Result<Option<TurnIssuerConfig>, &'static str> {
if !enabled {
return Ok(None);
}
let urls = grants::turn::ice_urls(turn_urls)?;
let secret = if grants::turn::has_turn_url(&urls) {
Some(grants::turn::TurnSecret::from_sources(
secret_file,
env_secret,
)?)
} else if secret_file.is_some() || env_secret.is_some() {
return Err("a TURN secret was given but no --turn-url is turn: or turns:");
} else {
None
};
Ok(Some((urls, secret)))
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
Expand Down Expand Up @@ -316,17 +346,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if !cli.bind.ip().is_loopback() && !cli.allow_public_bind {
return Err("refusing a non-loopback bind without --allow-public-bind".into());
}
let turn_issuer = if cli.turn_issuer {
Some((
grants::turn::ice_urls(&cli.turn_urls)?,
grants::turn::TurnSecret::from_sources(
cli.turn_secret_file.as_deref(),
std::env::var_os(grants::turn::SECRET_ENV),
)?,
))
} else {
None
};
let turn_issuer = issuer_config(
cli.turn_issuer,
&cli.turn_urls,
cli.turn_secret_file.as_deref(),
std::env::var_os(grants::turn::SECRET_ENV),
)?;
let store = Store::open(StoreConfig {
root: data_dir.clone(),
quota_bytes: cli.quota_bytes,
Expand Down Expand Up @@ -912,6 +937,75 @@ mod tests {
]
);
assert!(grants::turn::ice_urls(&cli.turn_urls).is_ok());
// STUN alone is a valid configuration and needs no secret.
let cli = Cli::try_parse_from([
"wildbloomd",
"--device-grants",
"--turn-issuer",
"--turn-url",
"stun:stun.example.net:3478",
])
.unwrap();
let urls = grants::turn::ice_urls(&cli.turn_urls).unwrap();
assert!(cli.turn_issuer && !grants::turn::has_turn_url(&urls));
assert!(cli.turn_secret_file.is_none());
}

#[test]
fn the_turn_secret_is_required_if_and_only_if_a_url_is_turn() {
let parse = |urls: &[&str], file: Option<&str>| {
let mut args = vec!["wildbloomd", "--device-grants", "--turn-issuer"];
for url in urls {
args.extend(["--turn-url", url]);
}
if let Some(file) = file {
args.extend(["--turn-secret-file", file]);
}
Cli::try_parse_from(args).unwrap()
};
let issuer_config_for = |cli: &Cli, env: Option<std::ffi::OsString>| {
issuer_config(
cli.turn_issuer,
&cli.turn_urls,
cli.turn_secret_file.as_deref(),
env,
)
};
let secret = || {
Some(std::ffi::OsString::from(
"synthetic-wildbloom-turn-secret-0001",
))
};
let stun = ["stun:stun.example.net:3478"];
let turn = ["stun:turn.example.net:3478", "turn:turn.example.net:3478"];
// STUN alone starts with no secret, and holds none.
let config = issuer_config_for(&parse(&stun, None), None)
.unwrap()
.unwrap();
assert_eq!(config.0, stun);
assert!(config.1.is_none());
// A secret given with STUN alone is an error, from either source.
let message = "a TURN secret was given but no --turn-url is turn: or turns:";
assert_eq!(
issuer_config_for(&parse(&stun, Some("/var/lib/example/turn-secret")), None).err(),
Some(message)
);
assert_eq!(
issuer_config_for(&parse(&stun, None), secret()).err(),
Some(message)
);
// TURN needs one, as before.
assert!(issuer_config_for(&parse(&turn, None), None).is_err());
let config = issuer_config_for(&parse(&turn, None), secret())
.unwrap()
.unwrap();
assert!(config.1.is_some());
// Not asked for: nothing.
assert!(
issuer_config_for(&Cli::try_parse_from(["wildbloomd"]).unwrap(), secret())
.unwrap()
.is_none()
);
}

const KEEPER: &str = "ab0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd";
Expand Down
6 changes: 4 additions & 2 deletions docs/THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,16 @@
HTTP, and never logs or returns it. A granted device can obtain at most 30
one-use live creation grants in any rolling hour; each grant is usable for
five minutes and allocates sessions of at most ten minutes.
- The optional TURN credential issuer holds the TURN server's
`static-auth-secret`, read from a `0600` file (recommended) or the
- The optional ICE issuer, when any configured URL is TURN, holds the TURN
server's `static-auth-secret`, read from a `0600` file (recommended) or the
environment, removes it from child process environments, and never logs or
returns it; only HMAC outputs leave the node. It uses the pairing issuer's
NIP-98 and device grant checks with a separate budget of 30 requests per
device in any rolling hour. Anyone holding a returned credential can open
allocations on that TURN server until it expires an hour later, so Drop
hands the peer's credential only to the person whose check words matched.
With STUN URLs alone it holds no secret and mints nothing: it returns the
URLs with empty credentials, which cannot open an allocation.
- Revocation is committed and enforced by the gate at once; it waits for no
upload. Delegated write bodies must keep arriving and keep up a minimum
average rate after a grace period, and a sweep waits only briefly for one ended key's in-flight
Expand Down
Loading
Loading