lib

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

commit f431ef3a91938612079ce49cd405a5566d1d0eea
parent b67960ff3d076da140e14154e81f5719ccc82c89
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 18:15:17 +0000

identity: define canonical public key and identity identifiers

- add validated PublicKey, IdentityId, and AccountId byte newtypes
- canonicalize checked hexadecimal parsing, display, ordering, and serde
- separate portable identifier errors from the temporary legacy identity error
- prove host, wasm, feature, API-boundary, and no-string-deref behavior

Diffstat:
MCargo.lock | 1+
Mcrates/identity/Cargo.toml | 18++++++++++--------
Mcrates/identity/src/account.rs | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/identity/src/error.rs | 35+++++++++++++++++++++++------------
Mcrates/identity/src/key.rs | 364++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/identity/src/lib.rs | 15++++++++-------
Acrates/identity/tests/public_identifiers.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 566 insertions(+), 34 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4557,6 +4557,7 @@ version = "0.1.0" name = "radroots-identity" version = "0.1.0" dependencies = [ + "k256", "nostr", "radroots_protected_store", "radroots_runtime", diff --git a/crates/identity/Cargo.toml b/crates/identity/Cargo.toml @@ -16,23 +16,24 @@ readme = "README.md" name = "radroots_identity" [features] -default = ["std", "json-file", "nip49"] +default = ["std", "serde", "json-file", "nip49"] std = [ "dep:nostr", "dep:radroots_protected_store", "dep:radroots_runtime_paths", "dep:radroots_secret_vault", - "dep:serde", "dep:serde_json", - "dep:thiserror", "dep:tracing", + "thiserror/std", ] -json-file = ["std", "dep:radroots_runtime"] -nip49 = ["std", "nostr/nip49"] -secrecy = ["dep:secrecy"] -zeroize = ["dep:zeroize"] +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"] } radroots_runtime = { workspace = true, optional = true } radroots_protected_store = { workspace = true, optional = true, features = [ "std", @@ -45,11 +46,12 @@ 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, 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"] } tempfile = { workspace = true } [lints] diff --git a/crates/identity/src/account.rs b/crates/identity/src/account.rs @@ -1,4 +1,66 @@ //! Public account value types. -//! -//! The canonical account identifiers and records are introduced by the -//! ordered identity migration steps. + +use crate::{IdentityId, key::define_identifier}; + +define_identifier! { + /// A canonical public account identifier. + pub struct AccountId; +} + +impl AccountId { + /// Derives the account identifier from its public identity identifier. + #[must_use] + pub const fn from_identity_id(identity_id: IdentityId) -> Self { + Self::from_validated_bytes(identity_id.into_bytes()) + } +} + +impl From<IdentityId> for AccountId { + fn from(value: IdentityId) -> Self { + Self::from_identity_id(value) + } +} + +#[cfg(test)] +mod tests { + use core::str::FromStr; + + use super::*; + + const ALICE: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + const BOB: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; + + #[test] + fn account_ids_round_trip_through_identity_ids() { + let identity_id = IdentityId::from_hex(ALICE).expect("valid identity ID"); + let account_id = AccountId::from(identity_id); + + assert_eq!(account_id.to_hex(), ALICE); + assert_eq!(account_id.as_bytes(), identity_id.as_bytes()); + assert_eq!(AccountId::from_str(ALICE).unwrap(), account_id); + } + + #[test] + fn account_ids_validate_and_order_canonical_bytes() { + let alice = AccountId::from_hex(ALICE).expect("alice account"); + let bob = AccountId::from_hex(BOB).expect("bob account"); + + assert_eq!(AccountId::from_bytes(alice.into_bytes()).unwrap(), alice); + assert!(alice < bob); + assert!(AccountId::from_hex("not-an-account").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn account_ids_serde_as_validated_canonical_hex() { + let account_id = AccountId::from_hex(ALICE).expect("valid account ID"); + let encoded = serde_json::to_string(&account_id).expect("serialize account ID"); + + assert_eq!(encoded, format!("\"{ALICE}\"")); + assert_eq!( + serde_json::from_str::<AccountId>(&encoded).expect("deserialize account ID"), + account_id + ); + assert!(serde_json::from_str::<AccountId>("\"not-an-account\"").is_err()); + } +} diff --git a/crates/identity/src/error.rs b/crates/identity/src/error.rs @@ -1,35 +1,48 @@ use thiserror::Error; -#[cfg(not(feature = "std"))] -use alloc::string::String; - #[cfg(all(feature = "std", feature = "json-file"))] use radroots_runtime::RuntimeJsonError; #[cfg(feature = "std")] -use std::{io, path::PathBuf}; +use std::{io, path::PathBuf, string::String}; + +/// Errors produced while validating public identity values. +#[non_exhaustive] +#[derive(Debug, Error)] +pub enum Error { + #[error("identifier byte representation must contain {expected} bytes, but contained {actual}")] + InvalidByteLength { expected: usize, actual: usize }, + + #[error( + "identifier hexadecimal representation must contain {expected} bytes, but contained {actual}" + )] + InvalidHexLength { expected: usize, actual: usize }, + + #[error("identifier contains non-hexadecimal data at byte {index}")] + InvalidHexCharacter { index: usize }, + #[error("public key bytes are not a valid secp256k1 x-only public key")] + InvalidPublicKeyBytes, +} + +/// Transitional errors from the legacy secret and filesystem identity API. +#[cfg(feature = "std")] #[derive(Debug, Error)] pub enum IdentityError { - #[cfg(feature = "std")] #[error("identity file missing at {0}")] NotFound(PathBuf), - #[cfg(feature = "std")] #[error( "identity file missing at {0} and generation is not permitted \ (pass --allow-generate-identity)" )] GenerationNotAllowed(PathBuf), - #[cfg(feature = "std")] #[error("failed to read identity file at {0}: {1}")] Read(PathBuf, #[source] io::Error), - #[cfg(feature = "std")] #[error("failed to create identity directory {0}: {1}")] CreateDir(PathBuf, #[source] io::Error), - #[cfg(feature = "std")] #[error("failed to write identity file at {0}: {1}")] Write(PathBuf, #[source] io::Error), @@ -60,15 +73,13 @@ pub enum IdentityError { #[error("unsupported identity file format")] InvalidIdentityFormat, - #[cfg(all(feature = "std", feature = "json-file"))] + #[cfg(feature = "json-file")] #[error(transparent)] Store(#[from] RuntimeJsonError), - #[cfg(feature = "std")] #[error(transparent)] Paths(#[from] radroots_runtime_paths::RadrootsRuntimePathsError), - #[cfg(feature = "std")] #[error("protected identity storage error at {path}: {message}")] ProtectedStorage { path: PathBuf, message: String }, } diff --git a/crates/identity/src/key.rs b/crates/identity/src/key.rs @@ -1,4 +1,360 @@ -//! Canonical public-key value types. -//! -//! The canonical public-key and identity identifier definitions are -//! introduced by the ordered identity migration steps. +//! Canonical public-key and identity identifier value types. + +use crate::Error; + +pub(crate) const IDENTIFIER_BYTE_LENGTH: usize = 32; +pub(crate) const IDENTIFIER_HEX_LENGTH: usize = IDENTIFIER_BYTE_LENGTH * 2; + +const HEX_ALPHABET: &[u8; 16] = b"0123456789abcdef"; + +pub(crate) struct EncodedHex([u8; IDENTIFIER_HEX_LENGTH]); + +impl EncodedHex { + pub(crate) fn new(bytes: &[u8; IDENTIFIER_BYTE_LENGTH]) -> Self { + let mut encoded = [0; IDENTIFIER_HEX_LENGTH]; + for (index, byte) in bytes.iter().copied().enumerate() { + encoded[index * 2] = HEX_ALPHABET[usize::from(byte >> 4)]; + encoded[index * 2 + 1] = HEX_ALPHABET[usize::from(byte & 0x0f)]; + } + Self(encoded) + } + + pub(crate) fn as_str(&self) -> &str { + core::str::from_utf8(&self.0).expect("the hexadecimal alphabet is valid UTF-8") + } +} + +fn decode_nibble(byte: u8, index: usize) -> Result<u8, Error> { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + b'A'..=b'F' => Ok(byte - b'A' + 10), + _ => Err(Error::InvalidHexCharacter { index }), + } +} + +pub(crate) fn parse_hex(value: &str) -> Result<[u8; IDENTIFIER_BYTE_LENGTH], Error> { + let encoded = value.as_bytes(); + if encoded.len() != IDENTIFIER_HEX_LENGTH { + return Err(Error::InvalidHexLength { + expected: IDENTIFIER_HEX_LENGTH, + actual: encoded.len(), + }); + } + + let mut bytes = [0; IDENTIFIER_BYTE_LENGTH]; + for (index, output) in bytes.iter_mut().enumerate() { + let high_index = index * 2; + let high = decode_nibble(encoded[high_index], high_index)?; + let low = decode_nibble(encoded[high_index + 1], high_index + 1)?; + *output = (high << 4) | low; + } + Ok(bytes) +} + +pub(crate) fn validate_public_key_bytes(bytes: &[u8; IDENTIFIER_BYTE_LENGTH]) -> Result<(), Error> { + let mut compressed = [0; IDENTIFIER_BYTE_LENGTH + 1]; + compressed[0] = 0x02; + compressed[1..].copy_from_slice(bytes); + k256::PublicKey::from_sec1_bytes(&compressed) + .map(|_| ()) + .map_err(|_| Error::InvalidPublicKeyBytes) +} + +macro_rules! define_identifier { + ($(#[$meta:meta])* $visibility:vis struct $name:ident;) => { + $(#[$meta])* + #[repr(transparent)] + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + $visibility struct $name([u8; $crate::key::IDENTIFIER_BYTE_LENGTH]); + + impl $name { + /// The canonical binary representation length. + pub const BYTE_LENGTH: usize = $crate::key::IDENTIFIER_BYTE_LENGTH; + + /// The canonical hexadecimal representation length. + pub const HEX_LENGTH: usize = $crate::key::IDENTIFIER_HEX_LENGTH; + + /// Constructs the value from its canonical fixed-width bytes. + pub fn from_bytes(bytes: [u8; Self::BYTE_LENGTH]) -> Result<Self, $crate::Error> { + $crate::key::validate_public_key_bytes(&bytes)?; + Ok(Self::from_validated_bytes(bytes)) + } + + pub(crate) const fn from_validated_bytes( + bytes: [u8; Self::BYTE_LENGTH], + ) -> Self { + Self(bytes) + } + + /// Parses an exact-width byte slice. + pub fn from_slice(bytes: &[u8]) -> Result<Self, $crate::Error> { + let bytes: [u8; Self::BYTE_LENGTH] = bytes.try_into().map_err(|_| { + $crate::Error::InvalidByteLength { + expected: Self::BYTE_LENGTH, + actual: bytes.len(), + } + })?; + Self::from_bytes(bytes) + } + + /// Parses a 64-character hexadecimal representation. + pub fn from_hex(value: &str) -> Result<Self, $crate::Error> { + Self::from_bytes($crate::key::parse_hex(value)?) + } + + /// Borrows the canonical fixed-width bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; Self::BYTE_LENGTH] { + &self.0 + } + + /// Returns the canonical fixed-width bytes. + #[must_use] + pub const fn into_bytes(self) -> [u8; Self::BYTE_LENGTH] { + self.0 + } + + /// Encodes the value as canonical lowercase hexadecimal text. + #[must_use] + pub fn to_hex(self) -> alloc::string::String { + alloc::string::String::from( + $crate::key::EncodedHex::new(&self.0).as_str(), + ) + } + } + + impl core::fmt::Debug for $name { + fn fmt( + &self, + formatter: &mut core::fmt::Formatter<'_>, + ) -> core::fmt::Result { + write!(formatter, "{}(\"{}\")", stringify!($name), self) + } + } + + impl core::fmt::Display for $name { + fn fmt( + &self, + formatter: &mut core::fmt::Formatter<'_>, + ) -> core::fmt::Result { + formatter.write_str($crate::key::EncodedHex::new(&self.0).as_str()) + } + } + + impl core::str::FromStr for $name { + type Err = $crate::Error; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::from_hex(value) + } + } + + impl TryFrom<&str> for $name { + type Error = $crate::Error; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + Self::from_hex(value) + } + } + + impl TryFrom<alloc::string::String> for $name { + type Error = $crate::Error; + + fn try_from(value: alloc::string::String) -> Result<Self, Self::Error> { + Self::from_hex(&value) + } + } + + impl TryFrom<&[u8]> for $name { + type Error = $crate::Error; + + fn try_from(value: &[u8]) -> Result<Self, Self::Error> { + Self::from_slice(value) + } + } + + impl TryFrom<[u8; $crate::key::IDENTIFIER_BYTE_LENGTH]> for $name { + type Error = $crate::Error; + + fn try_from( + value: [u8; $crate::key::IDENTIFIER_BYTE_LENGTH], + ) -> Result<Self, Self::Error> { + Self::from_bytes(value) + } + } + + impl AsRef<[u8]> for $name { + fn as_ref(&self) -> &[u8] { + self.as_bytes() + } + } + + #[cfg(feature = "serde")] + impl serde::Serialize for $name { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str($crate::key::EncodedHex::new(&self.0).as_str()) + } + } + + #[cfg(feature = "serde")] + impl<'de> serde::Deserialize<'de> for $name { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + struct IdentifierVisitor; + + impl serde::de::Visitor<'_> for IdentifierVisitor { + type Value = $name; + + fn expecting( + &self, + formatter: &mut core::fmt::Formatter<'_>, + ) -> core::fmt::Result { + formatter.write_str(concat!( + "a 64-character hexadecimal ", + stringify!($name) + )) + } + + fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> + where + E: serde::de::Error, + { + $name::from_hex(value).map_err(E::custom) + } + } + + deserializer.deserialize_str(IdentifierVisitor) + } + } + }; +} + +pub(crate) use define_identifier; + +define_identifier! { + /// A canonical 32-byte public key. + /// + /// The key is an explicit byte value and intentionally does not dereference + /// to text: + /// + /// ```compile_fail + /// use radroots_identity::PublicKey; + /// + /// fn accepts_text(_: &str) {} + /// let key = PublicKey::from_hex( + /// "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + /// ).unwrap(); + /// accepts_text(&key); + /// ``` + pub struct PublicKey; +} + +define_identifier! { + /// A canonical public identity identifier. + pub struct IdentityId; +} + +impl IdentityId { + /// Derives the identity identifier from its canonical public key. + #[must_use] + pub const fn from_public_key(public_key: PublicKey) -> Self { + Self::from_validated_bytes(public_key.into_bytes()) + } +} + +impl From<PublicKey> for IdentityId { + fn from(value: PublicKey) -> Self { + Self::from_public_key(value) + } +} + +#[cfg(test)] +mod tests { + use core::str::FromStr; + + use super::*; + + const ALICE: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + const BOB: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; + + #[test] + fn public_keys_validate_and_canonicalize_hex() { + let uppercase = ALICE.to_ascii_uppercase(); + let public_key = PublicKey::from_hex(&uppercase).expect("valid fixture public key"); + + assert_eq!(public_key.to_hex(), ALICE); + assert_eq!(public_key.to_string(), ALICE); + assert_eq!(PublicKey::from_str(ALICE).unwrap(), public_key); + assert_eq!(PublicKey::try_from(ALICE).unwrap(), public_key); + } + + #[test] + fn public_keys_reject_invalid_encodings_and_curve_points() { + assert!(matches!( + PublicKey::from_hex("00"), + Err(Error::InvalidHexLength { + expected: PublicKey::HEX_LENGTH, + actual: 2, + }) + )); + + let mut invalid_hex = ALICE.as_bytes().to_vec(); + invalid_hex[17] = b'g'; + let invalid_hex = String::from_utf8(invalid_hex).expect("ASCII test input"); + assert!(matches!( + PublicKey::from_hex(&invalid_hex), + Err(Error::InvalidHexCharacter { index: 17 }) + )); + assert!(matches!( + PublicKey::from_bytes([0; PublicKey::BYTE_LENGTH]), + Err(Error::InvalidPublicKeyBytes) + )); + assert!(matches!( + PublicKey::from_slice(&[0; PublicKey::BYTE_LENGTH - 1]), + Err(Error::InvalidByteLength { + expected: PublicKey::BYTE_LENGTH, + actual, + }) if actual == PublicKey::BYTE_LENGTH - 1 + )); + } + + #[test] + fn canonical_bytes_round_trip_and_order() { + let alice = PublicKey::from_hex(ALICE).expect("alice fixture"); + let bob = PublicKey::from_hex(BOB).expect("bob fixture"); + + assert_eq!(PublicKey::from_bytes(alice.into_bytes()).unwrap(), alice); + assert_eq!(PublicKey::try_from(alice.as_ref()).unwrap(), alice); + assert!(alice < bob); + } + + #[test] + fn identity_ids_are_distinct_key_derived_values() { + let public_key = PublicKey::from_hex(ALICE).expect("valid fixture public key"); + let identity_id = IdentityId::from(public_key); + + assert_eq!(identity_id.to_hex(), ALICE); + assert_eq!(identity_id.as_bytes(), public_key.as_bytes()); + assert_eq!(IdentityId::from_hex(ALICE).unwrap(), identity_id); + } + + #[cfg(feature = "serde")] + #[test] + fn key_values_serde_as_validated_canonical_hex() { + let public_key = PublicKey::from_hex(ALICE).expect("valid fixture public key"); + let encoded = serde_json::to_string(&public_key).expect("serialize public key"); + + assert_eq!(encoded, format!("\"{ALICE}\"")); + assert_eq!( + serde_json::from_str::<PublicKey>(&encoded).expect("deserialize public key"), + public_key + ); + assert!(serde_json::from_str::<IdentityId>("\"invalid\"").is_err()); + } +} diff --git a/crates/identity/src/lib.rs b/crates/identity/src/lib.rs @@ -2,32 +2,33 @@ #![cfg_attr(coverage_nightly, feature(coverage_attribute))] #![forbid(unsafe_code)] -#[cfg(not(feature = "std"))] extern crate alloc; pub mod account; -#[cfg(feature = "std")] pub mod error; -#[cfg(feature = "std")] +#[cfg(all(feature = "std", feature = "serde"))] pub mod identity; pub mod key; pub mod profile; -#[cfg(feature = "std")] +#[cfg(all(feature = "std", feature = "serde"))] pub mod storage; pub mod username; +pub use account::AccountId; +pub use error::Error; #[cfg(feature = "std")] pub use error::IdentityError; -#[cfg(feature = "std")] +#[cfg(all(feature = "std", feature = "serde"))] pub use identity::{ DEFAULT_IDENTITY_PATH, RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityId, RadrootsIdentityProfile, RadrootsIdentityPublic, RadrootsIdentitySecretKeyFormat, }; -#[cfg(all(feature = "std", feature = "nip49"))] +#[cfg(all(feature = "std", feature = "serde", feature = "nip49"))] pub use identity::{ RadrootsIdentityEncryptedSecretKeyOptions, RadrootsIdentityEncryptedSecretKeySecurity, }; -#[cfg(feature = "std")] +pub use key::{IdentityId, PublicKey}; +#[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, diff --git a/crates/identity/tests/public_identifiers.rs b/crates/identity/tests/public_identifiers.rs @@ -0,0 +1,99 @@ +use core::str::FromStr; + +use radroots_identity::{AccountId, Error, IdentityId, PublicKey}; + +const ALICE_PUBLIC_KEY: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; +const BOB_PUBLIC_KEY: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; + +#[test] +fn public_key_canonicalizes_hex_and_round_trips_bytes() { + let uppercase = ALICE_PUBLIC_KEY.to_ascii_uppercase(); + let public_key = PublicKey::from_hex(&uppercase).expect("valid fixture public key"); + + assert_eq!(public_key.to_string(), ALICE_PUBLIC_KEY); + assert_eq!(public_key.to_hex(), ALICE_PUBLIC_KEY); + assert_eq!(PublicKey::from_str(ALICE_PUBLIC_KEY).unwrap(), public_key); + assert_eq!( + PublicKey::try_from(public_key.into_bytes()).unwrap(), + public_key + ); + assert_eq!( + PublicKey::try_from(public_key.as_ref()).unwrap(), + public_key + ); +} + +#[test] +fn public_key_rejects_noncanonical_lengths_characters_and_points() { + assert!(matches!( + PublicKey::from_hex("00"), + Err(Error::InvalidHexLength { + expected: PublicKey::HEX_LENGTH, + actual: 2, + }) + )); + + let mut invalid_hex = ALICE_PUBLIC_KEY.as_bytes().to_vec(); + invalid_hex[17] = b'g'; + let invalid_hex = core::str::from_utf8(&invalid_hex).unwrap(); + assert!(matches!( + PublicKey::from_hex(invalid_hex), + Err(Error::InvalidHexCharacter { index: 17 }) + )); + + assert!(matches!( + PublicKey::from_slice(&[1; PublicKey::BYTE_LENGTH - 1]), + Err(Error::InvalidByteLength { + expected: PublicKey::BYTE_LENGTH, + actual, + }) if actual == PublicKey::BYTE_LENGTH - 1 + )); + assert!(matches!( + PublicKey::from_bytes([0; PublicKey::BYTE_LENGTH]), + Err(Error::InvalidPublicKeyBytes) + )); +} + +#[test] +fn identity_and_account_identifiers_preserve_semantics_and_ordering() { + let alice_key = PublicKey::from_hex(ALICE_PUBLIC_KEY).unwrap(); + let bob_key = PublicKey::from_hex(BOB_PUBLIC_KEY).unwrap(); + let alice_identity = IdentityId::from_public_key(alice_key); + let alice_account = AccountId::from_identity_id(alice_identity); + + assert_eq!(alice_identity.to_hex(), ALICE_PUBLIC_KEY); + assert_eq!(alice_account.to_hex(), ALICE_PUBLIC_KEY); + assert_eq!( + IdentityId::from_hex(ALICE_PUBLIC_KEY).unwrap(), + alice_identity + ); + assert_eq!( + AccountId::from_hex(ALICE_PUBLIC_KEY).unwrap(), + alice_account + ); + assert!(alice_identity < IdentityId::from_public_key(bob_key)); +} + +#[cfg(feature = "serde")] +#[test] +fn public_identifiers_use_checked_canonical_serde_strings() { + let public_key = PublicKey::from_hex(ALICE_PUBLIC_KEY).unwrap(); + let identity_id = IdentityId::from(public_key); + let account_id = AccountId::from(identity_id); + + for encoded in [ + serde_json::to_string(&public_key).unwrap(), + serde_json::to_string(&identity_id).unwrap(), + serde_json::to_string(&account_id).unwrap(), + ] { + assert_eq!(encoded, format!("\"{ALICE_PUBLIC_KEY}\"")); + } + + assert_eq!( + serde_json::from_str::<PublicKey>(&format!("\"{}\"", ALICE_PUBLIC_KEY.to_uppercase())) + .unwrap(), + public_key + ); + assert!(serde_json::from_str::<IdentityId>("\"invalid\"").is_err()); + assert!(serde_json::from_str::<AccountId>("null").is_err()); +}