Privacy-preserving location sharing with end-to-end encryption coord.is
at master 1772 lines 59 kB view raw
1use aes_gcm::{ 2 aead::{Aead, KeyInit}, 3 Aes256Gcm, Nonce, 4}; 5use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 6use curve25519_dalek::edwards::CompressedEdwardsY; 7use ed25519_dalek::{Signer, SigningKey}; 8use rand::rngs::OsRng; 9use std::collections::HashMap; 10 11/// Blob format version for future compatibility 12/// v2: JSON-encoded location 13/// v3: Binary-encoded location (20 bytes) 14const BLOB_VERSION: u8 = 0x03; 15use std::fs; 16use std::path::PathBuf; 17use std::sync::atomic::{AtomicU64, Ordering}; 18use std::sync::{Mutex, OnceLock}; 19use std::time::{SystemTime, UNIX_EPOCH}; 20use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret}; 21 22/// Global storage directory path, set once at app startup 23static STORAGE_PATH: OnceLock<PathBuf> = OnceLock::new(); 24 25/// In-memory friends list, persisted to disk on changes 26static FRIENDS: Mutex<Vec<Friend>> = Mutex::new(Vec::new()); 27 28/// Track the last successfully uploaded location timestamp to avoid uploading stale data 29/// Persisted to disk to survive app restarts 30static LAST_UPLOADED_TIMESTAMP: AtomicU64 = AtomicU64::new(0); 31 32uniffi::setup_scaffolding!(); 33 34/// Get the version of the transponder core library 35#[uniffi::export] 36pub fn get_version() -> String { 37 env!("CARGO_PKG_VERSION").to_string() 38} 39 40/// License information grouped by license type 41#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] 42pub struct LicenseGroup { 43 pub id: String, 44 pub name: String, 45 pub text: String, 46 pub packages: Vec<String>, 47} 48 49/// Get license information for all dependencies, grouped by license type 50#[uniffi::export] 51pub fn get_licenses() -> Vec<LicenseGroup> { 52 let json = include_str!("../licenses.json"); 53 serde_json::from_str(json).unwrap_or_default() 54} 55 56#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] 57pub struct Location { 58 pub latitude: f64, 59 pub longitude: f64, 60 #[serde(default)] 61 pub altitude: f64, 62 pub accuracy: f32, 63 pub timestamp: u64, 64} 65 66// ============================================================================= 67// Wire Format Conversion Utilities 68// ============================================================================= 69 70/// Convert degrees to microdegrees (i32) 71/// 1 microdegree ≈ 11cm at the equator 72fn degrees_to_micro(degrees: f64) -> i32 { 73 (degrees * 1_000_000.0).round() as i32 74} 75 76/// Convert microdegrees (i32) to degrees 77fn micro_to_degrees(micro: i32) -> f64 { 78 micro as f64 / 1_000_000.0 79} 80 81/// Wire format for location (20 bytes total) 82/// All integers are big-endian 83struct WireLocation { 84 lat_micro: i32, // 4 bytes - microdegrees 85 long_micro: i32, // 4 bytes - microdegrees 86 alt: i16, // 2 bytes - meters 87 accuracy: u16, // 2 bytes - meters 88 timestamp: u64, // 8 bytes - ms since epoch 89} 90 91impl WireLocation { 92 fn from_location(loc: &Location) -> Self { 93 Self { 94 lat_micro: degrees_to_micro(loc.latitude), 95 long_micro: degrees_to_micro(loc.longitude), 96 alt: loc.altitude.clamp(-32768.0, 32767.0) as i16, 97 accuracy: (loc.accuracy as u32).min(65535) as u16, 98 timestamp: loc.timestamp, 99 } 100 } 101 102 fn to_location(&self) -> Location { 103 Location { 104 latitude: micro_to_degrees(self.lat_micro), 105 longitude: micro_to_degrees(self.long_micro), 106 altitude: self.alt as f64, 107 accuracy: self.accuracy as f32, 108 timestamp: self.timestamp, 109 } 110 } 111 112 fn encode(&self) -> [u8; 20] { 113 let mut buf = [0u8; 20]; 114 buf[0..4].copy_from_slice(&self.lat_micro.to_be_bytes()); 115 buf[4..8].copy_from_slice(&self.long_micro.to_be_bytes()); 116 buf[8..10].copy_from_slice(&self.alt.to_be_bytes()); 117 buf[10..12].copy_from_slice(&self.accuracy.to_be_bytes()); 118 buf[12..20].copy_from_slice(&self.timestamp.to_be_bytes()); 119 buf 120 } 121 122 fn decode(buf: &[u8; 20]) -> Self { 123 Self { 124 lat_micro: i32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]), 125 long_micro: i32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]), 126 alt: i16::from_be_bytes([buf[8], buf[9]]), 127 accuracy: u16::from_be_bytes([buf[10], buf[11]]), 128 timestamp: u64::from_be_bytes([buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19]]), 129 } 130 } 131} 132 133#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] 134pub struct Friend { 135 pub pubkey: String, 136 pub server: String, 137 pub name: String, 138 pub share_with: bool, 139 pub fetch_from: bool, 140 pub location: Option<Location>, 141 pub fetched_at: Option<u64>, 142 /// Computed color for this friend's marker (hex string like "#4A90D9") 143 /// Not persisted - derived from pubkey on load 144 #[serde(skip, default)] 145 pub color: String, 146} 147 148/// Derive a consistent color from a pubkey string. 149/// Returns a hex color string like "#4A90D9". 150/// Uses HSL with fixed saturation/lightness for good visibility. 151#[uniffi::export] 152pub fn color_from_pubkey(pubkey: String) -> String { 153 color_from_pubkey_internal(&pubkey) 154} 155 156/// Get the color for an identity (what others see when viewing you) 157#[uniffi::export] 158pub fn get_identity_color(identity: &Identity) -> String { 159 let pubkey = URL_SAFE_NO_PAD.encode(&identity.ed25519_public); 160 color_from_pubkey_internal(&pubkey) 161} 162 163/// Internal color derivation function 164fn color_from_pubkey_internal(pubkey: &str) -> String { 165 // Hash the pubkey to get a consistent number 166 let hash: u64 = pubkey.bytes().fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64)); 167 168 // Map to hue (0-360), keeping saturation and lightness fixed 169 let hue = (hash % 360) as f64; 170 let saturation = 0.65; 171 let lightness = 0.45; 172 173 // Convert HSL to RGB 174 let (r, g, b) = hsl_to_rgb(hue, saturation, lightness); 175 176 format!("#{:02X}{:02X}{:02X}", r, g, b) 177} 178 179/// Convert HSL to RGB values (0-255) 180fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) { 181 let c = (1.0 - (2.0 * l - 1.0).abs()) * s; 182 let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); 183 let m = l - c / 2.0; 184 185 let (r1, g1, b1) = if h < 60.0 { 186 (c, x, 0.0) 187 } else if h < 120.0 { 188 (x, c, 0.0) 189 } else if h < 180.0 { 190 (0.0, c, x) 191 } else if h < 240.0 { 192 (0.0, x, c) 193 } else if h < 300.0 { 194 (x, 0.0, c) 195 } else { 196 (c, 0.0, x) 197 }; 198 199 let r = ((r1 + m) * 255.0) as u8; 200 let g = ((g1 + m) * 255.0) as u8; 201 let b = ((b1 + m) * 255.0) as u8; 202 203 (r, g, b) 204} 205 206#[derive(Debug, Clone, uniffi::Record)] 207pub struct Identity { 208 pub ed25519_private: Vec<u8>, 209 pub ed25519_public: Vec<u8>, 210 pub x25519_private: Vec<u8>, 211 pub x25519_public: Vec<u8>, 212} 213 214#[derive(Debug, thiserror::Error, uniffi::Error)] 215pub enum CoreError { 216 #[error("Invalid key")] 217 InvalidKey, 218 #[error("Encryption failed")] 219 EncryptionFailed, 220 #[error("Decryption failed")] 221 DecryptionFailed, 222 #[error("Invalid data")] 223 InvalidData, 224 #[error("Storage not initialized")] 225 StorageNotInitialized, 226 #[error("Storage error: {details}")] 227 StorageError { details: String }, 228 #[error("Invalid link format")] 229 InvalidLink, 230 #[error("Friend not found")] 231 FriendNotFound, 232 #[error("Location is older than last upload")] 233 StaleLocation, 234} 235 236/// Derive X25519 private key bytes from Ed25519 seed 237/// Uses SHA-512 like Ed25519, but returns raw bytes without mod l reduction 238/// (x25519_dalek::StaticSecret::from() will apply clamping when used) 239fn derive_x25519_scalar(ed25519_seed: &[u8; 32]) -> [u8; 32] { 240 use sha2::{Sha512, Digest}; 241 let hash = Sha512::digest(ed25519_seed); 242 let mut scalar = [0u8; 32]; 243 scalar.copy_from_slice(&hash[..32]); 244 scalar 245} 246 247/// Generate a new identity with Ed25519 (signing) and X25519 (key exchange) keypairs 248/// X25519 keys are derived from Ed25519 for simplicity - only Ed25519 pubkey needed in friend links 249#[uniffi::export] 250pub fn generate_identity() -> Identity { 251 // Generate Ed25519 signing keypair 252 let ed_signing = SigningKey::generate(&mut OsRng); 253 let ed_public = ed_signing.verifying_key(); 254 255 // Derive X25519 scalar from Ed25519 (raw scalar, no additional clamping) 256 let x_scalar = derive_x25519_scalar(&ed_signing.to_bytes()); 257 258 // Derive X25519 public by converting Ed25519 public (consistent with encryption) 259 let x_public_bytes = ed25519_pubkey_to_x25519(&ed_public.to_bytes()) 260 .expect("Valid Ed25519 pubkey should convert to X25519"); 261 262 Identity { 263 ed25519_private: ed_signing.to_bytes().to_vec(), 264 ed25519_public: ed_public.to_bytes().to_vec(), 265 x25519_private: x_scalar.to_vec(), 266 x25519_public: x_public_bytes.to_vec(), 267 } 268} 269 270/// Derive shared secret using X25519 ECDH 271#[uniffi::export] 272pub fn derive_shared_secret( 273 my_x25519_private: Vec<u8>, 274 their_x25519_public: Vec<u8>, 275) -> Result<Vec<u8>, CoreError> { 276 let private_bytes: [u8; 32] = my_x25519_private 277 .try_into() 278 .map_err(|_| CoreError::InvalidKey)?; 279 let public_bytes: [u8; 32] = their_x25519_public 280 .try_into() 281 .map_err(|_| CoreError::InvalidKey)?; 282 283 let private = StaticSecret::from(private_bytes); 284 let public = X25519PublicKey::from(public_bytes); 285 286 let shared = private.diffie_hellman(&public); 287 Ok(shared.as_bytes().to_vec()) 288} 289 290/// Encrypt location data with AES-256-GCM using shared secret 291#[uniffi::export] 292pub fn encrypt_location(location: Location, shared_secret: Vec<u8>) -> Result<Vec<u8>, CoreError> { 293 let key_bytes: [u8; 32] = shared_secret 294 .try_into() 295 .map_err(|_| CoreError::InvalidKey)?; 296 297 let cipher = Aes256Gcm::new_from_slice(&key_bytes).map_err(|_| CoreError::InvalidKey)?; 298 299 // Serialize location to JSON 300 let plaintext = serde_json::to_vec(&LocationJson { 301 latitude: location.latitude, 302 longitude: location.longitude, 303 altitude: location.altitude, 304 accuracy: location.accuracy, 305 timestamp: location.timestamp, 306 }) 307 .map_err(|_| CoreError::InvalidData)?; 308 309 // Generate random nonce 310 let mut nonce_bytes = [0u8; 12]; 311 rand::RngCore::fill_bytes(&mut OsRng, &mut nonce_bytes); 312 let nonce = Nonce::from_slice(&nonce_bytes); 313 314 // Encrypt 315 let ciphertext = cipher 316 .encrypt(nonce, plaintext.as_ref()) 317 .map_err(|_| CoreError::EncryptionFailed)?; 318 319 // Prepend nonce to ciphertext 320 let mut result = nonce_bytes.to_vec(); 321 result.extend(ciphertext); 322 Ok(result) 323} 324 325/// Decrypt location data with AES-256-GCM using shared secret 326#[uniffi::export] 327pub fn decrypt_location( 328 encrypted: Vec<u8>, 329 shared_secret: Vec<u8>, 330) -> Result<Option<Location>, CoreError> { 331 if encrypted.len() < 12 { 332 return Err(CoreError::InvalidData); 333 } 334 335 let key_bytes: [u8; 32] = shared_secret 336 .try_into() 337 .map_err(|_| CoreError::InvalidKey)?; 338 339 let cipher = Aes256Gcm::new_from_slice(&key_bytes).map_err(|_| CoreError::InvalidKey)?; 340 341 // Split nonce and ciphertext 342 let (nonce_bytes, ciphertext) = encrypted.split_at(12); 343 let nonce = Nonce::from_slice(nonce_bytes); 344 345 // Decrypt 346 let plaintext = cipher 347 .decrypt(nonce, ciphertext) 348 .map_err(|_| CoreError::DecryptionFailed)?; 349 350 // Deserialize 351 let json: LocationJson = 352 serde_json::from_slice(&plaintext).map_err(|_| CoreError::InvalidData)?; 353 354 Ok(Some(Location { 355 latitude: json.latitude, 356 longitude: json.longitude, 357 altitude: json.altitude, 358 accuracy: json.accuracy, 359 timestamp: json.timestamp, 360 })) 361} 362 363// Internal JSON representation for legacy single-recipient encryption 364#[derive(serde::Serialize, serde::Deserialize)] 365struct LocationJson { 366 latitude: f64, 367 longitude: f64, 368 #[serde(default)] 369 altitude: f64, 370 accuracy: f32, 371 timestamp: u64, 372} 373 374/// HTTP request prepared by Rust core for native layer to execute 375#[derive(Debug, Clone, uniffi::Record)] 376pub struct PreparedRequest { 377 pub url: String, 378 pub method: String, 379 pub headers: HashMap<String, String>, 380 pub body: Vec<u8>, 381} 382 383/// Convert Ed25519 public key to X25519 public key 384/// Both are points on Curve25519, just different representations (Edwards vs Montgomery) 385fn ed25519_pubkey_to_x25519(ed_pubkey: &[u8; 32]) -> Result<[u8; 32], CoreError> { 386 let compressed = CompressedEdwardsY::from_slice(ed_pubkey).map_err(|_| CoreError::InvalidKey)?; 387 let edwards_point = compressed.decompress().ok_or(CoreError::InvalidKey)?; 388 Ok(edwards_point.to_montgomery().to_bytes()) 389} 390 391/// Calculate padded entry count for privacy (hides exact friend count) 392/// Power of 2 up to 64, then nearest 50 above that 393fn padded_entry_count(n: usize) -> usize { 394 if n == 0 { 395 return 0; 396 } 397 if n <= 64 { 398 n.next_power_of_two() 399 } else { 400 ((n + 49) / 50) * 50 401 } 402} 403 404/// Create multi-recipient encrypted blob with trial decryption support 405/// Format: version(1) | ephemeral_pubkey(32) | entry_count(2) | [nonce(12) | encrypted_dek(48)]... | data_nonce(12) | ciphertext 406/// Entry count includes padding entries (random bytes that fail auth for everyone) 407fn create_encrypted_blob( 408 location: &Location, 409 recipient_ed25519_pubkeys: &[[u8; 32]], 410) -> Result<Vec<u8>, CoreError> { 411 // Generate ephemeral X25519 keypair for this blob 412 let ephemeral_private = StaticSecret::random_from_rng(OsRng); 413 let ephemeral_public = X25519PublicKey::from(&ephemeral_private); 414 415 // Generate random data encryption key (DEK) 416 let mut dek = [0u8; 32]; 417 rand::RngCore::fill_bytes(&mut OsRng, &mut dek); 418 419 // Encode location to binary wire format (20 bytes) 420 let wire_location = WireLocation::from_location(location); 421 let plaintext = wire_location.encode(); 422 423 // Encrypt location with DEK 424 let data_cipher = Aes256Gcm::new_from_slice(&dek).map_err(|_| CoreError::InvalidKey)?; 425 let mut data_nonce_bytes = [0u8; 12]; 426 rand::RngCore::fill_bytes(&mut OsRng, &mut data_nonce_bytes); 427 let data_nonce = Nonce::from_slice(&data_nonce_bytes); 428 let ciphertext = data_cipher 429 .encrypt(data_nonce, plaintext.as_ref()) 430 .map_err(|_| CoreError::EncryptionFailed)?; 431 432 // Build blob 433 let mut blob = Vec::new(); 434 435 // Version byte for future compatibility 436 blob.push(BLOB_VERSION); 437 438 // Ephemeral public key (32 bytes) 439 blob.extend_from_slice(ephemeral_public.as_bytes()); 440 441 // Calculate padded entry count (real entries + padding) 442 let real_count = recipient_ed25519_pubkeys.len(); 443 let padded_count = padded_entry_count(real_count); 444 445 // Entry count (2 bytes, big-endian) - includes padding 446 blob.extend_from_slice(&(padded_count as u16).to_be_bytes()); 447 448 // Real entries: encrypt DEK for each recipient (no pubkey stored - trial decryption) 449 for ed_pubkey in recipient_ed25519_pubkeys { 450 let x_pubkey_bytes = ed25519_pubkey_to_x25519(ed_pubkey)?; 451 let x_pubkey = X25519PublicKey::from(x_pubkey_bytes); 452 453 // ECDH to get shared secret 454 let shared_secret = ephemeral_private.diffie_hellman(&x_pubkey); 455 456 // Encrypt DEK with shared secret 457 let key_cipher = 458 Aes256Gcm::new_from_slice(shared_secret.as_bytes()).map_err(|_| CoreError::InvalidKey)?; 459 let mut key_nonce_bytes = [0u8; 12]; 460 rand::RngCore::fill_bytes(&mut OsRng, &mut key_nonce_bytes); 461 let key_nonce = Nonce::from_slice(&key_nonce_bytes); 462 let encrypted_dek = key_cipher 463 .encrypt(key_nonce, dek.as_ref()) 464 .map_err(|_| CoreError::EncryptionFailed)?; 465 466 // Append: nonce (12) | encrypted DEK (48 = 32 + 16 tag) 467 blob.extend_from_slice(&key_nonce_bytes); 468 blob.extend_from_slice(&encrypted_dek); 469 } 470 471 // Padding entries: random bytes that will fail AES-GCM auth for everyone 472 for _ in real_count..padded_count { 473 let mut dummy = [0u8; 60]; // 12-byte nonce + 48-byte "encrypted" DEK 474 rand::RngCore::fill_bytes(&mut OsRng, &mut dummy); 475 blob.extend_from_slice(&dummy); 476 } 477 478 // Append encrypted location: nonce (12) | ciphertext 479 blob.extend_from_slice(&data_nonce_bytes); 480 blob.extend_from_slice(&ciphertext); 481 482 Ok(blob) 483} 484 485/// Prepare a location upload request for the native HTTP layer to execute 486/// Returns StaleLocation error if the location timestamp is older than the last successful upload 487#[uniffi::export] 488pub fn prepare_location_upload( 489 identity: &Identity, 490 location: Location, 491 friends: Vec<Friend>, 492 my_server: String, 493) -> Result<PreparedRequest, CoreError> { 494 // Check if this location is stale (only if storage is initialized) 495 if STORAGE_PATH.get().is_some() { 496 let last_ts = LAST_UPLOADED_TIMESTAMP.load(Ordering::Relaxed); 497 if location.timestamp <= last_ts { 498 return Err(CoreError::StaleLocation); 499 } 500 } 501 502 // Collect recipient pubkeys: all friends + self (so we can fetch our own location) 503 let mut recipient_pubkeys: Vec<[u8; 32]> = Vec::new(); 504 505 // Add self 506 let my_ed_pubkey: [u8; 32] = identity 507 .ed25519_public 508 .clone() 509 .try_into() 510 .map_err(|_| CoreError::InvalidKey)?; 511 recipient_pubkeys.push(my_ed_pubkey); 512 513 // Add friends 514 for friend in &friends { 515 let pubkey_bytes = URL_SAFE_NO_PAD 516 .decode(&friend.pubkey) 517 .map_err(|_| CoreError::InvalidKey)?; 518 let pubkey_array: [u8; 32] = pubkey_bytes.try_into().map_err(|_| CoreError::InvalidKey)?; 519 recipient_pubkeys.push(pubkey_array); 520 } 521 522 // Create encrypted blob 523 let blob = create_encrypted_blob(&location, &recipient_pubkeys)?; 524 let blob_b64 = URL_SAFE_NO_PAD.encode(&blob); 525 526 // Get current timestamp 527 let timestamp = SystemTime::now() 528 .duration_since(UNIX_EPOCH) 529 .map_err(|_| CoreError::InvalidData)? 530 .as_secs(); 531 532 // Sign: blob || timestamp (as 8-byte big-endian) 533 let signing_key_bytes: [u8; 32] = identity 534 .ed25519_private 535 .clone() 536 .try_into() 537 .map_err(|_| CoreError::InvalidKey)?; 538 let signing_key = SigningKey::from_bytes(&signing_key_bytes); 539 540 let mut message_to_sign = blob.clone(); 541 message_to_sign.extend_from_slice(&timestamp.to_be_bytes()); 542 let signature = signing_key.sign(&message_to_sign); 543 let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 544 545 // Build request body 546 let pubkey_b64 = URL_SAFE_NO_PAD.encode(&identity.ed25519_public); 547 let body = serde_json::to_vec(&serde_json::json!({ 548 "pubkey": pubkey_b64, 549 "timestamp": timestamp, 550 "blob": blob_b64, 551 "signature": signature_b64 552 })) 553 .map_err(|_| CoreError::InvalidData)?; 554 555 // Build URL 556 let url = format!("{}/api/location", my_server.trim_end_matches('/')); 557 558 // Headers 559 let mut headers = HashMap::new(); 560 headers.insert("Content-Type".to_string(), "application/json".to_string()); 561 562 Ok(PreparedRequest { 563 url, 564 method: "PUT".to_string(), 565 headers, 566 body, 567 }) 568} 569 570/// Decrypt a multi-recipient blob using trial decryption 571/// Format: version(1) | ephemeral(32) | count(2) | [nonce(12) | encrypted_dek(48)]... | data_nonce(12) | ciphertext 572/// Returns the decrypted Location if we're a valid recipient 573#[uniffi::export] 574pub fn decrypt_blob(identity: &Identity, blob: Vec<u8>) -> Result<Location, CoreError> { 575 // Minimum blob size: version(1) + ephemeral(32) + count(2) + at least one entry(60) + nonce(12) + some ciphertext 576 if blob.len() < 1 + 32 + 2 + 60 + 12 + 16 { 577 return Err(CoreError::InvalidData); 578 } 579 580 // Check version byte 581 if blob[0] != BLOB_VERSION { 582 return Err(CoreError::InvalidData); 583 } 584 585 // Parse ephemeral public key bytes (after version byte) 586 let ephemeral_bytes: [u8; 32] = blob[1..33].try_into().map_err(|_| CoreError::InvalidData)?; 587 let ephemeral_public = X25519PublicKey::from(ephemeral_bytes); 588 589 // Parse entry count (includes padding) 590 let entry_count = 591 u16::from_be_bytes(blob[33..35].try_into().map_err(|_| CoreError::InvalidData)?) as usize; 592 593 // Calculate where entries end 594 // Each entry: nonce(12) + encrypted_dek(48) = 60 bytes 595 let entries_end = 35 + entry_count * 60; 596 if blob.len() < entries_end + 12 + 16 { 597 return Err(CoreError::InvalidData); 598 } 599 600 // Get our X25519 private key for ECDH 601 let my_x25519_private: [u8; 32] = identity 602 .x25519_private 603 .clone() 604 .try_into() 605 .map_err(|_| CoreError::InvalidKey)?; 606 let my_secret = StaticSecret::from(my_x25519_private); 607 608 // Compute shared secret once (same for all entries since ephemeral is fixed) 609 let shared_secret = my_secret.diffie_hellman(&ephemeral_public); 610 let cipher = Aes256Gcm::new_from_slice(shared_secret.as_bytes()) 611 .map_err(|_| CoreError::InvalidKey)?; 612 613 // Trial decryption: try each entry until one succeeds 614 let mut dek: Option<[u8; 32]> = None; 615 for i in 0..entry_count { 616 let entry_start = 35 + i * 60; 617 let nonce_bytes: [u8; 12] = blob[entry_start..entry_start + 12] 618 .try_into() 619 .map_err(|_| CoreError::InvalidData)?; 620 let encrypted_dek = &blob[entry_start + 12..entry_start + 60]; 621 622 let nonce = Nonce::from_slice(&nonce_bytes); 623 624 // Try to decrypt - will fail with auth error for wrong entries and padding 625 if let Ok(decrypted_dek) = cipher.decrypt(nonce, encrypted_dek) { 626 if let Ok(dek_array) = decrypted_dek.try_into() { 627 dek = Some(dek_array); 628 break; 629 } 630 } 631 } 632 633 let dek = dek.ok_or(CoreError::DecryptionFailed)?; // We're not a recipient 634 635 // Decrypt the location data 636 let data_nonce: [u8; 12] = blob[entries_end..entries_end + 12] 637 .try_into() 638 .map_err(|_| CoreError::InvalidData)?; 639 let ciphertext = &blob[entries_end + 12..]; 640 641 let cipher = Aes256Gcm::new_from_slice(&dek).map_err(|_| CoreError::InvalidKey)?; 642 let nonce = Nonce::from_slice(&data_nonce); 643 let plaintext = cipher 644 .decrypt(nonce, ciphertext) 645 .map_err(|_| CoreError::DecryptionFailed)?; 646 647 // Decode binary wire format (20 bytes) 648 if plaintext.len() != 20 { 649 return Err(CoreError::InvalidData); 650 } 651 let wire_bytes: [u8; 20] = plaintext.try_into().map_err(|_| CoreError::InvalidData)?; 652 let wire_location = WireLocation::decode(&wire_bytes); 653 654 Ok(wire_location.to_location()) 655} 656 657/// Result of fetching a friend's location 658#[derive(Debug, Clone, uniffi::Record)] 659pub struct FetchedLocation { 660 pub pubkey: String, 661 pub location: Option<Location>, 662 pub updated: Option<u64>, 663} 664 665/// Prepare location fetch requests, grouped by server for federation 666/// Returns one PreparedRequest per unique server 667#[uniffi::export] 668pub fn prepare_location_fetch(friends: Vec<Friend>) -> Vec<PreparedRequest> { 669 // Group friends by server 670 let mut by_server: HashMap<String, Vec<String>> = HashMap::new(); 671 for friend in friends { 672 by_server 673 .entry(friend.server.clone()) 674 .or_default() 675 .push(friend.pubkey.clone()); 676 } 677 678 // Create one request per server 679 by_server 680 .into_iter() 681 .map(|(server, pubkeys)| { 682 let url = format!("{}/api/location", server.trim_end_matches('/')); 683 let body = serde_json::to_vec(&serde_json::json!({ "ids": pubkeys })) 684 .unwrap_or_default(); 685 686 let mut headers = HashMap::new(); 687 headers.insert("Content-Type".to_string(), "application/json".to_string()); 688 689 PreparedRequest { 690 url, 691 method: "POST".to_string(), 692 headers, 693 body, 694 } 695 }) 696 .collect() 697} 698 699/// Prepare a request to fetch our own location from the server 700/// Used to verify what the server has stored for us 701#[uniffi::export] 702pub fn prepare_self_location_fetch(identity: &Identity, server: String) -> PreparedRequest { 703 let pubkey_b64 = URL_SAFE_NO_PAD.encode(&identity.ed25519_public); 704 let url = format!("{}/api/location", server.trim_end_matches('/')); 705 let body = serde_json::to_vec(&serde_json::json!({ "ids": [pubkey_b64] })).unwrap_or_default(); 706 707 let mut headers = HashMap::new(); 708 headers.insert("Content-Type".to_string(), "application/json".to_string()); 709 710 PreparedRequest { 711 url, 712 method: "POST".to_string(), 713 headers, 714 body, 715 } 716} 717 718/// Server response format for POST /location 719#[derive(serde::Deserialize)] 720struct StoredLocationResponse { 721 blob: String, 722 updated: u64, 723} 724 725/// Process a fetch response from the server and decrypt locations 726/// Returns decrypted locations for each friend we can decrypt 727#[uniffi::export] 728pub fn process_fetch_response( 729 identity: &Identity, 730 response_body: Vec<u8>, 731) -> Result<Vec<FetchedLocation>, CoreError> { 732 // Parse response JSON: { pubkey: { blob, updated } | null, ... } 733 let response: HashMap<String, Option<StoredLocationResponse>> = 734 serde_json::from_slice(&response_body).map_err(|_| CoreError::InvalidData)?; 735 736 let mut results = Vec::new(); 737 738 for (pubkey, stored) in response { 739 let fetched = match stored { 740 Some(data) => { 741 // Decode and decrypt blob 742 let blob_bytes = URL_SAFE_NO_PAD 743 .decode(&data.blob) 744 .map_err(|_| CoreError::InvalidData)?; 745 746 match decrypt_blob(identity, blob_bytes) { 747 Ok(location) => FetchedLocation { 748 pubkey, 749 location: Some(location), 750 updated: Some(data.updated), 751 }, 752 Err(_) => FetchedLocation { 753 pubkey, 754 location: None, 755 updated: Some(data.updated), 756 }, 757 } 758 } 759 None => FetchedLocation { 760 pubkey, 761 location: None, 762 updated: None, 763 }, 764 }; 765 results.push(fetched); 766 } 767 768 Ok(results) 769} 770 771// ============================================================================= 772// Storage & Friend Management 773// ============================================================================= 774 775fn friends_file_path() -> Result<PathBuf, CoreError> { 776 STORAGE_PATH 777 .get() 778 .map(|p| p.join("friends.json")) 779 .ok_or(CoreError::StorageNotInitialized) 780} 781 782fn last_upload_file_path() -> Option<PathBuf> { 783 STORAGE_PATH.get().map(|p| p.join("last_upload_timestamp")) 784} 785 786fn save_last_upload_timestamp(timestamp: u64) { 787 if let Some(path) = last_upload_file_path() { 788 let _ = fs::write(&path, timestamp.to_string()); 789 } 790} 791 792fn load_last_upload_timestamp() -> u64 { 793 last_upload_file_path() 794 .and_then(|path| fs::read_to_string(&path).ok()) 795 .and_then(|content| content.trim().parse().ok()) 796 .unwrap_or(0) 797} 798 799fn save_friends(friends: &[Friend]) -> Result<(), CoreError> { 800 let path = friends_file_path()?; 801 let json = serde_json::to_string_pretty(friends).map_err(|e| CoreError::StorageError { 802 details: e.to_string(), 803 })?; 804 fs::write(&path, json).map_err(|e| CoreError::StorageError { 805 details: e.to_string(), 806 })?; 807 Ok(()) 808} 809 810fn load_friends() -> Result<Vec<Friend>, CoreError> { 811 let path = friends_file_path()?; 812 if !path.exists() { 813 return Ok(Vec::new()); 814 } 815 let json = fs::read_to_string(&path).map_err(|e| CoreError::StorageError { 816 details: e.to_string(), 817 })?; 818 let mut friends: Vec<Friend> = serde_json::from_str(&json).map_err(|e| CoreError::StorageError { 819 details: e.to_string(), 820 })?; 821 822 // Compute colors for all friends (not stored in JSON) 823 for friend in &mut friends { 824 friend.color = color_from_pubkey_internal(&friend.pubkey); 825 } 826 827 Ok(friends) 828} 829 830/// Initialize storage with the app's data directory path. 831/// Must be called once at app startup before any friend operations. 832#[uniffi::export] 833pub fn init_storage(path: String) -> Result<(), CoreError> { 834 let storage_path = PathBuf::from(path); 835 836 // Create directory if it doesn't exist 837 if !storage_path.exists() { 838 fs::create_dir_all(&storage_path).map_err(|e| CoreError::StorageError { 839 details: e.to_string(), 840 })?; 841 } 842 843 // Set the global storage path (fails if already set) 844 STORAGE_PATH 845 .set(storage_path) 846 .map_err(|_| CoreError::StorageError { 847 details: "Storage already initialized".to_string(), 848 })?; 849 850 // Load friends from disk into memory 851 let loaded = load_friends()?; 852 let mut friends = FRIENDS.lock().unwrap(); 853 *friends = loaded; 854 855 // Load last upload timestamp 856 let last_ts = load_last_upload_timestamp(); 857 LAST_UPLOADED_TIMESTAMP.store(last_ts, Ordering::Relaxed); 858 859 Ok(()) 860} 861 862/// Migrate friend server URLs from old domain to new domain. 863/// Returns the number of friends that were migrated. 864#[uniffi::export] 865pub fn migrate_server_urls(from_domain: String, to_domain: String) -> Result<u32, CoreError> { 866 let mut friends = FRIENDS.lock().unwrap(); 867 let mut migrated = 0u32; 868 869 for friend in friends.iter_mut() { 870 if friend.server.contains(&from_domain) { 871 friend.server = friend.server.replace(&from_domain, &to_domain); 872 migrated += 1; 873 } 874 } 875 876 if migrated > 0 { 877 save_friends(&friends)?; 878 } 879 880 Ok(migrated) 881} 882 883/// Add a new friend. If a friend with the same pubkey exists, updates their info. 884#[uniffi::export] 885pub fn add_friend( 886 pubkey: String, 887 server: String, 888 name: String, 889 share_with: bool, 890 fetch_from: bool, 891) -> Result<(), CoreError> { 892 let mut friends = FRIENDS.lock().unwrap(); 893 894 // Check if friend already exists 895 if let Some(existing) = friends.iter_mut().find(|f| f.pubkey == pubkey) { 896 existing.server = server; 897 existing.name = name; 898 existing.share_with = share_with; 899 existing.fetch_from = fetch_from; 900 } else { 901 let color = color_from_pubkey_internal(&pubkey); 902 friends.push(Friend { 903 pubkey, 904 server, 905 name, 906 share_with, 907 fetch_from, 908 location: None, 909 fetched_at: None, 910 color, 911 }); 912 } 913 914 save_friends(&friends) 915} 916 917/// Update an existing friend's settings 918#[uniffi::export] 919pub fn update_friend( 920 pubkey: String, 921 share_with: Option<bool>, 922 fetch_from: Option<bool>, 923 name: Option<String>, 924) -> Result<(), CoreError> { 925 let mut friends = FRIENDS.lock().unwrap(); 926 927 let friend = friends 928 .iter_mut() 929 .find(|f| f.pubkey == pubkey) 930 .ok_or(CoreError::FriendNotFound)?; 931 932 if let Some(sw) = share_with { 933 friend.share_with = sw; 934 } 935 if let Some(ff) = fetch_from { 936 friend.fetch_from = ff; 937 } 938 if let Some(n) = name { 939 friend.name = n; 940 } 941 942 save_friends(&friends) 943} 944 945/// Remove a friend by pubkey 946#[uniffi::export] 947pub fn remove_friend(pubkey: String) -> Result<(), CoreError> { 948 let mut friends = FRIENDS.lock().unwrap(); 949 let initial_len = friends.len(); 950 friends.retain(|f| f.pubkey != pubkey); 951 952 if friends.len() == initial_len { 953 return Err(CoreError::FriendNotFound); 954 } 955 956 save_friends(&friends) 957} 958 959/// List all friends 960#[uniffi::export] 961pub fn list_friends() -> Vec<Friend> { 962 FRIENDS.lock().unwrap().clone() 963} 964 965/// Generate mock friends for development/testing. 966/// Colors are computed automatically from pubkeys. 967#[uniffi::export] 968pub fn mock_friends() -> Vec<Friend> { 969 let now = SystemTime::now() 970 .duration_since(UNIX_EPOCH) 971 .unwrap() 972 .as_millis() as u64; 973 974 // (pubkey, name, Option<(lat, lng, alt, acc, ts_offset, fetch_offset)>) 975 let mock_data = [ 976 ("abc123", "Alice", Some((37.7749, -122.4194, 50.0, 10.0, 300_000, 60_000))), 977 ("def456", "Bob", Some((37.7849, -122.4094, 25.0, 25.0, 3_600_000, 120_000))), 978 ("ghi789", "Charlie", None), 979 ("jkl012", "Diana", Some((37.7649, -122.4294, 100.0, 15.0, 1_800_000, 180_000))), 980 ("mno345", "Evan", Some((37.7949, -122.3994, 75.0, 50.0, 7_200_000, 300_000))), 981 ("pqr678", "Fiona", Some((37.7549, -122.4394, 10.0, 8.0, 120_000, 30_000))), 982 ("stu901", "George", Some((37.8049, -122.4094, 200.0, 100.0, 86_400_000, 600_000))), 983 ("vwx234", "Hannah", Some((37.7699, -122.4494, 30.0, 20.0, 900_000, 90_000))), 984 ("yza567", "Ivan", Some((37.7599, -122.3894, 45.0, 12.0, 600_000, 45_000))), 985 ("bcd890", "Julia", Some((37.7899, -122.4594, 60.0, 30.0, 2_700_000, 150_000))), 986 ("efg123", "Kevin", None), 987 ("hij456", "Laura", Some((37.7749, -122.4294, 80.0, 18.0, 450_000, 75_000))), 988 ]; 989 990 mock_data 991 .into_iter() 992 .map(|(pubkey, name, loc_data)| { 993 let location = loc_data.map(|(lat, lng, alt, acc, ts_offset, _)| Location { 994 latitude: lat, 995 longitude: lng, 996 altitude: alt, 997 accuracy: acc as f32, 998 timestamp: now - ts_offset, 999 }); 1000 let fetched_at = loc_data.map(|(_, _, _, _, _, fetch_offset)| now - fetch_offset); 1001 1002 Friend { 1003 pubkey: pubkey.to_string(), 1004 server: "https://example.com".to_string(), 1005 name: name.to_string(), 1006 share_with: true, 1007 fetch_from: true, 1008 location, 1009 fetched_at, 1010 color: color_from_pubkey_internal(pubkey), 1011 } 1012 }) 1013 .collect() 1014} 1015 1016/// Get friends to share location with (share_with == true) 1017#[uniffi::export] 1018pub fn get_share_recipients() -> Vec<Friend> { 1019 FRIENDS 1020 .lock() 1021 .unwrap() 1022 .iter() 1023 .filter(|f| f.share_with) 1024 .cloned() 1025 .collect() 1026} 1027 1028/// Get friends to fetch location from (fetch_from == true) 1029#[uniffi::export] 1030pub fn get_fetch_targets() -> Vec<Friend> { 1031 FRIENDS 1032 .lock() 1033 .unwrap() 1034 .iter() 1035 .filter(|f| f.fetch_from) 1036 .cloned() 1037 .collect() 1038} 1039 1040/// Update a friend's cached location after fetching 1041#[uniffi::export] 1042pub fn update_friend_location( 1043 pubkey: String, 1044 location: Option<Location>, 1045 fetched_at: u64, 1046) -> Result<(), CoreError> { 1047 let mut friends = FRIENDS.lock().unwrap(); 1048 1049 let friend = friends 1050 .iter_mut() 1051 .find(|f| f.pubkey == pubkey) 1052 .ok_or(CoreError::FriendNotFound)?; 1053 1054 friend.location = location; 1055 friend.fetched_at = Some(fetched_at); 1056 1057 save_friends(&friends) 1058} 1059 1060/// Mark a location upload as successful, updating the last uploaded timestamp 1061/// Call this after successfully uploading a location to prevent re-uploading stale data 1062#[uniffi::export] 1063pub fn mark_upload_success(timestamp: u64) { 1064 LAST_UPLOADED_TIMESTAMP.store(timestamp, Ordering::Relaxed); 1065 save_last_upload_timestamp(timestamp); 1066} 1067 1068// ============================================================================= 1069// Friend Link Generation & Parsing 1070// ============================================================================= 1071 1072/// Generate a friend link for sharing our identity 1073/// Format: coord://<server>/add/<pubkey>#<name> 1074#[uniffi::export] 1075pub fn generate_friend_link(identity: &Identity, server: String, name: String) -> String { 1076 let pubkey_b64 = URL_SAFE_NO_PAD.encode(&identity.ed25519_public); 1077 let encoded_name = urlencoding::encode(&name); 1078 1079 // Strip protocol from server for the link format 1080 let server_host = server 1081 .trim_start_matches("https://") 1082 .trim_start_matches("http://") 1083 .trim_end_matches('/'); 1084 1085 format!("coord://{}/add/{}#{}", server_host, pubkey_b64, encoded_name) 1086} 1087 1088/// Parsed friend link data 1089#[derive(Debug, Clone, uniffi::Record)] 1090pub struct ParsedFriendLink { 1091 pub pubkey: String, 1092 pub server: String, 1093 pub name: String, 1094} 1095 1096/// Parse a friend link into its components 1097/// Returns None if the link format is invalid 1098#[uniffi::export] 1099pub fn parse_friend_link(url: String) -> Result<ParsedFriendLink, CoreError> { 1100 // Expected format: coord://<server>/add/<pubkey>#<name> 1101 let url = url.trim(); 1102 1103 // Check protocol 1104 let rest = url 1105 .strip_prefix("coord://") 1106 .ok_or(CoreError::InvalidLink)?; 1107 1108 // Split off fragment (name) 1109 let (path_part, name) = rest.split_once('#').ok_or(CoreError::InvalidLink)?; 1110 1111 // Parse path: <server>/add/<pubkey> 1112 let add_idx = path_part.find("/add/").ok_or(CoreError::InvalidLink)?; 1113 let server_host = &path_part[..add_idx]; 1114 let pubkey = &path_part[add_idx + 5..]; // skip "/add/" 1115 1116 if server_host.is_empty() || pubkey.is_empty() { 1117 return Err(CoreError::InvalidLink); 1118 } 1119 1120 // Validate pubkey is valid base64url and correct length (32 bytes for ed25519) 1121 let pubkey_bytes = URL_SAFE_NO_PAD 1122 .decode(pubkey) 1123 .map_err(|_| CoreError::InvalidLink)?; 1124 if pubkey_bytes.len() != 32 { 1125 return Err(CoreError::InvalidLink); 1126 } 1127 1128 // Decode name from URL encoding 1129 let decoded_name = 1130 urlencoding::decode(name).map_err(|_| CoreError::InvalidLink)?; 1131 1132 Ok(ParsedFriendLink { 1133 pubkey: pubkey.to_string(), 1134 server: format!("https://{}", server_host), 1135 name: decoded_name.into_owned(), 1136 }) 1137} 1138 1139// ============================================================================= 1140// City Database - Privacy-preserving local reverse geocoding 1141// ============================================================================= 1142 1143/// City information for reverse geocoding 1144#[derive(Debug, Clone, serde::Deserialize, uniffi::Record)] 1145pub struct City { 1146 #[serde(rename = "n")] 1147 pub name: String, 1148 #[serde(rename = "la")] 1149 pub lat: f64, 1150 #[serde(rename = "lo")] 1151 pub lng: f64, 1152 #[serde(rename = "r", default)] 1153 pub region: String, 1154 #[serde(rename = "c", default)] 1155 pub country: String, 1156 #[serde(rename = "p", default)] 1157 pub population: u32, 1158} 1159 1160impl City { 1161 /// Returns a display string like "Toronto, ON" or "Paris, France" 1162 pub fn display_name(&self) -> String { 1163 if !self.region.is_empty() { 1164 format!("{}, {}", self.name, self.region) 1165 } else { 1166 format!("{}, {}", self.name, self.country) 1167 } 1168 } 1169} 1170 1171/// Lazily loaded city database 1172static CITIES: OnceLock<Vec<City>> = OnceLock::new(); 1173 1174/// Load cities from embedded JSON (called lazily on first access) 1175fn get_cities() -> &'static Vec<City> { 1176 CITIES.get_or_init(|| { 1177 let json = include_str!("cities.json"); 1178 serde_json::from_str(json).expect("Failed to parse embedded cities.json") 1179 }) 1180} 1181 1182/// Find the nearest city to the given coordinates. 1183/// Uses a weighted score that prefers larger cities when distances are similar. 1184#[uniffi::export] 1185pub fn find_nearest_city(lat: f64, lng: f64) -> Option<City> { 1186 let cities = get_cities(); 1187 1188 cities 1189 .iter() 1190 .min_by(|a, b| { 1191 let score_a = city_score(a, lat, lng); 1192 let score_b = city_score(b, lat, lng); 1193 score_a.partial_cmp(&score_b).unwrap_or(std::cmp::Ordering::Equal) 1194 }) 1195 .cloned() 1196} 1197 1198/// Calculate score for a city (lower is better) 1199/// Score = distance - population bonus 1200fn city_score(city: &City, lat: f64, lng: f64) -> f64 { 1201 let distance = haversine_distance(lat, lng, city.lat, city.lng); 1202 let population_bonus = (city.population.max(1) as f64).ln() * 0.5; 1203 distance - population_bonus 1204} 1205 1206/// Haversine distance in kilometers between two points 1207fn haversine_distance(lat1: f64, lng1: f64, lat2: f64, lng2: f64) -> f64 { 1208 const R: f64 = 6371.0; // Earth radius in km 1209 let d_lat = (lat2 - lat1).to_radians(); 1210 let d_lng = (lng2 - lng1).to_radians(); 1211 let a = (d_lat / 2.0).sin().powi(2) 1212 + lat1.to_radians().cos() * lat2.to_radians().cos() * (d_lng / 2.0).sin().powi(2); 1213 let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt()); 1214 R * c 1215} 1216 1217// ============================================================================= 1218// Region Boundaries - Point-in-polygon lookup for accurate geocoding 1219// ============================================================================= 1220 1221/// A region boundary polygon with bounding box for fast rejection 1222#[derive(Debug, Clone)] 1223struct RegionBoundary { 1224 name: String, 1225 country: String, 1226 /// Bounding box: (min_lng, min_lat, max_lng, max_lat) 1227 bbox: (f64, f64, f64, f64), 1228 /// Polygon rings: outer ring first, then holes. Each ring is vec of (lng, lat) 1229 rings: Vec<Vec<(f64, f64)>>, 1230} 1231 1232/// Lazily loaded region boundaries 1233static BOUNDARIES: OnceLock<Vec<RegionBoundary>> = OnceLock::new(); 1234 1235/// Load boundaries from embedded GeoJSON 1236fn get_boundaries() -> &'static Vec<RegionBoundary> { 1237 BOUNDARIES.get_or_init(|| { 1238 let json = include_str!("boundaries.json"); 1239 parse_boundaries_geojson(json) 1240 }) 1241} 1242 1243/// Parse GeoJSON FeatureCollection into RegionBoundary structs 1244fn parse_boundaries_geojson(json: &str) -> Vec<RegionBoundary> { 1245 let parsed: serde_json::Value = serde_json::from_str(json).unwrap_or_default(); 1246 let features = parsed["features"].as_array(); 1247 1248 let Some(features) = features else { 1249 return Vec::new(); 1250 }; 1251 1252 let mut boundaries = Vec::with_capacity(features.len()); 1253 1254 for feature in features { 1255 let props = &feature["properties"]; 1256 let name = props["name"].as_str().unwrap_or("").to_string(); 1257 let country = props["admin"].as_str().unwrap_or("").to_string(); 1258 1259 let geom = &feature["geometry"]; 1260 let geom_type = geom["type"].as_str().unwrap_or(""); 1261 1262 let polygons: Vec<Vec<Vec<(f64, f64)>>> = match geom_type { 1263 "Polygon" => { 1264 if let Some(rings) = parse_polygon_coords(&geom["coordinates"]) { 1265 vec![rings] 1266 } else { 1267 continue; 1268 } 1269 } 1270 "MultiPolygon" => { 1271 let Some(coords) = geom["coordinates"].as_array() else { 1272 continue; 1273 }; 1274 coords.iter() 1275 .filter_map(|poly| parse_polygon_coords(poly)) 1276 .collect() 1277 } 1278 _ => continue, 1279 }; 1280 1281 // Create a boundary for each polygon in a MultiPolygon 1282 for rings in polygons { 1283 if rings.is_empty() || rings[0].is_empty() { 1284 continue; 1285 } 1286 1287 // Calculate bounding box from outer ring 1288 let outer = &rings[0]; 1289 let mut min_lng = f64::MAX; 1290 let mut min_lat = f64::MAX; 1291 let mut max_lng = f64::MIN; 1292 let mut max_lat = f64::MIN; 1293 1294 for &(lng, lat) in outer { 1295 min_lng = min_lng.min(lng); 1296 min_lat = min_lat.min(lat); 1297 max_lng = max_lng.max(lng); 1298 max_lat = max_lat.max(lat); 1299 } 1300 1301 boundaries.push(RegionBoundary { 1302 name: name.clone(), 1303 country: country.clone(), 1304 bbox: (min_lng, min_lat, max_lng, max_lat), 1305 rings, 1306 }); 1307 } 1308 } 1309 1310 boundaries 1311} 1312 1313/// Parse polygon coordinates from GeoJSON 1314fn parse_polygon_coords(coords: &serde_json::Value) -> Option<Vec<Vec<(f64, f64)>>> { 1315 let rings = coords.as_array()?; 1316 let mut result = Vec::with_capacity(rings.len()); 1317 1318 for ring in rings { 1319 let points = ring.as_array()?; 1320 let mut ring_coords = Vec::with_capacity(points.len()); 1321 1322 for point in points { 1323 let arr = point.as_array()?; 1324 let lng = arr.first()?.as_f64()?; 1325 let lat = arr.get(1)?.as_f64()?; 1326 ring_coords.push((lng, lat)); 1327 } 1328 1329 result.push(ring_coords); 1330 } 1331 1332 Some(result) 1333} 1334 1335/// Region lookup result 1336#[derive(Debug, Clone, uniffi::Record)] 1337pub struct Region { 1338 pub name: String, 1339 pub country: String, 1340} 1341 1342/// Find which region contains the given coordinates using point-in-polygon tests. 1343/// Returns None if the point is not within any known region (e.g., ocean, coastal areas). 1344#[uniffi::export] 1345pub fn find_region(lat: f64, lng: f64) -> Option<Region> { 1346 let boundaries = get_boundaries(); 1347 1348 for boundary in boundaries { 1349 // Fast bounding box rejection 1350 let (min_lng, min_lat, max_lng, max_lat) = boundary.bbox; 1351 if lng < min_lng || lng > max_lng || lat < min_lat || lat > max_lat { 1352 continue; 1353 } 1354 1355 // Point-in-polygon test 1356 if point_in_polygon(lng, lat, &boundary.rings) { 1357 return Some(Region { 1358 name: boundary.name.clone(), 1359 country: boundary.country.clone(), 1360 }); 1361 } 1362 } 1363 1364 None 1365} 1366 1367/// Ray casting algorithm for point-in-polygon test. 1368/// Handles polygons with holes (first ring is outer, rest are holes). 1369fn point_in_polygon(x: f64, y: f64, rings: &[Vec<(f64, f64)>]) -> bool { 1370 if rings.is_empty() { 1371 return false; 1372 } 1373 1374 // Must be inside outer ring 1375 if !point_in_ring(x, y, &rings[0]) { 1376 return false; 1377 } 1378 1379 // Must not be inside any holes 1380 for hole in rings.iter().skip(1) { 1381 if point_in_ring(x, y, hole) { 1382 return false; 1383 } 1384 } 1385 1386 true 1387} 1388 1389/// Ray casting for a single ring 1390fn point_in_ring(x: f64, y: f64, ring: &[(f64, f64)]) -> bool { 1391 let n = ring.len(); 1392 if n < 3 { 1393 return false; 1394 } 1395 1396 let mut inside = false; 1397 let mut j = n - 1; 1398 1399 for i in 0..n { 1400 let (xi, yi) = ring[i]; 1401 let (xj, yj) = ring[j]; 1402 1403 if ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi) { 1404 inside = !inside; 1405 } 1406 1407 j = i; 1408 } 1409 1410 inside 1411} 1412 1413/// Find the nearest city to the given coordinates, filtered by region. 1414/// First determines which region the coordinates are in, then only considers 1415/// cities in that region. Falls back to unfiltered search if no region match. 1416#[uniffi::export] 1417pub fn find_nearest_city_in_region(lat: f64, lng: f64) -> Option<City> { 1418 let cities = get_cities(); 1419 1420 // Try to find which region the point is in 1421 if let Some(region) = find_region(lat, lng) { 1422 // Filter cities to this region 1423 let regional_cities: Vec<&City> = cities 1424 .iter() 1425 .filter(|c| c.region == region.name && c.country == region.country) 1426 .collect(); 1427 1428 if !regional_cities.is_empty() { 1429 return regional_cities 1430 .into_iter() 1431 .min_by(|a, b| { 1432 let score_a = city_score(a, lat, lng); 1433 let score_b = city_score(b, lat, lng); 1434 score_a.partial_cmp(&score_b).unwrap_or(std::cmp::Ordering::Equal) 1435 }) 1436 .cloned(); 1437 } 1438 } 1439 1440 // Fallback: no region match or no cities in region, use global search 1441 find_nearest_city(lat, lng) 1442} 1443 1444#[cfg(test)] 1445mod tests { 1446 use super::*; 1447 1448 #[test] 1449 fn test_encrypt_decrypt_roundtrip() { 1450 let identity = generate_identity(); 1451 let location = Location { 1452 latitude: 37.7749, 1453 longitude: -122.4194, 1454 altitude: 50.0, 1455 accuracy: 10.0, 1456 timestamp: 1234567890, 1457 }; 1458 1459 // Encrypt for self only 1460 let my_ed_pubkey: [u8; 32] = identity.ed25519_public.clone().try_into().unwrap(); 1461 let blob = create_encrypted_blob(&location, &[my_ed_pubkey]).unwrap(); 1462 1463 // Decrypt 1464 let decrypted = decrypt_blob(&identity, blob).unwrap(); 1465 1466 assert_eq!(decrypted.latitude, location.latitude); 1467 assert_eq!(decrypted.longitude, location.longitude); 1468 assert_eq!(decrypted.accuracy, location.accuracy); 1469 assert_eq!(decrypted.timestamp, location.timestamp); 1470 } 1471 1472 #[test] 1473 fn test_multi_recipient_encryption() { 1474 let alice = generate_identity(); 1475 let bob = generate_identity(); 1476 1477 let location = Location { 1478 latitude: 40.7128, 1479 longitude: -74.0060, 1480 altitude: 10.0, 1481 accuracy: 15.0, 1482 timestamp: 9876543210, 1483 }; 1484 1485 // Encrypt for both Alice and Bob 1486 let alice_pubkey: [u8; 32] = alice.ed25519_public.clone().try_into().unwrap(); 1487 let bob_pubkey: [u8; 32] = bob.ed25519_public.clone().try_into().unwrap(); 1488 let blob = create_encrypted_blob(&location, &[alice_pubkey, bob_pubkey]).unwrap(); 1489 1490 // Both can decrypt 1491 let alice_decrypted = decrypt_blob(&alice, blob.clone()).unwrap(); 1492 let bob_decrypted = decrypt_blob(&bob, blob).unwrap(); 1493 1494 assert_eq!(alice_decrypted.latitude, location.latitude); 1495 assert_eq!(bob_decrypted.latitude, location.latitude); 1496 } 1497 1498 #[test] 1499 fn test_non_recipient_cannot_decrypt() { 1500 let alice = generate_identity(); 1501 let bob = generate_identity(); 1502 let eve = generate_identity(); 1503 1504 let location = Location { 1505 latitude: 51.5074, 1506 longitude: -0.1278, 1507 altitude: 25.0, 1508 accuracy: 20.0, 1509 timestamp: 1111111111, 1510 }; 1511 1512 // Encrypt for Alice and Bob only 1513 let alice_pubkey: [u8; 32] = alice.ed25519_public.clone().try_into().unwrap(); 1514 let bob_pubkey: [u8; 32] = bob.ed25519_public.clone().try_into().unwrap(); 1515 let blob = create_encrypted_blob(&location, &[alice_pubkey, bob_pubkey]).unwrap(); 1516 1517 // Eve cannot decrypt 1518 let result = decrypt_blob(&eve, blob); 1519 assert!(result.is_err()); 1520 } 1521 1522 #[test] 1523 fn test_friend_link_roundtrip() { 1524 let identity = generate_identity(); 1525 let server = "https://coord.is".to_string(); 1526 let name = "Alice".to_string(); 1527 1528 let link = generate_friend_link(&identity, server.clone(), name.clone()); 1529 let parsed = parse_friend_link(link).unwrap(); 1530 1531 assert_eq!(parsed.server, server); 1532 assert_eq!(parsed.name, name); 1533 1534 // Verify pubkey matches 1535 let expected_pubkey = URL_SAFE_NO_PAD.encode(&identity.ed25519_public); 1536 assert_eq!(parsed.pubkey, expected_pubkey); 1537 } 1538 1539 #[test] 1540 fn test_friend_link_with_special_characters() { 1541 let identity = generate_identity(); 1542 let server = "https://relay.example.com".to_string(); 1543 let name = "Bob & Alice's Friend".to_string(); 1544 1545 let link = generate_friend_link(&identity, server.clone(), name.clone()); 1546 let parsed = parse_friend_link(link).unwrap(); 1547 1548 assert_eq!(parsed.name, name); 1549 assert_eq!(parsed.server, server); 1550 } 1551 1552 #[test] 1553 fn test_friend_link_strips_protocol() { 1554 let identity = generate_identity(); 1555 1556 // With https:// 1557 let link1 = generate_friend_link(&identity, "https://example.com".to_string(), "Test".to_string()); 1558 assert!(link1.starts_with("coord://example.com/add/")); 1559 1560 // With http:// 1561 let link2 = generate_friend_link(&identity, "http://example.com".to_string(), "Test".to_string()); 1562 assert!(link2.starts_with("coord://example.com/add/")); 1563 1564 // Without protocol (shouldn't happen, but handle gracefully) 1565 let link3 = generate_friend_link(&identity, "example.com".to_string(), "Test".to_string()); 1566 assert!(link3.starts_with("coord://example.com/add/")); 1567 } 1568 1569 #[test] 1570 fn test_parse_invalid_links() { 1571 // Wrong protocol 1572 assert!(parse_friend_link("https://example.com/add/abc#Name".to_string()).is_err()); 1573 1574 // Missing fragment 1575 assert!(parse_friend_link("coord://example.com/add/abc".to_string()).is_err()); 1576 1577 // Missing /add/ 1578 assert!(parse_friend_link("coord://example.com/abc#Name".to_string()).is_err()); 1579 1580 // Empty pubkey 1581 assert!(parse_friend_link("coord://example.com/add/#Name".to_string()).is_err()); 1582 1583 // Empty server 1584 assert!(parse_friend_link("coord:///add/abc#Name".to_string()).is_err()); 1585 } 1586 1587 #[test] 1588 fn test_stale_location_rejected_when_storage_initialized() { 1589 // Set up a temporary storage directory 1590 let temp_dir = std::env::temp_dir().join(format!("transponder_test_{}", std::process::id())); 1591 let _ = fs::create_dir_all(&temp_dir); 1592 1593 // We can't easily test with init_storage since it uses OnceLock, 1594 // but we can test the atomic and file operations directly 1595 1596 // Test mark_upload_success updates the atomic 1597 LAST_UPLOADED_TIMESTAMP.store(0, Ordering::Relaxed); 1598 assert_eq!(LAST_UPLOADED_TIMESTAMP.load(Ordering::Relaxed), 0); 1599 1600 LAST_UPLOADED_TIMESTAMP.store(1000, Ordering::Relaxed); 1601 assert_eq!(LAST_UPLOADED_TIMESTAMP.load(Ordering::Relaxed), 1000); 1602 1603 // Clean up 1604 let _ = fs::remove_dir_all(&temp_dir); 1605 } 1606 1607 #[test] 1608 fn test_stale_location_skipped_when_storage_uninitialized() { 1609 // When STORAGE_PATH is not set, prepare_location_upload should NOT check timestamps 1610 // This test verifies the behavior without storage initialized 1611 1612 // Reset timestamp to simulate fresh state 1613 LAST_UPLOADED_TIMESTAMP.store(5000, Ordering::Relaxed); 1614 1615 let identity = generate_identity(); 1616 let location = Location { 1617 latitude: 37.7749, 1618 longitude: -122.4194, 1619 altitude: 0.0, 1620 accuracy: 10.0, 1621 timestamp: 1000, // Older than LAST_UPLOADED_TIMESTAMP 1622 }; 1623 1624 // If storage is NOT initialized (STORAGE_PATH.get() returns None), 1625 // prepare_location_upload should succeed even with old timestamp 1626 // Note: This test may behave differently if storage was already initialized 1627 // in another test (OnceLock limitation) 1628 if STORAGE_PATH.get().is_none() { 1629 let result = prepare_location_upload( 1630 &identity, 1631 location, 1632 vec![], 1633 "https://example.com".to_string(), 1634 ); 1635 // Should succeed because storage check is skipped 1636 assert!(result.is_ok()); 1637 } 1638 } 1639 1640 #[test] 1641 fn test_timestamp_comparison_logic() { 1642 // Test the comparison: timestamp <= last_ts should be rejected 1643 1644 // Equal timestamps should be rejected (already uploaded this exact location) 1645 LAST_UPLOADED_TIMESTAMP.store(1000, Ordering::Relaxed); 1646 let last = LAST_UPLOADED_TIMESTAMP.load(Ordering::Relaxed); 1647 assert!(1000u64 <= last); // Would be rejected 1648 assert!(999u64 <= last); // Would be rejected (older) 1649 assert!(!(1001u64 <= last)); // Would be accepted (newer) 1650 } 1651 1652 #[test] 1653 fn test_load_save_timestamp_helpers() { 1654 // Test the helper functions work correctly when storage path exists 1655 // Note: These functions gracefully handle missing storage path 1656 1657 let loaded = load_last_upload_timestamp(); 1658 // Should return 0 or existing value if storage is initialized 1659 assert!(loaded == 0 || loaded > 0); 1660 1661 // save_last_upload_timestamp silently does nothing if path not set 1662 save_last_upload_timestamp(12345); 1663 // Can't easily verify without storage initialized 1664 } 1665 1666 #[test] 1667 fn test_padded_entry_count() { 1668 // Power of 2 up to 64 1669 assert_eq!(padded_entry_count(0), 0); 1670 assert_eq!(padded_entry_count(1), 1); 1671 assert_eq!(padded_entry_count(2), 2); 1672 assert_eq!(padded_entry_count(3), 4); 1673 assert_eq!(padded_entry_count(4), 4); 1674 assert_eq!(padded_entry_count(5), 8); 1675 assert_eq!(padded_entry_count(8), 8); 1676 assert_eq!(padded_entry_count(9), 16); 1677 assert_eq!(padded_entry_count(17), 32); 1678 assert_eq!(padded_entry_count(33), 64); 1679 assert_eq!(padded_entry_count(64), 64); 1680 1681 // Nearest 50 above 64 1682 assert_eq!(padded_entry_count(65), 100); 1683 assert_eq!(padded_entry_count(100), 100); 1684 assert_eq!(padded_entry_count(101), 150); 1685 assert_eq!(padded_entry_count(150), 150); 1686 assert_eq!(padded_entry_count(151), 200); 1687 } 1688 1689 #[test] 1690 fn test_blob_version_byte() { 1691 let identity = generate_identity(); 1692 let location = Location { 1693 latitude: 37.7749, 1694 longitude: -122.4194, 1695 altitude: 100.0, 1696 accuracy: 10.0, 1697 timestamp: 1234567890, 1698 }; 1699 1700 let my_pubkey: [u8; 32] = identity.ed25519_public.clone().try_into().unwrap(); 1701 let blob = create_encrypted_blob(&location, &[my_pubkey]).unwrap(); 1702 1703 // First byte should be version 1704 assert_eq!(blob[0], BLOB_VERSION); 1705 1706 // Blob should work with decrypt 1707 let decrypted = decrypt_blob(&identity, blob).unwrap(); 1708 assert_eq!(decrypted.latitude, location.latitude); 1709 } 1710 1711 #[test] 1712 fn test_wire_format_roundtrip() { 1713 let location = Location { 1714 latitude: 37.7749295, // 7 decimal places 1715 longitude: -122.4194155, 1716 altitude: 123.0, 1717 accuracy: 15.5, 1718 timestamp: 1234567890123, 1719 }; 1720 1721 let wire = WireLocation::from_location(&location); 1722 let decoded = wire.to_location(); 1723 1724 // Microdegrees gives ~11cm precision, so 6 decimal places should match 1725 assert!((decoded.latitude - 37.774929).abs() < 0.000001); 1726 assert!((decoded.longitude - (-122.419415)).abs() < 0.000001); 1727 assert_eq!(decoded.altitude, 123.0); 1728 assert_eq!(decoded.accuracy, 15.0); // u16 truncates fractional part 1729 assert_eq!(decoded.timestamp, location.timestamp); 1730 } 1731 1732 #[test] 1733 fn test_wire_format_binary_size() { 1734 let location = Location { 1735 latitude: 0.0, 1736 longitude: 0.0, 1737 altitude: 0.0, 1738 accuracy: 0.0, 1739 timestamp: 0, 1740 }; 1741 1742 let wire = WireLocation::from_location(&location); 1743 let encoded = wire.encode(); 1744 1745 // Wire format should be exactly 20 bytes 1746 assert_eq!(encoded.len(), 20); 1747 } 1748 1749 #[test] 1750 fn test_blob_padding_applied() { 1751 let identity = generate_identity(); 1752 let location = Location { 1753 latitude: 37.7749, 1754 longitude: -122.4194, 1755 altitude: 50.0, 1756 accuracy: 10.0, 1757 timestamp: 1234567890, 1758 }; 1759 1760 // With 3 recipients, should pad to 4 entries 1761 let pubkey: [u8; 32] = identity.ed25519_public.clone().try_into().unwrap(); 1762 let blob = create_encrypted_blob(&location, &[pubkey, pubkey, pubkey]).unwrap(); 1763 1764 // Entry count at bytes 33-35 should be 4 (padded from 3) 1765 let entry_count = u16::from_be_bytes([blob[33], blob[34]]) as usize; 1766 assert_eq!(entry_count, 4); 1767 1768 // Decryption should still work 1769 let decrypted = decrypt_blob(&identity, blob).unwrap(); 1770 assert_eq!(decrypted.latitude, location.latitude); 1771 } 1772}