app

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

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:
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 17++++++++++++++---
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt | 10+++++++---
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt | 2+-
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 7+++++--
Mcore/Cargo.lock | 1+
Mcore/crates/domain/Cargo.toml | 1+
Mcore/crates/domain/src/key.rs | 30+++++++++++++++++++++++++++++-
Mcore/crates/domain/src/lib.rs | 4+++-
Mcore/crates/ffi/src/commands.rs | 4++--
Mcore/crates/ffi/src/observer.rs | 2+-
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");