diff --git a/core/src/version/v0_3_6_alpha_0.rs b/core/src/version/v0_3_6_alpha_0.rs index fbae2fc2f..e9c623bad 100644 --- a/core/src/version/v0_3_6_alpha_0.rs +++ b/core/src/version/v0_3_6_alpha_0.rs @@ -143,7 +143,8 @@ pub struct Version; impl VersionT for Version { type Previous = v0_3_5_2::Version; - type PreUpRes = (AccountInfo, SshKeys, CifsTargets); + /// (package_id, host_id, expanded_key) + type PreUpRes = (AccountInfo, SshKeys, CifsTargets, Vec<(String, String, [u8; 64])>); fn semver(self) -> exver::Version { V0_3_6_alpha_0.clone() } @@ -158,15 +159,17 @@ impl VersionT for Version { let cifs = previous_cifs(&pg).await?; + let tor_keys = previous_tor_keys(&pg).await?; + Command::new("systemctl") .arg("stop") .arg("postgresql@*.service") .invoke(crate::ErrorKind::Database) .await?; - Ok((account, ssh_keys, cifs)) + Ok((account, ssh_keys, cifs, tor_keys)) } - fn up(self, db: &mut Value, (account, ssh_keys, cifs): Self::PreUpRes) -> Result { + fn up(self, db: &mut Value, (account, ssh_keys, cifs, tor_keys): Self::PreUpRes) -> Result { let prev_package_data = db["package-data"].clone(); let wifi = json!({ @@ -183,6 +186,11 @@ impl VersionT for Version { "shuttingDown": db["server-info"]["status-info"]["shutting-down"], "restarting": db["server-info"]["status-info"]["restarting"], }); + let tor_address: String = from_value(db["server-info"]["tor-address"].clone())?; + let onion_address = tor_address + .replace("https://", "") + .replace("http://", "") + .replace(".onion/", ""); let server_info = { let mut server_info = json!({ "arch": db["server-info"]["arch"], @@ -196,15 +204,9 @@ impl VersionT for Version { }); server_info["postInitMigrationTodos"] = json!({}); - let tor_address: String = from_value(db["server-info"]["tor-address"].clone())?; // Maybe we do this like the Public::init does - server_info["torAddress"] = json!(tor_address); - server_info["onionAddress"] = json!( - tor_address - .replace("https://", "") - .replace("http://", "") - .replace(".onion/", "") - ); + server_info["torAddress"] = json!(&tor_address); + server_info["onionAddress"] = json!(&onion_address); server_info["networkInterfaces"] = json!({}); server_info["statusInfo"] = status_info; server_info["wifi"] = wifi; @@ -233,6 +235,30 @@ impl VersionT for Version { let private = { let mut value = json!({}); value["keyStore"] = crate::dbg!(to_value(&keystore)?); + // Preserve tor onion keys so later migrations (v0_4_0_alpha_20) can + // include them in onion-migration.json for the tor service. + if !tor_keys.is_empty() { + let mut onion_map: Value = json!({}); + let onion_obj = onion_map.as_object_mut().unwrap(); + let mut tor_migration = imbl::Vector::::new(); + for (package_id, host_id, key_bytes) in &tor_keys { + let onion_addr = onion_address_from_key(key_bytes); + let encoded_key = + base64::Engine::encode(&crate::util::serde::BASE64, key_bytes); + onion_obj.insert( + onion_addr.as_str().into(), + Value::String(encoded_key.clone().into()), + ); + tor_migration.push_back(json!({ + "hostname": &onion_addr, + "packageId": package_id, + "hostId": host_id, + "key": &encoded_key, + })); + } + value["keyStore"]["onion"] = onion_map; + value["torMigration"] = Value::Array(tor_migration); + } value["password"] = to_value(&account.password)?; value["compatS9pkKey"] = to_value(&crate::db::model::private::generate_developer_key())?; @@ -498,3 +524,109 @@ async fn previous_ssh_keys(pg: &sqlx::Pool) -> Result`. +/// Server key uses `("STARTOS", "STARTOS")`. +#[tracing::instrument(skip_all)] +async fn previous_tor_keys( + pg: &sqlx::Pool, +) -> Result, Error> { + let mut keys = Vec::new(); + + // Server tor key from the account table. + // Older installs have tor_key (64 bytes). Newer installs (post-NetworkKeys migration) + // made tor_key nullable and use network_key (32 bytes, needs expansion) instead. + let row = sqlx::query(r#"SELECT tor_key, network_key FROM account"#) + .fetch_one(pg) + .await + .with_kind(ErrorKind::Database)?; + if let Ok(tor_key) = row.try_get::, _>("tor_key") { + if let Ok(key) = <[u8; 64]>::try_from(tor_key) { + keys.push(("STARTOS".to_owned(), "STARTOS".to_owned(), key)); + } + } else if let Ok(net_key) = row.try_get::, _>("network_key") { + if let Ok(seed) = <[u8; 32]>::try_from(net_key) { + keys.push(( + "STARTOS".to_owned(), + "STARTOS".to_owned(), + crate::util::crypto::ed25519_expand_key(&seed), + )); + } + } + + // Package tor keys from the network_keys table (32-byte keys that need expansion) + if let Ok(rows) = sqlx::query(r#"SELECT package, interface, key FROM network_keys"#) + .fetch_all(pg) + .await + { + for row in rows { + let Ok(package) = row.try_get::("package") else { + continue; + }; + let Ok(interface) = row.try_get::("interface") else { + continue; + }; + let Ok(key_bytes) = row.try_get::, _>("key") else { + continue; + }; + if let Ok(seed) = <[u8; 32]>::try_from(key_bytes) { + keys.push(( + package, + interface, + crate::util::crypto::ed25519_expand_key(&seed), + )); + } + } + } + + // Package tor keys from the tor table (already 64-byte expanded keys) + if let Ok(rows) = sqlx::query(r#"SELECT package, interface, key FROM tor"#) + .fetch_all(pg) + .await + { + for row in rows { + let Ok(package) = row.try_get::("package") else { + continue; + }; + let Ok(interface) = row.try_get::("interface") else { + continue; + }; + let Ok(key_bytes) = row.try_get::, _>("key") else { + continue; + }; + if let Ok(key) = <[u8; 64]>::try_from(key_bytes) { + keys.push((package, interface, key)); + } + } + } + + Ok(keys) +} + +/// Derive the tor v3 onion address (without .onion suffix) from a 64-byte +/// expanded ed25519 secret key. +fn onion_address_from_key(expanded_key: &[u8; 64]) -> String { + use sha3::Digest; + + // Derive public key from expanded secret key using ed25519-dalek v1 + let esk = + ed25519_dalek_v1::ExpandedSecretKey::from_bytes(expanded_key).expect("invalid tor key"); + let pk = ed25519_dalek_v1::PublicKey::from(&esk); + let pk_bytes = pk.to_bytes(); + + // Compute onion v3 address: base32(pubkey || checksum || version) + // checksum = SHA3-256(".onion checksum" || pubkey || version)[0..2] + let mut hasher = sha3::Sha3_256::new(); + hasher.update(b".onion checksum"); + hasher.update(&pk_bytes); + hasher.update(b"\x03"); + let hash = hasher.finalize(); + + let mut raw = [0u8; 35]; + raw[..32].copy_from_slice(&pk_bytes); + raw[32] = hash[0]; // checksum byte 0 + raw[33] = hash[1]; // checksum byte 1 + raw[34] = 0x03; // version + + base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &raw).to_ascii_lowercase() +} diff --git a/core/src/version/v0_4_0_alpha_20.rs b/core/src/version/v0_4_0_alpha_20.rs index 62b454bb1..01bff2251 100644 --- a/core/src/version/v0_4_0_alpha_20.rs +++ b/core/src/version/v0_4_0_alpha_20.rs @@ -2,11 +2,13 @@ use std::path::Path; use exver::{PreReleaseSegment, VersionRange}; use imbl_value::json; +use reqwest::Url; use super::v0_3_5::V0_3_0_COMPAT; use super::{VersionT, v0_4_0_alpha_19}; use crate::context::RpcContext; use crate::prelude::*; +use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile; lazy_static::lazy_static! { static ref V0_4_0_alpha_20: exver::Version = exver::Version::new( @@ -33,74 +35,106 @@ impl VersionT for Version { } #[instrument(skip_all)] fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result { - // Extract onion migration data before removing it - let onion_store = db + // Use the pre-built torMigration data from v0_3_6_alpha_0 if available. + // This contains all (hostname, packageId, hostId, key) entries with keys + // already resolved, avoiding the issue where packageData is empty during + // migration (packages aren't reinstalled until post_up). + let migration_data = if let Some(tor_migration) = db .get("private") - .and_then(|p| p.get("keyStore")) - .and_then(|k| k.get("onion")) - .cloned() - .unwrap_or(Value::Object(Default::default())); - - let mut addresses = imbl::Vector::::new(); - - // Extract OS host onion addresses - if let Some(onions) = db - .get("public") - .and_then(|p| p.get("serverInfo")) - .and_then(|s| s.get("network")) - .and_then(|n| n.get("host")) - .and_then(|h| h.get("onions")) - .and_then(|o| o.as_array()) + .and_then(|p| p.get("torMigration")) + .and_then(|t| t.as_array()) { - for onion in onions { - if let Some(hostname) = onion.as_str() { - let key = onion_store - .get(hostname) - .and_then(|v| v.as_str()) - .unwrap_or_default(); - addresses.push_back(json!({ - "hostname": hostname, - "packageId": "STARTOS", - "hostId": "STARTOS", - "key": key, - })); + json!({ + "addresses": tor_migration.clone(), + }) + } else { + // Fallback for fresh installs or installs that didn't go through + // v0_3_6_alpha_0 with the torMigration field. + let onion_store = db + .get("private") + .and_then(|p| p.get("keyStore")) + .and_then(|k| k.get("onion")) + .cloned() + .unwrap_or(Value::Object(Default::default())); + + let mut addresses = imbl::Vector::::new(); + + // Extract OS host onion addresses + if let Some(onions) = db + .get("public") + .and_then(|p| p.get("serverInfo")) + .and_then(|s| s.get("network")) + .and_then(|n| n.get("host")) + .and_then(|h| h.get("onions")) + .and_then(|o| o.as_array()) + { + for onion in onions { + if let Some(hostname) = onion.as_str() { + let key = onion_store + .get(hostname) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + Error::new( + eyre!("missing tor key for onion address {hostname}"), + ErrorKind::Database, + ) + })?; + addresses.push_back(json!({ + "hostname": hostname, + "packageId": "STARTOS", + "hostId": "startos-ui", + "key": key, + })); + } } } - } - // Extract package host onion addresses - if let Some(packages) = db - .get("public") - .and_then(|p| p.get("packageData")) - .and_then(|p| p.as_object()) - { - for (package_id, package) in packages.iter() { - if let Some(hosts) = package.get("hosts").and_then(|h| h.as_object()) { - for (host_id, host) in hosts.iter() { - if let Some(onions) = host.get("onions").and_then(|o| o.as_array()) { - for onion in onions { - if let Some(hostname) = onion.as_str() { - let key = onion_store - .get(hostname) - .and_then(|v| v.as_str()) - .unwrap_or_default(); - addresses.push_back(json!({ - "hostname": hostname, - "packageId": &**package_id, - "hostId": &**host_id, - "key": key, - })); + // Extract package host onion addresses + if let Some(packages) = db + .get("public") + .and_then(|p| p.get("packageData")) + .and_then(|p| p.as_object()) + { + for (package_id, package) in packages.iter() { + if let Some(hosts) = package.get("hosts").and_then(|h| h.as_object()) { + for (host_id, host) in hosts.iter() { + if let Some(onions) = host.get("onions").and_then(|o| o.as_array()) { + for onion in onions { + if let Some(hostname) = onion.as_str() { + let key = onion_store + .get(hostname) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + Error::new( + eyre!( + "missing tor key for onion address {hostname}" + ), + ErrorKind::Database, + ) + })?; + addresses.push_back(json!({ + "hostname": hostname, + "packageId": &**package_id, + "hostId": &**host_id, + "key": key, + })); + } } } } } } } - } - let migration_data = json!({ - "addresses": addresses, - }); + json!({ + "addresses": addresses, + }) + }; + + // Clean up torMigration from private + if let Some(private) = db.get_mut("private").and_then(|p| p.as_object_mut()) { + private.remove("torMigration"); + } // Remove onions and tor-related fields from server host if let Some(host) = db @@ -200,7 +234,7 @@ impl VersionT for Version { } #[instrument(skip_all)] - async fn post_up(self, _ctx: &RpcContext, input: Value) -> Result<(), Error> { + async fn post_up(self, ctx: &RpcContext, input: Value) -> Result<(), Error> { let path = Path::new( "/media/startos/data/package-data/volumes/tor/data/startos/onion-migration.json", ); @@ -209,6 +243,53 @@ impl VersionT for Version { crate::util::io::write_file_atomic(path, json).await?; + // Sideload the bundled tor s9pk + let s9pk_path_str = format!("/usr/lib/startos/tor_{}.s9pk", crate::ARCH); + let s9pk_path = Path::new(&s9pk_path_str); + if tokio::fs::metadata(s9pk_path).await.is_ok() { + if let Err(e) = async { + let package_s9pk = tokio::fs::File::open(s9pk_path).await?; + let file = MultiCursorFile::open(&package_s9pk).await?; + + let key = ctx.db.peek().await.into_private().into_developer_key(); + let registry_url = + Url::parse("https://registry.start9.com/").with_kind(ErrorKind::ParseUrl)?; + + ctx.services + .install( + ctx.clone(), + || crate::s9pk::load(file.clone(), || Ok(key.de()?.0), None), + None, + None::, + None, + ) + .await? + .await? + .await?; + + // Set the marketplace URL on the installed tor package + let tor_id = "tor".parse::()?; + ctx.db + .mutate(|db| { + if let Some(pkg) = + db.as_public_mut().as_package_data_mut().as_idx_mut(&tor_id) + { + pkg.as_registry_mut().ser(&Some(registry_url))?; + } + Ok(()) + }) + .await + .result?; + + Ok::<_, Error>(()) + } + .await + { + tracing::error!("Error installing tor package: {e}"); + tracing::debug!("{e:?}"); + } + } + Ok(()) } fn down(self, _db: &mut Value) -> Result<(), Error> {