identity_storage.rs (21459B)
1 use std::ffi::OsString; 2 use std::fs::{self, OpenOptions}; 3 use std::io::Write; 4 use std::path::{Path, PathBuf}; 5 6 use anyhow::{Context, Result, bail}; 7 use chacha20poly1305::aead::{Aead, KeyInit, Payload}; 8 use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; 9 use nostr::{Keys, SecretKey}; 10 use radroots_secrets::context::{ 11 EnvelopeContext, EnvelopePurpose, EnvelopeSubject, PayloadSchemaId, 12 }; 13 use radroots_secrets::envelope::{ 14 ENVELOPE_VERSION, LEGACY_ENVELOPE_VERSION, LegacyV1ResealAuthority, Nonce, SealMaterial, 15 SealRequest, 16 }; 17 use radroots_secrets::error::Operation; 18 use radroots_secrets::id::{BackendKind, KeyVersion}; 19 use radroots_secrets::wrapping::{ 20 BoxFuture, LegacyV1UnwrapRequest, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret, 21 }; 22 use radroots_secrets::{EncryptedEnvelope, KeyWrapping, SecretId, SecretRef}; 23 use serde::{Deserialize, Serialize}; 24 use zeroize::Zeroize; 25 26 const RADROOTSD_IDENTITY_KEY_SLOT: &str = "radrootsd_identity"; 27 const WRAPPING_KEY_BYTES: usize = 32; 28 const WRAPPING_NONCE_BYTES: usize = 24; 29 const LEGACY_WRAPPED_KEY_VERSION: u8 = 1; 30 const WRAPPED_KEY_VERSION: u8 = 2; 31 const WRAPPING_AAD_DOMAIN: &[u8] = b"radrootsd.wrapped_data_key.v2"; 32 33 struct DaemonFileKeyWrapping { 34 key_path: PathBuf, 35 } 36 37 impl DaemonFileKeyWrapping { 38 fn new(identity_path: &Path) -> Self { 39 Self { 40 key_path: encrypted_identity_key_path(identity_path), 41 } 42 } 43 44 fn load_or_create_key(&self) -> Result<[u8; WRAPPING_KEY_BYTES], radroots_secrets::Error> { 45 if let Ok(raw) = fs::read(&self.key_path) { 46 return key_from_bytes(raw.as_slice()); 47 } 48 if let Some(parent) = self 49 .key_path 50 .parent() 51 .filter(|path| !path.as_os_str().is_empty()) 52 { 53 fs::create_dir_all(parent).map_err(|_| radroots_secrets::Error::BackendFailure { 54 backend: BackendKind::External, 55 operation: radroots_secrets::error::Operation::Provision, 56 })?; 57 } 58 let key: [u8; WRAPPING_KEY_BYTES] = rand::random(); 59 match OpenOptions::new() 60 .write(true) 61 .create_new(true) 62 .open(&self.key_path) 63 { 64 Ok(mut file) => { 65 file.write_all(&key) 66 .map_err(|_| secret_backend_failure(Operation::Write))?; 67 file.sync_all() 68 .map_err(|_| secret_backend_failure(Operation::Write))?; 69 set_secret_permissions(&self.key_path) 70 .map_err(|_| secret_backend_failure(Operation::Write))?; 71 Ok(key) 72 } 73 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { 74 let raw = fs::read(&self.key_path) 75 .map_err(|_| secret_backend_failure(Operation::Read))?; 76 key_from_bytes(raw.as_slice()) 77 } 78 Err(_) => Err(secret_backend_failure(Operation::Provision)), 79 } 80 } 81 82 fn load_key(&self) -> Result<[u8; WRAPPING_KEY_BYTES], radroots_secrets::Error> { 83 let raw = fs::read(&self.key_path).map_err(|_| secret_backend_failure(Operation::Read))?; 84 key_from_bytes(raw.as_slice()) 85 } 86 } 87 88 impl KeyWrapping for DaemonFileKeyWrapping { 89 fn wrap<'a>( 90 &'a self, 91 request: WrapRequest<'a>, 92 ) -> BoxFuture<'a, Result<WrappedSecret, radroots_secrets::Error>> { 93 Box::pin(async move { 94 validate_daemon_reference(request.reference(), Operation::Wrap)?; 95 let mut key = self.load_or_create_key()?; 96 let nonce: [u8; WRAPPING_NONCE_BYTES] = rand::random(); 97 let aad = wrapping_aad(request.reference(), request.context()); 98 let ciphertext = request.plaintext().expose_secret(|plaintext| { 99 XChaCha20Poly1305::new(Key::from_slice(&key)).encrypt( 100 XNonce::from_slice(&nonce), 101 Payload { 102 msg: plaintext, 103 aad: aad.as_slice(), 104 }, 105 ) 106 }); 107 key.zeroize(); 108 let ciphertext = ciphertext.map_err(|_| secret_backend_failure(Operation::Wrap))?; 109 let mut wrapped = Vec::with_capacity(1 + nonce.len() + ciphertext.len()); 110 wrapped.push(WRAPPED_KEY_VERSION); 111 wrapped.extend_from_slice(&nonce); 112 wrapped.extend_from_slice(ciphertext.as_slice()); 113 WrappedSecret::from_bytes(wrapped) 114 }) 115 } 116 117 fn unwrap<'a>( 118 &'a self, 119 request: UnwrapRequest<'a>, 120 ) -> BoxFuture<'a, Result<SecretMaterial, radroots_secrets::Error>> { 121 Box::pin(async move { 122 validate_daemon_reference(request.reference(), Operation::Unwrap)?; 123 let aad = wrapping_aad(request.reference(), request.context()); 124 self.unwrap_with_aad(request.wrapped(), WRAPPED_KEY_VERSION, aad.as_slice()) 125 }) 126 } 127 128 fn unwrap_legacy_v1<'a>( 129 &'a self, 130 request: LegacyV1UnwrapRequest<'a>, 131 ) -> BoxFuture<'a, Result<SecretMaterial, radroots_secrets::Error>> { 132 Box::pin(async move { 133 validate_daemon_reference(request.reference(), Operation::Unwrap)?; 134 self.unwrap_with_aad( 135 request.wrapped(), 136 LEGACY_WRAPPED_KEY_VERSION, 137 request.reference().id().as_str().as_bytes(), 138 ) 139 }) 140 } 141 } 142 143 impl DaemonFileKeyWrapping { 144 fn unwrap_with_aad( 145 &self, 146 wrapped: &WrappedSecret, 147 expected_version: u8, 148 aad: &[u8], 149 ) -> Result<SecretMaterial, radroots_secrets::Error> { 150 let wrapped = wrapped.as_bytes(); 151 if wrapped.len() <= 1 + WRAPPING_NONCE_BYTES || wrapped[0] != expected_version { 152 return Err(secret_backend_failure(Operation::Unwrap)); 153 } 154 let mut key = self.load_key()?; 155 let plaintext = XChaCha20Poly1305::new(Key::from_slice(&key)).decrypt( 156 XNonce::from_slice(&wrapped[1..1 + WRAPPING_NONCE_BYTES]), 157 Payload { 158 msg: &wrapped[1 + WRAPPING_NONCE_BYTES..], 159 aad, 160 }, 161 ); 162 key.zeroize(); 163 SecretMaterial::from_slice( 164 &plaintext.map_err(|_| secret_backend_failure(Operation::Unwrap))?, 165 ) 166 } 167 } 168 169 fn wrapping_aad(reference: &SecretRef, context: &EnvelopeContext) -> Vec<u8> { 170 let id = reference.id().as_str().as_bytes(); 171 let mut aad = Vec::with_capacity(WRAPPING_AAD_DOMAIN.len() + 2 + id.len() + 4 + 32); 172 aad.extend_from_slice(WRAPPING_AAD_DOMAIN); 173 aad.extend_from_slice( 174 &u16::try_from(id.len()) 175 .expect("validated secret identifier length fits u16") 176 .to_be_bytes(), 177 ); 178 aad.extend_from_slice(id); 179 aad.extend_from_slice(&reference.key_version().get().to_be_bytes()); 180 aad.extend_from_slice(&context.authentication_digest()); 181 aad 182 } 183 184 fn validate_daemon_reference( 185 reference: &SecretRef, 186 operation: Operation, 187 ) -> Result<(), radroots_secrets::Error> { 188 if reference.backend() != BackendKind::External 189 || reference.key_version().get() != 1 190 || reference.id().as_str() != RADROOTSD_IDENTITY_KEY_SLOT 191 { 192 return Err(secret_backend_failure(operation)); 193 } 194 Ok(()) 195 } 196 197 fn identity_secret_ref() -> Result<SecretRef, radroots_secrets::Error> { 198 Ok(SecretRef::new( 199 SecretId::parse(RADROOTSD_IDENTITY_KEY_SLOT)?, 200 BackendKind::External, 201 KeyVersion::new(1)?, 202 )) 203 } 204 205 fn identity_envelope_context() -> Result<EnvelopeContext, radroots_secrets::Error> { 206 Ok(EnvelopeContext::new( 207 EnvelopePurpose::parse("radroots.service_identity")?, 208 EnvelopeSubject::parse("service", "radrootsd")?, 209 PayloadSchemaId::parse("radroots.daemon_identity.v1")?, 210 )) 211 } 212 213 fn secret_backend_failure(operation: Operation) -> radroots_secrets::Error { 214 radroots_secrets::Error::BackendFailure { 215 backend: BackendKind::External, 216 operation, 217 } 218 } 219 220 fn key_from_bytes(raw: &[u8]) -> Result<[u8; WRAPPING_KEY_BYTES], radroots_secrets::Error> { 221 raw.try_into() 222 .map_err(|_| secret_backend_failure(Operation::Read)) 223 } 224 225 /// Host-private service signing identity. 226 /// 227 /// Public identity values cross package boundaries through 228 /// `radroots_identity`; secret-key generation and custody remain daemon-owned. 229 #[derive(Clone)] 230 pub(crate) struct DaemonIdentity { 231 keys: Keys, 232 } 233 234 impl core::fmt::Debug for DaemonIdentity { 235 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 236 formatter 237 .debug_struct("DaemonIdentity") 238 .field("public_key", &self.public_key_hex()) 239 .finish_non_exhaustive() 240 } 241 } 242 243 #[derive(Deserialize, Serialize)] 244 #[serde(deny_unknown_fields)] 245 struct DaemonIdentityFile { 246 secret_key: String, 247 #[serde(skip_serializing_if = "Option::is_none")] 248 public_key: Option<String>, 249 #[serde(skip_serializing_if = "Option::is_none")] 250 identifier: Option<String>, 251 #[serde(skip_serializing_if = "Option::is_none")] 252 metadata: Option<nostr::Event>, 253 #[serde(skip_serializing_if = "Option::is_none")] 254 application_handler: Option<nostr::Event>, 255 } 256 257 impl DaemonIdentity { 258 pub(crate) fn generate() -> Self { 259 Self { 260 keys: Keys::generate(), 261 } 262 } 263 264 pub(crate) const fn keys(&self) -> &Keys { 265 &self.keys 266 } 267 268 pub(crate) fn public_key(&self) -> nostr::PublicKey { 269 self.keys.public_key() 270 } 271 272 pub(crate) fn public_key_hex(&self) -> String { 273 self.public_key().to_hex() 274 } 275 276 #[cfg(test)] 277 pub(crate) fn id(&self) -> String { 278 self.public_key_hex() 279 } 280 281 fn to_file(&self) -> DaemonIdentityFile { 282 DaemonIdentityFile { 283 secret_key: self.keys.secret_key().to_secret_hex(), 284 public_key: Some(self.public_key_hex()), 285 identifier: None, 286 metadata: None, 287 application_handler: None, 288 } 289 } 290 291 fn from_file(file: DaemonIdentityFile) -> Result<Self> { 292 let secret_key = SecretKey::parse(file.secret_key.as_str()) 293 .map_err(|_| anyhow::anyhow!("invalid daemon identity secret"))?; 294 let identity = Self { 295 keys: Keys::new(secret_key), 296 }; 297 if file 298 .public_key 299 .as_deref() 300 .is_some_and(|expected| expected != identity.public_key_hex()) 301 { 302 bail!("daemon identity public key does not match encrypted secret"); 303 } 304 Ok(identity) 305 } 306 } 307 308 pub fn encrypted_identity_key_path(path: impl AsRef<Path>) -> PathBuf { 309 let mut value = OsString::from(path.as_ref().as_os_str()); 310 value.push(".key"); 311 PathBuf::from(value) 312 } 313 314 pub fn load_service_identity(path: Option<&Path>, allow_generate: bool) -> Result<DaemonIdentity> { 315 let path = resolved_identity_path(path); 316 if path.exists() { 317 return load_encrypted_identity(&path); 318 } 319 if !allow_generate { 320 bail!( 321 "daemon identity generation is not allowed at {}", 322 path.display() 323 ); 324 } 325 326 let identity = DaemonIdentity::generate(); 327 store_encrypted_identity(&path, &identity)?; 328 Ok(identity) 329 } 330 331 pub fn store_encrypted_identity(path: impl AsRef<Path>, identity: &DaemonIdentity) -> Result<()> { 332 let path = path.as_ref(); 333 if let Some(parent) = path.parent().filter(|path| !path.as_os_str().is_empty()) { 334 fs::create_dir_all(parent)?; 335 } 336 let payload = serde_json::to_vec(&identity.to_file())?; 337 let plaintext = SecretMaterial::from_slice(payload.as_slice())?; 338 let data_key = SecretMaterial::from_slice(&rand::random::<[u8; 32]>())?; 339 let nonce = Nonce::new(rand::random()); 340 let wrapping = DaemonFileKeyWrapping::new(path); 341 let context = identity_envelope_context()?; 342 let envelope = futures_executor::block_on(EncryptedEnvelope::seal( 343 &wrapping, 344 SealRequest::new( 345 identity_secret_ref()?, 346 context, 347 &plaintext, 348 SealMaterial::new(data_key, nonce), 349 ), 350 ))?; 351 persist_envelope(path, envelope.encode()?.as_slice()) 352 } 353 354 fn persist_envelope(path: &Path, encoded: &[u8]) -> Result<()> { 355 let mut temporary = tempfile::NamedTempFile::new_in( 356 path.parent() 357 .filter(|parent| !parent.as_os_str().is_empty()) 358 .unwrap_or(Path::new(".")), 359 )?; 360 temporary.write_all(encoded)?; 361 temporary.as_file().sync_all()?; 362 set_file_permissions(temporary.as_file())?; 363 temporary.persist(path)?; 364 sync_parent(path)?; 365 Ok(()) 366 } 367 368 pub fn load_encrypted_identity(path: impl AsRef<Path>) -> Result<DaemonIdentity> { 369 let path = path.as_ref(); 370 let encoded = fs::read(path)?; 371 let envelope = EncryptedEnvelope::decode(encoded.as_slice()) 372 .context("decode encrypted daemon identity")?; 373 let wrapping = DaemonFileKeyWrapping::new(path); 374 let context = identity_envelope_context()?; 375 if envelope.version() == LEGACY_ENVELOPE_VERSION { 376 return migrate_legacy_identity(path, envelope, &wrapping, context); 377 } 378 if envelope.version() != ENVELOPE_VERSION { 379 bail!("unsupported encrypted daemon identity version"); 380 } 381 open_identity(&envelope, &wrapping, &context) 382 } 383 384 fn open_identity( 385 envelope: &EncryptedEnvelope, 386 wrapping: &DaemonFileKeyWrapping, 387 context: &EnvelopeContext, 388 ) -> Result<DaemonIdentity> { 389 let payload = futures_executor::block_on(envelope.open(wrapping, context)) 390 .context("open encrypted daemon identity")?; 391 let file: DaemonIdentityFile = payload.expose_secret(|bytes| serde_json::from_slice(bytes))?; 392 DaemonIdentity::from_file(file) 393 } 394 395 fn migrate_legacy_identity( 396 path: &Path, 397 envelope: EncryptedEnvelope, 398 wrapping: &DaemonFileKeyWrapping, 399 context: EnvelopeContext, 400 ) -> Result<DaemonIdentity> { 401 let expected_reference = identity_secret_ref()?; 402 let resealed = futures_executor::block_on(envelope.reseal_legacy_v1( 403 wrapping, 404 &LegacyV1ResealAuthority::new(), 405 &expected_reference, 406 identity_secret_ref()?, 407 context.clone(), 408 &valid_identity_payload, 409 SealMaterial::new( 410 SecretMaterial::from_slice(&rand::random::<[u8; 32]>())?, 411 Nonce::new(rand::random()), 412 ), 413 )) 414 .context("migrate legacy encrypted daemon identity")?; 415 let envelope = resealed.into_envelope(); 416 let identity = open_identity(&envelope, wrapping, &context)?; 417 persist_envelope(path, envelope.encode()?.as_slice())?; 418 Ok(identity) 419 } 420 421 fn valid_identity_payload(bytes: &[u8]) -> bool { 422 serde_json::from_slice::<DaemonIdentityFile>(bytes) 423 .ok() 424 .and_then(|file| DaemonIdentity::from_file(file).ok()) 425 .is_some() 426 } 427 428 #[cfg(unix)] 429 fn set_secret_permissions(path: &Path) -> std::io::Result<()> { 430 use std::os::unix::fs::PermissionsExt; 431 fs::set_permissions(path, fs::Permissions::from_mode(0o600)) 432 } 433 434 #[cfg(not(unix))] 435 fn set_secret_permissions(_path: &Path) -> std::io::Result<()> { 436 Ok(()) 437 } 438 439 fn set_file_permissions(file: &fs::File) -> std::io::Result<()> { 440 #[cfg(unix)] 441 { 442 use std::os::unix::fs::PermissionsExt; 443 file.set_permissions(fs::Permissions::from_mode(0o600)) 444 } 445 #[cfg(not(unix))] 446 { 447 let _ = file; 448 Ok(()) 449 } 450 } 451 452 fn sync_parent(path: &Path) -> std::io::Result<()> { 453 let parent = path 454 .parent() 455 .filter(|parent| !parent.as_os_str().is_empty()) 456 .unwrap_or(Path::new(".")); 457 fs::File::open(parent)?.sync_all() 458 } 459 460 fn resolved_identity_path(path: Option<&Path>) -> PathBuf { 461 path.map(Path::to_path_buf).unwrap_or_else(|| { 462 crate::app::paths::default_identity_path_for_process() 463 .expect("resolve canonical radrootsd identity path") 464 }) 465 } 466 467 #[cfg(test)] 468 mod tests { 469 use std::fs; 470 471 use chacha20poly1305::aead::{Aead, KeyInit, Payload}; 472 use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; 473 use radroots_secrets::EncryptedEnvelope; 474 use radroots_secrets::envelope::{ENVELOPE_VERSION, LEGACY_ENVELOPE_VERSION}; 475 476 use super::{ 477 DaemonIdentity, RADROOTSD_IDENTITY_KEY_SLOT, encrypted_identity_key_path, 478 identity_envelope_context, load_service_identity, set_secret_permissions, 479 }; 480 481 #[test] 482 fn load_service_identity_generates_encrypted_identity_artifacts() { 483 let temp = tempfile::tempdir().expect("tempdir"); 484 let path = temp.path().join("radrootsd-identity.secret.json"); 485 486 let generated = 487 load_service_identity(Some(&path), true).expect("generate encrypted identity"); 488 let loaded = load_service_identity(Some(&path), false).expect("load encrypted identity"); 489 490 assert_eq!(generated.id(), loaded.id()); 491 assert!(path.is_file()); 492 assert!(encrypted_identity_key_path(&path).is_file()); 493 let envelope = EncryptedEnvelope::decode(&fs::read(&path).expect("read envelope")) 494 .expect("decode envelope"); 495 assert_eq!(envelope.version(), ENVELOPE_VERSION); 496 assert_eq!( 497 envelope.context(), 498 Some(&identity_envelope_context().expect("identity context")) 499 ); 500 } 501 502 #[test] 503 fn load_service_identity_fails_when_wrapping_key_is_missing() { 504 let temp = tempfile::tempdir().expect("tempdir"); 505 let path = temp.path().join("radrootsd-identity.secret.json"); 506 let _ = load_service_identity(Some(&path), true).expect("generate encrypted identity"); 507 std::fs::remove_file(encrypted_identity_key_path(&path)).expect("remove wrapping key"); 508 509 let err = load_service_identity(Some(&path), false) 510 .expect_err("missing wrapping key should fail"); 511 assert!(err.to_string().contains("identity")); 512 } 513 514 #[test] 515 fn load_service_identity_atomically_migrates_legacy_v1() { 516 let temp = tempfile::tempdir().expect("tempdir"); 517 let path = temp.path().join("radrootsd-identity.secret.json"); 518 let expected = DaemonIdentity::generate(); 519 store_legacy_identity(&path, &expected); 520 521 let loaded = load_service_identity(Some(&path), false).expect("migrate legacy identity"); 522 assert_eq!(loaded.id(), expected.id()); 523 524 let encoded = fs::read(&path).expect("read migrated identity"); 525 let migrated = EncryptedEnvelope::decode(&encoded).expect("decode migrated identity"); 526 assert_eq!(migrated.version(), ENVELOPE_VERSION); 527 assert_eq!( 528 migrated.context(), 529 Some(&identity_envelope_context().expect("identity context")) 530 ); 531 let reloaded = load_service_identity(Some(&path), false).expect("reload migrated identity"); 532 assert_eq!(reloaded.id(), expected.id()); 533 } 534 535 fn store_legacy_identity(path: &std::path::Path, identity: &DaemonIdentity) { 536 const NONCE_BYTES: usize = 24; 537 const TAG_BYTES: usize = 16; 538 539 let wrapping_key = [0x11; 32]; 540 let data_key = [0x22; 32]; 541 let wrapping_nonce = [0x33; NONCE_BYTES]; 542 let envelope_nonce = [0x44; NONCE_BYTES]; 543 let payload = serde_json::to_vec(&identity.to_file()).expect("encode identity payload"); 544 545 let wrapped_ciphertext = XChaCha20Poly1305::new(Key::from_slice(&wrapping_key)) 546 .encrypt( 547 XNonce::from_slice(&wrapping_nonce), 548 Payload { 549 msg: &data_key, 550 aad: RADROOTSD_IDENTITY_KEY_SLOT.as_bytes(), 551 }, 552 ) 553 .expect("wrap legacy data key"); 554 let mut wrapped = Vec::with_capacity(1 + NONCE_BYTES + wrapped_ciphertext.len()); 555 wrapped.push(1); 556 wrapped.extend_from_slice(&wrapping_nonce); 557 wrapped.extend_from_slice(&wrapped_ciphertext); 558 559 let id = RADROOTSD_IDENTITY_KEY_SLOT.as_bytes(); 560 let ciphertext_len = u32::try_from(payload.len() + TAG_BYTES).expect("ciphertext length"); 561 let mut encoded = Vec::new(); 562 encoded.extend_from_slice(b"RRS1"); 563 encoded.extend_from_slice(&LEGACY_ENVELOPE_VERSION.to_be_bytes()); 564 encoded.push(1); // XChaCha20-Poly1305 565 encoded.push(1); // provider-wrapped key source 566 encoded.push(4); // external backend 567 encoded.extend_from_slice(&1_u32.to_be_bytes()); 568 encoded.extend_from_slice(&u16::try_from(id.len()).expect("id length").to_be_bytes()); 569 encoded.extend_from_slice(id); 570 encoded.extend_from_slice(&envelope_nonce); 571 encoded.extend_from_slice( 572 &u32::try_from(wrapped.len()) 573 .expect("wrapped length") 574 .to_be_bytes(), 575 ); 576 encoded.extend_from_slice(&wrapped); 577 encoded.extend_from_slice(&ciphertext_len.to_be_bytes()); 578 let ciphertext = XChaCha20Poly1305::new(Key::from_slice(&data_key)) 579 .encrypt( 580 XNonce::from_slice(&envelope_nonce), 581 Payload { 582 msg: &payload, 583 aad: &encoded, 584 }, 585 ) 586 .expect("encrypt legacy payload"); 587 encoded.extend_from_slice(&ciphertext); 588 589 fs::write(path, encoded).expect("write legacy envelope"); 590 let key_path = encrypted_identity_key_path(path); 591 fs::write(&key_path, wrapping_key).expect("write wrapping key"); 592 set_secret_permissions(&key_path).expect("secure wrapping key"); 593 } 594 }