Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions crates/runtime-ffi/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>) -> RuntimeResultFfi<Vec<u8>> {
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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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,
)
Expand Down
4 changes: 3 additions & 1 deletion crates/runtime-types-ffi/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,14 +328,16 @@ pub struct InstallAppPayloadFfi {
pub installed_app_id: String,
pub network_seed: Option<String>,
pub roles_settings: Option<HashMap<String, RoleSettingsFfi>>,
#[uniffi(default = None)]
pub agent_key: Option<Vec<u8>>,
}

impl TryInto<InstallAppPayload> for InstallAppPayloadFfi {
type Error = AppBundleError;
fn try_into(self) -> Result<InstallAppPayload, Self::Error> {
Ok(InstallAppPayload {
source: AppBundleSource::Bytes(self.source.into()),
agent_key: None,
agent_key: self.agent_key.map(|k| HoloHash::<Agent>::from_raw_39(k)),
installed_app_id: Some(self.installed_app_id),
network_seed: self.network_seed,
roles_settings: self
Expand Down
2 changes: 1 addition & 1 deletion crates/runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
3 changes: 3 additions & 0 deletions crates/runtime/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = Result<T, RuntimeError>;
46 changes: 45 additions & 1 deletion crates/runtime/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AgentPubKey> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class SetupAppConfigInvokeArg {
lateinit var happBundleBytes: ByteArray
lateinit var networkSeed: String
lateinit var rolesSettings: Map<String, RoleSettingsFfi>
var agentKey: ByteArray? = null
var enableAfterInstall: Boolean = true
}

Expand All @@ -21,6 +22,7 @@ fun SetupAppConfigInvokeArg.toInstallAppPayloadFfi(): InstallAppPayloadFfi =
installedAppId = this.appId,
networkSeed = this.networkSeed,
rolesSettings = this.rolesSettings,
agentKey = this.agentKey,
)

@InvokeArg
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class InstallAppPayloadFfiInvokeArg {
lateinit var installedAppId: String
lateinit var networkSeed: String
lateinit var roleSettings: Map<String, RoleSettingsFfi>
var agentKey: ByteArray? = null
}

fun InstallAppPayloadFfiInvokeArg.toFfi(): InstallAppPayloadFfi =
Expand All @@ -21,6 +22,7 @@ fun InstallAppPayloadFfiInvokeArg.toFfi(): InstallAppPayloadFfi =
this.installedAppId,
this.networkSeed,
this.roleSettings,
this.agentKey,
)

@InvokeArg
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ open class IHolochainServiceCallbackStubDeferred<T> : IHolochainServiceCallback.
this.deferred.completeExceptionally(response.inner)
}

override fun onFailure(message: String) {
Log.d(logTag, "onFailure: $message")
this.deferred.completeExceptionally(Exception(message))
}

/*
Success response placeholders.

Expand All @@ -52,6 +57,8 @@ open class IHolochainServiceCallbackStubDeferred<T> : IHolochainServiceCallback.
override fun ensureAppWebsocket(response: AppAuthFfiParcel) {}

override fun signZomeCall(response: ZomeCallParamsSignedFfiParcel) {}

override fun importKeySeed(response: ByteArray) {}
}

/*
Expand Down Expand Up @@ -121,3 +128,10 @@ class SignZomeCallCallbackDeferred : IHolochainServiceCallbackStubDeferred<ZomeC
deferred.complete(response.inner)
}
}

class ImportKeySeedCallbackDeferred : IHolochainServiceCallbackStubDeferred<ByteArray>() {
override fun importKeySeed(response: ByteArray) {
Log.d(logTag, "importKeySeed")
deferred.complete(response)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ object InstallAppPayloadFfiParceler : Parceler<InstallAppPayloadFfi> {
installedAppId!!,
parcel.readString(),
readRoleSettingsMap(parcel),
parcel.createByteArray(),
)
}

Expand All @@ -73,10 +74,8 @@ object InstallAppPayloadFfiParceler : Parceler<InstallAppPayloadFfi> {
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<String, RoleSettingsFfi>? {
Expand All @@ -101,15 +100,15 @@ object InstallAppPayloadFfiParceler : Parceler<InstallAppPayloadFfi> {
map: Map<String, RoleSettingsFfi>?,
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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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())
}
}
}

Expand Down
Loading