The first IP plugin, built on the data plane boundary the core already had. Plugin: - one X25519 key per network in the plugin's own wireguard.sqlite, separate from the iroh identity and from the network secret; a damaged store is an error, never a silently regenerated identity - deterministic IPv6 ULA overlay: every member derives the same /64 from the network id and its own /128 from its WireGuard public key, so no coordinator allocates addresses - AllowedIPs are derived locally, never taken from a peer's announcement, so a member cannot claim another member's overlay address; a mismatched claim is rejected - bounded, versioned, validated announcement carried as the existing opaque capability payload, which the core still never parses - each agent builds its own full-mesh configuration (N-1 peers) and reconciles on every change and on a timer, repairing drift - WireguardBackend abstraction: RecordingBackend in memory, and WgToolBackend driving real wg/ip on Linux, split into a pure planner plus parsers and a thin executor so everything interesting is testable without root Core, three generic additions the plugin needed: - IpPlugin::on_network_activated, so per-network state is ready before peers - PluginContext for re-announcements and error reports from plugin tasks, with errors counted by the owning network runtime - IpPlugin::shutdown, awaited with a grace period, so system objects go away 94 tests pass offline with no privileges: 35 new WireGuard unit tests and 12 integration tests over real iroh connections. The real wg/ip backend needs root and is behind --ignored in tests/wireguard_system.rs; it was not run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
223 lines
7.9 KiB
Rust
223 lines
7.9 KiB
Rust
//! The plugin's own key store.
|
|
//!
|
|
//! Deliberately a separate SQLite file from the agent's `state.sqlite`: plugin
|
|
//! keys are not the iroh identity and not the network secret, and their
|
|
//! lifecycle is the plugin's business alone.
|
|
//!
|
|
//! One key per network, so a participant presents a different WireGuard
|
|
//! identity — and therefore a different overlay address — in each network it
|
|
//! belongs to.
|
|
//!
|
|
//! A damaged key store is an error, never a silent regeneration: a new key
|
|
//! would silently move this agent to a different overlay address and orphan
|
|
//! every peer's configuration.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Mutex;
|
|
|
|
use rusqlite::{Connection, OptionalExtension, params};
|
|
|
|
use crate::dataplane::PluginError;
|
|
use crate::identity::NetworkId;
|
|
|
|
use super::keys::{KEY_LEN, WgSecretKey};
|
|
|
|
/// Schema version written by this build.
|
|
pub const SCHEMA_VERSION: i64 = 1;
|
|
|
|
/// Per-network WireGuard private keys.
|
|
#[derive(Debug)]
|
|
pub struct WgKeyStore {
|
|
conn: Mutex<Connection>,
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl WgKeyStore {
|
|
/// Opens, creating the file and its directory if needed.
|
|
pub fn open(path: impl AsRef<Path>) -> Result<Self, PluginError> {
|
|
let path = path.as_ref().to_path_buf();
|
|
if let Some(parent) = path.parent() {
|
|
crate::storage::create_dir(parent)
|
|
.map_err(|err| PluginError::Other(format!("cannot create {parent:?}: {err}")))?;
|
|
}
|
|
let existed = path.exists();
|
|
let conn = Connection::open(&path).map_err(|err| {
|
|
PluginError::Other(format!("cannot open the WireGuard key store: {err}"))
|
|
})?;
|
|
crate::storage::restrict_path_permissions(&path)
|
|
.map_err(|err| PluginError::Other(format!("cannot secure the key store: {err}")))?;
|
|
|
|
conn.busy_timeout(std::time::Duration::from_secs(5))
|
|
.and_then(|()| conn.pragma_update(None, "journal_mode", "WAL"))
|
|
.and_then(|()| conn.pragma_update(None, "synchronous", "NORMAL"))
|
|
.map_err(|err| PluginError::Other(format!("cannot configure the key store: {err}")))?;
|
|
|
|
if existed {
|
|
let integrity: String = conn
|
|
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
|
|
.map_err(|err| {
|
|
PluginError::Other(format!("WireGuard key store is unusable: {err}"))
|
|
})?;
|
|
if integrity != "ok" {
|
|
return Err(PluginError::Other(format!(
|
|
"WireGuard key store at {} is corrupt and will not be recreated: {integrity}",
|
|
path.display()
|
|
)));
|
|
}
|
|
}
|
|
|
|
let found: i64 = conn
|
|
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
|
.map_err(|err| PluginError::Other(format!("cannot read the schema version: {err}")))?;
|
|
if found > SCHEMA_VERSION {
|
|
return Err(PluginError::Other(format!(
|
|
"WireGuard key store schema {found} is newer than {SCHEMA_VERSION}"
|
|
)));
|
|
}
|
|
if found < SCHEMA_VERSION {
|
|
conn.execute_batch(
|
|
"BEGIN;
|
|
CREATE TABLE IF NOT EXISTS network_keys (
|
|
network_id BLOB PRIMARY KEY,
|
|
secret BLOB NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
PRAGMA user_version = 1;
|
|
COMMIT;",
|
|
)
|
|
.map_err(|err| PluginError::Other(format!("cannot create the schema: {err}")))?;
|
|
}
|
|
|
|
Ok(Self {
|
|
conn: Mutex::new(conn),
|
|
path,
|
|
})
|
|
}
|
|
|
|
/// Path of the underlying file.
|
|
pub fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
|
|
fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
|
|
match self.conn.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
}
|
|
}
|
|
|
|
/// Returns this agent's key for a network, creating it on first use.
|
|
pub fn load_or_create(&self, network: NetworkId) -> Result<WgSecretKey, PluginError> {
|
|
let conn = self.lock();
|
|
let stored: Option<Vec<u8>> = conn
|
|
.query_row(
|
|
"SELECT secret FROM network_keys WHERE network_id = ?1",
|
|
params![network.as_bytes().as_slice()],
|
|
|row| row.get(0),
|
|
)
|
|
.optional()
|
|
.map_err(|err| PluginError::Other(format!("cannot read the WireGuard key: {err}")))?;
|
|
|
|
if let Some(bytes) = stored {
|
|
let bytes = <[u8; KEY_LEN]>::try_from(bytes.as_slice()).map_err(|_| {
|
|
PluginError::Other(format!(
|
|
"the stored WireGuard key for network {} is not {KEY_LEN} bytes; \
|
|
refusing to replace it",
|
|
network.fmt_short()
|
|
))
|
|
})?;
|
|
return Ok(WgSecretKey::from_bytes(&bytes));
|
|
}
|
|
|
|
let key = WgSecretKey::generate();
|
|
conn.execute(
|
|
"INSERT INTO network_keys (network_id, secret, created_at) VALUES (?1, ?2, ?3)",
|
|
params![
|
|
network.as_bytes().as_slice(),
|
|
key.expose().as_slice(),
|
|
now_unix()
|
|
],
|
|
)
|
|
.map_err(|err| PluginError::Other(format!("cannot store the WireGuard key: {err}")))?;
|
|
Ok(key)
|
|
}
|
|
|
|
/// Deletes the key for a network.
|
|
///
|
|
/// Not called when a network is merely deactivated: coming back should
|
|
/// keep the same overlay address.
|
|
pub fn forget(&self, network: NetworkId) -> Result<(), PluginError> {
|
|
self.lock()
|
|
.execute(
|
|
"DELETE FROM network_keys WHERE network_id = ?1",
|
|
params![network.as_bytes().as_slice()],
|
|
)
|
|
.map_err(|err| PluginError::Other(format!("cannot remove the WireGuard key: {err}")))?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
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 tests {
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
use super::*;
|
|
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
|
|
|
fn network(name: &str) -> NetworkId {
|
|
NetworkKeys::derive(
|
|
&NetworkName::new(name).unwrap(),
|
|
&NetworkSecret::from_bytes(vec![8u8; 32]).unwrap(),
|
|
)
|
|
.network_id()
|
|
}
|
|
|
|
#[test]
|
|
fn keys_are_per_network_and_survive_reopening() {
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let path = dir.path().join("wireguard.sqlite");
|
|
let first = network("one");
|
|
let second = network("two");
|
|
|
|
let (key_one, key_two) = {
|
|
let store = WgKeyStore::open(&path).unwrap();
|
|
let a = store.load_or_create(first).unwrap();
|
|
let b = store.load_or_create(second).unwrap();
|
|
assert_ne!(a.public(), b.public(), "networks get separate identities");
|
|
assert_eq!(a.public(), store.load_or_create(first).unwrap().public());
|
|
(a.public(), b.public())
|
|
};
|
|
|
|
let reopened = WgKeyStore::open(&path).unwrap();
|
|
assert_eq!(reopened.load_or_create(first).unwrap().public(), key_one);
|
|
assert_eq!(reopened.load_or_create(second).unwrap().public(), key_two);
|
|
|
|
reopened.forget(first).unwrap();
|
|
assert_ne!(reopened.load_or_create(first).unwrap().public(), key_one);
|
|
}
|
|
|
|
#[test]
|
|
fn a_corrupt_key_store_is_an_error_not_a_new_key() {
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
let path = dir.path().join("wireguard.sqlite");
|
|
let original = {
|
|
let store = WgKeyStore::open(&path).unwrap();
|
|
store.load_or_create(network("keep")).unwrap().public()
|
|
};
|
|
|
|
std::fs::write(&path, [0x5a; 4096]).unwrap();
|
|
let result = WgKeyStore::open(&path);
|
|
assert!(
|
|
result.is_err(),
|
|
"a damaged key store must not silently mint a new identity (was {original})"
|
|
);
|
|
}
|
|
}
|