#![allow(clippy::wildcard_enum_match_arm)]
use std::{
net::{Ipv4Addr, TcpListener},
path::Path,
time::{Duration, Instant},
};
use freenet::{
dev_tool::{
BundleKeyMaterial, ImportReport, Secrets, SecretsStore, TargetScope, import_bundle,
},
local_node::NodeConfig,
server::serve_client_api,
test_utils::load_delegate,
};
use freenet_stdlib::{
client_api::{ClientRequest, DelegateRequest, HostResponse, WebApi},
prelude::*,
};
use serde::{Deserialize, Serialize};
use tokio::time::timeout;
use tokio_tungstenite::{connect_async, tungstenite::client::IntoClientRequest};
use tracing::info;
const TEST_DELEGATE: &str = "test-delegate-2";
const TEST_DELEGATE_CIPHER: [u8; 32] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00,
0x0f, 0x1e, 0x2d, 0x3c, 0x4b, 0x5a, 0x69, 0x78, 0x87, 0x96, 0xa5, 0xb4, 0xc3, 0xd2, 0xe1, 0xf0,
];
const TEST_DELEGATE_NONCE: [u8; 24] = [0u8; 24];
#[allow(dead_code)]
#[derive(Debug, Serialize)]
enum InboundAppMessage {
CreateInboxRequest,
PleaseSignMessage(Vec<u8>),
WriteContext(Vec<u8>),
ReadContext,
ClearContext,
IncrementCounter,
HasSecret(Vec<u8>),
GetNonExistentSecret(Vec<u8>),
StoreSecret { key: Vec<u8>, value: Vec<u8> },
RemoveSecret(Vec<u8>),
WriteLargeContext(usize),
StoreLargeSecret { key: Vec<u8>, size: usize },
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
enum OutboundAppMessage {
CreateInboxResponse(Vec<u8>),
MessageSigned(Vec<u8>),
ContextData(Vec<u8>),
CounterValue(u32),
SecretExists(bool),
SecretResult(Option<Vec<u8>>),
ContextWritten,
ContextCleared,
SecretStored,
SecretRemoved,
LargeContextWritten(usize),
LargeSecretStored(usize),
SecretStoreFailed,
}
fn reserve_port() -> anyhow::Result<u16> {
let listener = TcpListener::bind("127.0.0.1:0")?;
Ok(listener.local_addr()?.port())
}
fn node_config(
dir: &Path,
ws_port: u16,
network_port: u16,
keypair_path: &Path,
hosted_mode: bool,
) -> freenet::config::ConfigArgs {
freenet::config::ConfigArgs {
ws_api: freenet::config::WebsocketApiArgs {
address: Some(Ipv4Addr::LOCALHOST.into()),
ws_api_port: Some(ws_port),
hosted_mode: Some(hosted_mode),
..Default::default()
},
network_api: freenet::config::NetworkArgs {
public_address: Some(Ipv4Addr::LOCALHOST.into()),
public_port: Some(network_port),
is_gateway: true,
skip_load_from_network: true,
gateways: Some(vec![]),
location: Some(0.5),
ignore_protocol_checking: true,
address: Some(Ipv4Addr::LOCALHOST.into()),
network_port: Some(network_port),
..Default::default()
},
config_paths: freenet::config::ConfigPathsArgs {
config_dir: Some(dir.to_path_buf()),
data_dir: Some(dir.to_path_buf()),
log_dir: Some(dir.to_path_buf()),
},
secrets: freenet::config::SecretArgs {
transport_keypair: Some(keypair_path.to_path_buf()),
..Default::default()
},
..Default::default()
}
}
struct TestNode {
ws_port: u16,
shutdown: freenet::ShutdownHandle,
run: tokio::task::JoinHandle<Result<std::convert::Infallible, anyhow::Error>>,
_data_dir: tempfile::TempDir,
}
impl TestNode {
async fn start(hosted_mode: bool) -> anyhow::Result<Self> {
let data_dir = tempfile::tempdir()?;
let data_path = data_dir.path().to_path_buf();
let key = freenet::dev_tool::TransportKeypair::new();
let keypair_path = data_path.join("private.pem");
key.save(&keypair_path)?;
key.public().save(data_path.join("public.pem"))?;
let ws_port = reserve_port()?;
let net_port = reserve_port()?;
let cfg = node_config(&data_path, ws_port, net_port, &keypair_path, hosted_mode)
.build()
.await?;
let node = NodeConfig::new(cfg.clone())
.await?
.build(serve_client_api(cfg.ws_api.clone()).await?)
.await?;
let shutdown = node.shutdown_handle();
let run = tokio::spawn(async move { node.run().await });
wait_ws_ready(ws_port, Duration::from_secs(30)).await?;
Ok(Self {
ws_port,
shutdown,
run,
_data_dir: data_dir,
})
}
async fn shutdown(self) {
self.shutdown.shutdown().await;
if timeout(Duration::from_secs(30), self.run).await.is_err() {
info!("node run loop did not exit within 30s of shutdown (cleanup only)");
}
}
}
async fn wait_ws_ready(port: u16, within: Duration) -> anyhow::Result<()> {
let url = format!("ws://127.0.0.1:{port}/v1/contract/command?encodingProtocol=native");
let deadline = Instant::now() + within;
loop {
match connect_async(&url).await {
Ok((stream, _)) => {
drop(stream);
return Ok(());
}
Err(e) => {
if Instant::now() >= deadline {
anyhow::bail!("WS API on port {port} did not come up within {within:?}: {e}");
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}
}
async fn connect_hosted(
port: u16,
user_token: Option<&str>,
xfp_https: bool,
) -> anyhow::Result<WebApi> {
let base = format!("ws://127.0.0.1:{port}/v1/contract/command?encodingProtocol=native");
let url = match user_token {
Some(t) => format!("{base}&userToken={t}"),
None => base,
};
let mut request = url.as_str().into_client_request()?;
if xfp_https {
request
.headers_mut()
.insert("X-Forwarded-Proto", "https".parse()?);
}
let (stream, _) = connect_async(request).await?;
Ok(WebApi::start(stream))
}
async fn register_delegate(
client: &mut WebApi,
delegate: &DelegateContainer,
delegate_key: &DelegateKey,
) -> anyhow::Result<()> {
client
.send(ClientRequest::DelegateOp(
DelegateRequest::RegisterDelegate {
delegate: delegate.clone(),
cipher: TEST_DELEGATE_CIPHER,
nonce: TEST_DELEGATE_NONCE,
},
))
.await?;
let resp = timeout(Duration::from_secs(15), client.recv()).await??;
match resp {
HostResponse::DelegateResponse { key, .. } => {
anyhow::ensure!(
&key == delegate_key,
"register acked a different key: {key}"
);
Ok(())
}
other => anyhow::bail!("unexpected response to RegisterDelegate: {other:?}"),
}
}
async fn store_secret(
client: &mut WebApi,
delegate_key: &DelegateKey,
key: &[u8],
value: &[u8],
) -> anyhow::Result<()> {
let payload = bincode::serialize(&InboundAppMessage::StoreSecret {
key: key.to_vec(),
value: value.to_vec(),
})?;
client
.send(ClientRequest::DelegateOp(
DelegateRequest::ApplicationMessages {
key: delegate_key.clone(),
params: Parameters::from(vec![]),
inbound: vec![InboundDelegateMsg::ApplicationMessage(
ApplicationMessage::new(payload),
)],
},
))
.await?;
let resp = timeout(Duration::from_secs(15), client.recv()).await??;
match resp {
HostResponse::DelegateResponse { values, .. } => {
let out = match &values[0] {
OutboundDelegateMsg::ApplicationMessage(m) => m,
other => anyhow::bail!("expected ApplicationMessage, got {other:?}"),
};
match bincode::deserialize::<OutboundAppMessage>(&out.payload)? {
OutboundAppMessage::SecretStored => Ok(()),
other => anyhow::bail!("expected SecretStored, got {other:?}"),
}
}
other => anyhow::bail!("unexpected response to ApplicationMessages: {other:?}"),
}
}
async fn http_export(
port: u16,
token: Option<&str>,
xfp_https: bool,
) -> anyhow::Result<reqwest::Response> {
let url = format!("http://127.0.0.1:{port}/v1/hosted/export");
let mut req = reqwest::Client::new().get(&url);
if let Some(t) = token {
req = req.header("X-Freenet-User-Token", t);
}
if xfp_https {
req = req.header("X-Forwarded-Proto", "https");
}
Ok(req.send().await?)
}
async fn import_into_fresh_store(
bundle: &[u8],
token: &[u8],
target: &TargetScope,
) -> anyhow::Result<ImportReport> {
let tmp = tempfile::tempdir()?;
let secrets_dir = tmp.path().join("secrets");
std::fs::create_dir_all(&secrets_dir)?;
let db = freenet::storages::Storage::new(tmp.path()).await?;
let secrets = Secrets::load_for_secrets_dir(&secrets_dir)?;
let mut store = SecretsStore::new(secrets_dir, secrets, db)?;
let report = import_bundle(
&mut store,
bundle,
&BundleKeyMaterial::Token(token),
target,
false,
)?;
Ok(report)
}
#[test_log::test(tokio::test(flavor = "multi_thread", worker_threads = 4))]
async fn hosted_export_secure_round_trip() -> anyhow::Result<()> {
let node = TestNode::start( true).await?;
let port = node.ws_port;
let delegate = load_delegate(TEST_DELEGATE, Parameters::from(vec![]))?;
let delegate_key = delegate.key().clone();
const TOKEN: &str = "user-token-for-export-aaaaaaaaaaaaaaaa";
const SECRET_KEY: &[u8] = b"exported-secret-key";
const VALUE: &[u8] = b"the-value-user-A-wants-to-take-with-them";
let mut conn = connect_hosted(port, Some(TOKEN), true).await?;
register_delegate(&mut conn, &delegate, &delegate_key).await?;
store_secret(&mut conn, &delegate_key, SECRET_KEY, VALUE).await?;
let resp = http_export(port, Some(TOKEN), true).await?;
assert_eq!(
resp.status(),
reqwest::StatusCode::OK,
"secure export must be 200 OK"
);
let ctype = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
assert_eq!(ctype, "application/octet-stream", "wrong Content-Type");
let cdisp = resp
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(
cdisp.contains("attachment") && cdisp.contains("freenet-data.fnsx"),
"wrong Content-Disposition: {cdisp}"
);
let bundle = resp.bytes().await?.to_vec();
assert!(!bundle.is_empty(), "export bundle must be non-empty");
assert_eq!(&bundle[0..4], b"FNSX", "not an FNSX bundle");
assert!(
!bundle.windows(VALUE.len()).any(|w| w == VALUE),
"plaintext leaked into the bundle"
);
let report = import_into_fresh_store(&bundle, TOKEN.as_bytes(), &TargetScope::Local).await?;
assert_eq!(report.imported, 1, "exactly the one stored secret imports");
assert!(report.skipped.is_empty());
let user_report = import_into_fresh_store(
&bundle,
TOKEN.as_bytes(),
&TargetScope::user_from_token(TOKEN.as_bytes()),
)
.await?;
assert_eq!(user_report.imported, 1);
drop(conn);
node.shutdown().await;
Ok(())
}
#[test_log::test(tokio::test(flavor = "multi_thread", worker_threads = 4))]
async fn hosted_export_bundle_rejects_wrong_token() -> anyhow::Result<()> {
let node = TestNode::start(true).await?;
let port = node.ws_port;
let delegate = load_delegate(TEST_DELEGATE, Parameters::from(vec![]))?;
let delegate_key = delegate.key().clone();
const TOKEN: &str = "the-real-token-bbbbbbbbbbbbbbbbbbbbbb";
let mut conn = connect_hosted(port, Some(TOKEN), true).await?;
register_delegate(&mut conn, &delegate, &delegate_key).await?;
store_secret(&mut conn, &delegate_key, b"k", b"v").await?;
let bundle = http_export(port, Some(TOKEN), true)
.await?
.bytes()
.await?
.to_vec();
let err = import_into_fresh_store(&bundle, b"a-different-token", &TargetScope::Local)
.await
.expect_err("wrong token must not decrypt the bundle");
assert!(
err.to_string().to_lowercase().contains("auth")
|| err.to_string().to_lowercase().contains("passphrase")
|| err.to_string().to_lowercase().contains("token"),
"expected an auth failure, got: {err}"
);
drop(conn);
node.shutdown().await;
Ok(())
}
#[test_log::test(tokio::test(flavor = "multi_thread", worker_threads = 4))]
async fn hosted_export_rejects_insecure_requests() -> anyhow::Result<()> {
let node = TestNode::start( true).await?;
let port = node.ws_port;
let resp = http_export(port, None, true).await?;
assert_eq!(
resp.status(),
reqwest::StatusCode::FORBIDDEN,
"no-token export must be 403"
);
let body = resp.bytes().await?;
assert_ne!(
&body[..body.len().min(4)],
b"FNSX",
"must not return a bundle"
);
let resp = http_export(port, Some("some-token-value"), false).await?;
assert_eq!(
resp.status(),
reqwest::StatusCode::FORBIDDEN,
"token-without-https export must be 403"
);
let body = resp.bytes().await?;
assert_ne!(
&body[..body.len().min(4)],
b"FNSX",
"must not return a bundle"
);
let resp = http_export(port, Some(""), true).await?;
assert_eq!(
resp.status(),
reqwest::StatusCode::FORBIDDEN,
"empty-token export must be 403"
);
node.shutdown().await;
Ok(())
}
#[test_log::test(tokio::test(flavor = "multi_thread", worker_threads = 4))]
async fn hosted_export_flag_off_rejects() -> anyhow::Result<()> {
let node = TestNode::start( false).await?;
let port = node.ws_port;
let resp = http_export(port, Some("ignored-because-flag-off"), true).await?;
assert_eq!(
resp.status(),
reqwest::StatusCode::FORBIDDEN,
"with hosted_mode=false the export endpoint must 403"
);
let body = resp.bytes().await?;
assert_ne!(
&body[..body.len().min(4)],
b"FNSX",
"must not return a bundle"
);
node.shutdown().await;
Ok(())
}