245 lines
8.7 KiB
Rust
245 lines
8.7 KiB
Rust
//! The device's own identity: the name it answers to and the key it signs
|
|||
|
|
//! with, and what happens when either is changed.
|
||
|
|
//!
|
||
|
|
//! Both are things a user may reasonably change on a machine they own, and
|
||
|
|
//! neither may leave the state store in a shape the next start cannot use.
|
||
|
|
//! That is what these check: not that changing them is prevented, but that
|
||
|
|
//! the store survives it and says something true afterwards.
|
||
|
|
|
||
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||
|
|
|
||
|
|
mod common;
|
||
|
|
|
||
|
|
use common::{config_with, network, wait_until};
|
||
|
|
use tempfile::TempDir;
|
||
|
|
use tsunagi::Agent;
|
||
|
|
use tsunagi::config::StoragePaths;
|
||
|
|
use tsunagi::discovery::SharedMemoryDiscovery;
|
||
|
|
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||
|
|
use tsunagi::state::{RecordBody, StateSet};
|
||
|
|
use tsunagi::storage::StateStore;
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn a_changed_name_reaches_peers_and_replaces_the_old_claim() {
|
||
|
|
let discovery = SharedMemoryDiscovery::new();
|
||
|
|
let (name, secret) = network("renaming");
|
||
|
|
|
||
|
|
let a = TempDir::new().unwrap();
|
||
|
|
let b = TempDir::new().unwrap();
|
||
|
|
let agent_a = Agent::spawn(config_with(a.path(), &discovery))
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
let agent_b = Agent::spawn(config_with(b.path(), &discovery))
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
let network_id = agent_a.join_network(&name, &secret).await.unwrap();
|
||
|
|
agent_b.join_network(&name, &secret).await.unwrap();
|
||
|
|
|
||
|
|
let accepted = agent_a.set_hostname("Renamed Host").await.unwrap();
|
||
|
|
assert_eq!(accepted, "renamedhost", "reduced to a canonical form");
|
||
|
|
assert_eq!(agent_a.hostname(), "renamedhost");
|
||
|
|
|
||
|
|
// The peer is told, rather than finding out on its next restart.
|
||
|
|
wait_until("the peer learns the new name", || async {
|
||
|
|
let status = agent_b.network_status(network_id).await.ok()?;
|
||
|
|
status
|
||
|
|
.peers
|
||
|
|
.iter()
|
||
|
|
.any(|peer| peer.hostname.as_deref() == Some("renamedhost"))
|
||
|
|
.then_some(())
|
||
|
|
})
|
||
|
|
.await;
|
||
|
|
|
||
|
|
// And the signed claim says it, so the name outlives the session.
|
||
|
|
wait_until("the claim carries the new name", || async {
|
||
|
|
let status = agent_b.network_status(network_id).await.ok()?;
|
||
|
|
status
|
||
|
|
.members
|
||
|
|
.iter()
|
||
|
|
.any(|member| member.hostname.as_deref() == Some("renamedhost"))
|
||
|
|
.then_some(())
|
||
|
|
})
|
||
|
|
.await;
|
||
|
|
|
||
|
|
agent_a.shutdown().await;
|
||
|
|
agent_b.shutdown().await;
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn a_name_that_reduces_to_nothing_is_refused_rather_than_stored() {
|
||
|
|
let discovery = SharedMemoryDiscovery::new();
|
||
|
|
let dir = TempDir::new().unwrap();
|
||
|
|
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
|
||
|
|
let before = agent.hostname();
|
||
|
|
assert!(agent.set_hostname("---").await.is_err());
|
||
|
|
assert!(agent.set_hostname("").await.is_err());
|
||
|
|
assert_eq!(agent.hostname(), before, "the old name still stands");
|
||
|
|
|
||
|
|
agent.shutdown().await;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Replacing the signing key must leave a store the next run can use.
|
||
|
|
///
|
||
|
|
/// The user is entitled to do this on a machine they own, and they lose the
|
||
|
|
/// address and name the old key held — there is no way to sign on a dead
|
||
|
|
/// key's behalf, and nothing here may overrule an author. What must not
|
||
|
|
/// happen is that the store is left in a shape that breaks.
|
||
|
|
#[tokio::test]
|
||
|
|
async fn rotating_the_signing_key_releases_what_it_held_and_leaves_a_usable_store() {
|
||
|
|
let dir = TempDir::new().unwrap();
|
||
|
|
let paths = StoragePaths::under(dir.path());
|
||
|
|
let discovery = SharedMemoryDiscovery::new();
|
||
|
|
let (name, secret) = network("rotation");
|
||
|
|
let network_id = NetworkKeys::derive(&name, &secret).network_id();
|
||
|
|
|
||
|
|
// Run once so there is an identity, a network, and a claim to give up.
|
||
|
|
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
agent.join_network(&name, &secret).await.unwrap();
|
||
|
|
let before = agent.endpoint_id();
|
||
|
|
wait_until("the agent claims an address", || async {
|
||
|
|
let status = agent.network_status(network_id).await.ok()?;
|
||
|
|
status
|
||
|
|
.members
|
||
|
|
.iter()
|
||
|
|
.any(|member| member.endpoint_id == before && member.overlay_address_v4.is_some())
|
||
|
|
.then_some(())
|
||
|
|
})
|
||
|
|
.await;
|
||
|
|
let claimed = agent
|
||
|
|
.network_status(network_id)
|
||
|
|
.await
|
||
|
|
.unwrap()
|
||
|
|
.members
|
||
|
|
.iter()
|
||
|
|
.find(|member| member.endpoint_id == before)
|
||
|
|
.and_then(|member| member.overlay_address_v4)
|
||
|
|
.expect("an address was claimed");
|
||
|
|
agent.shutdown().await;
|
||
|
|
|
||
|
|
let (replacement, released) = {
|
||
|
|
let store = StateStore::open(paths.state_db()).unwrap();
|
||
|
|
store.rotate_device_identity().unwrap()
|
||
|
|
};
|
||
|
|
assert_ne!(replacement.endpoint_id(), before, "a different author");
|
||
|
|
assert_eq!(released, vec![network_id]);
|
||
|
|
|
||
|
|
// The outgoing key signed a release, and it still verifies: a record
|
||
|
|
// whose author no longer runs is not thereby invalid.
|
||
|
|
{
|
||
|
|
let store = StateStore::open(paths.state_db()).unwrap();
|
||
|
|
assert_eq!(
|
||
|
|
store.device_identity().unwrap().map(|id| id.endpoint_id()),
|
||
|
|
Some(replacement.endpoint_id())
|
||
|
|
);
|
||
|
|
|
||
|
|
let mut set = StateSet::new();
|
||
|
|
for record in store.signed_records(network_id).unwrap() {
|
||
|
|
set.merge(network_id, record)
|
||
|
|
.expect("every record verifies");
|
||
|
|
}
|
||
|
|
let old = set.get(&before).expect("the old author is still on record");
|
||
|
|
assert!(matches!(old.body, RecordBody::Release));
|
||
|
|
assert_eq!(
|
||
|
|
set.address_of(&before),
|
||
|
|
None,
|
||
|
|
"the address it held is free again"
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
!set.address_holders().contains_key(&claimed),
|
||
|
|
"{claimed} is no longer reserved"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// The whole point: the next run comes up on it.
|
||
|
|
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
assert_eq!(agent.endpoint_id(), replacement.endpoint_id());
|
||
|
|
let network_id = agent.join_network(&name, &secret).await.unwrap();
|
||
|
|
wait_until("the new identity claims an address of its own", || async {
|
||
|
|
let status = agent.network_status(network_id).await.ok()?;
|
||
|
|
status
|
||
|
|
.members
|
||
|
|
.iter()
|
||
|
|
.any(|member| {
|
||
|
|
member.endpoint_id == replacement.endpoint_id()
|
||
|
|
&& member.overlay_address_v4.is_some()
|
||
|
|
})
|
||
|
|
.then_some(())
|
||
|
|
})
|
||
|
|
.await;
|
||
|
|
agent.shutdown().await;
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn rotating_a_store_that_has_never_run_just_creates_an_identity() {
|
||
|
|
let dir = TempDir::new().unwrap();
|
||
|
|
let paths = StoragePaths::under(dir.path());
|
||
|
|
|
||
|
|
let store = StateStore::open(paths.state_db()).unwrap();
|
||
|
|
assert!(store.device_identity().unwrap().is_none());
|
||
|
|
|
||
|
|
let (identity, released) = store.rotate_device_identity().unwrap();
|
||
|
|
assert!(
|
||
|
|
released.is_empty(),
|
||
|
|
"nothing was held, so nothing is given up"
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
store.device_identity().unwrap().map(|id| id.endpoint_id()),
|
||
|
|
Some(identity.endpoint_id())
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn the_hostname_defaults_to_the_machines_own_name() {
|
||
|
|
let discovery = SharedMemoryDiscovery::new();
|
||
|
|
let dir = TempDir::new().unwrap();
|
||
|
|
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
|
||
|
|
// Whatever this machine is called, the agent uses it rather than
|
||
|
|
// inventing a name from the key — unless the host has no usable one.
|
||
|
|
match tsunagi::agent::system_hostname() {
|
||
|
|
Some(system) => assert_eq!(agent.hostname(), system),
|
||
|
|
None => assert!(agent.hostname().starts_with("tsunagi-")),
|
||
|
|
}
|
||
|
|
|
||
|
|
agent.shutdown().await;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// A secret must not reach the control socket, whatever else `id` prints.
|
||
|
|
#[tokio::test]
|
||
|
|
async fn secrets_stay_out_of_the_status_report() {
|
||
|
|
let discovery = SharedMemoryDiscovery::new();
|
||
|
|
let dir = TempDir::new().unwrap();
|
||
|
|
let (name, secret) = network("no-secrets-on-the-wire");
|
||
|
|
|
||
|
|
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
agent.join_network(&name, &secret).await.unwrap();
|
||
|
|
|
||
|
|
let status = agent.status().await.unwrap();
|
||
|
|
let rendered = format!("{status:?}");
|
||
|
|
assert!(!rendered.contains(secret.encode().as_str()));
|
||
|
|
|
||
|
|
agent.shutdown().await;
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn a_network_name_and_secret_are_unaffected_by_the_device_key() {
|
||
|
|
// Network identity is derived from the name and secret only. Replacing
|
||
|
|
// the device key must not move the network the device belongs to.
|
||
|
|
let name = NetworkName::new("stable").unwrap();
|
||
|
|
let secret = NetworkSecret::from_bytes(vec![9u8; 32]).unwrap();
|
||
|
|
let first = NetworkKeys::derive(&name, &secret).network_id();
|
||
|
|
let second = NetworkKeys::derive(&name, &secret).network_id();
|
||
|
|
assert_eq!(first, second);
|
||
|
|
}
|