Make identity something you can look at and change
`id` now shows what this device is — the key it signs with, the name it
answers to, the secret of every network it has joined — and changes all
of it. One shape throughout: name a thing to see it, name it with a value
to change it. `secret` folded in as `id secret generate`, and the path
flags became global so they work either side of a subcommand.
There is no separate signing certificate to show: the endpoint key is
what signs records, and the report says so rather than leaving it to be
guessed.
Secrets appear in `id`, which is where you go to ask for one, and stay
out of `status`, logs, `Debug` and anything sent to a peer.
The hostname is now a signed claim, which is what makes changing it a
revocation. Records are one per author, so a new version replaces the
whole claim and no replica can keep the old name standing. RecordBody
generalised to Claim { address, range, hostname } + Release for that,
with the signing domain bumped; a name is bounded and canonicalised, and
a non-canonical one is rejected rather than repaired, because a repaired
version is not what its author signed. Two members claiming one name
resolve it like an address: lowest id wins, computed identically
everywhere. A member with only a name now has a record too, so an
IPv6-only network finally has a durable roster and an absent member can
be named rather than shown as a bare id.
Replacing the signing key is allowed and does not break the store. The
outgoing key signs a release for every network first, so the address and
name it held are freed rather than reserved forever to a key nobody has
— nothing can sign for a retired author, and by design no authority
could overrule one. Identity and releases commit together: a crash
between them would leave the old key gone and unable to sign what it
owed. It refuses while an agent holds the directory, rather than failing
on the lock with a message that says nothing about what to do.
The version counter is keyed by author as well as network, so a
replacement key starts its own sequence. The migration drops records
written under the previous signing domain instead of carrying rows that
every read must reject and that look exactly like corruption.
The hostname defaults to the machine's own name. Also fixed a
pre-existing flaky test: 40 random authors in a /24 collide by the
birthday problem often enough that its threshold failed about one run in
six, so the authors are fixed now and it tests a property rather than a
coin flip.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -177,6 +177,89 @@ async fn a_state_store_from_a_newer_build_is_refused() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A store written by the previous schema must come up, not break.
|
||||
///
|
||||
/// The record body gained a hostname, which changed the signing domain, so
|
||||
/// records written before it can never verify again. Leaving them in place
|
||||
/// would mean every read rejecting rows that look exactly like corruption.
|
||||
#[tokio::test]
|
||||
async fn a_state_store_from_the_previous_schema_is_migrated_and_stays_usable() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
std::fs::create_dir_all(&paths.state_dir).unwrap();
|
||||
|
||||
// Build a schema-2 store by hand, with a record in it.
|
||||
{
|
||||
let conn = rusqlite::Connection::open(paths.state_db()).unwrap();
|
||||
conn.execute_batch(
|
||||
"BEGIN;
|
||||
CREATE TABLE device_identity (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
secret_key BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE networks (
|
||||
network_id BLOB PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
secret BLOB NOT NULL,
|
||||
auto_start INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE signed_records (
|
||||
network_id BLOB NOT NULL,
|
||||
author BLOB NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
body BLOB NOT NULL,
|
||||
signature BLOB NOT NULL,
|
||||
PRIMARY KEY (network_id, author)
|
||||
);
|
||||
CREATE TABLE own_record_version (
|
||||
network_id BLOB PRIMARY KEY,
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO signed_records VALUES (x'00', x'11', 7, x'2222', x'3333');
|
||||
INSERT INTO own_record_version VALUES (x'00', 7);
|
||||
PRAGMA user_version = 2;
|
||||
COMMIT;",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// An agent comes up on it, which is the whole point.
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let (name, secret) = network("migrated");
|
||||
let network_id = agent.join_network(&name, &secret).await.unwrap();
|
||||
assert!(agent.network_status(network_id).await.is_ok());
|
||||
agent.shutdown().await;
|
||||
|
||||
let conn = rusqlite::Connection::open(paths.state_db()).unwrap();
|
||||
let version: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(version, tsunagi::storage::SCHEMA_VERSION);
|
||||
|
||||
// The unverifiable record is gone rather than left to be rejected for
|
||||
// ever, and the counter is keyed by author now.
|
||||
let stale: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM signed_records WHERE author = x'11'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stale, 0, "records from the old signing domain are dropped");
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM own_record_version WHERE author IS NOT NULL",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.expect("the counter is keyed by author");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secrets_never_appear_in_status_or_debug_output() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! 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);
|
||||
}
|
||||
+1
-1
@@ -56,7 +56,7 @@ async fn two_agents_authenticate_and_exchange_messages() {
|
||||
// Both sides announce a hostname over the authenticated session.
|
||||
let status = a.agent.network_status(network_id).await.unwrap();
|
||||
let peer = &status.peers[0];
|
||||
assert_eq!(peer.hostname.as_deref(), Some(b.agent.hostname()));
|
||||
assert_eq!(peer.hostname.as_deref(), Some(b.agent.hostname().as_str()));
|
||||
assert!(peer.transport != tsunagi::net::TransportKind::Unknown);
|
||||
assert!(peer.rtt.is_some(), "a verified path must report an RTT");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user