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:
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.