lib

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

commit 646a0ea56c31e089ba3a1e92075ce7fcdc90fe85
parent 11f1fc28540bdc1977fff99aa5fe679630f65eff
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 23:23:27 +0000

custody: bound and clear imported key transport

- replace string FFI import with bounded secret byte transport
- zeroize Rust transport buffers on every validation path
- clear Kotlin drafts before dispatch and byte buffers after use
- preserve duplicate rejection and explicit missing-key repair

Diffstat:
Mcrates/studio_domain/Cargo.toml | 1+
Mcrates/studio_domain/src/key.rs | 30+++++++++++++++++++++++++++++-
Mcrates/studio_domain/src/lib.rs | 4+++-
Mcrates/studio_ffi/src/commands.rs | 4++--
Mcrates/studio_ffi/src/observer.rs | 2+-
5 files changed, 36 insertions(+), 5 deletions(-)

diff --git a/crates/studio_domain/Cargo.toml b/crates/studio_domain/Cargo.toml @@ -9,6 +9,7 @@ repository.workspace = true [dependencies] bech32.workspace = true secrecy.workspace = true +zeroize.workspace = true url.workspace = true [lints] diff --git a/crates/studio_domain/src/key.rs b/crates/studio_domain/src/key.rs @@ -4,11 +4,13 @@ use std::fmt::{self, Display, Formatter}; use std::str::FromStr; use secrecy::{ExposeSecret, SecretString}; +use zeroize::Zeroizing; 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; +pub const MAX_SECRET_KEY_INPUT_BYTES: usize = 128; const NIP19_KEY_LENGTH: usize = 63; const BECH32_DATA_CHARSET: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l"; @@ -106,6 +108,23 @@ pub struct SecretKeyInput { } impl SecretKeyInput { + /// Moves bounded transport bytes into the zeroizing secret boundary. + /// + /// The source byte allocation is cleared on every return path. + /// + /// # Errors + /// + /// Returns a safe invalid-secret-key error for oversized, non-UTF-8, or + /// structurally invalid input. + pub fn parse_bytes(value: Vec<u8>) -> Result<Self, SafeError> { + let value = Zeroizing::new(value); + if value.len() > MAX_SECRET_KEY_INPUT_BYTES { + return Err(invalid_secret_key()); + } + let encoded = std::str::from_utf8(&value).map_err(|_| invalid_secret_key())?; + Self::parse(encoded.to_owned()) + } + /// Moves one secret input string into a zeroizing boundary. /// /// Nsec inputs receive complete NIP-19 validation in the Nostr adapter. @@ -255,7 +274,8 @@ mod tests { use std::str::FromStr; use super::{ - Npub, Nsec, PUBLIC_KEY_BYTE_LENGTH, PublicKey, SecretKeyInput, SecretKeyInputKind, + MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PUBLIC_KEY_BYTE_LENGTH, PublicKey, SecretKeyInput, + SecretKeyInputKind, }; use crate::SafeErrorCode; @@ -333,6 +353,14 @@ mod tests { } #[test] + fn secret_byte_transport_is_bounded_and_validated() { + let parsed = SecretKeyInput::parse_bytes(HEX.as_bytes().to_vec()).expect("bytes"); + assert_eq!(parsed.with_exposed_secret(str::len), 64); + assert!(SecretKeyInput::parse_bytes(vec![0xff]).is_err()); + assert!(SecretKeyInput::parse_bytes(vec![b'a'; MAX_SECRET_KEY_INPUT_BYTES + 1]).is_err()); + } + + #[test] fn npub_is_public_display_data_but_not_canonical_identity() { let npub = Npub::from_encoded(NPUB.to_owned()).expect("valid npub shape"); diff --git a/crates/studio_domain/src/lib.rs b/crates/studio_domain/src/lib.rs @@ -12,7 +12,9 @@ pub use account::{ BindingRepairAction, LocalSignerBinding, }; pub use error::{SafeError, SafeErrorCode, SafeMessage}; -pub use key::{Npub, Nsec, PublicKey, SecretKeyInput, SecretKeyInputKind}; +pub use key::{ + MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PublicKey, SecretKeyInput, SecretKeyInputKind, +}; pub use profile::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0}; pub use relay::{RelayUrl, normalize_relay_urls}; pub use time::UnixTimestamp; diff --git a/crates/studio_ffi/src/commands.rs b/crates/studio_ffi/src/commands.rs @@ -138,9 +138,9 @@ impl StudioAppCore { /// Returns a safe validation, keyring, storage, or account error. pub async fn import_secret_key( &self, - secret_key: String, + secret_key: Vec<u8>, ) -> Result<AppSnapshotDto, StudioError> { - let input = SecretKeyInput::parse(secret_key).map_err(StudioError::from)?; + let input = SecretKeyInput::parse_bytes(secret_key).map_err(StudioError::from)?; self.inner .actor .import_secret_key(input) diff --git a/crates/studio_ffi/src/observer.rs b/crates/studio_ffi/src/observer.rs @@ -249,7 +249,7 @@ mod tests { .await .expect("subscribe"); let imported = core - .import_secret_key(SECRET_HEX.to_owned()) + .import_secret_key(SECRET_HEX.as_bytes().to_vec()) .await .expect("import"); let public_key = imported.selected_public_key_hex.expect("selection");