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:
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");