//! The mandatory state store, `state.sqlite`. //! //! Holds the persistent device identity, configured networks (including their //! shared secrets, which are needed to re-derive keys after a restart), the //! stored hostname and auto-start flags. //! //! Corruption is reported, never silently repaired: a damaged state store must //! not quietly turn into a brand new identity. //! //! Future work will add per-author record versions, accepted signed states and //! revocations here. When that lands, writing an event and bumping the author's //! own counter must happen in one SQLite transaction *before* the change is //! published to the network. use std::path::{Path, PathBuf}; use rusqlite::{Connection, OptionalExtension, params}; use crate::error::{Error, Result}; use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret}; use crate::state::{MAX_RETIREMENT_LEN, RecordBody, SignedRecord, SignedRetirement}; /// Schema version written by this build. pub const SCHEMA_VERSION: i64 = 9; /// Key of the stored hostname setting. const SETTING_HOSTNAME: &str = "hostname"; /// A network as persisted in the state store. /// /// The secret is held in a [`NetworkSecret`], which redacts itself from `Debug` /// and zeroizes on drop. #[derive(Debug, Clone)] pub struct StoredNetwork { /// Ordered local protocol override; absent inherits the agent's defaults. pub protocols: Option>, /// Derived public network identifier. pub network_id: NetworkId, /// Network name. pub name: NetworkName, /// Shared secret, needed to re-derive keys after restart. pub secret: NetworkSecret, /// Whether the network is activated automatically at agent startup. pub auto_start: bool, /// Local broadcast participation, enabled unless explicitly disabled. pub broadcast: bool, /// This agent offers itself as an exit node here. Off unless turned on. pub exit_node: bool, /// The member this device sends all its internet traffic through, if any. pub exit_via: Option<[u8; 32]>, } /// The mandatory state store. #[derive(Debug)] pub struct StateStore { conn: Connection, path: PathBuf, } impl StateStore { /// Opens (creating if absent) the state store at `path`. /// /// Returns [`Error::StateCorrupted`] if the file exists but is not a usable /// database. The file is never deleted or recreated by this function. pub fn open(path: impl AsRef) -> Result { let path = path.as_ref().to_path_buf(); let existed = path.exists(); // The database file is created on demand, so the directory holding // it has to be too — with the same restricted permissions the agent // would have given it, never looser. if let Some(parent) = path.parent() && !parent.as_os_str().is_empty() { super::create_dir(parent)?; } let conn = Connection::open(&path).map_err(|err| Error::StateCorrupted { path: path.clone(), reason: format!("cannot open database: {err}"), })?; super::restrict_path_permissions(&path)?; super::apply_common_pragmas(&conn).map_err(|err| Error::StateCorrupted { path: path.clone(), reason: format!("cannot configure database: {err}"), })?; if existed { let integrity: String = conn .query_row("PRAGMA integrity_check", [], |row| row.get(0)) .map_err(|err| Error::StateCorrupted { path: path.clone(), reason: format!("integrity check failed: {err}"), })?; if integrity != "ok" { return Err(Error::StateCorrupted { path, reason: format!("integrity check reported: {integrity}"), }); } } let store = Self { conn, path }; store.migrate()?; Ok(store) } /// Path of the underlying file. pub fn path(&self) -> &Path { &self.path } fn corrupt(&self, reason: impl std::fmt::Display) -> Error { Error::StateCorrupted { path: self.path.clone(), reason: reason.to_string(), } } fn migrate(&self) -> Result<()> { let found: i64 = self .conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .map_err(|err| self.corrupt(format!("cannot read schema version: {err}")))?; if found > SCHEMA_VERSION { return Err(Error::UnsupportedSchema { found, supported: SCHEMA_VERSION, }); } if found == SCHEMA_VERSION { return self.verify_shape(); } // Migration 2 -> 3: the record body gained a hostname, which changed // the signing domain, and the version counter gained an author. // // The stored records are discarded rather than carried over. They // were signed under a domain that no longer verifies, so keeping them // would mean holding rows that every read has to reject — and one of // those rejections could be mistaken for corruption. Each member // re-publishes its claim on the next run, which is the one thing here // that repairs itself. if (2..3).contains(&found) { self.conn .execute_batch( "BEGIN; DROP TABLE IF EXISTS signed_records; DROP TABLE IF EXISTS own_record_version; COMMIT;", ) .map_err(|err| self.corrupt(format!("cannot migrate schema to 3: {err}")))?; self.conn .execute_batch(SIGNED_RECORDS_SCHEMA) .map_err(|err| self.corrupt(format!("cannot migrate schema to 3: {err}")))?; } // Migration 1 -> 2: signed records that outlive a session. if (1..2).contains(&found) { self.conn .execute_batch(SIGNED_RECORDS_SCHEMA) .map_err(|err| self.corrupt(format!("cannot migrate schema to 2: {err}")))?; } // Migration 0 -> 1: initial schema. if found < 1 { self.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 ); PRAGMA user_version = 1; COMMIT;", ) .map_err(|err| self.corrupt(format!("cannot create schema: {err}")))?; self.conn .execute_batch(SIGNED_RECORDS_SCHEMA) .map_err(|err| self.corrupt(format!("cannot create schema: {err}")))?; } if found < 4 { self.conn.execute_batch("BEGIN; ALTER TABLE networks ADD COLUMN broadcast INTEGER NOT NULL DEFAULT 1 CHECK (broadcast IN (0,1)); PRAGMA user_version = 4; COMMIT;").map_err(|err| self.corrupt(format!("cannot migrate schema to 4: {err}")))?; } if found < 5 { self.conn .execute_batch( "BEGIN; CREATE TABLE IF NOT EXISTS peer_hostnames ( network_id BLOB NOT NULL, endpoint_id BLOB NOT NULL, hostname TEXT NOT NULL, PRIMARY KEY (network_id, endpoint_id) ); PRAGMA user_version = 5; COMMIT;", ) .map_err(|err| self.corrupt(format!("cannot migrate schema to 5: {err}")))?; } if found < 6 { self.conn .execute_batch( "BEGIN; ALTER TABLE networks ADD COLUMN exit_node INTEGER NOT NULL DEFAULT 0 CHECK (exit_node IN (0,1)); ALTER TABLE networks ADD COLUMN exit_via BLOB; PRAGMA user_version = 6; COMMIT;", ) .map_err(|err| self.corrupt(format!("cannot migrate schema to 6: {err}")))?; } // Migration 6 -> 7: a claim gained the time its hostname was first // claimed, which changed the signing domain. Records signed before // no longer verify, so they are discarded rather than kept as rows // every read has to reject; each member re-publishes its own claim on // its next run and the others arrive again from their authors. The // version counter is kept, so nothing is ever published twice at one // version. if found < 7 { self.conn .execute_batch( "BEGIN; DELETE FROM signed_records; PRAGMA user_version = 7; COMMIT;", ) .map_err(|err| self.corrupt(format!("cannot migrate schema to 7: {err}")))?; } if found < 8 { self.conn .execute_batch( "BEGIN; ALTER TABLE networks ADD COLUMN protocols TEXT; PRAGMA user_version = 8; COMMIT;", ) .map_err(|err| self.corrupt(format!("cannot migrate schema to 8: {err}")))?; } if found < 9 { self.conn .execute_batch( "BEGIN; CREATE TABLE IF NOT EXISTS retirements ( network_id BLOB NOT NULL, target BLOB NOT NULL, data BLOB NOT NULL, PRIMARY KEY (network_id, target) ); PRAGMA user_version = 9; COMMIT;", ) .map_err(|err| self.corrupt(format!("cannot migrate schema to 9: {err}")))?; } Ok(()) } /// Confirms the expected tables exist, so that a truncated or foreign /// database is reported rather than used. fn verify_shape(&self) -> Result<()> { for table in [ "device_identity", "networks", "settings", "signed_records", "peer_hostnames", "retirements", ] { let present: Option = self .conn .query_row( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?1", params![table], |row| row.get(0), ) .optional() .map_err(|err| self.corrupt(format!("cannot inspect schema: {err}")))?; if present.is_none() { return Err(self.corrupt(format!("table `{table}` is missing"))); } } Ok(()) } /// Loads the stored device identity, if there is one. /// /// Reads and never writes, so asking who this device is does not decide /// it. A store that has never run an agent has no identity yet, which is /// `None` rather than an error. /// /// A stored key of the wrong length is a corruption error, never a reason /// to silently mint a new identity. pub fn device_identity(&self) -> Result> { let stored: Option> = self .conn .query_row( "SELECT secret_key FROM device_identity WHERE id = 1", [], |row| row.get(0), ) .optional() .map_err(|err| self.corrupt(format!("cannot read device identity: {err}")))?; let Some(bytes) = stored else { return Ok(None); }; let bytes: [u8; 32] = bytes.as_slice().try_into().map_err(|_| { self.corrupt(format!( "stored device key has {} bytes, expected 32; refusing to replace it", bytes.len() )) })?; Ok(Some(DeviceIdentity::from_secret_bytes(&bytes))) } /// Loads the stored device identity, creating one on first use. pub fn load_or_create_device_identity(&self) -> Result { if let Some(identity) = self.device_identity()? { return Ok(identity); } let identity = DeviceIdentity::generate(); self.conn .execute( "INSERT INTO device_identity (id, secret_key, created_at) VALUES (1, ?1, ?2)", params![identity.secret_bytes().as_slice(), now_unix()], ) .map_err(|err| self.corrupt(format!("cannot store device identity: {err}")))?; Ok(identity) } /// Replaces the device identity, giving up what the outgoing key held. /// /// A device key is the author of every record this agent has signed, so /// replacing it makes this a different member. The addresses and names /// the old key claimed would otherwise stay reserved to a key nobody /// holds, and nothing could ever free them — there is no way to sign on /// another author's behalf, and by design there is no authority that /// could overrule one. /// /// So the outgoing key signs a release for every network on its way out. /// That is the revocation: a positive statement, merged like any other, /// which frees the address and the name for whoever wants them next. /// /// All of it commits together. A crash part way through must not leave an /// identity that has already been replaced beside releases that were /// never written, because the old key would then be gone and unable to /// sign them. /// /// Returns the new identity and the networks a release was signed for. pub fn rotate_device_identity(&self) -> Result<(DeviceIdentity, Vec)> { let outgoing = self.device_identity()?; let networks = self.list_networks()?; let replacement = DeviceIdentity::generate(); let transaction = self .conn .unchecked_transaction() .map_err(|err| Error::Storage(format!("cannot begin a transaction: {err}")))?; let mut released = Vec::new(); if let Some(outgoing) = &outgoing { let author = outgoing.endpoint_id(); let signing = outgoing.signing_key(); for network in &networks { let previous: Option = transaction .query_row( "SELECT version FROM own_record_version WHERE network_id = ?1 AND author = ?2", params![ network.network_id.as_bytes().as_slice(), author.as_bytes().as_slice() ], |row| row.get(0), ) .optional() .map_err(|err| Error::Storage(format!("cannot read our version: {err}")))?; // A key that never published anything has nothing to give up. let Some(previous) = previous else { continue }; let version = (previous.max(0) as u64).saturating_add(1); let record = SignedRecord::sign(&signing, network.network_id, version, RecordBody::Release); let body = postcard::to_stdvec(&record.body) .map_err(|err| Error::Storage(format!("cannot encode a record body: {err}")))?; transaction .execute( "INSERT INTO signed_records (network_id, author, version, body, signature) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(network_id, author) DO UPDATE SET version = excluded.version, body = excluded.body, signature = excluded.signature", params![ record.network.as_slice(), record.author.as_slice(), record.version as i64, body, record.signature ], ) .map_err(|err| Error::Storage(format!("cannot store a release: {err}")))?; transaction .execute( "INSERT INTO own_record_version (network_id, author, version) VALUES (?1, ?2, ?3) ON CONFLICT(network_id, author) DO UPDATE SET version = max(version, excluded.version)", params![ record.network.as_slice(), record.author.as_slice(), record.version as i64 ], ) .map_err(|err| Error::Storage(format!("cannot store our version: {err}")))?; released.push(network.network_id); } } transaction .execute( "INSERT INTO device_identity (id, secret_key, created_at) VALUES (1, ?1, ?2) ON CONFLICT(id) DO UPDATE SET secret_key = excluded.secret_key, created_at = excluded.created_at", params![replacement.secret_bytes().as_slice(), now_unix()], ) .map_err(|err| Error::Storage(format!("cannot store the new identity: {err}")))?; transaction .commit() .map_err(|err| Error::Storage(format!("cannot commit the new identity: {err}")))?; Ok((replacement, released)) } /// Inserts or updates a network configuration. pub fn upsert_network( &self, network_id: NetworkId, name: &NetworkName, secret: &NetworkSecret, auto_start: bool, ) -> Result<()> { self.conn .execute( "INSERT INTO networks (network_id, name, secret, auto_start, created_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(network_id) DO UPDATE SET name = excluded.name, secret = excluded.secret, auto_start = excluded.auto_start", params![ network_id.as_bytes().as_slice(), name.as_str(), secret.expose(), auto_start as i64, now_unix() ], ) .map_err(|err| Error::Storage(format!("cannot store network: {err}")))?; Ok(()) } /// Sets the auto-start flag of a configured network. pub fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> { let changed = self .conn .execute( "UPDATE networks SET auto_start = ?2 WHERE network_id = ?1", params![network_id.as_bytes().as_slice(), auto_start as i64], ) .map_err(|err| Error::Storage(format!("cannot update network: {err}")))?; if changed == 0 { return Err(Error::NetworkUnknown(network_id)); } Ok(()) } /// Saves local participation without changing identity, membership or auto-start. pub fn set_broadcast(&self, network_id: NetworkId, enabled: bool) -> Result<()> { let changed = self .conn .execute( "UPDATE networks SET broadcast = ?2 WHERE network_id = ?1", params![network_id.as_bytes().as_slice(), enabled as i64], ) .map_err(|err| Error::Storage(format!("cannot update broadcast policy: {err}")))?; if changed == 0 { return Err(Error::NetworkUnknown(network_id)); } Ok(()) } /// Saves a local ordered protocol selection without changing membership. pub fn set_protocols(&self, network_id: NetworkId, protocols: &[String]) -> Result<()> { crate::dataplane::validate_protocols( protocols, crate::config::Limits::default().max_capabilities, )?; let changed = self .conn .execute( "UPDATE networks SET protocols = ?2 WHERE network_id = ?1", params![network_id.as_bytes().as_slice(), protocols.join(",")], ) .map_err(|err| Error::Storage(format!("cannot update protocols: {err}")))?; if changed == 0 { return Err(Error::NetworkUnknown(network_id)); } Ok(()) } /// Removes a network configuration entirely. pub fn remove_network(&self, network_id: NetworkId) -> Result<()> { self.conn .execute( "DELETE FROM networks WHERE network_id = ?1", params![network_id.as_bytes().as_slice()], ) .map_err(|err| Error::Storage(format!("cannot remove network: {err}")))?; Ok(()) } /// Saves whether this agent offers itself as an exit node in a network. pub fn set_exit_node(&self, network_id: NetworkId, enabled: bool) -> Result<()> { let changed = self .conn .execute( "UPDATE networks SET exit_node = ?2 WHERE network_id = ?1", params![network_id.as_bytes().as_slice(), enabled as i64], ) .map_err(|err| Error::Storage(format!("cannot update exit node offer: {err}")))?; if changed == 0 { return Err(Error::NetworkUnknown(network_id)); } Ok(()) } /// Saves which member this device sends its internet traffic through. /// /// One device has one default route, so choosing an exit node in one /// network clears the choice in every other. pub fn set_exit_via(&self, network_id: NetworkId, via: Option<&[u8; 32]>) -> Result<()> { let tx = self .conn .unchecked_transaction() .map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?; let changed = tx .execute( "UPDATE networks SET exit_via = ?2 WHERE network_id = ?1", params![network_id.as_bytes().as_slice(), via.map(|v| v.as_slice())], ) .map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?; if changed == 0 { return Err(Error::NetworkUnknown(network_id)); } if via.is_some() { tx.execute( "UPDATE networks SET exit_via = NULL WHERE network_id != ?1", params![network_id.as_bytes().as_slice()], ) .map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?; } tx.commit() .map_err(|err| Error::Storage(format!("cannot update exit node: {err}")))?; Ok(()) } /// Lists every configured network. pub fn list_networks(&self) -> Result> { let mut stmt = self .conn .prepare("SELECT network_id, name, secret, auto_start, broadcast, exit_node, exit_via, protocols FROM networks ORDER BY name") .map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?; let rows = stmt .query_map([], |row| { let id: Vec = row.get(0)?; let name: String = row.get(1)?; let secret: Vec = row.get(2)?; let auto_start: i64 = row.get(3)?; Ok(( id, name, secret, auto_start != 0, row.get::<_, bool>(4)?, row.get::<_, bool>(5)?, row.get::<_, Option>>(6)?, row.get::<_, Option>(7)?, )) }) .map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?; let mut out = Vec::new(); for row in rows { let (id, name, secret, auto_start, broadcast, exit_node, exit_via, protocols) = row.map_err(|err| Error::Storage(format!("cannot read network row: {err}")))?; let protocols = protocols .map(|text| { let max = crate::config::Limits::default().max_capabilities; if text.len() > max * (crate::dataplane::MAX_PROTOCOL_ID_LEN + 1) { return Err(self.corrupt("stored protocol selection is too long")); } let names = if text.is_empty() { Vec::new() } else { text.split(',').map(str::to_owned).collect() }; crate::dataplane::validate_protocols(&names, max) .map_err(|err| self.corrupt(err))?; Ok(names) }) .transpose()?; let id: [u8; 32] = id .as_slice() .try_into() .map_err(|_| self.corrupt("stored network id is not 32 bytes"))?; out.push(StoredNetwork { protocols, network_id: NetworkId::from_bytes(id), name: NetworkName::new(name)?, secret: NetworkSecret::from_bytes(secret)?, auto_start, broadcast, exit_node, exit_via: exit_via.and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok()), }); } Ok(out) } /// Reads the stored hostname, if any. pub fn hostname(&self) -> Result> { self.get_setting(SETTING_HOSTNAME) } /// Stores the hostname. /// /// Today this is a local setting. In the future a rename must be a signed /// record that revokes the specific old binding and announces the new one, /// ideally atomically in one record. pub fn set_hostname(&self, hostname: &str) -> Result<()> { self.set_setting(SETTING_HOSTNAME, hostname) } /// Loads every signed record known for a network. /// /// Records are returned as stored; the caller verifies them, because the /// database is not a trust boundary — a restored backup or a copied file /// could contain anything. pub fn signed_records(&self, network_id: NetworkId) -> Result> { let mut stmt = self .conn .prepare( "SELECT author, version, body, signature FROM signed_records WHERE network_id = ?1", ) .map_err(|err| Error::Storage(format!("cannot read signed records: {err}")))?; let rows = stmt .query_map(params![network_id.as_bytes().as_slice()], |row| { let author: Vec = row.get(0)?; let version: i64 = row.get(1)?; let body: Vec = row.get(2)?; let signature: Vec = row.get(3)?; Ok((author, version, body, signature)) }) .map_err(|err| Error::Storage(format!("cannot read signed records: {err}")))?; let mut out = Vec::new(); for row in rows { let (author, version, body, signature) = row.map_err(|err| Error::Storage(format!("cannot read a record row: {err}")))?; let Ok(author) = <[u8; 32]>::try_from(author.as_slice()) else { continue; }; let Ok(body) = postcard::from_bytes(&body) else { continue; }; out.push(SignedRecord { author, network: *network_id.as_bytes(), version: version as u64, body, signature, }); } Ok(out) } /// Stores a record received from somebody else. pub fn put_signed_record(&self, record: &SignedRecord) -> Result<()> { let body = postcard::to_stdvec(&record.body) .map_err(|err| Error::Storage(format!("cannot encode a record body: {err}")))?; self.conn .execute( "INSERT INTO signed_records (network_id, author, version, body, signature) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(network_id, author) DO UPDATE SET version = excluded.version, body = excluded.body, signature = excluded.signature", params![ record.network.as_slice(), record.author.as_slice(), record.version as i64, body, record.signature ], ) .map_err(|err| Error::Storage(format!("cannot store a signed record: {err}")))?; Ok(()) } /// Stores one of **our own** records and bumps our counter, atomically. /// /// The model requires that a record and the author's own version counter /// are committed together, and **before** the record is published, so a /// crash can never leave us able to reuse a version number we already put /// on the wire. pub fn publish_own_record(&self, record: &SignedRecord) -> Result<()> { let body = postcard::to_stdvec(&record.body) .map_err(|err| Error::Storage(format!("cannot encode a record body: {err}")))?; let transaction = self .conn .unchecked_transaction() .map_err(|err| Error::Storage(format!("cannot begin a transaction: {err}")))?; transaction .execute( "INSERT INTO signed_records (network_id, author, version, body, signature) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(network_id, author) DO UPDATE SET version = excluded.version, body = excluded.body, signature = excluded.signature", params![ record.network.as_slice(), record.author.as_slice(), record.version as i64, body, record.signature ], ) .map_err(|err| Error::Storage(format!("cannot store our record: {err}")))?; transaction .execute( "INSERT INTO own_record_version (network_id, author, version) VALUES (?1, ?2, ?3) ON CONFLICT(network_id, author) DO UPDATE SET version = max(version, excluded.version)", params![ record.network.as_slice(), record.author.as_slice(), record.version as i64 ], ) .map_err(|err| Error::Storage(format!("cannot store our version: {err}")))?; transaction .commit() .map_err(|err| Error::Storage(format!("cannot commit our record: {err}"))) } /// The highest version an author has ever published for a network. /// /// Monotonic even if the record is later replaced by a conflicting one, /// so a number is never reused. Keyed by author as well: a replaced /// device key is a different author and starts its own sequence, while /// the outgoing one keeps its place so the release it signs on the way /// out cannot collide with something it already published. pub fn own_record_version( &self, network_id: NetworkId, author: iroh::EndpointId, ) -> Result { let version: Option = self .conn .query_row( "SELECT version FROM own_record_version WHERE network_id = ?1 AND author = ?2", params![ network_id.as_bytes().as_slice(), author.as_bytes().as_slice() ], |row| row.get(0), ) .optional() .map_err(|err| Error::Storage(format!("cannot read our version: {err}")))?; Ok(version.unwrap_or(0).max(0) as u64) } /// Loads durable cleanup markers without allocating oversized stored blobs. pub fn retirements(&self, network: NetworkId, max: usize) -> Result> { let mut stmt = self .conn .prepare( "SELECT CASE WHEN length(data) <= ?2 THEN data ELSE NULL END FROM retirements WHERE network_id = ?1 ORDER BY target", ) .map_err(|err| self.corrupt(err))?; let rows = stmt .query_map( params![network.as_bytes().as_slice(), MAX_RETIREMENT_LEN as i64], |row| row.get::<_, Option>>(0), ) .map_err(|err| self.corrupt(err))?; let mut out = Vec::new(); for row in rows { if out.len() >= max { return Err(Error::Storage( "stored retirements exceed the configured limit".into(), )); } let bytes = row .map_err(|err| self.corrupt(err))? .ok_or_else(|| self.corrupt("oversized retirement"))?; let marker: SignedRetirement = postcard::from_bytes(&bytes).map_err(|err| self.corrupt(err))?; marker.verify(network).map_err(|err| self.corrupt(err))?; out.push(marker); } Ok(out) } /// Persists a strongest verified marker received from an authenticated session. pub fn put_retirement(&self, marker: &SignedRetirement) -> Result<()> { let network = NetworkId::from_bytes(marker.network); marker .verify(network) .map_err(|err| Error::Storage(err.to_string()))?; let old: Option>> = self.conn.query_row( "SELECT CASE WHEN length(data) <= ?3 THEN data ELSE NULL END FROM retirements WHERE network_id = ?1 AND target = ?2", params![marker.network.as_slice(), marker.target.author.as_slice(), MAX_RETIREMENT_LEN as i64], |row| row.get(0)) .optional().map_err(|err| self.corrupt(err))?; if let Some(bytes) = old { let bytes = bytes.ok_or_else(|| self.corrupt("oversized retirement"))?; let old: SignedRetirement = postcard::from_bytes(&bytes).map_err(|err| self.corrupt(err))?; old.verify(network).map_err(|err| self.corrupt(err))?; if !marker.supersedes(&old) { return Ok(()); } } let bytes = postcard::to_stdvec(marker).map_err(|err| Error::Storage(err.to_string()))?; if bytes.len() > MAX_RETIREMENT_LEN { return Err(Error::Storage( "retirement exceeds its encoded bound".into(), )); } self.conn .execute( "INSERT INTO retirements (network_id, target, data) VALUES (?1, ?2, ?3) ON CONFLICT(network_id, target) DO UPDATE SET data = excluded.data", params![ marker.network.as_slice(), marker.target.author.as_slice(), bytes ], ) .map_err(|err| Error::Storage(format!("cannot store retirement: {err}")))?; Ok(()) } /// Commits our signed cleanup marker and author counter in one transaction. pub fn publish_own_retirement(&self, marker: &SignedRetirement) -> Result<()> { let transaction = self .conn .unchecked_transaction() .map_err(|err| Error::Storage(err.to_string()))?; self.put_retirement(marker)?; transaction .execute( "INSERT INTO own_record_version (network_id, author, version) VALUES (?1, ?2, ?3) ON CONFLICT(network_id, author) DO UPDATE SET version = max(version, excluded.version)", params![ marker.network.as_slice(), marker.author.as_slice(), marker.version as i64 ], ) .map_err(|err| Error::Storage(format!("cannot store retirement counter: {err}")))?; transaction .commit() .map_err(|err| Error::Storage(format!("cannot commit retirement: {err}"))) } /// Drops a forgotten network's local cleanup proofs. pub fn forget_retirements(&self, network: NetworkId) -> Result<()> { self.conn .execute( "DELETE FROM retirements WHERE network_id = ?1", params![network.as_bytes().as_slice()], ) .map_err(|err| Error::Storage(err.to_string()))?; Ok(()) } /// Forgets every record of a network. pub fn forget_signed_records(&self, network_id: NetworkId) -> Result<()> { self.conn .execute( "DELETE FROM signed_records WHERE network_id = ?1", params![network_id.as_bytes().as_slice()], ) .map_err(|err| Error::Storage(format!("cannot clear signed records: {err}")))?; Ok(()) } /// Remembers the hostname a member last announced. pub fn remember_peer_hostname( &self, network_id: NetworkId, endpoint_id: &[u8; 32], hostname: &str, ) -> Result<()> { self.conn .execute( "INSERT INTO peer_hostnames (network_id, endpoint_id, hostname) VALUES (?1, ?2, ?3) ON CONFLICT(network_id, endpoint_id) DO UPDATE SET hostname = excluded.hostname", params![ network_id.as_bytes().as_slice(), endpoint_id.as_slice(), hostname ], ) .map_err(|err| Error::Storage(format!("cannot remember a hostname: {err}")))?; Ok(()) } /// The last hostname every member of a network announced. pub fn peer_hostnames(&self, network_id: NetworkId) -> Result> { let mut stmt = self .conn .prepare("SELECT endpoint_id, hostname FROM peer_hostnames WHERE network_id = ?1") .map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?; let rows = stmt .query_map(params![network_id.as_bytes().as_slice()], |row| { Ok((row.get::<_, Vec>(0)?, row.get::<_, String>(1)?)) }) .map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?; let mut out = Vec::new(); for row in rows { let (id, hostname) = row.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?; if let Ok(id) = <[u8; 32]>::try_from(id.as_slice()) { out.push((id, hostname)); } } Ok(out) } /// Forgets every remembered hostname of a network. pub fn forget_peer_hostnames(&self, network_id: NetworkId) -> Result<()> { self.conn .execute( "DELETE FROM peer_hostnames WHERE network_id = ?1", params![network_id.as_bytes().as_slice()], ) .map_err(|err| Error::Storage(format!("cannot forget hostnames: {err}")))?; Ok(()) } /// Atomically enables a network and puts it first in the startup order. pub fn prefer_network(&self, network: NetworkId) -> Result<()> { let configured = self.list_networks()?; if !configured.iter().any(|n| n.network_id == network) { return Err(Error::NetworkUnknown(network)); } let existing = self.get_setting("network_start_order")?.unwrap_or_default(); let mut order = vec![network]; for word in existing.split(',') { if let Ok(id) = word.parse::() && configured.iter().any(|n| n.network_id == id) && !order.contains(&id) { order.push(id); } } let value = order .iter() .map(ToString::to_string) .collect::>() .join(","); let transaction = self .conn .unchecked_transaction() .map_err(|err| Error::Storage(format!("cannot begin network preference: {err}")))?; transaction .execute( "UPDATE networks SET auto_start = 1 WHERE network_id = ?1", params![network.as_bytes().as_slice()], ) .map_err(|err| Error::Storage(format!("cannot enable network: {err}")))?; transaction.execute("INSERT INTO settings (key, value) VALUES ('network_start_order', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", params![value]) .map_err(|err| Error::Storage(format!("cannot store network preference: {err}")))?; transaction .commit() .map_err(|err| Error::Storage(format!("cannot commit network preference: {err}"))) } /// Reads an arbitrary setting. pub fn get_setting(&self, key: &str) -> Result> { self.conn .query_row( "SELECT value FROM settings WHERE key = ?1", params![key], |row| row.get(0), ) .optional() .map_err(|err| Error::Storage(format!("cannot read setting `{key}`: {err}"))) } /// Writes an arbitrary setting. pub fn set_setting(&self, key: &str, value: &str) -> Result<()> { self.conn .execute( "INSERT INTO settings (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value", params![key, value], ) .map_err(|err| Error::Storage(format!("cannot write setting `{key}`: {err}")))?; Ok(()) } } /// Schema for the signed records described in [`crate::state`]. /// /// The version counter is keyed by author as well as network. A device key /// can be replaced, and the replacement is a different author: it must start /// its own sequence rather than inherit one, and the outgoing author's last /// version has to survive so its release record cannot collide with /// something it already published. const SIGNED_RECORDS_SCHEMA: &str = "BEGIN; CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS own_record_version ( network_id BLOB NOT NULL, author BLOB NOT NULL, version INTEGER NOT NULL, PRIMARY KEY (network_id, author) ); PRAGMA user_version = 3; COMMIT;"; /// Seconds since the Unix epoch, saturating at 0 before it. pub(crate) fn now_unix() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } #[cfg(test)] mod broadcast_storage_tests { #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::*; use crate::identity::NetworkKeys; #[test] fn exit_node_choices_are_off_by_default_and_one_choice_clears_the_others() { let dir = tempfile::tempdir().unwrap(); let store = StateStore::open(dir.path().join("state.sqlite")).unwrap(); let secret = NetworkSecret::from_bytes([5; 32]).unwrap(); let make = |name: &str| { let name = NetworkName::new(name).unwrap(); let id = NetworkKeys::derive(&name, &secret).network_id(); store.upsert_network(id, &name, &secret, true).unwrap(); id }; let (a, b) = (make("exit-a"), make("exit-b")); let listed = || store.list_networks().unwrap(); assert!( listed() .iter() .all(|n| !n.exit_node && n.exit_via.is_none()) ); store.set_exit_node(a, true).unwrap(); store.set_exit_via(a, Some(&[7; 32])).unwrap(); store.set_exit_via(b, Some(&[8; 32])).unwrap(); let by_id = |id: NetworkId| listed().into_iter().find(|n| n.network_id == id).unwrap(); assert!(by_id(a).exit_node, "offering is per network and kept"); assert_eq!(by_id(a).exit_via, None, "one default route: a was cleared"); assert_eq!(by_id(b).exit_via, Some([8; 32])); store.set_exit_via(b, None).unwrap(); assert_eq!(by_id(b).exit_via, None); } #[test] fn a_remembered_hostname_survives_a_restart_and_follows_the_latest_name() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("state.sqlite"); let name = NetworkName::new("hostnames").unwrap(); let secret = NetworkSecret::from_bytes([9; 32]).unwrap(); let id = NetworkKeys::derive(&name, &secret).network_id(); let peer = [3u8; 32]; let store = StateStore::open(&path).unwrap(); store.remember_peer_hostname(id, &peer, "laptop").unwrap(); store.remember_peer_hostname(id, &peer, "laptop-2").unwrap(); drop(store); let store = StateStore::open(&path).unwrap(); assert_eq!( store.peer_hostnames(id).unwrap(), vec![(peer, "laptop-2".to_string())] ); store.forget_peer_hostnames(id).unwrap(); assert!(store.peer_hostnames(id).unwrap().is_empty()); } #[test] fn retirement_migration_and_atomic_counter_commit_preserve_existing_state() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("state.sqlite"); let store = StateStore::open(&path).unwrap(); let identity = store.load_or_create_device_identity().unwrap(); let name = NetworkName::new("retirement-migration").unwrap(); let secret = NetworkSecret::from_bytes([6; 32]).unwrap(); let id = NetworkKeys::derive(&name, &secret).network_id(); store.upsert_network(id, &name, &secret, true).unwrap(); store.set_protocols(id, &["awg".into()]).unwrap(); let own = SignedRecord::sign(&identity.signing_key(), id, 8, RecordBody::Release); store.publish_own_record(&own).unwrap(); store .conn .execute_batch("DROP TABLE retirements; PRAGMA user_version = 8;") .unwrap(); drop(store); let store = StateStore::open(&path).unwrap(); assert_eq!( store.device_identity().unwrap().unwrap().endpoint_id(), identity.endpoint_id() ); assert_eq!( store.list_networks().unwrap()[0].protocols, Some(vec!["awg".into()]) ); assert_eq!(store.signed_records(id).unwrap(), vec![own]); let target_key = iroh::SecretKey::generate(); let target = SignedRecord::sign( &target_key, id, 3, RecordBody::Claim { address: Some("10.13.37.2".parse().unwrap()), range: Some(crate::state::DEFAULT_IPV4_RANGE), hostname: Some("old-device".into()), hostname_since: Some(123), }, ); let marker = SignedRetirement::sign(&identity.signing_key(), id, 9, target); store .conn .execute_batch( "CREATE TRIGGER fail_retirement_counter BEFORE INSERT ON own_record_version WHEN NEW.version = 9 BEGIN SELECT RAISE(ABORT, 'counter write refused'); END;", ) .unwrap(); assert!(store.publish_own_retirement(&marker).is_err()); assert!( store.retirements(id, 16).unwrap().is_empty(), "marker must roll back with counter failure" ); assert_eq!( store .own_record_version(id, identity.endpoint_id()) .unwrap(), 8 ); store .conn .execute_batch("DROP TRIGGER fail_retirement_counter;") .unwrap(); store.publish_own_retirement(&marker).unwrap(); drop(store); let store = StateStore::open(&path).unwrap(); assert_eq!(store.retirements(id, 16).unwrap(), vec![marker.clone()]); assert_eq!( store .own_record_version(id, identity.endpoint_id()) .unwrap(), 9 ); assert!( store.retirements(id, 0).is_err(), "capacity changes must not silently drop markers" ); store .conn .execute( "UPDATE retirements SET data = ?1", params![vec![0u8; MAX_RETIREMENT_LEN + 1]], ) .unwrap(); assert!(matches!( store.retirements(id, 16), Err(Error::StateCorrupted { .. }) )); } #[test] fn v7_migration_keeps_signed_state_and_protocol_overrides_are_local_and_durable() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("state.sqlite"); let store = StateStore::open(&path).unwrap(); let identity = store.load_or_create_device_identity().unwrap(); let name = NetworkName::new("protocol-migration").unwrap(); let secret = NetworkSecret::from_bytes([4; 32]).unwrap(); let id = NetworkKeys::derive(&name, &secret).network_id(); store.upsert_network(id, &name, &secret, true).unwrap(); store.set_broadcast(id, false).unwrap(); let record = SignedRecord::sign(&identity.signing_key(), id, 17, RecordBody::Release); store.publish_own_record(&record).unwrap(); store .conn .execute_batch("ALTER TABLE networks DROP COLUMN protocols; PRAGMA user_version=7;") .unwrap(); drop(store); let store = StateStore::open(&path).unwrap(); assert_eq!( store .load_or_create_device_identity() .unwrap() .endpoint_id(), identity.endpoint_id() ); assert_eq!(store.signed_records(id).unwrap(), vec![record]); let saved = store.list_networks().unwrap(); assert!(saved[0].protocols.is_none()); assert!(!saved[0].broadcast); assert!(saved[0].auto_start); assert_eq!(saved[0].secret, secret); let chosen = vec!["awg".into(), "wg-quic".into()]; store.set_protocols(id, &chosen).unwrap(); assert!( store .set_protocols(id, &["awg".into(), "awg".into()]) .is_err() ); assert!( store .set_protocols(NetworkId::from_bytes([1; 32]), &[]) .is_err() ); drop(store); let store = StateStore::open(&path).unwrap(); assert_eq!(store.list_networks().unwrap()[0].protocols, Some(chosen)); store.upsert_network(id, &name, &secret, true).unwrap(); store.set_protocols(id, &[]).unwrap(); drop(store); let store = StateStore::open(&path).unwrap(); assert_eq!(store.list_networks().unwrap()[0].protocols, Some(vec![])); store .conn .execute("UPDATE networks SET protocols = 'awg,awg'", []) .unwrap(); assert!(matches!( store.list_networks(), Err(Error::StateCorrupted { .. }) )); } #[test] fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("state.sqlite"); let name = NetworkName::new("migration-broadcast").unwrap(); let secret = NetworkSecret::from_bytes([7; 32]).unwrap(); let id = NetworkKeys::derive(&name, &secret).network_id(); let store = StateStore::open(&path).unwrap(); let identity = store .load_or_create_device_identity() .unwrap() .endpoint_id(); store.upsert_network(id, &name, &secret, false).unwrap(); store.set_hostname("old-host").unwrap(); store .conn .execute_batch( "ALTER TABLE networks DROP COLUMN broadcast; \ ALTER TABLE networks DROP COLUMN exit_node; \ ALTER TABLE networks DROP COLUMN exit_via; \ ALTER TABLE networks DROP COLUMN protocols; \ PRAGMA user_version=3;", ) .unwrap(); drop(store); let store = StateStore::open(&path).unwrap(); let networks = store.list_networks().unwrap(); assert_eq!(networks.len(), 1); assert_eq!(networks[0].network_id, id); assert!(!networks[0].auto_start); assert!(networks[0].broadcast); assert_eq!(store.hostname().unwrap().as_deref(), Some("old-host")); assert_eq!( store .load_or_create_device_identity() .unwrap() .endpoint_id(), identity ); store.set_broadcast(id, false).unwrap(); store.upsert_network(id, &name, &secret, true).unwrap(); drop(store); let store = StateStore::open(&path).unwrap(); assert!( !store.list_networks().unwrap()[0].broadcast, "rejoin and restart preserve opt-out" ); store.remove_network(id).unwrap(); store.upsert_network(id, &name, &secret, true).unwrap(); assert!( store.list_networks().unwrap()[0].broadcast, "forgotten network gets defaults" ); } }