commit 649f3d53aebff0a24f642365fd54b1680af44c0d
parent 5ea63f8112d28f9a272932774a919a9889a7b5ac
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:
10 files changed, 64 insertions(+), 14 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt
@@ -23,6 +23,8 @@ data class StudioStoreState(
val problem: String? = null,
)
+const val MAX_IMPORT_SECRET_CHARS: Int = 128
+
class StudioAppStore(
private val gateway: StudioCoreGateway,
private val scope: CoroutineScope,
@@ -53,7 +55,10 @@ class StudioAppStore(
}
fun editImportDraft(value: String) {
- mutableState.value = mutableState.value.copy(importDraft = value, problem = null)
+ mutableState.value = mutableState.value.copy(
+ importDraft = value.take(MAX_IMPORT_SECRET_CHARS),
+ problem = null,
+ )
}
fun generateAccount() {
@@ -72,9 +77,15 @@ class StudioAppStore(
fun importSecretKey() {
if (command?.isActive == true) return
- val input = mutableState.value.importDraft
+ val input = mutableState.value.importDraft.encodeToByteArray()
mutableState.value = mutableState.value.copy(importDraft = "")
- runCommand { gateway.importSecretKey(input) }
+ runCommand {
+ try {
+ gateway.importSecretKey(input)
+ } finally {
+ input.fill(0)
+ }
+ }
}
fun selectAccount(publicKeyHex: String) {
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt
@@ -18,7 +18,7 @@ interface StudioCoreGateway : AutoCloseable {
suspend fun generateAccount(): GeneratedAccountDto
- suspend fun importSecretKey(secretKey: String): AppSnapshotDto
+ suspend fun importSecretKey(secretKey: ByteArray): AppSnapshotDto
suspend fun selectAccount(publicKeyHex: String): AppSnapshotDto
@@ -53,8 +53,12 @@ class NativeStudioCoreGateway(
override suspend fun generateAccount(): GeneratedAccountDto = core.generateAccount()
- override suspend fun importSecretKey(secretKey: String): AppSnapshotDto =
- core.importSecretKey(secretKey)
+ override suspend fun importSecretKey(secretKey: ByteArray): AppSnapshotDto =
+ try {
+ core.importSecretKey(secretKey)
+ } finally {
+ secretKey.fill(0)
+ }
override suspend fun selectAccount(publicKeyHex: String): AppSnapshotDto =
core.selectAccount(publicKeyHex)
diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt
@@ -80,7 +80,7 @@ private class ApplicationGateway : StudioCoreGateway {
override suspend fun bootstrap() = applicationSnapshot(1UL)
override suspend fun generateAccount(): org.radroots.studio.ffi.GeneratedAccountDto =
error("unused")
- override suspend fun importSecretKey(secretKey: String) = error("unused")
+ override suspend fun importSecretKey(secretKey: ByteArray) = error("unused")
override suspend fun selectAccount(publicKeyHex: String) = error("unused")
override suspend fun activateAccount(publicKeyHex: String) = error("unused")
override suspend fun signOut() = error("unused")
diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt
@@ -113,6 +113,7 @@ class StudioAppStoreTest {
assertEquals(emptyList(), gateway.importedSecrets)
advanceUntilIdle()
assertEquals(listOf("nsec1secret"), gateway.importedSecrets)
+ assertEquals(true, gateway.lastImportBuffer?.all { it == 0.toByte() })
store.close()
}
}
@@ -125,6 +126,7 @@ private class FakeStudioCoreGateway(
var subscriptionClosed = false
var signOutCalls = 0
val importedSecrets = mutableListOf<String>()
+ var lastImportBuffer: ByteArray? = null
var failRemovalConfirmation = false
var lastRemovalTicket: FakeRemovalTicket? = null
@@ -148,8 +150,9 @@ private class FakeStudioCoreGateway(
return GeneratedAccountDto(account(), next, "nsec1secret")
}
- override suspend fun importSecretKey(secretKey: String): AppSnapshotDto = current.also {
- importedSecrets += secretKey
+ override suspend fun importSecretKey(secretKey: ByteArray): AppSnapshotDto = current.also {
+ lastImportBuffer = secretKey
+ importedSecrets += secretKey.decodeToString()
}
override suspend fun selectAccount(publicKeyHex: String): AppSnapshotDto = current
override suspend fun activateAccount(publicKeyHex: String): AppSnapshotDto = current
diff --git a/core/Cargo.lock b/core/Cargo.lock
@@ -1737,6 +1737,7 @@ dependencies = [
"bech32",
"secrecy",
"url",
+ "zeroize",
]
[[package]]
diff --git a/core/crates/domain/Cargo.toml b/core/crates/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/core/crates/domain/src/key.rs b/core/crates/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/core/crates/domain/src/lib.rs b/core/crates/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/core/crates/ffi/src/commands.rs b/core/crates/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/core/crates/ffi/src/observer.rs b/core/crates/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");