From 3f92ea936d866c76aa54c06ed64175fff53bb824 Mon Sep 17 00:00:00 2001 From: Davide Garberi Date: Tue, 24 Mar 2026 18:13:00 +0100 Subject: [PATCH 1/2] Allow passing a custom key to lair --- crates/runtime-ffi/src/runtime.rs | 13 ++++++ crates/runtime-types-ffi/src/types.rs | 3 +- crates/runtime/Cargo.toml | 2 +- crates/runtime/src/error.rs | 3 ++ crates/runtime/src/runtime.rs | 46 ++++++++++++++++++- .../android/src/main/java/InvokeTypes.kt | 2 + .../plugin/holochain_service/InvokeTypes.kt | 2 + .../client/IHolochainServiceAdmin.aidl | 1 + .../client/IHolochainServiceApp.aidl | 1 + .../client/IHolochainServiceCallback.aidl | 2 + .../client/HolochainServiceAdminClient.kt | 19 ++++++++ .../client/HolochainServiceAppClient.kt | 19 ++++++++ .../client/IHolochainServiceCallbackStub.kt | 14 ++++++ .../androidserviceruntime/client/Parcelers.kt | 23 +++++----- .../service/HolochainService.kt | 46 ++++++++++++++++++- 15 files changed, 180 insertions(+), 16 deletions(-) diff --git a/crates/runtime-ffi/src/runtime.rs b/crates/runtime-ffi/src/runtime.rs index df5ff593..46d770d7 100644 --- a/crates/runtime-ffi/src/runtime.rs +++ b/crates/runtime-ffi/src/runtime.rs @@ -148,6 +148,16 @@ impl RuntimeFfi { .into()) } + /// Import a private key seed into the lair keystore + pub async fn import_key_seed(&self, seed: Vec) -> RuntimeResultFfi> { + debug!("RuntimeFfi::import_key_seed"); + let seed: [u8; 32] = seed.try_into().map_err(|_| { + holochain_conductor_runtime::RuntimeError::InvalidArguments("Seed must be 32 bytes".to_string()) + })?; + let agent_pub_key = self.0.import_key_seed(seed).await?; + Ok(agent_pub_key.into_inner()) + } + /// Authorize a client to call the given app id pub fn authorize_app_client( &self, @@ -190,6 +200,7 @@ mod test { installed_app_id: app_id.into(), network_seed: Some(Uuid::new_v4().to_string()), roles_settings: Some(HashMap::new()), + agent_key: None, }) .await .unwrap() @@ -254,6 +265,7 @@ mod test { installed_app_id: "my-app-1".into(), network_seed: Some(Uuid::new_v4().to_string()), roles_settings: Some(HashMap::new()), + agent_key: None, }) .await; assert!(res.is_ok()); @@ -502,6 +514,7 @@ mod test { installed_app_id: "my-app-1".into(), network_seed: Some(Uuid::new_v4().to_string()), roles_settings: Some(HashMap::new()), + agent_key: None, }, true, ) diff --git a/crates/runtime-types-ffi/src/types.rs b/crates/runtime-types-ffi/src/types.rs index 182ae45c..9b166e7d 100644 --- a/crates/runtime-types-ffi/src/types.rs +++ b/crates/runtime-types-ffi/src/types.rs @@ -328,6 +328,7 @@ pub struct InstallAppPayloadFfi { pub installed_app_id: String, pub network_seed: Option, pub roles_settings: Option>, + pub agent_key: Option>, } impl TryInto for InstallAppPayloadFfi { @@ -335,7 +336,7 @@ impl TryInto for InstallAppPayloadFfi { fn try_into(self) -> Result { Ok(InstallAppPayload { source: AppBundleSource::Bytes(self.source.into()), - agent_key: None, + agent_key: self.agent_key.map(|k| HoloHash::::from_raw_39(k)), installed_app_id: Some(self.installed_app_id), network_seed: self.network_seed, roles_settings: self diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index 24531c54..c70922f4 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -21,8 +21,8 @@ log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } rustls = { workspace = true } +uuid = { workspace = true } [dev-dependencies] tempfile = { workspace = true } tokio = { workspace = true, features = ["test-util"] } -uuid = { workspace = true } diff --git a/crates/runtime/src/error.rs b/crates/runtime/src/error.rs index 3831e0a6..43624596 100644 --- a/crates/runtime/src/error.rs +++ b/crates/runtime/src/error.rs @@ -45,6 +45,9 @@ pub enum RuntimeError { #[error("Failed to write persisted data to file: {0}")] PersistedFileWriteError(String), + + #[error("Invalid Arguments: {0}")] + InvalidArguments(String), } pub type RuntimeResult = Result; diff --git a/crates/runtime/src/runtime.rs b/crates/runtime/src/runtime.rs index 65546372..87cb7e39 100644 --- a/crates/runtime/src/runtime.rs +++ b/crates/runtime/src/runtime.rs @@ -7,7 +7,7 @@ use holochain::{ api::{AdminInterfaceApi, AdminRequest, AdminResponse, AppInfo}, ConductorBuilder, ConductorHandle, }, - prelude::{InstallAppPayload, InstalledAppId, ZomeCallParams}, + prelude::{AgentPubKey, InstallAppPayload, InstalledAppId, ZomeCallParams}, }; use holochain_types::websocket::AllowedOrigins; use lair_keystore_api::types::SharedLockedArray; @@ -145,6 +145,50 @@ impl Runtime { .any(|app_info| app_info.installed_app_id == installed_app_id)) } + pub async fn import_key_seed(&self, seed: [u8; 32]) -> RuntimeResult { + let client = self.conductor.keystore().lair_client(); + + // Generate a temporary local x25519 keypair for the sender side. + // This keypair never enters Lair — it is only used to box-encrypt the seed. + let mut sender_pk = [0u8; sodoken::crypto_box::XSALSA_PUBLICKEYBYTES]; + let mut sender_sk = sodoken::SizedLockedArray::new().map_err(|e| RuntimeError::Lair(e.into()))?; + sodoken::crypto_box::xsalsa_keypair(&mut sender_pk, &mut sender_sk.lock()) + .map_err(|e| RuntimeError::Lair(e.into()))?; + + // Generate a second temporary local x25519 keypair for the recipient side. + // Lair's `import_seed` decrypts with the private key of the recipient entry, + // so we first need to create a Lair seed entry to act as the recipient. + let recipient_info = client + .new_seed(uuid::Uuid::new_v4().to_string().into(), None, false) + .await + .map_err(RuntimeError::Lair)?; + let recipient_pk = recipient_info.x25519_pub_key; + + // Box-encrypt the seed bytes for the recipient. + let mut nonce = [0u8; sodoken::crypto_box::XSALSA_NONCEBYTES]; + sodoken::random::randombytes_buf(&mut nonce).map_err(|e| RuntimeError::Lair(e.into()))?; + let mut cipher = vec![0u8; 32 + sodoken::crypto_box::XSALSA_MACBYTES]; + sodoken::crypto_box::xsalsa_easy(&mut cipher, &seed, &nonce, &recipient_pk, &sender_sk.lock()) + .map_err(|e| RuntimeError::Lair(e.into()))?; + + // Import the encrypted seed into Lair under a fresh tag. + // Lair uses the recipient entry's private key to decrypt and store the seed. + let seed_info = client + .import_seed( + sender_pk.into(), + recipient_pk, + None, + nonce, + cipher.into(), + uuid::Uuid::new_v4().to_string().into(), + false, + ) + .await + .map_err(RuntimeError::Lair)?; + + Ok(AgentPubKey::from_raw_32(seed_info.ed25519_pub_key.0.to_vec())) + } + pub async fn sign_zome_call( &self, zome_call_params: ZomeCallParams, diff --git a/crates/tauri-plugin-client/android/src/main/java/InvokeTypes.kt b/crates/tauri-plugin-client/android/src/main/java/InvokeTypes.kt index 65487687..fae894ec 100644 --- a/crates/tauri-plugin-client/android/src/main/java/InvokeTypes.kt +++ b/crates/tauri-plugin-client/android/src/main/java/InvokeTypes.kt @@ -12,6 +12,7 @@ class SetupAppConfigInvokeArg { lateinit var happBundleBytes: ByteArray lateinit var networkSeed: String lateinit var rolesSettings: Map + var agentKey: ByteArray? = null var enableAfterInstall: Boolean = true } @@ -21,6 +22,7 @@ fun SetupAppConfigInvokeArg.toInstallAppPayloadFfi(): InstallAppPayloadFfi = installedAppId = this.appId, networkSeed = this.networkSeed, rolesSettings = this.rolesSettings, + agentKey = this.agentKey, ) @InvokeArg diff --git a/crates/tauri-plugin-service/android/src/main/java/com/plugin/holochain_service/InvokeTypes.kt b/crates/tauri-plugin-service/android/src/main/java/com/plugin/holochain_service/InvokeTypes.kt index e1f56d3c..777cb4dc 100644 --- a/crates/tauri-plugin-service/android/src/main/java/com/plugin/holochain_service/InvokeTypes.kt +++ b/crates/tauri-plugin-service/android/src/main/java/com/plugin/holochain_service/InvokeTypes.kt @@ -13,6 +13,7 @@ class InstallAppPayloadFfiInvokeArg { lateinit var installedAppId: String lateinit var networkSeed: String lateinit var roleSettings: Map + var agentKey: ByteArray? = null } fun InstallAppPayloadFfiInvokeArg.toFfi(): InstallAppPayloadFfi = @@ -21,6 +22,7 @@ fun InstallAppPayloadFfiInvokeArg.toFfi(): InstallAppPayloadFfi = this.installedAppId, this.networkSeed, this.roleSettings, + this.agentKey, ) @InvokeArg diff --git a/libraries/client/src/main/aidl/org/holochain/androidserviceruntime/client/IHolochainServiceAdmin.aidl b/libraries/client/src/main/aidl/org/holochain/androidserviceruntime/client/IHolochainServiceAdmin.aidl index 832712b5..95d5105f 100644 --- a/libraries/client/src/main/aidl/org/holochain/androidserviceruntime/client/IHolochainServiceAdmin.aidl +++ b/libraries/client/src/main/aidl/org/holochain/androidserviceruntime/client/IHolochainServiceAdmin.aidl @@ -16,4 +16,5 @@ interface IHolochainServiceAdmin { void isAppInstalled(IHolochainServiceCallback callback, String installedAppId); void ensureAppWebsocket(IHolochainServiceCallback callback, String installedAppId); void signZomeCall(IHolochainServiceCallback callback, in ZomeCallParamsFfiParcel request); + void importKeySeed(IHolochainServiceCallback callback, in byte[] seed); } diff --git a/libraries/client/src/main/aidl/org/holochain/androidserviceruntime/client/IHolochainServiceApp.aidl b/libraries/client/src/main/aidl/org/holochain/androidserviceruntime/client/IHolochainServiceApp.aidl index 9e9663f9..7fc43ef1 100644 --- a/libraries/client/src/main/aidl/org/holochain/androidserviceruntime/client/IHolochainServiceApp.aidl +++ b/libraries/client/src/main/aidl/org/holochain/androidserviceruntime/client/IHolochainServiceApp.aidl @@ -10,4 +10,5 @@ interface IHolochainServiceApp { void enableApp(IHolochainServiceCallback callback); void ensureAppWebsocket(IHolochainServiceCallback callback); void signZomeCall(IHolochainServiceCallback callback, in ZomeCallParamsFfiParcel request); + void importKeySeed(IHolochainServiceCallback callback, in byte[] seed); } diff --git a/libraries/client/src/main/aidl/org/holochain/androidserviceruntime/client/IHolochainServiceCallback.aidl b/libraries/client/src/main/aidl/org/holochain/androidserviceruntime/client/IHolochainServiceCallback.aidl index ea6e1435..929899d6 100644 --- a/libraries/client/src/main/aidl/org/holochain/androidserviceruntime/client/IHolochainServiceCallback.aidl +++ b/libraries/client/src/main/aidl/org/holochain/androidserviceruntime/client/IHolochainServiceCallback.aidl @@ -17,6 +17,8 @@ interface IHolochainServiceCallback { void isAppInstalled(boolean response); void ensureAppWebsocket(in AppAuthFfiParcel response); void signZomeCall(in ZomeCallParamsSignedFfiParcel response); + void importKeySeed(in byte[] response); + void onFailure(String message); // Error responses void adminBinderUnauthorizedException(in AdminBinderUnauthorizedExceptionParcel response); diff --git a/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/HolochainServiceAdminClient.kt b/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/HolochainServiceAdminClient.kt index 2f2d1bb0..c1a1804f 100644 --- a/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/HolochainServiceAdminClient.kt +++ b/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/HolochainServiceAdminClient.kt @@ -253,6 +253,25 @@ class HolochainServiceAdminClient( return callbackDeferred.await() } + /** + * Imports a private key seed into the Lair keystore. + * + * @param seed 32-byte private key seed + * @return AgentPubKey (raw bytes) of the imported key + * @throws HolochainServiceNotConnectedException if not connected to the service + */ + suspend fun importKeySeed(seed: ByteArray): ByteArray { + Log.d(logTag, "importKeySeed") + if (this.mService == null) { + throw HolochainServiceNotConnectedException() + } + + val callbackDeferred = ImportKeySeedCallbackDeferred() + this.mService!!.importKeySeed(callbackDeferred, seed) + + return callbackDeferred.await() + } + /** * Gets or creates an app websocket with authentication token. * diff --git a/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/HolochainServiceAppClient.kt b/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/HolochainServiceAppClient.kt index 1e191c79..43b1d35b 100644 --- a/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/HolochainServiceAppClient.kt +++ b/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/HolochainServiceAppClient.kt @@ -155,6 +155,25 @@ class HolochainServiceAppClient( return callbackDeferred.await() } + /** + * Imports a private key seed into the Lair keystore. + * + * @param seed 32-byte private key seed + * @return AgentPubKey (raw bytes) of the imported key + * @throws HolochainServiceNotConnectedException if not connected to the service + */ + suspend fun importKeySeed(seed: ByteArray): ByteArray { + Log.d(logTag, "importKeySeed") + if (this.mService == null) { + throw HolochainServiceNotConnectedException() + } + + val callbackDeferred = ImportKeySeedCallbackDeferred() + this.mService!!.importKeySeed(callbackDeferred, seed) + + return callbackDeferred.await() + } + /** * Checks if the Holochain runtime is ready to receive calls. * diff --git a/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/IHolochainServiceCallbackStub.kt b/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/IHolochainServiceCallbackStub.kt index 1063efa0..253b0a18 100644 --- a/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/IHolochainServiceCallbackStub.kt +++ b/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/IHolochainServiceCallbackStub.kt @@ -30,6 +30,11 @@ open class IHolochainServiceCallbackStubDeferred : IHolochainServiceCallback. this.deferred.completeExceptionally(response.inner) } + override fun onFailure(message: String) { + Log.d(logTag, "onFailure: $message") + this.deferred.completeExceptionally(Exception(message)) + } + /* Success response placeholders. @@ -52,6 +57,8 @@ open class IHolochainServiceCallbackStubDeferred : IHolochainServiceCallback. override fun ensureAppWebsocket(response: AppAuthFfiParcel) {} override fun signZomeCall(response: ZomeCallParamsSignedFfiParcel) {} + + override fun importKeySeed(response: ByteArray) {} } /* @@ -121,3 +128,10 @@ class SignZomeCallCallbackDeferred : IHolochainServiceCallbackStubDeferred() { + override fun importKeySeed(response: ByteArray) { + Log.d(logTag, "importKeySeed") + deferred.complete(response) + } +} diff --git a/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/Parcelers.kt b/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/Parcelers.kt index a7b9f415..bc1ba589 100644 --- a/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/Parcelers.kt +++ b/libraries/client/src/main/java/org/holochain/androidserviceruntime/client/Parcelers.kt @@ -56,6 +56,7 @@ object InstallAppPayloadFfiParceler : Parceler { installedAppId!!, parcel.readString(), readRoleSettingsMap(parcel), + parcel.createByteArray(), ) } @@ -73,10 +74,8 @@ object InstallAppPayloadFfiParceler : Parceler { sourceSharedMemory.writeToParcel(parcel, flags) parcel.writeString(installedAppId) parcel.writeString(networkSeed) - - if (rolesSettings != null) { - writeRoleSettingsMap(parcel, rolesSettings!!, flags) - } + writeRoleSettingsMap(parcel, rolesSettings, flags) + parcel.writeByteArray(agentKey) } private fun readRoleSettingsMap(parcel: Parcel): Map? { @@ -101,15 +100,15 @@ object InstallAppPayloadFfiParceler : Parceler { map: Map?, flags: Int, ) { - if (map != null) { - parcel.writeInt(map.size) - - map.forEach { (key, value) -> - parcel.writeString(key) - RoleSettingsFfiParcel(value).writeToParcel(parcel, flags) - } - } else { + if (map == null) { parcel.writeInt(0) + return + } + parcel.writeInt(map.size) + + map.forEach { (key, value) -> + parcel.writeString(key) + RoleSettingsFfiParcel(value).writeToParcel(parcel, flags) } } diff --git a/libraries/service/src/main/java/org/holochain/androidserviceruntime/service/HolochainService.kt b/libraries/service/src/main/java/org/holochain/androidserviceruntime/service/HolochainService.kt index ac4d3970..c194437b 100644 --- a/libraries/service/src/main/java/org/holochain/androidserviceruntime/service/HolochainService.kt +++ b/libraries/service/src/main/java/org/holochain/androidserviceruntime/service/HolochainService.kt @@ -533,6 +533,26 @@ class HolochainService : Service() { } } + override fun importKeySeed( + callback: IHolochainServiceCallback, + seed: ByteArray, + ) { + Log.d(logTag, "importKeySeed") + if (!this.isAuthorized()) { + callbackUnauthorized(callback) + return + } + + serviceScope.launch(Dispatchers.IO) { + try { + val agentPubKey = runtime!!.importKeySeed(seed) + callback.importKeySeed(agentPubKey) + } catch (e: Exception) { + callback.onFailure(e.toString()) + } + } + } + // We cannot call Binder.getCallingUid() within the onBind callback, // so instead we check the authorization within each IPC call private fun loadClientPackageName() { @@ -652,7 +672,31 @@ class HolochainService : Service() { } serviceScope.launch(Dispatchers.IO) { - callback.signZomeCall(ZomeCallParamsSignedFfiParcel(runtime!!.signZomeCall(req.inner))) + try { + callback.signZomeCall(ZomeCallParamsSignedFfiParcel(runtime!!.signZomeCall(req.inner))) + } catch (e: Exception) { + callback.onFailure(e.toString()) + } + } + } + + override fun importKeySeed( + callback: IHolochainServiceCallback, + seed: ByteArray, + ) { + Log.d(logTag, "importKeySeed") + if (!this.isAuthorized()) { + requestUserAuthorization(callback) + return + } + + serviceScope.launch(Dispatchers.IO) { + try { + val agentPubKey = runtime!!.importKeySeed(seed) + callback.importKeySeed(agentPubKey) + } catch (e: Exception) { + callback.onFailure(e.toString()) + } } } From daf4031a583408c92b195a66ac20dde3cb1627e0 Mon Sep 17 00:00:00 2001 From: Davide Garberi Date: Thu, 26 Mar 2026 15:41:19 +0100 Subject: [PATCH 2/2] runtime-types-ffi: Default the agent key to none --- crates/runtime-types-ffi/src/types.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/runtime-types-ffi/src/types.rs b/crates/runtime-types-ffi/src/types.rs index 9b166e7d..943ee66e 100644 --- a/crates/runtime-types-ffi/src/types.rs +++ b/crates/runtime-types-ffi/src/types.rs @@ -328,6 +328,7 @@ pub struct InstallAppPayloadFfi { pub installed_app_id: String, pub network_seed: Option, pub roles_settings: Option>, + #[uniffi(default = None)] pub agent_key: Option>, }