lib

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

commit a8569c9979949970e23107b8f54501ffb454f9e9
parent 8e4b381e7f9b3ae5e166fe20abaa7c3a57c8d61f
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 18:55:33 +0000

core(nostr): add key generation and parsing adapter

- pin the approved upstream Nostr 0.44.1 source revision
- isolate random key generation behind Radroots-owned types
- derive canonical pubkey npub secret-hex and nsec values
- verify known nsec and secret-hex vectors with redaction

Diffstat:
Mcrates/studio_nostr/Cargo.toml | 4++++
Acrates/studio_nostr/src/keys.rs | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_nostr/src/lib.rs | 10+++-------
Mimports/studio_workspace/Cargo.toml | 1+
4 files changed, 159 insertions(+), 7 deletions(-)

diff --git a/crates/studio_nostr/Cargo.toml b/crates/studio_nostr/Cargo.toml @@ -6,5 +6,9 @@ rust-version.workspace = true license.workspace = true repository.workspace = true +[dependencies] +nostr.workspace = true +radroots-studio-domain = { path = "../domain" } + [lints] workspace = true diff --git a/crates/studio_nostr/src/keys.rs b/crates/studio_nostr/src/keys.rs @@ -0,0 +1,151 @@ +use nostr::{Keys, ToBech32}; +use radroots_studio_domain::{ + Npub, Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, +}; + +pub struct GeneratedKeyMaterial { + public_key: PublicKey, + npub: Npub, + secret: SecretKeyInput, + nsec: Nsec, +} + +impl GeneratedKeyMaterial { + #[must_use] + pub fn into_parts(self) -> (PublicKey, Npub, SecretKeyInput, Nsec) { + (self.public_key, self.npub, self.secret, self.nsec) + } +} + +pub struct ImportedKeyMaterial { + public_key: PublicKey, + npub: Npub, + secret: SecretKeyInput, +} + +impl ImportedKeyMaterial { + #[must_use] + pub fn into_parts(self) -> (PublicKey, Npub, SecretKeyInput) { + (self.public_key, self.npub, self.secret) + } +} + +/// Generates one cryptographically random local Nostr keypair. +/// +/// # Errors +/// +/// Returns a safe key error if an upstream encoding cannot be represented by +/// the stricter Radroots domain boundary. +pub fn generate_local_keypair() -> Result<GeneratedKeyMaterial, SafeError> { + let keys = Keys::generate(); + let (public_key, npub, secret, nsec) = encode_keys(&keys)?; + Ok(GeneratedKeyMaterial { + public_key, + npub, + secret, + nsec, + }) +} + +/// Parses nsec or canonical secret hex and derives public Nostr identity. +/// +/// # Errors +/// +/// Returns a safe invalid-secret-key error for checksum, scalar, or encoding +/// failures without exposing the rejected input. +pub fn import_secret(input: SecretKeyInput) -> Result<ImportedKeyMaterial, SafeError> { + let keys = input + .with_exposed_secret(Keys::parse) + .map_err(|_| invalid_secret_key())?; + drop(input); + let public_key = PublicKey::from_bytes(keys.public_key().to_bytes()); + let npub = keys + .public_key() + .to_bech32() + .map_err(|_| invalid_public_key()) + .and_then(Npub::from_encoded)?; + let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?; + Ok(ImportedKeyMaterial { + public_key, + npub, + secret, + }) +} + +fn encode_keys(keys: &Keys) -> Result<(PublicKey, Npub, SecretKeyInput, Nsec), SafeError> { + let public_key = PublicKey::from_bytes(keys.public_key().to_bytes()); + let npub = keys + .public_key() + .to_bech32() + .map_err(|_| invalid_public_key()) + .and_then(Npub::from_encoded)?; + let secret = SecretKeyInput::parse(keys.secret_key().to_secret_hex())?; + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|_| invalid_secret_key()) + .and_then(Nsec::from_encoded)?; + Ok((public_key, npub, secret, nsec)) +} + +const fn invalid_secret_key() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidSecretKey, + SafeMessage::new("The Nostr secret key is invalid."), + ) +} + +const fn invalid_public_key() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidPublicKey, + SafeMessage::new("The Nostr public key is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_domain::{SafeErrorCode, SecretKeyInput}; + + use super::{generate_local_keypair, import_secret}; + + const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; + const NSEC_PUBLIC_HEX: &str = + "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"; + const HEX_PUBLIC_HEX: &str = "0cfda0afa91cc2fbbd6050c285802fe95c7a1755e0f68323999e13760501dc40"; + + #[test] + fn keys_generate_valid_redacted_material() { + let generated = generate_local_keypair().expect("generated"); + let (public_key, npub, secret, nsec) = generated.into_parts(); + assert_eq!(public_key.to_hex().len(), 64); + assert!(npub.as_str().starts_with("npub1")); + assert_eq!(secret.with_exposed_secret(str::len), 64); + assert_eq!(nsec.with_exposed_secret(str::len), 63); + assert!(!format!("{secret:?} {nsec:?}").contains("nsec1")); + } + + #[test] + fn keys_import_known_nsec_and_hex_vectors() { + let from_nsec = import_secret(SecretKeyInput::parse(NSEC.to_owned()).expect("nsec")) + .expect("import nsec"); + let from_hex = import_secret(SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("hex")) + .expect("import hex"); + let (nsec_public, nsec_npub, _) = from_nsec.into_parts(); + let (hex_public, hex_npub, _) = from_hex.into_parts(); + assert_eq!(nsec_public.to_hex(), NSEC_PUBLIC_HEX); + assert_eq!(hex_public.to_hex(), HEX_PUBLIC_HEX); + assert!(nsec_npub.as_str().starts_with("npub1")); + assert!(hex_npub.as_str().starts_with("npub1")); + } + + #[test] + fn keys_reject_structurally_plausible_nsec_with_invalid_checksum() { + let input = SecretKeyInput::parse( + "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(), + ) + .expect("domain shape"); + let error = import_secret(input).err().expect("invalid checksum"); + assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); + } +} diff --git a/crates/studio_nostr/src/lib.rs b/crates/studio_nostr/src/lib.rs @@ -1,9 +1,5 @@ #![doc = "Radroots Studio Nostr protocol adapters."] -#[cfg(test)] -mod tests { - #[test] - fn crate_is_available_to_the_workspace() { - assert_eq!(env!("CARGO_PKG_NAME"), "radroots-studio-nostr"); - } -} +pub mod keys; + +pub use keys::{GeneratedKeyMaterial, ImportedKeyMaterial, generate_local_keypair, import_secret}; diff --git a/imports/studio_workspace/Cargo.toml b/imports/studio_workspace/Cargo.toml @@ -25,6 +25,7 @@ pedantic = "deny" [workspace.dependencies] keyring = "=4.1.6" +nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } refinery = { version = "=0.9.2", default-features = false, features = ["rusqlite"] } rusqlite = { version = "=0.39.0", features = ["bundled"] } secrecy = "=0.10.3"