app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 5990822112bae2624691fb90ec00454651c6bcd4
parent 41bbb6e18c119fbce09d8d805e815fcdf87158ca
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 18:01:51 +0000

core(domain): add Nostr public key identity

- validate canonical lowercase sixty-four-character hex
- retain exact thirty-two-byte identity representation
- expose deterministic display and shortened public helpers
- cover malformed input, round trips, and stable ordering

Diffstat:
Mcore/crates/domain/src/key.rs | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/domain/src/lib.rs | 1+
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
3 files changed, 144 insertions(+), 1 deletion(-)

diff --git a/core/crates/domain/src/key.rs b/core/crates/domain/src/key.rs @@ -1 +1,143 @@ //! Validated Nostr public and secret-key boundary values. + +use std::fmt::{self, Display, Formatter}; +use std::str::FromStr; + +use crate::{SafeError, SafeErrorCode, SafeMessage}; + +pub const PUBLIC_KEY_BYTE_LENGTH: usize = 32; +pub const PUBLIC_KEY_HEX_LENGTH: usize = PUBLIC_KEY_BYTE_LENGTH * 2; + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct PublicKey([u8; PUBLIC_KEY_BYTE_LENGTH]); + +impl PublicKey { + #[must_use] + pub const fn from_bytes(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Self { + Self(bytes) + } + + /// Parses a canonical lowercase hexadecimal Nostr public key. + /// + /// # Errors + /// + /// Returns a safe invalid-public-key error when the value is not exactly + /// 64 lowercase hexadecimal characters. + pub fn from_hex(value: &str) -> Result<Self, SafeError> { + if value.len() != PUBLIC_KEY_HEX_LENGTH + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(invalid_public_key()); + } + + let mut bytes = [0_u8; PUBLIC_KEY_BYTE_LENGTH]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + let high = decode_hex_digit(pair[0]).ok_or_else(invalid_public_key)?; + let low = decode_hex_digit(pair[1]).ok_or_else(invalid_public_key)?; + bytes[index] = (high << 4) | low; + } + Ok(Self(bytes)) + } + + #[must_use] + pub const fn as_bytes(&self) -> &[u8; PUBLIC_KEY_BYTE_LENGTH] { + &self.0 + } + + #[must_use] + pub fn to_hex(self) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(PUBLIC_KEY_HEX_LENGTH); + for byte in self.0 { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + output + } + + #[must_use] + pub fn short_hex(self) -> String { + let hex = self.to_hex(); + format!("{}…{}", &hex[..8], &hex[PUBLIC_KEY_HEX_LENGTH - 8..]) + } +} + +impl Display for PublicKey { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.to_hex()) + } +} + +impl From<[u8; PUBLIC_KEY_BYTE_LENGTH]> for PublicKey { + fn from(bytes: [u8; PUBLIC_KEY_BYTE_LENGTH]) -> Self { + Self::from_bytes(bytes) + } +} + +impl FromStr for PublicKey { + type Err = SafeError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::from_hex(value) + } +} + +const fn invalid_public_key() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidPublicKey, + SafeMessage::new("The Nostr public key is invalid."), + ) +} + +const fn decode_hex_digit(byte: u8) -> Option<u8> { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::{PUBLIC_KEY_BYTE_LENGTH, PublicKey}; + use crate::SafeErrorCode; + + const HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + + #[test] + fn public_key_round_trips_canonical_hex_and_bytes() { + let key = PublicKey::from_str(HEX).expect("valid public key"); + + assert_eq!(key.to_hex(), HEX); + assert_eq!(key.to_string(), HEX); + assert_eq!(key.short_hex(), "7e7e9c42…2107f6d7"); + assert_eq!(PublicKey::from_bytes(*key.as_bytes()), key); + assert_eq!(key.as_bytes().len(), PUBLIC_KEY_BYTE_LENGTH); + } + + #[test] + fn public_key_rejects_noncanonical_or_malformed_hex() { + for value in [ + "", + "00", + "7E7E9C42A91BFEF19FA7EA99D52D8AFDB67D893A8FEFBA1F5CB9793F2107F6D7", + "ze7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + " 7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + ] { + let error = PublicKey::from_hex(value).expect_err("invalid public key"); + assert_eq!(error.code(), SafeErrorCode::InvalidPublicKey); + } + } + + #[test] + fn public_keys_are_ordered_by_canonical_bytes() { + let low = PublicKey::from_bytes([0_u8; PUBLIC_KEY_BYTE_LENGTH]); + let high = PublicKey::from_bytes([1_u8; PUBLIC_KEY_BYTE_LENGTH]); + + assert!(low < high); + } +} diff --git a/core/crates/domain/src/lib.rs b/core/crates/domain/src/lib.rs @@ -8,3 +8,4 @@ pub mod relay; pub mod time; pub use error::{SafeError, SafeErrorCode, SafeMessage}; +pub use key::PublicKey; diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -457,7 +457,7 @@ handoff commit sequence. - [x] 03. Add Rust formatting, lint, and governed check hooks. Use Cargo, Gradle, and Makefile; do not add CI workflows or scripts. - [x] 04. Define domain module layout and safe error shell. -- [ ] 05. Implement Nostr public key value object. +- [x] 05. Implement Nostr public key value object. - [ ] 06. Implement secret input boundary and redacted secret wrapper. - [ ] 07. Add NIP-19 public/secret display contract types. - [ ] 08. Implement relay URL parser and policy.