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:
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());
+}