lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 122d089ddbc09269dd79e59a811bb531a163d4b4
parent f67175bcf8d08464f354606c2fa3227d8ba1dffa
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 18:34:56 +0000

identity: remove secret-key ownership and generation

- Delete secret-bearing identity types, key generation, exports, and tests.
- Remove Nostr, NIP-49, secrecy, and zeroize package edges.
- Retain only a transitional public-profile file helper for Step 039.
- Document final signing, Nostr key, secrets, and storage owners.

Diffstat:
MCargo.lock | 3---
Mcontracts/releases/api_boundaries.toml | 41-----------------------------------------
Mcrates/identity/Cargo.toml | 9+--------
Mcrates/identity/README.md | 20++++++++++----------
Mcrates/identity/src/error.rs | 37++-----------------------------------
Dcrates/identity/src/identity.rs | 552-------------------------------------------------------------------------------
Mcrates/identity/src/lib.rs | 23+++--------------------
Mcrates/identity/src/profile.rs | 18++++++++++++++++++
Mcrates/identity/src/storage.rs | 664++++++-------------------------------------------------------------------------
Dcrates/identity/src/test_fixtures.rs | 107-------------------------------------------------------------------------------
Dcrates/identity/tests/identity.rs | 1017-------------------------------------------------------------------------------
Adocs/migration/identity.md | 24++++++++++++++++++++++++
12 files changed, 102 insertions(+), 2413 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4558,18 +4558,15 @@ name = "radroots-identity" version = "0.1.0" dependencies = [ "k256", - "nostr", "radroots_protected_store", "radroots_runtime", "radroots_runtime_paths", "radroots_secret_vault", - "secrecy", "serde", "serde_json", "tempfile", "thiserror 2.0.18", "tracing", - "zeroize", ] [[package]] diff --git a/contracts/releases/api_boundaries.toml b/contracts/releases/api_boundaries.toml @@ -98,47 +98,6 @@ allowed_public_paths = [] name = "radroots" allowed_public_paths = [] [[exception]] -id = "RCRV1-API-001" -package = "radroots-identity" -source = "src/error.rs" -forbidden_path = "nostr" -items = ["error::IdentityError"] -observed_paths = ["nostr::key::Error"] -adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" -removal_step = 42 -rationale = "The legacy identity error still exposes a Nostr key error until the identity package conformance refactor." - -[[exception]] -id = "RCRV1-API-002" -package = "radroots-identity" -source = "src/identity.rs" -forbidden_path = "nostr" -items = [ - "identity::RadrootsIdentity::from", - "identity::RadrootsIdentity::into_keys", - "identity::RadrootsIdentity::keys", - "identity::RadrootsIdentity::new", - "identity::RadrootsIdentity::public_key", - "identity::RadrootsIdentity::secret_key_bytes", - "identity::RadrootsIdentity::secret_key_bytes_zeroizing", - "identity::RadrootsIdentity::with_profile", - "identity::RadrootsIdentityFile", - "identity::RadrootsIdentityId::from", - "identity::RadrootsIdentityId::from_public_key", - "identity::RadrootsIdentityProfile", - "identity::RadrootsIdentityPublic::new", -] -observed_paths = [ - "nostr::Event", - "nostr::Keys", - "nostr::PublicKey", - "nostr::SecretKey::LEN", -] -adr = "docs/decisions/0001-public-api-leakage-migration-baseline.md" -removal_step = 42 -rationale = "Legacy identity values retain exact Nostr and secret-key signatures only until the public-only identity refactor and conformance gate." - -[[exception]] id = "RCRV1-API-003" package = "radroots-nostr" source = "src/client.rs" diff --git a/crates/identity/Cargo.toml b/crates/identity/Cargo.toml @@ -16,9 +16,8 @@ readme = "README.md" name = "radroots_identity" [features] -default = ["std", "serde", "json-file", "nip49"] +default = ["std", "serde", "json-file"] std = [ - "dep:nostr", "dep:radroots_protected_store", "dep:radroots_runtime_paths", "dep:radroots_secret_vault", @@ -28,9 +27,6 @@ std = [ ] serde = ["dep:serde"] json-file = ["std", "serde", "dep:radroots_runtime"] -nip49 = ["std", "serde", "nostr/nip49"] -secrecy = ["std", "serde", "dep:secrecy"] -zeroize = ["std", "serde", "dep:zeroize"] [dependencies] k256 = { version = "0.13", default-features = false, features = ["arithmetic"] } @@ -42,13 +38,10 @@ radroots_runtime_paths = { workspace = true, optional = true } radroots_secret_vault = { workspace = true, optional = true, features = [ "std", ] } -nostr = { workspace = true, optional = true } -secrecy = { workspace = true, optional = true } serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } thiserror = { version = "2", default-features = false } tracing = { workspace = true, optional = true } -zeroize = { workspace = true, optional = true } [dev-dependencies] serde_json = { workspace = true, features = ["std"] } diff --git a/crates/identity/README.md b/crates/identity/README.md @@ -1,18 +1,18 @@ # radroots-identity -This is the README for `radroots_identity`, which provides identity models, -encrypted file storage, and profile utilities for the `radroots` core -libraries. +This is the README for `radroots_identity`, which provides public identity, +account, and profile value types for the Radroots package family. ## Overview - * public and private identity, profile, file, and identifier types; - * username validation, normalization, and parser helpers; - * default identity path constants and JSON file support behind feature flags; - * encrypted identity-file and public-profile storage helpers for local runtime - consumers; - * optional NIP-49, `secrecy`, and `zeroize` support for protected key - material. + * validated canonical public keys, identity IDs, and account IDs; + * public identity profiles and normalized usernames; + * no raw secret keys, key generation, nsec/NIP-49 helpers, or secret export; + * transitional public-profile JSON file helpers pending their extraction to + the host storage layer. + +See `docs/migration/identity.md` in the repository for the approved signing, +Nostr-key, secrets, and storage ownership boundaries. ## Copyright diff --git a/crates/identity/src/error.rs b/crates/identity/src/error.rs @@ -3,7 +3,7 @@ use thiserror::Error; #[cfg(all(feature = "std", feature = "json-file"))] use radroots_runtime::RuntimeJsonError; #[cfg(feature = "std")] -use std::{io, path::PathBuf, string::String}; +use std::{io, path::PathBuf}; /// Errors produced while validating public identity values. #[non_exhaustive] @@ -40,19 +40,13 @@ pub enum Error { InvalidUsernameDotPlacement, } -/// Transitional errors from the legacy secret and filesystem identity API. +/// Transitional errors from the legacy filesystem identity API. #[cfg(feature = "std")] #[derive(Debug, Error)] pub enum IdentityError { #[error("identity file missing at {0}")] NotFound(PathBuf), - #[error( - "identity file missing at {0} and generation is not permitted \ - (pass --allow-generate-identity)" - )] - GenerationNotAllowed(PathBuf), - #[error("failed to read identity file at {0}: {1}")] Read(PathBuf, #[source] io::Error), @@ -65,37 +59,10 @@ pub enum IdentityError { #[error("invalid identity JSON: {0}")] InvalidJson(#[from] serde_json::Error), - #[error("invalid secret key: {0}")] - InvalidSecretKey(#[from] nostr::key::Error), - - #[cfg(feature = "nip49")] - #[error("failed to encrypt secret key: {0}")] - EncryptSecretKey(String), - - #[cfg(feature = "nip49")] - #[error("invalid encrypted secret key: {0}")] - InvalidEncryptedSecretKey(String), - - #[cfg(feature = "nip49")] - #[error("failed to decrypt encrypted secret key: {0}")] - DecryptEncryptedSecretKey(String), - - #[error("invalid public key: {0}")] - InvalidPublicKey(String), - - #[error("public key does not match secret key")] - PublicKeyMismatch, - - #[error("unsupported identity file format")] - InvalidIdentityFormat, - #[cfg(feature = "json-file")] #[error(transparent)] Store(#[from] RuntimeJsonError), #[error(transparent)] Paths(#[from] radroots_runtime_paths::RadrootsRuntimePathsError), - - #[error("protected identity storage error at {path}: {message}")] - ProtectedStorage { path: PathBuf, message: String }, } diff --git a/crates/identity/src/identity.rs b/crates/identity/src/identity.rs @@ -1,552 +0,0 @@ -use crate::error::IdentityError; -use core::convert::Infallible; -use core::fmt; -use nostr::{Keys, SecretKey}; -#[cfg(feature = "nip49")] -use nostr::{ - nips::nip19::{FromBech32, ToBech32}, - nips::nip49::{EncryptedSecretKey, KeySecurity}, -}; -use serde::{Deserialize, Serialize}; - -#[cfg(not(feature = "std"))] -use alloc::string::String; -#[cfg(all(feature = "std", feature = "json-file"))] -use radroots_runtime::JsonFile; -#[cfg(feature = "std")] -use radroots_runtime_paths::{ - RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, default_shared_identity_path, -}; -#[cfg(feature = "std")] -use std::{ - fs, - path::{Path, PathBuf}, -}; - -pub const DEFAULT_IDENTITY_PATH: &str = "default.json"; - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct RadrootsIdentityId(String); - -#[derive(Debug, Clone)] -pub struct RadrootsIdentity { - keys: Keys, - profile: Option<RadrootsIdentityProfile>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RadrootsIdentityPublic { - pub id: RadrootsIdentityId, - pub public_key_hex: String, - pub public_key_npub: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub profile: Option<RadrootsIdentityProfile>, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RadrootsIdentityProfile { - #[serde(skip_serializing_if = "Option::is_none")] - pub identifier: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option<nostr::Event>, - #[serde(skip_serializing_if = "Option::is_none")] - pub application_handler: Option<nostr::Event>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RadrootsIdentityFile { - pub secret_key: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub public_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub identifier: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option<nostr::Event>, - #[serde(skip_serializing_if = "Option::is_none")] - pub application_handler: Option<nostr::Event>, -} - -#[derive(Debug, Clone, Copy)] -pub enum RadrootsIdentitySecretKeyFormat { - Hex, - Nsec, -} - -#[cfg(feature = "nip49")] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum RadrootsIdentityEncryptedSecretKeySecurity { - Weak, - Medium, - #[default] - Unknown, -} - -#[cfg(feature = "nip49")] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RadrootsIdentityEncryptedSecretKeyOptions { - pub log_n: u8, - pub key_security: RadrootsIdentityEncryptedSecretKeySecurity, -} - -#[cfg(feature = "nip49")] -impl Default for RadrootsIdentityEncryptedSecretKeyOptions { - fn default() -> Self { - Self { - log_n: 16, - key_security: RadrootsIdentityEncryptedSecretKeySecurity::Unknown, - } - } -} - -#[cfg(feature = "nip49")] -impl From<RadrootsIdentityEncryptedSecretKeySecurity> for KeySecurity { - fn from(value: RadrootsIdentityEncryptedSecretKeySecurity) -> Self { - match value { - RadrootsIdentityEncryptedSecretKeySecurity::Weak => Self::Weak, - RadrootsIdentityEncryptedSecretKeySecurity::Medium => Self::Medium, - RadrootsIdentityEncryptedSecretKeySecurity::Unknown => Self::Unknown, - } - } -} - -impl RadrootsIdentityId { - pub fn from_public_key(public_key: nostr::PublicKey) -> Self { - Self(public_key.to_hex()) - } - - pub fn parse(value: &str) -> Result<Self, IdentityError> { - let public_key = parse_public_key(value)?; - Ok(Self::from_public_key(public_key)) - } - - pub fn as_str(&self) -> &str { - self.0.as_str() - } - - pub fn into_string(self) -> String { - self.0 - } -} - -impl From<nostr::PublicKey> for RadrootsIdentityId { - fn from(value: nostr::PublicKey) -> Self { - Self::from_public_key(value) - } -} - -impl TryFrom<&str> for RadrootsIdentityId { - type Error = IdentityError; - - fn try_from(value: &str) -> Result<Self, Self::Error> { - Self::parse(value) - } -} - -impl AsRef<str> for RadrootsIdentityId { - fn as_ref(&self) -> &str { - self.as_str() - } -} - -impl fmt::Display for RadrootsIdentityId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.0.as_str()) - } -} - -impl RadrootsIdentityPublic { - pub fn new(public_key: nostr::PublicKey) -> Self { - let id = RadrootsIdentityId::from_public_key(public_key); - use nostr::nips::nip19::ToBech32; - let public_key_npub = infallible_to_string(public_key.to_bech32()); - Self { - id, - public_key_hex: public_key.to_hex(), - public_key_npub, - profile: None, - } - } - - pub fn with_profile(mut self, profile: RadrootsIdentityProfile) -> Self { - self.profile = if profile.is_empty() { - None - } else { - Some(profile) - }; - self - } -} - -impl RadrootsIdentityProfile { - pub fn is_empty(&self) -> bool { - self.identifier.is_none() && self.metadata.is_none() && self.application_handler.is_none() - } -} - -impl RadrootsIdentity { - pub fn new(keys: Keys) -> Self { - Self { - keys, - profile: None, - } - } - - pub fn with_profile(keys: Keys, profile: RadrootsIdentityProfile) -> Self { - let profile = if profile.is_empty() { - None - } else { - Some(profile) - }; - Self { keys, profile } - } - - #[cfg(feature = "std")] - pub fn generate() -> Self { - Self::new(Keys::generate()) - } - - #[cfg(feature = "std")] - pub fn generate_with_profile(profile: RadrootsIdentityProfile) -> Self { - Self::with_profile(Keys::generate(), profile) - } - - pub fn keys(&self) -> &Keys { - &self.keys - } - - pub fn into_keys(self) -> Keys { - self.keys - } - - pub fn public_key(&self) -> nostr::PublicKey { - self.keys.public_key() - } - - pub fn id(&self) -> RadrootsIdentityId { - RadrootsIdentityId::from_public_key(self.keys.public_key()) - } - - pub fn public_key_hex(&self) -> String { - self.keys.public_key().to_hex() - } - - pub fn public_key_npub(&self) -> String { - use nostr::nips::nip19::ToBech32; - infallible_to_string(self.keys.public_key().to_bech32()) - } - - pub fn npub(&self) -> String { - self.public_key_npub() - } - - pub fn secret_key_hex(&self) -> String { - self.keys.secret_key().to_secret_hex() - } - - pub fn secret_key_nsec(&self) -> String { - use nostr::nips::nip19::ToBech32; - infallible_to_string(self.keys.secret_key().to_bech32()) - } - - pub fn nsec(&self) -> String { - self.secret_key_nsec() - } - - #[cfg(feature = "nip49")] - /// Export the current secret key as a NIP-49 `ncryptsec` payload. - /// - /// This is an explicit operator-facing import or export format, not the - /// canonical local file-storage contract for Radroots runtimes. - pub fn encrypt_secret_key_ncryptsec(&self, password: &str) -> Result<String, IdentityError> { - self.encrypt_secret_key_ncryptsec_with_options( - password, - RadrootsIdentityEncryptedSecretKeyOptions::default(), - ) - } - - #[cfg(feature = "nip49")] - /// Export the current secret key as a NIP-49 `ncryptsec` payload with - /// explicit encryption options. - /// - /// This remains scoped to import or export behavior and must not become the - /// generic local secret-storage format. - pub fn encrypt_secret_key_ncryptsec_with_options( - &self, - password: &str, - options: RadrootsIdentityEncryptedSecretKeyOptions, - ) -> Result<String, IdentityError> { - let encrypted = EncryptedSecretKey::new( - self.keys.secret_key(), - password, - options.log_n, - options.key_security.into(), - ) - .map_err(|source| IdentityError::EncryptSecretKey(source.to_string()))?; - // The ncryptsec HRP and payload shape are fixed here, so encoding should not fail. - Ok(encrypted - .to_bech32() - .expect("ncryptsec bech32 encoding should succeed")) - } - - pub fn secret_key_bytes(&self) -> [u8; SecretKey::LEN] { - self.keys.secret_key().to_secret_bytes() - } - - #[cfg(feature = "secrecy")] - pub fn secret_key_hex_secret(&self) -> secrecy::SecretString { - use secrecy::SecretString; - SecretString::new(self.secret_key_hex().into()) - } - - #[cfg(feature = "zeroize")] - pub fn secret_key_bytes_zeroizing(&self) -> zeroize::Zeroizing<[u8; SecretKey::LEN]> { - zeroize::Zeroizing::new(self.secret_key_bytes()) - } - - pub fn profile(&self) -> Option<&RadrootsIdentityProfile> { - self.profile.as_ref() - } - - pub fn profile_mut(&mut self) -> Option<&mut RadrootsIdentityProfile> { - self.profile.as_mut() - } - - pub fn set_profile(&mut self, profile: RadrootsIdentityProfile) { - self.profile = if profile.is_empty() { - None - } else { - Some(profile) - }; - } - - pub fn clear_profile(&mut self) { - self.profile = None; - } - - pub fn to_public(&self) -> RadrootsIdentityPublic { - let mut public = RadrootsIdentityPublic::new(self.keys.public_key()); - if let Some(profile) = &self.profile { - public.profile = Some(profile.clone()); - } - public - } - - pub fn to_file(&self) -> RadrootsIdentityFile { - self.to_file_with_secret_format(RadrootsIdentitySecretKeyFormat::Hex) - } - - pub fn to_file_with_secret_format( - &self, - format: RadrootsIdentitySecretKeyFormat, - ) -> RadrootsIdentityFile { - let secret_key = match format { - RadrootsIdentitySecretKeyFormat::Hex => self.secret_key_hex(), - RadrootsIdentitySecretKeyFormat::Nsec => self.secret_key_nsec(), - }; - let (identifier, metadata, application_handler) = match &self.profile { - Some(profile) => ( - profile.identifier.clone(), - profile.metadata.clone(), - profile.application_handler.clone(), - ), - None => (None, None, None), - }; - RadrootsIdentityFile { - secret_key, - public_key: Some(self.public_key_hex()), - identifier, - metadata, - application_handler, - } - } - - #[cfg(feature = "std")] - pub fn from_file(file: RadrootsIdentityFile) -> Result<Self, IdentityError> { - Self::try_from(file) - } - - #[cfg(feature = "std")] - pub fn from_secret_key_str(secret_key: &str) -> Result<Self, IdentityError> { - Ok(Self::new(Keys::parse(secret_key)?)) - } - - #[cfg(feature = "nip49")] - /// Import a secret key from a NIP-49 `ncryptsec` payload. - /// - /// This path is explicit by design so encrypted exports do not become an - /// ambient local file-storage format. - pub fn from_encrypted_secret_key_str( - secret_key: &str, - password: &str, - ) -> Result<Self, IdentityError> { - let encrypted = EncryptedSecretKey::from_bech32(secret_key) - .map_err(|source| IdentityError::InvalidEncryptedSecretKey(source.to_string()))?; - let secret_key = encrypted - .decrypt(password) - .map_err(|source| IdentityError::DecryptEncryptedSecretKey(source.to_string()))?; - Ok(Self::new(Keys::new(secret_key))) - } - - #[cfg(feature = "std")] - pub fn from_secret_key_bytes(secret_key: &[u8]) -> Result<Self, IdentityError> { - if secret_key.len() != SecretKey::LEN { - return Err(IdentityError::InvalidIdentityFormat); - } - let secret_key = SecretKey::from_slice(secret_key)?; - Ok(Self::new(Keys::new(secret_key))) - } - - #[cfg(feature = "std")] - pub fn load_from_path_auto(path: impl AsRef<Path>) -> Result<Self, IdentityError> { - let path = path.as_ref(); - let bytes = read_identity_bytes(path)?; - parse_identity_bytes(&bytes) - } - - #[cfg(feature = "std")] - pub fn default_path() -> Result<PathBuf, IdentityError> { - Self::default_path_for( - &RadrootsPathResolver::current(), - RadrootsPathProfile::InteractiveUser, - &RadrootsPathOverrides::default(), - ) - } - - #[cfg(feature = "std")] - pub fn default_path_for( - resolver: &RadrootsPathResolver, - profile: RadrootsPathProfile, - overrides: &RadrootsPathOverrides, - ) -> Result<PathBuf, IdentityError> { - Ok(default_shared_identity_path(resolver, profile, overrides)?) - } - - #[cfg(all(feature = "std", feature = "json-file"))] - fn resolve_load_or_generate_path<P: AsRef<Path>>( - path: Option<P>, - ) -> Result<PathBuf, IdentityError> { - path.map(|p| p.as_ref().to_path_buf()) - .map(Ok) - .unwrap_or_else(Self::default_path) - } - - #[cfg(all(feature = "std", feature = "json-file"))] - fn load_or_generate_at( - path: Result<PathBuf, IdentityError>, - allow_generate: bool, - ) -> Result<Self, IdentityError> { - let path = path?; - if path.exists() { - return Self::load_from_path_auto(&path); - } - if !allow_generate { - return Err(IdentityError::GenerationNotAllowed(path)); - } - let identity = Self::generate(); - identity.save_json(&path)?; - Ok(identity) - } - - #[cfg(all(feature = "std", feature = "json-file"))] - pub fn load_or_generate<P: AsRef<Path>>( - path: Option<P>, - allow_generate: bool, - ) -> Result<Self, IdentityError> { - Self::load_or_generate_at(Self::resolve_load_or_generate_path(path), allow_generate) - } - - #[cfg(all(feature = "std", feature = "json-file"))] - pub fn save_json(&self, path: impl AsRef<Path>) -> Result<(), IdentityError> { - let payload = self.to_file(); - let mut store = JsonFile::load_or_create_with(path.as_ref(), || payload.clone())?; - store.value = payload; - store.save()?; - Ok(()) - } -} - -#[cfg(feature = "std")] -impl TryFrom<RadrootsIdentityFile> for RadrootsIdentity { - type Error = IdentityError; - - fn try_from(file: RadrootsIdentityFile) -> Result<Self, Self::Error> { - let keys = Keys::parse(&file.secret_key)?; - validate_public_key(&keys, file.public_key.as_deref())?; - let profile = RadrootsIdentityProfile { - identifier: file.identifier, - metadata: file.metadata, - application_handler: file.application_handler, - }; - if profile.is_empty() { - Ok(Self::new(keys)) - } else { - Ok(Self::with_profile(keys, profile)) - } - } -} - -impl From<Keys> for RadrootsIdentity { - fn from(keys: Keys) -> Self { - Self::new(keys) - } -} - -#[cfg(feature = "std")] -fn read_identity_bytes(path: &Path) -> Result<Vec<u8>, IdentityError> { - match fs::read(path) { - Ok(bytes) => Ok(bytes), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - Err(IdentityError::NotFound(path.to_path_buf())) - } - Err(err) => Err(IdentityError::Read(path.to_path_buf(), err)), - } -} - -#[cfg(feature = "std")] -fn parse_identity_bytes(bytes: &[u8]) -> Result<RadrootsIdentity, IdentityError> { - if bytes.len() == SecretKey::LEN { - return RadrootsIdentity::from_secret_key_bytes(bytes); - } - - let text = std::str::from_utf8(bytes).map_err(|_| IdentityError::InvalidIdentityFormat)?; - let trimmed = text.trim(); - if trimmed.is_empty() { - return Err(IdentityError::InvalidIdentityFormat); - } - if trimmed.starts_with('{') { - let file: RadrootsIdentityFile = serde_json::from_str(trimmed)?; - return RadrootsIdentity::from_file(file); - } - RadrootsIdentity::from_secret_key_str(trimmed) -} - -fn validate_public_key(keys: &Keys, public_key: Option<&str>) -> Result<(), IdentityError> { - let Some(public_key) = public_key else { - return Ok(()); - }; - let parsed = parse_public_key(public_key)?; - if parsed != keys.public_key() { - return Err(IdentityError::PublicKeyMismatch); - } - Ok(()) -} - -fn parse_public_key(value: &str) -> Result<nostr::PublicKey, IdentityError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(IdentityError::InvalidPublicKey(value.to_string())); - } - nostr::PublicKey::parse(trimmed) - .or_else(|_| nostr::PublicKey::from_hex(trimmed)) - .map_err(|_| IdentityError::InvalidPublicKey(value.to_string())) -} - -fn infallible_to_string(value: Result<String, Infallible>) -> String { - match value { - Ok(value) => value, - Err(err) => match err {}, - } -} diff --git a/crates/identity/src/lib.rs b/crates/identity/src/lib.rs @@ -6,11 +6,9 @@ extern crate alloc; pub mod account; pub mod error; -#[cfg(all(feature = "std", feature = "serde"))] -pub mod identity; pub mod key; pub mod profile; -#[cfg(all(feature = "std", feature = "serde"))] +#[cfg(feature = "json-file")] pub mod storage; pub mod username; @@ -18,23 +16,8 @@ pub use account::AccountId; pub use error::Error; #[cfg(feature = "std")] pub use error::IdentityError; -#[cfg(all(feature = "std", feature = "serde"))] -pub use identity::{ - DEFAULT_IDENTITY_PATH, RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityId, - RadrootsIdentityProfile, RadrootsIdentityPublic, RadrootsIdentitySecretKeyFormat, -}; -#[cfg(all(feature = "std", feature = "serde", feature = "nip49"))] -pub use identity::{ - RadrootsIdentityEncryptedSecretKeyOptions, RadrootsIdentityEncryptedSecretKeySecurity, -}; pub use key::{IdentityId, PublicKey}; pub use profile::{Profile, PublicIdentity}; -#[cfg(all(feature = "std", feature = "serde"))] -pub use storage::{ - RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT, RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, - RadrootsEncryptedIdentityFile, encrypted_identity_wrapping_key_path, load_encrypted_identity, - load_encrypted_identity_with_key_slot, load_identity_profile, rotate_encrypted_identity, - rotate_encrypted_identity_with_key_slot, store_encrypted_identity, - store_encrypted_identity_with_key_slot, store_identity_profile, -}; +#[cfg(feature = "json-file")] +pub use storage::{load_identity_profile, store_identity_profile}; pub use username::Username; diff --git a/crates/identity/src/profile.rs b/crates/identity/src/profile.rs @@ -39,6 +39,24 @@ impl Profile { } /// A public identity with an invariant-matched identifier and public key. +/// +/// Secret-bearing identity containers are intentionally absent: +/// +/// ```compile_fail +/// use radroots_identity::RadrootsIdentity; +/// ``` +/// +/// Public identities expose no secret-key access: +/// +/// ```compile_fail +/// use radroots_identity::{PublicIdentity, PublicKey}; +/// +/// let key = PublicKey::from_hex( +/// "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", +/// ).unwrap(); +/// let identity = PublicIdentity::new(key); +/// let _ = identity.secret_key_bytes(); +/// ``` #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, Debug, PartialEq, Eq, Hash)] diff --git a/crates/identity/src/storage.rs b/crates/identity/src/storage.rs @@ -1,663 +1,87 @@ -use std::borrow::Cow; -use std::fs; -use std::path::{Path, PathBuf}; +//! Transitional filesystem helpers for public profile snapshots. +//! +//! Filesystem ownership is removed from this package in the next ordered +//! migration checkpoint. -use radroots_protected_store::{ - RadrootsProtectedFileKeySource, RadrootsProtectedStoreEnvelope, sidecar_path, -}; -use radroots_secret_vault::RadrootsSecretVaultAccessError; +use std::{fs, path::Path}; -use crate::{IdentityError, RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityPublic}; - -pub const RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT: &str = "radroots_identity"; -pub const RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX: &str = ".key"; - -#[derive(Debug, Clone)] -pub struct RadrootsEncryptedIdentityFile { - path: PathBuf, - key_slot: Cow<'static, str>, -} - -impl RadrootsEncryptedIdentityFile { - #[must_use] - pub fn new(path: impl AsRef<Path>) -> Self { - Self::new_path(path.as_ref()) - } - - #[must_use] - fn new_path(path: &Path) -> Self { - Self::with_key_slot_path(path, RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT) - } - - #[must_use] - pub fn with_key_slot(path: impl AsRef<Path>, key_slot: impl Into<Cow<'static, str>>) -> Self { - Self::with_key_slot_path(path.as_ref(), key_slot) - } - - #[must_use] - fn with_key_slot_path(path: &Path, key_slot: impl Into<Cow<'static, str>>) -> Self { - Self { - path: path.to_path_buf(), - key_slot: key_slot.into(), - } - } - - #[must_use] - pub fn path(&self) -> &Path { - self.path.as_path() - } - - #[must_use] - pub fn key_slot(&self) -> &str { - self.key_slot.as_ref() - } - - #[must_use] - pub fn wrapping_key_path(&self) -> PathBuf { - encrypted_identity_wrapping_key_path(&self.path) - } - - pub fn store(&self, identity: &RadrootsIdentity) -> Result<(), IdentityError> { - if let Some(parent) = self.path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent) - .map_err(|source| IdentityError::CreateDir(parent.to_path_buf(), source))?; - } - - let payload = identity_file_payload(identity); - let key_source = RadrootsProtectedFileKeySource::from_sidecar_suffix( - &self.path, - RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, - ); - let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key( - &key_source, - self.key_slot(), - &payload, - ) - .map_err(|error| { - protected_storage_message(&self.path, "seal encrypted identity", &error) - })?; - let encoded = encode_encrypted_identity(&envelope); - fs::write(&self.path, encoded) - .map_err(|source| IdentityError::Write(self.path.clone(), source))?; - apply_secret_permissions(&self.path)?; - Ok(()) - } - - pub fn load(&self) -> Result<RadrootsIdentity, IdentityError> { - let encoded = fs::read(&self.path).map_err(|source| { - if source.kind() == std::io::ErrorKind::NotFound { - IdentityError::NotFound(self.path.clone()) - } else { - IdentityError::Read(self.path.clone(), source) - } - })?; - let key_source = RadrootsProtectedFileKeySource::from_sidecar_suffix( - &self.path, - RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, - ); - let envelope = RadrootsProtectedStoreEnvelope::decode_json(&encoded).map_err(|error| { - protected_storage_message(&self.path, "decode encrypted identity", &error) - })?; - let plaintext = envelope - .open_with_wrapped_key(&key_source) - .map_err(|error| { - protected_storage_message(&self.path, "open encrypted identity", &error) - })?; - let file: RadrootsIdentityFile = serde_json::from_slice(&plaintext)?; - RadrootsIdentity::try_from(file) - } - - pub fn rotate(&self) -> Result<(), IdentityError> { - let identity = self.load()?; - let backup = self.rotation_backup()?; - - if let Err(error) = self.store(&identity) { - let _ = fs::write(&self.path, &backup.envelope); - let _ = set_secret_permissions(&self.path); - let _ = fs::write(&backup.key_path, &backup.key); - let _ = set_secret_permissions(&backup.key_path); - return Err(error); - } - - Ok(()) - } - - #[cfg_attr(coverage_nightly, coverage(off))] - fn rotation_backup(&self) -> Result<EncryptedIdentityRotationBackup, IdentityError> { - let envelope = fs::read(&self.path) - .map_err(|source| IdentityError::Read(self.path.clone(), source))?; - let key_path = self.wrapping_key_path(); - let key = - fs::read(&key_path).map_err(|source| IdentityError::Read(key_path.clone(), source))?; - - fs::remove_file(&key_path) - .map_err(|source| IdentityError::Write(key_path.clone(), source))?; - - Ok(EncryptedIdentityRotationBackup { - envelope, - key_path, - key, - }) - } -} - -struct EncryptedIdentityRotationBackup { - envelope: Vec<u8>, - key_path: PathBuf, - key: Vec<u8>, -} - -#[must_use] -pub fn encrypted_identity_wrapping_key_path(path: impl AsRef<Path>) -> PathBuf { - encrypted_identity_wrapping_key_path_ref(path.as_ref()) -} - -fn encrypted_identity_wrapping_key_path_ref(path: &Path) -> PathBuf { - sidecar_path(path, RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX) -} - -pub fn store_encrypted_identity( - path: impl AsRef<Path>, - identity: &RadrootsIdentity, -) -> Result<(), IdentityError> { - store_encrypted_identity_path(path.as_ref(), identity) -} - -fn store_encrypted_identity_path( - path: &Path, - identity: &RadrootsIdentity, -) -> Result<(), IdentityError> { - RadrootsEncryptedIdentityFile::new_path(path).store(identity) -} - -pub fn store_encrypted_identity_with_key_slot( - path: impl AsRef<Path>, - key_slot: impl Into<Cow<'static, str>>, - identity: &RadrootsIdentity, -) -> Result<(), IdentityError> { - store_encrypted_identity_with_key_slot_path(path.as_ref(), key_slot, identity) -} - -fn store_encrypted_identity_with_key_slot_path( - path: &Path, - key_slot: impl Into<Cow<'static, str>>, - identity: &RadrootsIdentity, -) -> Result<(), IdentityError> { - RadrootsEncryptedIdentityFile::with_key_slot_path(path, key_slot).store(identity) -} - -pub fn rotate_encrypted_identity(path: impl AsRef<Path>) -> Result<(), IdentityError> { - rotate_encrypted_identity_path(path.as_ref()) -} - -fn rotate_encrypted_identity_path(path: &Path) -> Result<(), IdentityError> { - RadrootsEncryptedIdentityFile::new_path(path).rotate() -} - -pub fn rotate_encrypted_identity_with_key_slot( - path: impl AsRef<Path>, - key_slot: impl Into<Cow<'static, str>>, -) -> Result<(), IdentityError> { - rotate_encrypted_identity_with_key_slot_path(path.as_ref(), key_slot) -} - -fn rotate_encrypted_identity_with_key_slot_path( - path: &Path, - key_slot: impl Into<Cow<'static, str>>, -) -> Result<(), IdentityError> { - RadrootsEncryptedIdentityFile::with_key_slot_path(path, key_slot).rotate() -} - -pub fn load_encrypted_identity(path: impl AsRef<Path>) -> Result<RadrootsIdentity, IdentityError> { - load_encrypted_identity_path(path.as_ref()) -} - -fn load_encrypted_identity_path(path: &Path) -> Result<RadrootsIdentity, IdentityError> { - RadrootsEncryptedIdentityFile::new_path(path).load() -} - -pub fn load_encrypted_identity_with_key_slot( - path: impl AsRef<Path>, - key_slot: impl Into<Cow<'static, str>>, -) -> Result<RadrootsIdentity, IdentityError> { - load_encrypted_identity_with_key_slot_path(path.as_ref(), key_slot) -} - -fn load_encrypted_identity_with_key_slot_path( - path: &Path, - key_slot: impl Into<Cow<'static, str>>, -) -> Result<RadrootsIdentity, IdentityError> { - RadrootsEncryptedIdentityFile::with_key_slot_path(path, key_slot).load() -} +use crate::{IdentityError, PublicIdentity}; +/// Stores a validated public identity profile as JSON. pub fn store_identity_profile( path: impl AsRef<Path>, - identity: &RadrootsIdentity, + identity: &PublicIdentity, ) -> Result<(), IdentityError> { store_identity_profile_path(path.as_ref(), identity) } fn store_identity_profile_path( path: &Path, - identity: &RadrootsIdentity, + identity: &PublicIdentity, ) -> Result<(), IdentityError> { - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { + if let Some(parent) = path.parent().filter(|value| !value.as_os_str().is_empty()) { fs::create_dir_all(parent) .map_err(|source| IdentityError::CreateDir(parent.to_path_buf(), source))?; } - - let encoded = identity_profile_payload(identity); - fs::write(path, encoded).map_err(|source| IdentityError::Write(path.to_path_buf(), source))?; - apply_secret_permissions(path)?; - Ok(()) + let encoded = serde_json::to_vec_pretty(identity)?; + fs::write(path, encoded).map_err(|source| IdentityError::Write(path.to_path_buf(), source)) } -pub fn load_identity_profile( - path: impl AsRef<Path>, -) -> Result<RadrootsIdentityPublic, IdentityError> { +/// Loads and revalidates a public identity profile from JSON. +pub fn load_identity_profile(path: impl AsRef<Path>) -> Result<PublicIdentity, IdentityError> { load_identity_profile_path(path.as_ref()) } -fn load_identity_profile_path(path: &Path) -> Result<RadrootsIdentityPublic, IdentityError> { - let encoded = match fs::read(path) { - Ok(encoded) => encoded, - Err(source) if source.kind() == std::io::ErrorKind::NotFound => { - return Err(IdentityError::NotFound(path.to_path_buf())); +fn load_identity_profile_path(path: &Path) -> Result<PublicIdentity, IdentityError> { + let encoded = fs::read(path).map_err(|source| { + if source.kind() == std::io::ErrorKind::NotFound { + IdentityError::NotFound(path.to_path_buf()) + } else { + IdentityError::Read(path.to_path_buf(), source) } - Err(source) => return Err(IdentityError::Read(path.to_path_buf(), source)), - }; - if let Ok(public_identity) = serde_json::from_slice::<RadrootsIdentityPublic>(&encoded) { - return Ok(public_identity); - } - RadrootsIdentity::load_from_path_auto(path).map(|identity| identity.to_public()) -} - -fn identity_file_payload(identity: &RadrootsIdentity) -> Vec<u8> { - serde_json::to_vec(&identity.to_file()).expect("identity file serialization is infallible") -} - -fn identity_profile_payload(identity: &RadrootsIdentity) -> Vec<u8> { - serde_json::to_vec_pretty(&identity.to_public()) - .expect("identity profile serialization is infallible") -} - -fn encode_encrypted_identity(envelope: &RadrootsProtectedStoreEnvelope) -> Vec<u8> { - envelope - .encode_json() - .expect("protected-store envelope serialization is infallible") -} - -#[cfg_attr(coverage_nightly, coverage(off))] -fn apply_secret_permissions(path: &Path) -> Result<(), IdentityError> { - set_secret_permissions(path).map_err(|error| secret_permission_error(path, error)) -} - -fn protected_storage_message( - path: &Path, - action: &str, - message: &dyn core::fmt::Display, -) -> IdentityError { - IdentityError::ProtectedStorage { - path: path.to_path_buf(), - message: format!("failed to {action}: {message}"), - } -} - -fn secret_permission_error(path: &Path, error: RadrootsSecretVaultAccessError) -> IdentityError { - protected_storage_message(path, "update secret-file permissions", &error) -} - -#[cfg(unix)] -#[cfg_attr(coverage_nightly, coverage(off))] -fn set_secret_permissions(path: &Path) -> Result<(), RadrootsSecretVaultAccessError> { - use std::os::unix::fs::PermissionsExt; - - let permissions = std::fs::Permissions::from_mode(0o600); - fs::set_permissions(path, permissions) - .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string())) -} - -#[cfg(not(unix))] -#[cfg_attr(coverage_nightly, coverage(off))] -fn set_secret_permissions(_path: &Path) -> Result<(), RadrootsSecretVaultAccessError> { - Ok(()) + })?; + serde_json::from_slice(&encoded).map_err(IdentityError::from) } #[cfg(test)] mod tests { use super::*; + use crate::{Profile, PublicKey, Username}; - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn encrypted_identity_round_trips() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("identity.enc.json"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - - store_encrypted_identity(&path, &identity).expect("store encrypted identity"); - - let loaded = load_encrypted_identity(&path).expect("load encrypted identity"); - assert_eq!(loaded.id(), identity.id()); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - assert!(encrypted_identity_wrapping_key_path(&path).is_file()); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn encrypted_identity_rotation_rewraps_key() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("identity.enc.json"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - - store_encrypted_identity(&path, &identity).expect("store encrypted identity"); - let key_path = encrypted_identity_wrapping_key_path(&path); - let before = fs::read(&key_path).expect("key before"); - - rotate_encrypted_identity(&path).expect("rotate encrypted identity"); - - let after = fs::read(&key_path).expect("key after"); - assert_ne!(before, after); - let loaded = load_encrypted_identity(&path).expect("load rotated identity"); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn encrypted_identity_supports_custom_key_slot() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("identity.enc.json"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - - store_encrypted_identity_with_key_slot(&path, "myc_identity", &identity) - .expect("store encrypted identity"); - let loaded = load_encrypted_identity_with_key_slot(&path, "myc_identity") - .expect("load encrypted identity"); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn identity_profile_round_trips() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("profile.json"); - let mut identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - identity.set_profile(crate::RadrootsIdentityProfile::default()); - - store_identity_profile(&path, &identity).expect("store profile"); - - let loaded = load_identity_profile(&path).expect("load profile"); - assert_eq!(loaded.id, identity.id()); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn encrypted_identity_file_accessors_and_wrappers_use_expected_paths() { - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("identity.enc.json"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - - let default_file = RadrootsEncryptedIdentityFile::new(path.as_path()); - assert_eq!(default_file.path(), path.as_path()); - assert_eq!( - default_file.key_slot(), - RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT - ); - assert_eq!( - default_file.wrapping_key_path(), - encrypted_identity_wrapping_key_path(path.as_path()) - ); - - let custom_file = - RadrootsEncryptedIdentityFile::with_key_slot(path.as_path(), "custom_identity"); - assert_eq!(custom_file.key_slot(), "custom_identity"); - - store_encrypted_identity(path.as_path(), &identity).expect("store encrypted identity"); - rotate_encrypted_identity(path.as_path()).expect("rotate encrypted identity"); - let loaded = load_encrypted_identity(path.as_path()).expect("load encrypted identity"); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); + const ALICE: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; - store_encrypted_identity_with_key_slot(path.as_path(), "custom_identity", &identity) - .expect("store encrypted identity with slot"); - rotate_encrypted_identity_with_key_slot(path.as_path(), "custom_identity") - .expect("rotate encrypted identity with slot"); - let loaded = load_encrypted_identity_with_key_slot(path.as_path(), "custom_identity") - .expect("load encrypted identity with slot"); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); + fn fixture_identity() -> PublicIdentity { + PublicIdentity::new(PublicKey::from_hex(ALICE).unwrap()) + .with_profile(Profile::new().with_username(Username::parse("alice.farm").unwrap())) } - #[cfg_attr(coverage_nightly, coverage(off))] #[test] - fn encrypted_identity_load_reports_read_decode_and_open_errors() { - let temp = tempfile::tempdir().expect("tempdir"); - let missing = temp.path().join("missing.enc.json"); - let missing_error = load_encrypted_identity(missing.as_path()).expect_err("missing"); - assert!(matches!(missing_error, IdentityError::NotFound(path) if path == missing)); - - let read_error = load_encrypted_identity(temp.path()).expect_err("directory read"); - assert!(matches!(read_error, IdentityError::Read(path, _) if path == temp.path())); - - let invalid = temp.path().join("invalid.enc.json"); - fs::write(&invalid, b"not-json").expect("write invalid envelope"); - let decode_error = load_encrypted_identity(invalid.as_path()).expect_err("decode error"); - assert!(matches!( - decode_error, - IdentityError::ProtectedStorage { path, message } - if path == invalid && message.contains("decode encrypted identity") - )); + fn public_profile_file_round_trip_revalidates_identity() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("profiles/alice.json"); + let identity = fixture_identity(); - let invalid_plaintext = temp.path().join("invalid-plaintext.enc.json"); - let key_source = RadrootsProtectedFileKeySource::from_sidecar_suffix( - invalid_plaintext.as_path(), - RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, - ); - let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key( - &key_source, - RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT, - b"not identity json", - ) - .expect("seal invalid plaintext"); - fs::write( - &invalid_plaintext, - envelope.encode_json().expect("encode invalid plaintext"), - ) - .expect("write invalid plaintext envelope"); - let invalid_plaintext_error = - load_encrypted_identity(invalid_plaintext.as_path()).expect_err("invalid plaintext"); - assert!(matches!( - invalid_plaintext_error, - IdentityError::InvalidJson(_) - )); - - let path = temp.path().join("identity.enc.json"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - store_encrypted_identity_with_key_slot(path.as_path(), "right_slot", &identity) - .expect("store encrypted identity"); - fs::write( - encrypted_identity_wrapping_key_path(path.as_path()), - b"short", - ) - .expect("corrupt wrapping key"); - let open_error = load_encrypted_identity(path.as_path()).expect_err("open"); - assert!(matches!( - open_error, - IdentityError::ProtectedStorage { path: error_path, message } - if error_path == path && message.contains("open encrypted identity") - )); + store_identity_profile(&path, &identity).unwrap(); + assert_eq!(load_identity_profile(&path).unwrap(), identity); } - #[cfg_attr(coverage_nightly, coverage(off))] #[test] - fn encrypted_identity_store_reports_create_write_and_seal_errors() { - let temp = tempfile::tempdir().expect("tempdir"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - - let blocked_parent = temp.path().join("blocked-parent"); - fs::write(&blocked_parent, b"not-a-directory").expect("blocked parent"); - let create_path = blocked_parent.join("identity.enc.json"); - let create_error = - store_encrypted_identity(create_path.as_path(), &identity).expect_err("create dir"); - assert!( - matches!(create_error, IdentityError::CreateDir(path, _) if path == blocked_parent) + fn public_profile_file_rejects_mismatched_or_missing_data() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("alice.json"); + let mut value = serde_json::to_value(fixture_identity()).unwrap(); + value["id"] = serde_json::Value::String( + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af".into(), ); + fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); - let directory_path = temp.path().join("identity-as-directory.enc.json"); - fs::create_dir(&directory_path).expect("identity directory"); - let write_error = - store_encrypted_identity(directory_path.as_path(), &identity).expect_err("write dir"); - assert!(matches!(write_error, IdentityError::Write(path, _) if path == directory_path)); - - let sealed_path = temp.path().join("seal-error.enc.json"); - fs::create_dir(encrypted_identity_wrapping_key_path(sealed_path.as_path())) - .expect("blocking key directory"); - let seal_error = - store_encrypted_identity(sealed_path.as_path(), &identity).expect_err("seal"); assert!(matches!( - seal_error, - IdentityError::ProtectedStorage { path, message } - if path == sealed_path && message.contains("seal encrypted identity") + load_identity_profile(&path), + Err(IdentityError::InvalidJson(_)) )); - } - - #[cfg(unix)] - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn encrypted_identity_rotation_restores_wrapping_key_after_store_failure() { - use std::os::unix::fs::PermissionsExt; - - let temp = tempfile::tempdir().expect("tempdir"); - let path = temp.path().join("identity.enc.json"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - - store_encrypted_identity(path.as_path(), &identity).expect("store encrypted identity"); - let key_path = encrypted_identity_wrapping_key_path(path.as_path()); - let key_before = fs::read(&key_path).expect("key before"); - - fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).expect("read only"); - let error = rotate_encrypted_identity(path.as_path()).expect_err("rotate failure"); - fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("writable"); - - assert!(matches!(error, IdentityError::Write(error_path, _) if error_path == path)); - assert_eq!(fs::read(&key_path).expect("restored key"), key_before); - let loaded = load_encrypted_identity(path.as_path()).expect("load restored identity"); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn identity_profile_storage_reports_errors_and_private_fallback() { - let temp = tempfile::tempdir().expect("tempdir"); - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - - let blocked_parent = temp.path().join("blocked-profile-parent"); - fs::write(&blocked_parent, b"not-a-directory").expect("blocked parent"); - let create_path = blocked_parent.join("profile.json"); - let create_error = - store_identity_profile(create_path.as_path(), &identity).expect_err("create dir"); - assert!( - matches!(create_error, IdentityError::CreateDir(path, _) if path == blocked_parent) - ); - - let directory_path = temp.path().join("profile-as-directory.json"); - fs::create_dir(&directory_path).expect("profile directory"); - let write_error = - store_identity_profile(directory_path.as_path(), &identity).expect_err("write dir"); - assert!(matches!(write_error, IdentityError::Write(path, _) if path == directory_path)); - - let missing = temp.path().join("missing-profile.json"); - let missing_error = load_identity_profile(missing.as_path()).expect_err("missing"); - assert!(matches!(missing_error, IdentityError::NotFound(path) if path == missing)); - - let read_error = load_identity_profile(temp.path()).expect_err("directory read"); - assert!(matches!(read_error, IdentityError::Read(path, _) if path == temp.path())); - - let private_profile = temp.path().join("private-profile.json"); - fs::write( - &private_profile, - serde_json::to_vec(&identity.to_file()).expect("identity file"), - ) - .expect("write private profile"); - let loaded = load_identity_profile(private_profile.as_path()).expect("load fallback"); - assert_eq!(loaded.id, identity.id()); - } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn protected_storage_permission_message_uses_operator_action() { - let path = Path::new("missing-secret-file"); - let error = secret_permission_error( - path, - RadrootsSecretVaultAccessError::Backend("permission denied".into()), - ); - assert!(matches!( - error, - IdentityError::ProtectedStorage { path: error_path, message } - if error_path == path - && message.contains("update secret-file permissions") - && message.contains("permission denied") + load_identity_profile(directory.path().join("missing.json")), + Err(IdentityError::NotFound(_)) )); } - - #[cfg_attr(coverage_nightly, coverage(off))] - #[test] - fn storage_supports_parentless_relative_files() { - let temp = tempfile::tempdir().expect("tempdir"); - let previous = std::env::current_dir().expect("current dir"); - std::env::set_current_dir(temp.path()).expect("set temp cwd"); - - let identity = RadrootsIdentity::from_secret_key_str( - "1111111111111111111111111111111111111111111111111111111111111111", - ) - .expect("identity"); - let encrypted_path = Path::new("identity.enc.json"); - store_encrypted_identity(encrypted_path, &identity).expect("store encrypted"); - let loaded = load_encrypted_identity(encrypted_path).expect("load encrypted"); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - - let profile_path = Path::new("profile.json"); - store_identity_profile(profile_path, &identity).expect("store profile"); - let loaded = load_identity_profile(profile_path).expect("load profile"); - assert_eq!(loaded.id, identity.id()); - - let empty_path = Path::new(""); - let encrypted_error = - store_encrypted_identity(empty_path, &identity).expect_err("empty encrypted path"); - assert!(matches!(encrypted_error, IdentityError::Write(_, _))); - let profile_error = - store_identity_profile(empty_path, &identity).expect_err("empty profile path"); - assert!(matches!(profile_error, IdentityError::Write(_, _))); - - std::env::set_current_dir(previous).expect("restore cwd"); - } } diff --git a/crates/identity/src/test_fixtures.rs b/crates/identity/src/test_fixtures.rs @@ -1,107 +0,0 @@ -#![forbid(unsafe_code)] -#![allow(dead_code)] - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ApprovedFixtureIdentity { - pub label: &'static str, - pub username: &'static str, - pub email: &'static str, - pub secret_key_hex: &'static str, - pub public_key_hex: &'static str, - pub nsec: &'static str, - pub npub: &'static str, -} - -pub const APPROVED_FIXTURE_NAMESPACE: &str = "radroots-approved-fixture-v1"; - -pub const FIXTURE_ALICE_LABEL: &str = "fixture_alice"; -pub const FIXTURE_ALICE_USERNAME: &str = "fixture_alice"; -pub const FIXTURE_ALICE_EMAIL: &str = "fixture_alice@fixtures.test"; -pub const FIXTURE_ALICE_SECRET_KEY_HEX: &str = - "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; -pub const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str = - "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; -pub const FIXTURE_ALICE_NSEC: &str = - "nsec1zrznqntvnt36rgt00ps0rny0tca8vgj6ye3m82vf5rthtyvm0h6syu7drz"; -pub const FIXTURE_ALICE_NPUB: &str = - "npub1tp2ez55a5zatxxemrv0eses3ea05xhw2snuh3jy7azjqejn3q00s3vy5a9"; -pub const FIXTURE_ALICE: ApprovedFixtureIdentity = ApprovedFixtureIdentity { - label: FIXTURE_ALICE_LABEL, - username: FIXTURE_ALICE_USERNAME, - email: FIXTURE_ALICE_EMAIL, - secret_key_hex: FIXTURE_ALICE_SECRET_KEY_HEX, - public_key_hex: FIXTURE_ALICE_PUBLIC_KEY_HEX, - nsec: FIXTURE_ALICE_NSEC, - npub: FIXTURE_ALICE_NPUB, -}; - -pub const FIXTURE_BOB_LABEL: &str = "fixture_bob"; -pub const FIXTURE_BOB_USERNAME: &str = "fixture_bob"; -pub const FIXTURE_BOB_EMAIL: &str = "fixture_bob@fixtures.test"; -pub const FIXTURE_BOB_SECRET_KEY_HEX: &str = - "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8"; -pub const FIXTURE_BOB_PUBLIC_KEY_HEX: &str = - "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; -pub const FIXTURE_BOB_NSEC: &str = - "nsec1tyujayrg7ejrrvf0wqscldsjs89kksea0unu2htp7xnrlcdfdluqrjya9h"; -pub const FIXTURE_BOB_NPUB: &str = - "npub1uqnxu08mp55gd7guw06ls68nhxp8xuf7tlxe0sypvcl42x9ykwhsd55k2g"; -pub const FIXTURE_BOB: ApprovedFixtureIdentity = ApprovedFixtureIdentity { - label: FIXTURE_BOB_LABEL, - username: FIXTURE_BOB_USERNAME, - email: FIXTURE_BOB_EMAIL, - secret_key_hex: FIXTURE_BOB_SECRET_KEY_HEX, - public_key_hex: FIXTURE_BOB_PUBLIC_KEY_HEX, - nsec: FIXTURE_BOB_NSEC, - npub: FIXTURE_BOB_NPUB, -}; - -pub const FIXTURE_CAROL_LABEL: &str = "fixture_carol"; -pub const FIXTURE_CAROL_USERNAME: &str = "fixture_carol"; -pub const FIXTURE_CAROL_EMAIL: &str = "fixture_carol@fixtures.test"; -pub const FIXTURE_CAROL_SECRET_KEY_HEX: &str = - "4d6c20fdd86857de77ff5cfa5c545751ba2efd126e0b6642dae9764d782d6509"; -pub const FIXTURE_CAROL_PUBLIC_KEY_HEX: &str = - "1952b8c6943898bceffcff1b7699c4a775a4d13b4a9ba0096ba26ef04492bb1c"; -pub const FIXTURE_CAROL_NSEC: &str = - "nsec1f4kzplwcdptaualltna9c4zh2xazalgjdc9kvsk6a9my67pdv5ys2pqkaj"; -pub const FIXTURE_CAROL_NPUB: &str = - "npub1r9ft33558zvtemluludhdxwy5a66f5fmf2d6qztt5fh0q3yjhvwqgzmkl6"; -pub const FIXTURE_CAROL: ApprovedFixtureIdentity = ApprovedFixtureIdentity { - label: FIXTURE_CAROL_LABEL, - username: FIXTURE_CAROL_USERNAME, - email: FIXTURE_CAROL_EMAIL, - secret_key_hex: FIXTURE_CAROL_SECRET_KEY_HEX, - public_key_hex: FIXTURE_CAROL_PUBLIC_KEY_HEX, - nsec: FIXTURE_CAROL_NSEC, - npub: FIXTURE_CAROL_NPUB, -}; - -pub const FIXTURE_DIEGO_LABEL: &str = "fixture_diego"; -pub const FIXTURE_DIEGO_USERNAME: &str = "fixture_diego"; -pub const FIXTURE_DIEGO_EMAIL: &str = "fixture_diego@fixtures.test"; -pub const FIXTURE_DIEGO_SECRET_KEY_HEX: &str = - "9de56c1fdfce9ab00af85b3d7003c1d15cffb84cdf303c3a83c1a3fb1a2d0db0"; -pub const FIXTURE_DIEGO_PUBLIC_KEY_HEX: &str = - "5d3eab6e78eb7e467a9e196a63456c9fafb93fb88b7052b83229870889923aa4"; -pub const FIXTURE_DIEGO_NSEC: &str = - "nsec1nhjkc87le6dtqzhctv7hqq7p69w0lwzvmucrcw5rcx3lkx3dpkcqkrmgp5"; -pub const FIXTURE_DIEGO_NPUB: &str = - "npub1t5l2kmncadlyv757r94xx3tvn7hmj0ac3dc99wpj9xrs3zvj82jqwwcglm"; -pub const FIXTURE_DIEGO: ApprovedFixtureIdentity = ApprovedFixtureIdentity { - label: FIXTURE_DIEGO_LABEL, - username: FIXTURE_DIEGO_USERNAME, - email: FIXTURE_DIEGO_EMAIL, - secret_key_hex: FIXTURE_DIEGO_SECRET_KEY_HEX, - public_key_hex: FIXTURE_DIEGO_PUBLIC_KEY_HEX, - nsec: FIXTURE_DIEGO_NSEC, - npub: FIXTURE_DIEGO_NPUB, -}; - -pub const RELAY_PRIMARY_WSS: &str = "wss://relay.example.com"; -pub const RELAY_SECONDARY_WSS: &str = "wss://relay-2.example.com"; -pub const RELAY_TERTIARY_WSS: &str = "wss://relay-3.example.com"; - -pub const APP_PRIMARY_HTTPS: &str = "https://app.example.com"; -pub const API_PRIMARY_HTTPS: &str = "https://api.example.com"; -pub const CDN_PRIMARY_HTTPS: &str = "https://cdn.example.com"; diff --git a/crates/identity/tests/identity.rs b/crates/identity/tests/identity.rs @@ -1,1017 +0,0 @@ -#[path = "../src/test_fixtures.rs"] -mod test_fixtures; - -use radroots_identity::{ - DEFAULT_IDENTITY_PATH, IdentityError, RadrootsIdentity, RadrootsIdentityId, - RadrootsIdentityProfile, RadrootsIdentityPublic, RadrootsIdentitySecretKeyFormat, -}; -use radroots_identity::{ - RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT, RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, - RadrootsEncryptedIdentityFile, encrypted_identity_wrapping_key_path, load_encrypted_identity, - load_encrypted_identity_with_key_slot, load_identity_profile, rotate_encrypted_identity, - rotate_encrypted_identity_with_key_slot, store_encrypted_identity, - store_encrypted_identity_with_key_slot, store_identity_profile, -}; -#[cfg(feature = "nip49")] -use radroots_identity::{ - RadrootsIdentityEncryptedSecretKeyOptions, RadrootsIdentityEncryptedSecretKeySecurity, -}; -use radroots_protected_store::{RadrootsProtectedFileKeySource, RadrootsProtectedStoreEnvelope}; -use radroots_runtime_paths::{ - RadrootsHostEnvironment, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, - RadrootsPlatform, -}; -use std::path::PathBuf; -use test_fixtures::{ApprovedFixtureIdentity, FIXTURE_ALICE, FIXTURE_BOB}; - -const MISSING_HOME_CHILD: &str = "RADROOTS_IDENTITY_MISSING_HOME_CHILD"; - -fn fixture_keys(fixture: ApprovedFixtureIdentity) -> nostr::Keys { - let secret = nostr::SecretKey::from_hex(fixture.secret_key_hex).unwrap(); - nostr::Keys::new(secret) -} - -fn fixture_identity(fixture: ApprovedFixtureIdentity) -> RadrootsIdentity { - RadrootsIdentity::from_secret_key_str(fixture.secret_key_hex).unwrap() -} - -fn profile_with_identifier(value: &str) -> RadrootsIdentityProfile { - RadrootsIdentityProfile { - identifier: Some(value.to_string()), - ..Default::default() - } -} - -fn sample_event(content: &str) -> nostr::Event { - nostr::EventBuilder::text_note(content) - .sign_with_keys(&fixture_keys(FIXTURE_ALICE)) - .unwrap() -} - -#[test] -fn load_from_json_file_hex() { - let identity = fixture_identity(FIXTURE_ALICE); - let json = serde_json::to_string(&identity.to_file()).unwrap(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - std::fs::write(&path, json).unwrap(); - - let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); - assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn legacy_embedded_profile_is_rejected_instead_of_silently_discarded() { - let identity = fixture_identity(FIXTURE_ALICE); - let mut file = serde_json::to_value(identity.to_file()).unwrap(); - file.as_object_mut().unwrap().insert( - "profile".to_owned(), - serde_json::json!({ - "name": "legacy-profile", - "picture": "https://example.test/unverified.png" - }), - ); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - let encoded = serde_json::to_vec_pretty(&file).unwrap(); - std::fs::write(&path, &encoded).unwrap(); - - let error = RadrootsIdentity::load_from_path_auto(&path).unwrap_err(); - assert!( - matches!(&error, IdentityError::InvalidJson(source) if source.to_string().contains("unknown field `profile`")), - "unexpected error: {error}" - ); - assert_eq!(std::fs::read(path).unwrap(), encoded); -} - -#[test] -fn legacy_nested_identity_profile_is_rejected_without_rewriting() { - let identity = fixture_identity(FIXTURE_ALICE); - let mut public = serde_json::to_value( - identity - .to_public() - .with_profile(profile_with_identifier("alice")), - ) - .unwrap(); - public["profile"].as_object_mut().unwrap().insert( - "profile".to_owned(), - serde_json::json!({ - "name": "legacy-profile", - "picture": "https://example.test/unverified.png" - }), - ); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("profile.json"); - let encoded = serde_json::to_vec_pretty(&public).unwrap(); - std::fs::write(&path, &encoded).unwrap(); - - let error = load_identity_profile(&path).unwrap_err(); - assert!(matches!(error, IdentityError::InvalidJson(_))); - assert_eq!(std::fs::read(path).unwrap(), encoded); -} - -#[test] -fn load_from_text_file_hex() { - let identity = fixture_identity(FIXTURE_ALICE); - let secret = identity.secret_key_hex(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.txt"); - std::fs::write(&path, secret).unwrap(); - - let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); - assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn load_from_text_file_nsec() { - let identity = fixture_identity(FIXTURE_ALICE); - let secret = identity.secret_key_nsec(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.txt"); - std::fs::write(&path, secret).unwrap(); - - let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); - assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn load_from_binary_file() { - let identity = fixture_identity(FIXTURE_ALICE); - let secret = identity.secret_key_bytes(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.key"); - std::fs::write(&path, secret).unwrap(); - - let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); - assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn load_or_generate_missing_disallowed() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - - let err = RadrootsIdentity::load_or_generate(Some(&path), false).unwrap_err(); - assert!(matches!(err, IdentityError::GenerationNotAllowed(p) if p == path)); -} - -#[test] -fn load_or_generate_missing_allowed_creates_json() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - - let identity = RadrootsIdentity::load_or_generate(Some(&path), true).unwrap(); - assert!(path.exists()); - - let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); - assert_eq!(loaded.public_key(), identity.public_key()); -} - -#[test] -fn load_from_json_file_public_key_npub() { - let identity = fixture_identity(FIXTURE_ALICE); - let mut file = identity.to_file(); - file.public_key = Some(identity.public_key_npub()); - let json = serde_json::to_string(&file).unwrap(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - std::fs::write(&path, json).unwrap(); - - let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); - assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn load_from_json_file_public_key_mismatch() { - let identity = fixture_identity(FIXTURE_ALICE); - let mut file = identity.to_file(); - file.public_key = Some(FIXTURE_BOB.public_key_hex.to_string()); - let json = serde_json::to_string(&file).unwrap(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - std::fs::write(&path, json).unwrap(); - - let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err(); - assert!(matches!(err, IdentityError::PublicKeyMismatch)); -} - -#[test] -fn identity_id_matches_public_key_hex() { - let identity = fixture_identity(FIXTURE_ALICE); - - let id = identity.id(); - assert_eq!(id.as_str(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn identity_id_parses_hex_and_npub() { - let from_hex = RadrootsIdentityId::parse(FIXTURE_ALICE.public_key_hex).unwrap(); - let from_npub = RadrootsIdentityId::parse(FIXTURE_ALICE.npub).unwrap(); - assert_eq!(from_hex.as_str(), FIXTURE_ALICE.public_key_hex); - assert_eq!(from_npub.as_str(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn to_public_projection_excludes_secret_key_fields() { - let identity = fixture_identity(FIXTURE_ALICE); - let public = identity.to_public(); - - assert_eq!(public.id.as_str(), FIXTURE_ALICE.public_key_hex); - assert_eq!(public.public_key_hex, FIXTURE_ALICE.public_key_hex); - assert_eq!(public.public_key_npub, FIXTURE_ALICE.npub); - assert!(public.profile.is_none()); - - let json = serde_json::to_string(&public).unwrap(); - assert!(!json.contains("secret_key")); - assert!(!json.contains(&identity.secret_key_hex())); -} - -#[test] -fn identity_id_trait_paths_and_string_conversions() { - let public_key = fixture_identity(FIXTURE_ALICE).public_key(); - let public_key_hex = FIXTURE_ALICE.public_key_hex.to_string(); - - let from_impl = RadrootsIdentityId::from(public_key); - assert_eq!(from_impl.as_ref(), public_key_hex); - - let from_try = RadrootsIdentityId::try_from(public_key_hex.as_str()).unwrap(); - assert_eq!(from_try.to_string(), public_key_hex); - assert_eq!(from_try.clone().into_string(), public_key_hex); -} - -#[test] -fn identity_profile_state_mutation_paths() { - let mut identity = RadrootsIdentity::with_profile( - fixture_keys(FIXTURE_ALICE), - RadrootsIdentityProfile::default(), - ); - assert!(identity.profile().is_none()); - - identity.set_profile(RadrootsIdentityProfile::default()); - assert!(identity.profile().is_none()); - - let profile = profile_with_identifier("radroots-user"); - identity.set_profile(profile.clone()); - assert!(identity.profile().is_some()); - - let profile_mut = identity.profile_mut().unwrap(); - profile_mut.identifier = Some("radroots-user-updated".to_string()); - assert_eq!( - identity.profile().and_then(|p| p.identifier.as_deref()), - Some("radroots-user-updated") - ); - - let public = identity.to_public(); - assert!(public.profile.is_some()); - - identity.clear_profile(); - assert!(identity.profile().is_none()); - - let public_without_profile = RadrootsIdentityPublic::new(identity.public_key()) - .with_profile(RadrootsIdentityProfile::default()); - assert!(public_without_profile.profile.is_none()); - - let public_with_profile = - RadrootsIdentityPublic::new(identity.public_key()).with_profile(profile); - assert!(public_with_profile.profile.is_some()); -} - -#[test] -fn identity_accessor_paths_and_secret_formats() { - let identity = fixture_identity(FIXTURE_ALICE); - - assert_eq!( - identity.keys().public_key().to_hex(), - FIXTURE_ALICE.public_key_hex - ); - assert_eq!(identity.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); - assert_eq!(identity.npub(), FIXTURE_ALICE.npub); - assert_eq!(identity.nsec(), FIXTURE_ALICE.nsec); - - let file_nsec = identity.to_file_with_secret_format(RadrootsIdentitySecretKeyFormat::Nsec); - assert_eq!(file_nsec.secret_key, FIXTURE_ALICE.nsec); - - let from_keys: RadrootsIdentity = fixture_keys(FIXTURE_ALICE).into(); - let roundtrip_keys = from_keys.clone().into_keys(); - assert_eq!( - roundtrip_keys.public_key().to_hex(), - FIXTURE_ALICE.public_key_hex - ); -} - -#[cfg(feature = "nip49")] -#[test] -fn encrypted_secret_key_round_trips_to_identity() { - let identity = fixture_identity(FIXTURE_ALICE); - let encrypted = identity - .encrypt_secret_key_ncryptsec("fixture-password") - .unwrap(); - assert!(encrypted.starts_with("ncryptsec1")); - - let decrypted = - RadrootsIdentity::from_encrypted_secret_key_str(&encrypted, "fixture-password").unwrap(); - assert_eq!(decrypted.public_key(), identity.public_key()); -} - -#[cfg(feature = "nip49")] -#[test] -fn encrypted_secret_key_options_propagate_to_output() { - use nostr::nips::nip19::FromBech32; - use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity}; - - let identity = fixture_identity(FIXTURE_ALICE); - let encrypted = identity - .encrypt_secret_key_ncryptsec_with_options( - "fixture-password", - RadrootsIdentityEncryptedSecretKeyOptions { - log_n: 15, - key_security: RadrootsIdentityEncryptedSecretKeySecurity::Medium, - }, - ) - .unwrap(); - let parsed = EncryptedSecretKey::from_bech32(&encrypted).unwrap(); - assert_eq!(parsed.log_n(), 15); - assert_eq!(parsed.key_security(), KeySecurity::Medium); -} - -#[cfg(feature = "nip49")] -#[test] -fn encrypted_secret_key_weak_security_and_invalid_log_n_paths() { - use nostr::nips::nip49::KeySecurity; - - assert_eq!( - KeySecurity::from(RadrootsIdentityEncryptedSecretKeySecurity::Weak), - KeySecurity::Weak - ); - - let identity = fixture_identity(FIXTURE_ALICE); - let err = identity - .encrypt_secret_key_ncryptsec_with_options( - "fixture-password", - RadrootsIdentityEncryptedSecretKeyOptions { - log_n: 255, - key_security: RadrootsIdentityEncryptedSecretKeySecurity::Weak, - }, - ) - .unwrap_err(); - assert!(matches!(err, IdentityError::EncryptSecretKey(_))); -} - -#[cfg(feature = "nip49")] -#[test] -fn encrypted_secret_key_rejects_invalid_and_wrong_password_inputs() { - let identity = fixture_identity(FIXTURE_ALICE); - let encrypted = identity - .encrypt_secret_key_ncryptsec("fixture-password") - .unwrap(); - - let invalid = - RadrootsIdentity::from_encrypted_secret_key_str("not-an-encrypted-secret", "password") - .unwrap_err(); - assert!(matches!( - invalid, - IdentityError::InvalidEncryptedSecretKey(_) - )); - - let wrong_password = - RadrootsIdentity::from_encrypted_secret_key_str(&encrypted, "wrong-password").unwrap_err(); - assert!(matches!( - wrong_password, - IdentityError::DecryptEncryptedSecretKey(_) - )); -} - -#[cfg(feature = "nip49")] -#[test] -fn load_from_path_auto_rejects_nip49_export_format() { - let identity = fixture_identity(FIXTURE_ALICE); - let encrypted = identity - .encrypt_secret_key_ncryptsec("fixture-password") - .unwrap(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.ncryptsec"); - std::fs::write(&path, encrypted).unwrap(); - - let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err(); - assert!(matches!(err, IdentityError::InvalidSecretKey(_))); -} - -#[test] -fn parse_failures_cover_public_key_errors() { - let err_empty = RadrootsIdentityId::parse(" ").unwrap_err(); - assert!(matches!(err_empty, IdentityError::InvalidPublicKey(_))); - - let err_invalid = RadrootsIdentityId::parse("invalid-public-key-value").unwrap_err(); - assert!(matches!(err_invalid, IdentityError::InvalidPublicKey(_))); -} - -#[test] -fn from_secret_key_bytes_rejects_wrong_length() { - let err = RadrootsIdentity::from_secret_key_bytes(&[1, 2, 3]).unwrap_err(); - assert!(matches!(err, IdentityError::InvalidIdentityFormat)); -} - -#[test] -fn from_secret_key_str_rejects_invalid_secret() { - let err = RadrootsIdentity::from_secret_key_str("not-a-secret-key").unwrap_err(); - assert!(matches!(err, IdentityError::InvalidSecretKey(_))); -} - -#[test] -fn from_secret_key_bytes_rejects_invalid_scalar() { - let err = RadrootsIdentity::from_secret_key_bytes(&[0u8; 32]).unwrap_err(); - assert!(matches!(err, IdentityError::InvalidSecretKey(_))); -} - -#[test] -fn load_from_path_reports_not_found_and_read_errors() { - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("missing-identity.json"); - let not_found = RadrootsIdentity::load_from_path_auto(&missing).unwrap_err(); - assert!(matches!(not_found, IdentityError::NotFound(path) if path == missing)); - - let read_error = RadrootsIdentity::load_from_path_auto(dir.path()).unwrap_err(); - assert!(matches!(read_error, IdentityError::Read(path, _) if path == dir.path())); -} - -#[test] -fn load_from_path_rejects_invalid_payloads() { - let dir = tempfile::tempdir().unwrap(); - - let blank_path = dir.path().join("identity-blank.txt"); - std::fs::write(&blank_path, " \n\t ").unwrap(); - let blank_err = RadrootsIdentity::load_from_path_auto(&blank_path).unwrap_err(); - assert!(matches!(blank_err, IdentityError::InvalidIdentityFormat)); - - let invalid_utf8_path = dir.path().join("identity-invalid-utf8.bin"); - std::fs::write(&invalid_utf8_path, [0xff, 0xfe, 0xfd]).unwrap(); - let utf8_err = RadrootsIdentity::load_from_path_auto(&invalid_utf8_path).unwrap_err(); - assert!(matches!(utf8_err, IdentityError::InvalidIdentityFormat)); - - let invalid_json_path = dir.path().join("identity-invalid-json.json"); - std::fs::write(&invalid_json_path, "{invalid").unwrap(); - let json_err = RadrootsIdentity::load_from_path_auto(&invalid_json_path).unwrap_err(); - assert!(matches!(json_err, IdentityError::InvalidJson(_))); -} - -#[test] -fn load_from_json_file_without_public_key_succeeds() { - let identity = fixture_identity(FIXTURE_ALICE); - let mut file = identity.to_file(); - file.public_key = None; - let json = serde_json::to_string(&file).unwrap(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - std::fs::write(&path, json).unwrap(); - - let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); - assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn load_from_json_file_rejects_invalid_secret_key_string() { - let payload = serde_json::json!({ - "secret_key": "invalid-secret-key", - "public_key": null, - }); - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - std::fs::write(&path, payload.to_string()).unwrap(); - - let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err(); - assert!(matches!(err, IdentityError::InvalidSecretKey(_))); -} - -#[test] -fn load_from_json_file_rejects_invalid_public_key_value() { - let identity = fixture_identity(FIXTURE_ALICE); - let mut file = identity.to_file(); - file.public_key = Some("invalid-public-key".to_string()); - let json = serde_json::to_string(&file).unwrap(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - std::fs::write(&path, json).unwrap(); - - let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err(); - assert!(matches!(err, IdentityError::InvalidPublicKey(_))); -} - -#[test] -fn save_json_rejects_directory_target() { - let identity = fixture_identity(FIXTURE_ALICE); - let dir = tempfile::tempdir().unwrap(); - let err = identity.save_json(dir.path()).unwrap_err(); - assert!(matches!(err, IdentityError::Store(_))); -} - -#[cfg(unix)] -#[test] -fn save_json_reports_write_failure_on_read_only_directory() { - use std::os::unix::fs::PermissionsExt; - - let identity = fixture_identity(FIXTURE_ALICE); - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - identity.save_json(path.as_path()).unwrap(); - - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap(); - let err_path = identity.save_json(path.as_path()).unwrap_err(); - assert!(matches!(err_path, IdentityError::Store(_))); - let err_path_buf = identity.save_json(&path).unwrap_err(); - assert!(matches!(err_path_buf, IdentityError::Store(_))); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); -} - -#[cfg(unix)] -#[test] -fn load_or_generate_reports_save_failure_when_parent_not_writable() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().unwrap(); - let parent = dir.path().join("readonly"); - std::fs::create_dir(&parent).unwrap(); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap(); - - let path = parent.join("identity.json"); - let err = RadrootsIdentity::load_or_generate::<&std::path::Path>(Some(path.as_path()), true) - .unwrap_err(); - assert!(matches!(err, IdentityError::Store(_))); - let err_path_buf = RadrootsIdentity::load_or_generate(Some(&path), true).unwrap_err(); - assert!(matches!(err_path_buf, IdentityError::Store(_))); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); -} - -#[test] -fn load_or_generate_uses_default_path_when_missing() { - let resolver = RadrootsPathResolver::new( - RadrootsPlatform::Linux, - RadrootsHostEnvironment { - home_dir: Some(PathBuf::from("/home/treesap")), - ..RadrootsHostEnvironment::default() - }, - ); - let default_path = RadrootsIdentity::default_path_for( - &resolver, - RadrootsPathProfile::InteractiveUser, - &RadrootsPathOverrides::default(), - ) - .unwrap(); - - let denied = RadrootsIdentity::load_or_generate::<&std::path::Path>(Some(&default_path), false) - .unwrap_err(); - assert!(matches!(denied, IdentityError::GenerationNotAllowed(path) if path == default_path)); - assert_eq!( - default_path.file_name().and_then(std::ffi::OsStr::to_str), - Some(DEFAULT_IDENTITY_PATH) - ); - assert_eq!( - default_path, - PathBuf::from("/home/treesap/.radroots/secrets/shared/identities/default.json") - ); -} - -#[test] -fn default_path_matches_current_resolver_default_path() { - let expected = RadrootsIdentity::default_path_for( - &RadrootsPathResolver::current(), - RadrootsPathProfile::InteractiveUser, - &RadrootsPathOverrides::default(), - ) - .unwrap(); - - assert_eq!(RadrootsIdentity::default_path().unwrap(), expected); -} - -#[test] -fn default_path_for_reports_missing_home_dir() { - let resolver = - RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); - let err = RadrootsIdentity::default_path_for( - &resolver, - RadrootsPathProfile::InteractiveUser, - &RadrootsPathOverrides::default(), - ) - .unwrap_err(); - assert!(matches!(err, IdentityError::Paths(_))); -} - -#[test] -fn load_or_generate_without_explicit_path_propagates_default_path_errors() { - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args([ - "--exact", - "load_or_generate_without_explicit_path_child", - "--nocapture", - ]) - .env_remove("HOME") - .env(MISSING_HOME_CHILD, "1") - .output() - .unwrap(); - assert!( - output.status.success(), - "child test failed:\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn load_or_generate_without_explicit_path_child() { - if std::env::var_os(MISSING_HOME_CHILD).is_none() { - return; - } - let err = RadrootsIdentity::load_or_generate::<&std::path::Path>(None, false).unwrap_err(); - assert!(matches!(err, IdentityError::Paths(_))); -} - -#[test] -fn load_or_generate_creates_at_explicit_default_path() { - let dir = tempfile::tempdir().unwrap(); - let default_path = dir.path().join(DEFAULT_IDENTITY_PATH); - let generated = - RadrootsIdentity::load_or_generate::<&std::path::Path>(Some(&default_path), true).unwrap(); - assert!(default_path.exists()); - - let loaded = RadrootsIdentity::load_from_path_auto(&default_path).unwrap(); - assert_eq!(generated.public_key(), loaded.public_key()); -} - -#[test] -fn load_or_generate_prefers_existing_path() { - let identity = fixture_identity(FIXTURE_ALICE); - let payload = serde_json::to_string(&identity.to_file()).unwrap(); - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("identity.json"); - std::fs::write(&path, payload).unwrap(); - - let loaded = RadrootsIdentity::load_or_generate(Some(&path), false).unwrap(); - assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn path_ref_variants_cover_success_paths() { - let identity = fixture_identity(FIXTURE_ALICE); - let dir = tempfile::tempdir().unwrap(); - - let saved_path = dir.path().join("saved.json"); - identity.save_json(saved_path.as_path()).unwrap(); - let loaded = RadrootsIdentity::load_from_path_auto(saved_path.as_path()).unwrap(); - assert_eq!(loaded.public_key(), identity.public_key()); - - let generated_path = dir.path().join("generated.json"); - let generated = - RadrootsIdentity::load_or_generate(Some(generated_path.as_path()), true).unwrap(); - assert!(generated_path.exists()); - let roundtrip = RadrootsIdentity::load_from_path_auto(generated_path.as_path()).unwrap(); - assert_eq!(generated.public_key(), roundtrip.public_key()); -} - -#[test] -fn generate_with_profile_retains_profile() { - let profile = profile_with_identifier("runtime-user"); - let identity = RadrootsIdentity::generate_with_profile(profile); - assert_eq!( - identity.profile().and_then(|p| p.identifier.as_deref()), - Some("runtime-user") - ); -} - -#[test] -fn identity_profile_is_empty_checks_metadata_and_application_handler() { - let profile_with_metadata = RadrootsIdentityProfile { - metadata: Some(sample_event("metadata")), - ..Default::default() - }; - assert!(!profile_with_metadata.is_empty()); - - let profile_with_handler = RadrootsIdentityProfile { - application_handler: Some(sample_event("handler")), - ..Default::default() - }; - assert!(!profile_with_handler.is_empty()); -} - -#[test] -fn identity_error_display_variants_are_exercised() { - let missing_path = PathBuf::from("/tmp/missing-identity.json"); - assert_eq!( - IdentityError::NotFound(missing_path.clone()).to_string(), - format!("identity file missing at {}", missing_path.display()) - ); - assert_eq!( - IdentityError::GenerationNotAllowed(missing_path.clone()).to_string(), - format!( - "identity file missing at {} and generation is not permitted (pass --allow-generate-identity)", - missing_path.display() - ) - ); - assert!( - IdentityError::Read(missing_path.clone(), std::io::Error::other("boom")) - .to_string() - .contains("failed to read identity file") - ); - - let json_err = serde_json::from_str::<serde_json::Value>("{").unwrap_err(); - assert!( - IdentityError::InvalidJson(json_err) - .to_string() - .contains("invalid identity JSON") - ); - - let secret_err = nostr::Keys::parse("not-a-secret-key").unwrap_err(); - assert!( - IdentityError::InvalidSecretKey(secret_err) - .to_string() - .contains("invalid secret key") - ); - - #[cfg(feature = "nip49")] - { - assert_eq!( - IdentityError::EncryptSecretKey("encrypt failed".into()).to_string(), - "failed to encrypt secret key: encrypt failed" - ); - assert_eq!( - IdentityError::InvalidEncryptedSecretKey("bad payload".into()).to_string(), - "invalid encrypted secret key: bad payload" - ); - assert_eq!( - IdentityError::DecryptEncryptedSecretKey("bad password".into()).to_string(), - "failed to decrypt encrypted secret key: bad password" - ); - } - - assert_eq!( - IdentityError::InvalidPublicKey("bad-pubkey".into()).to_string(), - "invalid public key: bad-pubkey" - ); - assert_eq!( - IdentityError::PublicKeyMismatch.to_string(), - "public key does not match secret key" - ); - assert_eq!( - IdentityError::InvalidIdentityFormat.to_string(), - "unsupported identity file format" - ); - - #[cfg(all(feature = "std", feature = "json-file"))] - { - let store_err = fixture_identity(FIXTURE_ALICE) - .save_json(tempfile::tempdir().unwrap().path()) - .unwrap_err(); - assert!(!store_err.to_string().is_empty()); - } - - let paths_err = IdentityError::from( - radroots_runtime_paths::RadrootsRuntimePathsError::MissingHomeDir { - platform: RadrootsPlatform::Linux, - }, - ); - assert_eq!( - paths_err.to_string(), - "interactive_user on linux requires a home directory" - ); -} - -#[cfg(feature = "secrecy")] -#[test] -fn secret_key_hex_secret_returns_secret_string() { - use secrecy::ExposeSecret; - - let identity = fixture_identity(FIXTURE_ALICE); - let secret = identity.secret_key_hex_secret(); - assert_eq!(secret.expose_secret(), &identity.secret_key_hex()); -} - -#[cfg(feature = "zeroize")] -#[test] -fn secret_key_zeroizing_bytes_matches_raw_secret() { - let identity = fixture_identity(FIXTURE_ALICE); - let raw = identity.secret_key_bytes(); - let protected = identity.secret_key_bytes_zeroizing(); - assert_eq!(&*protected, &raw); -} - -#[test] -fn encrypted_identity_storage_public_api_round_trips_and_reports_errors() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("identity.enc.json"); - let identity = fixture_identity(FIXTURE_ALICE); - - let default_file = RadrootsEncryptedIdentityFile::new(path.as_path()); - assert_eq!(default_file.path(), path.as_path()); - assert_eq!( - default_file.key_slot(), - RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT - ); - assert_eq!( - default_file.wrapping_key_path(), - encrypted_identity_wrapping_key_path(path.as_path()) - ); - - let custom_file = - RadrootsEncryptedIdentityFile::with_key_slot(path.as_path(), "field_identity"); - assert_eq!(custom_file.key_slot(), "field_identity"); - - store_encrypted_identity(path.as_path(), &identity).unwrap(); - rotate_encrypted_identity(path.as_path()).unwrap(); - let loaded = load_encrypted_identity(path.as_path()).unwrap(); - assert_eq!(loaded.public_key(), identity.public_key()); - - store_encrypted_identity_with_key_slot(path.as_path(), "field_identity", &identity).unwrap(); - rotate_encrypted_identity_with_key_slot(path.as_path(), "field_identity").unwrap(); - let loaded = load_encrypted_identity_with_key_slot(path.as_path(), "field_identity").unwrap(); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - - let path_buf_api = temp.path().join("identity-pathbuf.enc.json"); - let path_buf_ref = &path_buf_api; - let path_buf_file = RadrootsEncryptedIdentityFile::new(path_buf_ref); - assert_eq!(path_buf_file.path(), path_buf_api.as_path()); - let path_buf_file = - RadrootsEncryptedIdentityFile::with_key_slot(path_buf_ref, "path_buf_identity"); - assert_eq!(path_buf_file.key_slot(), "path_buf_identity"); - store_encrypted_identity(path_buf_ref, &identity).unwrap(); - rotate_encrypted_identity(path_buf_ref).unwrap(); - let loaded = load_encrypted_identity(path_buf_ref).unwrap(); - assert_eq!(loaded.public_key(), identity.public_key()); - store_encrypted_identity_with_key_slot(path_buf_ref, "path_buf_identity", &identity).unwrap(); - rotate_encrypted_identity_with_key_slot(path_buf_ref, "path_buf_identity").unwrap(); - let loaded = load_encrypted_identity_with_key_slot(path_buf_ref, "path_buf_identity").unwrap(); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - - let missing = temp.path().join("missing.enc.json"); - let missing_error = load_encrypted_identity(missing.as_path()).unwrap_err(); - assert!(matches!(missing_error, IdentityError::NotFound(error_path) if error_path == missing)); - - let read_error = load_encrypted_identity(temp.path()).unwrap_err(); - assert!(matches!(read_error, IdentityError::Read(error_path, _) if error_path == temp.path())); - - let invalid = temp.path().join("invalid.enc.json"); - std::fs::write(&invalid, b"not-json").unwrap(); - let decode_error = load_encrypted_identity(invalid.as_path()).unwrap_err(); - assert!(matches!( - decode_error, - IdentityError::ProtectedStorage { path: error_path, message } - if error_path == invalid && message.contains("decode encrypted identity") - )); - - let invalid_plaintext = temp.path().join("invalid-plaintext.enc.json"); - let key_source = RadrootsProtectedFileKeySource::from_sidecar_suffix( - invalid_plaintext.as_path(), - RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, - ); - let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key( - &key_source, - RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT, - b"not identity json", - ) - .unwrap(); - std::fs::write(&invalid_plaintext, envelope.encode_json().unwrap()).unwrap(); - let invalid_plaintext_error = load_encrypted_identity(invalid_plaintext.as_path()).unwrap_err(); - assert!(matches!( - invalid_plaintext_error, - IdentityError::InvalidJson(_) - )); - - std::fs::write( - encrypted_identity_wrapping_key_path(path.as_path()), - b"short", - ) - .unwrap(); - let open_error = load_encrypted_identity(path.as_path()).unwrap_err(); - assert!(matches!( - open_error, - IdentityError::ProtectedStorage { path: error_path, message } - if error_path == path && message.contains("open encrypted identity") - )); -} - -#[test] -fn encrypted_identity_storage_public_api_reports_store_errors() { - let temp = tempfile::tempdir().unwrap(); - let identity = fixture_identity(FIXTURE_ALICE); - - let blocked_parent = temp.path().join("blocked-parent"); - std::fs::write(&blocked_parent, b"not-a-directory").unwrap(); - let create_path = blocked_parent.join("identity.enc.json"); - let create_error = store_encrypted_identity(create_path.as_path(), &identity).unwrap_err(); - assert!(matches!(create_error, IdentityError::CreateDir(path, _) if path == blocked_parent)); - - let directory_path = temp.path().join("identity-as-directory.enc.json"); - std::fs::create_dir(&directory_path).unwrap(); - let write_error = store_encrypted_identity(directory_path.as_path(), &identity).unwrap_err(); - assert!(matches!(write_error, IdentityError::Write(path, _) if path == directory_path)); - - let sealed_path = temp.path().join("seal-error.enc.json"); - std::fs::create_dir(encrypted_identity_wrapping_key_path(sealed_path.as_path())).unwrap(); - let seal_error = store_encrypted_identity(sealed_path.as_path(), &identity).unwrap_err(); - assert!(matches!( - seal_error, - IdentityError::ProtectedStorage { path, message } - if path == sealed_path && message.contains("seal encrypted identity") - )); -} - -#[cfg(unix)] -#[test] -fn encrypted_identity_storage_public_api_restores_key_after_rotation_failure() { - use std::os::unix::fs::PermissionsExt; - - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("identity.enc.json"); - let identity = fixture_identity(FIXTURE_ALICE); - - store_encrypted_identity(path.as_path(), &identity).unwrap(); - let key_path = encrypted_identity_wrapping_key_path(path.as_path()); - let key_before = std::fs::read(&key_path).unwrap(); - - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap(); - let error = rotate_encrypted_identity(path.as_path()).unwrap_err(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); - - assert!(matches!(error, IdentityError::Write(error_path, _) if error_path == path)); - assert_eq!(std::fs::read(&key_path).unwrap(), key_before); - let loaded = load_encrypted_identity(path.as_path()).unwrap(); - assert_eq!(loaded.public_key(), identity.public_key()); -} - -#[test] -fn identity_profile_storage_public_api_reports_errors_and_private_fallback() { - let temp = tempfile::tempdir().unwrap(); - let identity = fixture_identity(FIXTURE_ALICE); - - let path = temp.path().join("profile.json"); - store_identity_profile(path.as_path(), &identity).unwrap(); - let loaded = load_identity_profile(path.as_path()).unwrap(); - assert_eq!(loaded.id, identity.id()); - - let path_buf_profile = temp.path().join("profile-pathbuf.json"); - store_identity_profile(&path_buf_profile, &identity).unwrap(); - let loaded = load_identity_profile(&path_buf_profile).unwrap(); - assert_eq!(loaded.id, identity.id()); - - let blocked_parent = temp.path().join("blocked-profile-parent"); - std::fs::write(&blocked_parent, b"not-a-directory").unwrap(); - let create_path = blocked_parent.join("profile.json"); - let create_error = store_identity_profile(create_path.as_path(), &identity).unwrap_err(); - assert!(matches!(create_error, IdentityError::CreateDir(path, _) if path == blocked_parent)); - - let directory_path = temp.path().join("profile-as-directory.json"); - std::fs::create_dir(&directory_path).unwrap(); - let write_error = store_identity_profile(directory_path.as_path(), &identity).unwrap_err(); - assert!(matches!(write_error, IdentityError::Write(path, _) if path == directory_path)); - - let missing = temp.path().join("missing-profile.json"); - let missing_error = load_identity_profile(missing.as_path()).unwrap_err(); - assert!(matches!(missing_error, IdentityError::NotFound(path) if path == missing)); - - let read_error = load_identity_profile(temp.path()).unwrap_err(); - assert!(matches!(read_error, IdentityError::Read(path, _) if path == temp.path())); - - let private_profile = temp.path().join("private-profile.json"); - std::fs::write( - &private_profile, - serde_json::to_vec(&identity.to_file()).unwrap(), - ) - .unwrap(); - let loaded = load_identity_profile(private_profile.as_path()).unwrap(); - assert_eq!(loaded.public_key_hex, FIXTURE_ALICE.public_key_hex); -} - -#[test] -fn storage_public_api_supports_parentless_relative_files() { - let temp = tempfile::tempdir().unwrap(); - let previous = std::env::current_dir().unwrap(); - std::env::set_current_dir(temp.path()).unwrap(); - - let identity = fixture_identity(FIXTURE_ALICE); - let encrypted_path = std::path::Path::new("identity.enc.json"); - store_encrypted_identity(encrypted_path, &identity).unwrap(); - let loaded = load_encrypted_identity(encrypted_path).unwrap(); - assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); - - let profile_path = std::path::Path::new("profile.json"); - store_identity_profile(profile_path, &identity).unwrap(); - let loaded = load_identity_profile(profile_path).unwrap(); - assert_eq!(loaded.id, identity.id()); - - let empty_path = std::path::Path::new(""); - let encrypted_error = store_encrypted_identity(empty_path, &identity).unwrap_err(); - assert!(matches!(encrypted_error, IdentityError::Write(_, _))); - let profile_error = store_identity_profile(empty_path, &identity).unwrap_err(); - assert!(matches!(profile_error, IdentityError::Write(_, _))); - - std::env::set_current_dir(previous).unwrap(); -} diff --git a/docs/migration/identity.md b/docs/migration/identity.md @@ -0,0 +1,24 @@ +# Identity, signing, and secret ownership migration + +`radroots-identity` now owns public values only: `PublicKey`, `IdentityId`, +`AccountId`, `PublicIdentity`, `Profile`, and `Username`. The removed +`RadrootsIdentity` API, raw secret bytes, key generation, nsec encoding, NIP-49 +encryption/decryption, and encrypted identity files have no compatibility +aliases in this package. + +The approved destination boundaries are: + +- `radroots-nostr::key` for explicit Nostr key parsing, nsec/NIP-49 conversion, + and host-requested local Nostr key creation; +- `radroots-signing` for the signer SPI, requests, receipts, authorization, and + actor provenance, without owning raw secret bytes; +- `radroots-nostr::signing` for concrete local Nostr signing adapters; +- `radroots-secrets::{reference, provider, envelope, wrapping}` for secret + references, providers, wrapping, and versioned encrypted envelopes; +- host storage adapters composed from `radroots-secrets` and + `radroots-storage-sqlite` for durable secret persistence. + +Those destination APIs are introduced by their ordered release checkpoints. +Until then, callers must not recreate secret ownership in `radroots-identity` +or add a compatibility shim. Public identity profile file helpers remain only +for the immediately following filesystem-extraction checkpoint.