diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d26bc2e..b6ebf83 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,6 +59,12 @@ jobs: - name: Build client run: cargo build -p holochain_client + + - name: Lint + run: cargo clippy --all-features -- -D warnings + + - name: Check formatting + run: cargo fmt --all --check - name: Run tests run: cargo test diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d0c4f8..e048b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## \[Unreleased\] ### Added +- Capability to create zome call signing credentials with the `AdminWebsocket` using `authorize_signing_credentials`. +- `ClientAgentSigner` type which can store (in memory) signing credentials created with `authorize_signing_credentials`. +- `AppAgentWebsocket` to simplify making zome calls. It is a wrapper around a `AppWebsocket` but can be created directly. ### Changed ### Fixed ### Removed +- The utilities crate, it is now replaced by signing built into the client. Please see the updated tests for examples of how to use this. ## 2024-02-29: v0.4.7 ### Added diff --git a/Cargo.lock b/Cargo.lock index e18fc49..f1e6bc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -958,6 +958,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "constant_time_eq" version = "0.1.5" @@ -1204,17 +1210,32 @@ dependencies = [ [[package]] name = "curve25519-dalek" -version = "3.2.0" +version = "4.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" +checksum = "0a677b8922c94e01bdbb12126b0bc852f00447528dee1782229af9c720c3f348" dependencies = [ - "byteorder", - "digest 0.9.0", - "rand_core 0.5.1", + "cfg-if 1.0.0", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "platforms", + "rustc_version", "subtle", "zeroize", ] +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2 1.0.78", + "quote 1.0.35", + "syn 2.0.52", +] + [[package]] name = "darling" version = "0.10.2" @@ -1383,6 +1404,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" +[[package]] +name = "der" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "derivative" version = "2.2.0" @@ -1547,24 +1578,26 @@ checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" [[package]] name = "ed25519" -version = "1.5.3" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ + "pkcs8", "signature", ] [[package]] name = "ed25519-dalek" -version = "1.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" dependencies = [ "curve25519-dalek", "ed25519", - "rand 0.7.3", + "rand_core 0.6.4", "serde", - "sha2 0.9.9", + "sha2", + "subtle", "zeroize", ] @@ -1771,6 +1804,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +[[package]] +name = "fiat-crypto" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1676f435fc1dadde4d03e43f5d62b259e1ce5f40bd4ffb21db2b42ebe59c1382" + [[package]] name = "filetime" version = "0.2.23" @@ -2526,19 +2565,21 @@ dependencies = [ "again", "anyhow", "arbitrary", + "async-trait", "ed25519-dalek", "holo_hash", "holochain", "holochain_conductor_api", "holochain_serialized_bytes", + "holochain_state", "holochain_types", "holochain_websocket", "holochain_zome_types", - "rand 0.7.3", + "parking_lot 0.12.1", + "rand 0.8.5", "serde", "tokio", "url 2.5.0", - "utilities", ] [[package]] @@ -3322,7 +3363,7 @@ dependencies = [ "hex-literal", "influxive-core", "reqwest", - "sha2 0.10.8", + "sha2", "tar", "tempfile", "tokio", @@ -4799,7 +4840,7 @@ dependencies = [ "digest 0.10.7", "hmac", "password-hash", - "sha2 0.10.8", + "sha2", ] [[package]] @@ -4877,12 +4918,28 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "platforms" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626dec3cac7cc0e1577a2ec3fc496277ec2baa084bebad95bb6fdbfae235f84c" + [[package]] name = "polling" version = "2.8.0" @@ -6191,19 +6248,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "sha2" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" -dependencies = [ - "block-buffer 0.9.0", - "cfg-if 1.0.0", - "cpufeatures", - "digest 0.9.0", - "opaque-debug", -] - [[package]] name = "sha2" version = "0.10.8" @@ -6258,9 +6302,12 @@ dependencies = [ [[package]] name = "signature" -version = "1.6.4" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] [[package]] name = "simba" @@ -6366,6 +6413,16 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlformat" version = "0.1.8" @@ -7248,7 +7305,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_json", - "sha2 0.10.8", + "sha2", "tempfile", "tracing", "url 2.5.0", @@ -7281,7 +7338,7 @@ dependencies = [ "libloading", "once_cell", "ouroboros", - "sha2 0.10.8", + "sha2", "tracing", "tx5-core", "zip", @@ -7298,7 +7355,7 @@ dependencies = [ "dunce", "if-addrs 0.10.2", "once_cell", - "sha2 0.10.8", + "sha2", "tokio", "tracing", "tx5-core", @@ -7323,7 +7380,7 @@ dependencies = [ "rustls-native-certs", "rustls-pemfile 1.0.4", "serde_json", - "sha2 0.10.8", + "sha2", "socket2 0.5.6", "tokio", "tokio-rustls", @@ -7539,18 +7596,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" -[[package]] -name = "utilities" -version = "0.1.0" -dependencies = [ - "arbitrary", - "ed25519-dalek", - "holochain_client", - "holochain_state", - "holochain_zome_types", - "rand 0.7.3", -] - [[package]] name = "uuid" version = "0.7.4" @@ -8346,20 +8391,6 @@ name = "zeroize" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" -dependencies = [ - "proc-macro2 1.0.78", - "quote 1.0.35", - "syn 2.0.52", -] [[package]] name = "zip" diff --git a/Cargo.toml b/Cargo.toml index 3368452..230695f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" version = "0.4.7" [workspace] -members = ["fixture/zomes/foo", "utilities"] +members = ["fixture/zomes/foo"] [workspace.dependencies] holochain_zome_types = "0.2.6" @@ -20,19 +20,21 @@ holochain_zome_types = "0.2.6" [dependencies] again = "0.1" anyhow = "1.0" -ed25519-dalek = "1" +ed25519-dalek = { version = "2.1", features = ["rand_core"] } holo_hash = { version = "0.2.6", features = ["encoding"] } holochain_conductor_api = "0.2.6" holochain_serialized_bytes = "0.0.53" +holochain_state = "0.2.6" holochain_types = "0.2.6" holochain_websocket = "0.2.6" holochain_zome_types = { workspace = true } serde = ">=1.0.0, <=1.0.166" url = "2.2" +rand = "0.8" +async-trait = "0.1" +parking_lot = "0.12.1" [dev-dependencies] arbitrary = "1.2" holochain = { version = "0.2.6", features = ["test_utils"] } -rand = "0.7" tokio = { version = "1.3", features = ["full"] } -utilities = { path = "utilities" } diff --git a/flake.lock b/flake.lock index c6a8b32..e54dc0b 100644 --- a/flake.lock +++ b/flake.lock @@ -141,16 +141,16 @@ "holochain": { "flake": false, "locked": { - "lastModified": 1706211948, - "narHash": "sha256-gdhJfb5uv2AnGKqytwNcGe/ecaYV7iNdIIzXqLQETio=", + "lastModified": 1707245081, + "narHash": "sha256-l9WHMlD9IDuEv/N/3WDCsP3XLUUnZFrOBEZjbWnC+/Y=", "owner": "holochain", "repo": "holochain", - "rev": "5613b68e882b404d49e9c42b169cd64c9128728f", + "rev": "0a3b2408b2d6482b913b9f0faf58a39b567f763a", "type": "github" }, "original": { "owner": "holochain", - "ref": "holochain-0.2.5-rc.1", + "ref": "holochain-0.2.6", "repo": "holochain", "type": "github" } @@ -204,11 +204,11 @@ ] }, "locked": { - "lastModified": 1709216689, - "narHash": "sha256-l7MXlWkjTHCj078ixGMCHP8UJ42wTxgDoFwHtCGxu8o=", + "lastModified": 1709620314, + "narHash": "sha256-d7vekpj538VqdDrChFbVQpSVGDMnU1nSksbSzacKvyM=", "owner": "holochain", "repo": "holochain", - "rev": "7019aa187a0c0fd6dae44394b0ddad4a52e8062c", + "rev": "392bdfd729fb6ce50f78f9e7f1c757dc392675f4", "type": "github" }, "original": { @@ -268,11 +268,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1709150264, - "narHash": "sha256-HofykKuisObPUfj0E9CJVfaMhawXkYx3G8UIFR/XQ38=", + "lastModified": 1709479366, + "narHash": "sha256-n6F0n8UV6lnTZbYPl1A9q1BS0p4hduAv1mGAP17CVd0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "9099616b93301d5cf84274b184a3a5ec69e94e08", + "rev": "b8697e57f10292a6165a20f03d2f42920dfaf973", "type": "github" }, "original": { @@ -346,11 +346,11 @@ ] }, "locked": { - "lastModified": 1709172595, - "narHash": "sha256-0oYeE5VkhnPA7YBl+0Utq2cYoHcfsEhSGwraCa27Vs8=", + "lastModified": 1709604635, + "narHash": "sha256-le4fwmWmjGRYWwkho0Gr7mnnZndOOe4XGbLw68OvF40=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "72fa0217f76020ad3aeb2dd9dd72490905b23b6f", + "rev": "e86c0fb5d3a22a5f30d7f64ecad88643fe26449d", "type": "github" }, "original": { @@ -400,11 +400,11 @@ }, "locked": { "dir": "versions/0_2", - "lastModified": 1709216689, - "narHash": "sha256-l7MXlWkjTHCj078ixGMCHP8UJ42wTxgDoFwHtCGxu8o=", + "lastModified": 1709620314, + "narHash": "sha256-d7vekpj538VqdDrChFbVQpSVGDMnU1nSksbSzacKvyM=", "owner": "holochain", "repo": "holochain", - "rev": "7019aa187a0c0fd6dae44394b0ddad4a52e8062c", + "rev": "392bdfd729fb6ce50f78f9e7f1c757dc392675f4", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index e914099..701d2f6 100644 --- a/flake.nix +++ b/flake.nix @@ -4,7 +4,7 @@ versions.url = "github:holochain/holochain?dir=versions/0_2"; holonix.url = "github:holochain/holochain"; holonix.inputs.versions.follows = "versions"; - holonix.inputs.holochain.url = "github:holochain/holochain/holochain-0.2.5-rc.1"; + holonix.inputs.holochain.url = "github:holochain/holochain/holochain-0.2.6"; }; outputs = inputs@{ holonix, ... }: diff --git a/src/admin_websocket.rs b/src/admin_websocket.rs index e3bd52f..e0f5361 100644 --- a/src/admin_websocket.rs +++ b/src/admin_websocket.rs @@ -8,7 +8,7 @@ use holochain_types::{ prelude::{CellId, DeleteCloneCellPayload, InstallAppPayload}, }; use holochain_websocket::{connect, WebsocketConfig, WebsocketReceiver, WebsocketSender}; -use holochain_zome_types::{DnaDef, GrantZomeCallCapabilityPayload, Record}; +use holochain_zome_types::{DnaDef, GrantZomeCallCapabilityPayload, GrantedFunctions, Record}; use serde::{Deserialize, Serialize}; use url::Url; @@ -25,6 +25,12 @@ pub struct EnableAppResponse { pub errors: Vec<(CellId, String)>, } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AuthorizeSigningCredentialsPayload { + pub cell_id: CellId, + pub functions: Option, +} + impl AdminWebsocket { pub async fn connect(admin_url: String) -> Result { let url = Url::parse(&admin_url).context("invalid ws:// URL")?; @@ -38,10 +44,9 @@ impl AdminWebsocket { Ok(Self { tx, rx }) } - pub fn close(&mut self) -> () { - match self.rx.take_handle() { - Some(h) => h.close(), - None => (), + pub fn close(&mut self) { + if let Some(h) = self.rx.take_handle() { + h.close() } } @@ -196,12 +201,49 @@ impl AdminWebsocket { } } + pub async fn authorize_signing_credentials( + &mut self, + request: AuthorizeSigningCredentialsPayload, + ) -> Result { + use holochain_zome_types::capability::{ZomeCallCapGrant, CAP_SECRET_BYTES}; + use rand::{rngs::OsRng, RngCore}; + use std::collections::BTreeSet; + + let mut csprng = OsRng; + let keypair = ed25519_dalek::SigningKey::generate(&mut csprng); + let public_key = keypair.verifying_key(); + let signing_agent_key = AgentPubKey::from_raw_32(public_key.as_bytes().to_vec()); + + let mut cap_secret = [0; CAP_SECRET_BYTES]; + csprng.fill_bytes(&mut cap_secret); + + self.grant_zome_call_capability(GrantZomeCallCapabilityPayload { + cell_id: request.cell_id, + cap_grant: ZomeCallCapGrant { + tag: "zome-call-signing-key".to_string(), + access: holochain_zome_types::capability::CapAccess::Assigned { + secret: cap_secret.into(), + assignees: BTreeSet::from([signing_agent_key.clone()]), + }, + functions: request.functions.unwrap_or(GrantedFunctions::All), + }, + }) + .await + .map_err(|e| anyhow::anyhow!("Conductor API error: {:?}", e))?; + + Ok(crate::signing::client_signing::SigningCredentials { + signing_agent_key, + keypair, + cap_secret: cap_secret.into(), + }) + } + async fn send(&mut self, msg: AdminRequest) -> ConductorApiResult { let response: AdminResponse = self .tx .request(msg) .await - .map_err(|err| ConductorApiError::WebsocketError(err))?; + .map_err(ConductorApiError::WebsocketError)?; match response { AdminResponse::Error(error) => Err(ConductorApiError::ExternalApiWireError(error)), _ => Ok(response), diff --git a/src/app_agent_websocket.rs b/src/app_agent_websocket.rs new file mode 100644 index 0000000..0ca38ca --- /dev/null +++ b/src/app_agent_websocket.rs @@ -0,0 +1,206 @@ +use std::{ops::DerefMut, sync::Arc}; + +use crate::{ + signing::{sign_zome_call, AgentSigner}, + AppWebsocket, ConductorApiError, ConductorApiResult, +}; +use anyhow::{anyhow, Result}; +use holo_hash::AgentPubKey; +use holochain_conductor_api::{AppInfo, CellInfo, ClonedCell, ProvisionedCell}; +use holochain_state::nonce::fresh_nonce; +use holochain_types::{app::InstalledAppId, prelude::CloneId}; +use holochain_zome_types::prelude::{ + CellId, ExternIO, FunctionName, RoleName, Timestamp, ZomeCallUnsigned, ZomeName, +}; +use std::ops::Deref; + +#[derive(Clone)] +pub struct AppAgentWebsocket { + pub my_pub_key: AgentPubKey, + app_ws: AppWebsocket, + app_info: AppInfo, + signer: Arc>, +} + +impl AppAgentWebsocket { + pub async fn connect( + url: String, + app_id: InstalledAppId, + signer: Arc>, + ) -> Result { + let app_ws = AppWebsocket::connect(url).await?; + AppAgentWebsocket::from_existing(app_ws, app_id, signer).await + } + + pub async fn from_existing( + mut app_ws: AppWebsocket, + app_id: InstalledAppId, + signer: Arc>, + ) -> Result { + let app_info = app_ws + .app_info(app_id.clone()) + .await + .map_err(|err| anyhow!("Error fetching app_info {err:?}"))? + .ok_or(anyhow!("App doesn't exist"))?; + + Ok(AppAgentWebsocket { + my_pub_key: app_info.agent_pub_key.clone(), + app_ws, + app_info, + signer, + }) + } + + pub async fn call_zome( + &mut self, + target: ZomeCallTarget, + zome_name: ZomeName, + fn_name: FunctionName, + payload: ExternIO, + ) -> ConductorApiResult { + let cell_id = match target { + ZomeCallTarget::CellId(cell_id) => cell_id, + ZomeCallTarget::RoleName(role_name) => self.get_cell_id_from_role_name(&role_name)?, + ZomeCallTarget::CloneId(clone_id) => self.get_cell_id_from_role_name(&clone_id.0)?, + }; + + let (nonce, expires_at) = + fresh_nonce(Timestamp::now()).map_err(ConductorApiError::FreshNonceError)?; + + let zome_call_unsigned = ZomeCallUnsigned { + provenance: self.signer.get_provenance(&cell_id).ok_or( + ConductorApiError::SignZomeCallError("Provenance not found".to_string()), + )?, + cap_secret: self.signer.get_cap_secret(&cell_id), + cell_id: cell_id.clone(), + zome_name, + fn_name, + payload, + expires_at, + nonce, + }; + + let signed_zome_call = sign_zome_call(zome_call_unsigned, self.signer.clone()) + .await + .map_err(|e| ConductorApiError::SignZomeCallError(e.to_string()))?; + + let result = self.app_ws.call_zome(signed_zome_call).await?; + + Ok(result) + } + + /// Gets a new copy of the [AppInfo] for the app this agent is connected to. + /// + /// This is useful if you have made changes to the app, such as creating new clone cells, and need to refresh the app info. + pub async fn refresh_app_info(&mut self) -> Result<()> { + self.app_info = self + .app_ws + .app_info(self.app_info.installed_app_id.clone()) + .await + .map_err(|err| anyhow!("Error fetching app_info {err:?}"))? + .ok_or(anyhow!("App doesn't exist"))?; + + Ok(()) + } + + fn get_cell_id_from_role_name(&self, role_name: &RoleName) -> ConductorApiResult { + if is_clone_id(role_name) { + let base_role_name = get_base_role_name_from_clone_id(role_name); + + let Some(role_cells) = self.app_info.cell_info.get(&base_role_name) else { + return Err(ConductorApiError::CellNotFound); + }; + + let maybe_clone_cell: Option = + role_cells.iter().find_map(|cell| match cell { + CellInfo::Cloned(cloned_cell) => { + if cloned_cell.clone_id.0.eq(role_name) { + Some(cloned_cell.clone()) + } else { + None + } + } + _ => None, + }); + + let clone_cell = maybe_clone_cell.ok_or(ConductorApiError::CellNotFound)?; + Ok(clone_cell.cell_id) + } else { + let Some(role_cells) = self.app_info.cell_info.get(role_name) else { + return Err(ConductorApiError::CellNotFound); + }; + + let maybe_provisioned: Option = + role_cells.iter().find_map(|cell| match cell { + CellInfo::Provisioned(provisioned_cell) => Some(provisioned_cell.clone()), + _ => None, + }); + + let provisioned_cell = maybe_provisioned.ok_or(ConductorApiError::CellNotFound)?; + Ok(provisioned_cell.cell_id) + } + } +} + +pub enum ZomeCallTarget { + CellId(CellId), + /// Call a cell by its role name. + /// + /// Note that when using clone cells, if you create them after creating the [AppAgentWebsocket], you will need to call [AppAgentWebsocket::refresh_app_info] + /// for the right CellId to be found to make the call. + RoleName(RoleName), + /// Call a cell by its clone id. + /// + /// Note that when using clone cells, if you create them after creating the [AppAgentWebsocket], you will need to call [AppAgentWebsocket::refresh_app_info] + /// for the right CellId to be found to make the call. + CloneId(CloneId), +} + +impl From for ZomeCallTarget { + fn from(cell_id: CellId) -> Self { + ZomeCallTarget::CellId(cell_id) + } +} + +impl From for ZomeCallTarget { + fn from(role_name: RoleName) -> Self { + ZomeCallTarget::RoleName(role_name) + } +} + +impl From for ZomeCallTarget { + fn from(clone_id: CloneId) -> Self { + ZomeCallTarget::CloneId(clone_id) + } +} + +fn is_clone_id(role_name: &RoleName) -> bool { + role_name.as_str().contains('.') +} + +fn get_base_role_name_from_clone_id(role_name: &RoleName) -> RoleName { + RoleName::from( + role_name + .as_str() + .split('.') + .map(|s| s.to_string()) + .collect::>() + .first() + .unwrap(), + ) +} + +/// Make the [AppWebsocket] functionality available through the [AppAgentWebsocket] +impl Deref for AppAgentWebsocket { + type Target = AppWebsocket; + + fn deref(&self) -> &Self::Target { + &self.app_ws + } +} + +impl DerefMut for AppAgentWebsocket { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.app_ws + } +} diff --git a/src/app_websocket.rs b/src/app_websocket.rs index 14bbcbf..ae3ae40 100644 --- a/src/app_websocket.rs +++ b/src/app_websocket.rs @@ -110,7 +110,7 @@ impl AppWebsocket { .tx .request(msg) .await - .map_err(|err| ConductorApiError::WebsocketError(err))?; + .map_err(ConductorApiError::WebsocketError)?; match response { AppResponse::Error(error) => Err(ConductorApiError::ExternalApiWireError(error)), diff --git a/src/error.rs b/src/error.rs index 0ee7aac..7f4af11 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,10 +1,14 @@ use holochain_conductor_api::ExternalApiWireError; +use holochain_state::prelude::DatabaseError; use holochain_websocket::WebsocketError; #[derive(Debug)] pub enum ConductorApiError { WebsocketError(WebsocketError), ExternalApiWireError(ExternalApiWireError), + FreshNonceError(DatabaseError), + SignZomeCallError(String), + CellNotFound, } pub type ConductorApiResult = Result; diff --git a/src/lib.rs b/src/lib.rs index 40c78a9..64fd4b2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,11 @@ mod admin_websocket; +mod app_agent_websocket; mod app_websocket; mod error; +mod signing; -pub use admin_websocket::{AdminWebsocket, EnableAppResponse}; +pub use admin_websocket::{AdminWebsocket, AuthorizeSigningCredentialsPayload, EnableAppResponse}; +pub use app_agent_websocket::{AppAgentWebsocket, ZomeCallTarget}; pub use app_websocket::AppWebsocket; pub use error::{ConductorApiError, ConductorApiResult}; pub use holochain_conductor_api::{ @@ -12,3 +15,5 @@ pub use holochain_types::{ app::{InstallAppPayload, InstalledAppId}, dna::AgentPubKey, }; +pub use signing::client_signing::{ClientAgentSigner, SigningCredentials}; +pub use signing::AgentSigner; diff --git a/src/signing.rs b/src/signing.rs new file mode 100644 index 0000000..4869fa9 --- /dev/null +++ b/src/signing.rs @@ -0,0 +1,56 @@ +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use holo_hash::AgentPubKey; +use holochain_conductor_api::ZomeCall; +use holochain_zome_types::{ + capability::CapSecret, cell::CellId, dependencies::holochain_integrity_types::Signature, + zome_io::ZomeCallUnsigned, +}; + +pub(crate) mod client_signing; + +#[async_trait] +pub trait AgentSigner { + /// Sign the given data with the public key found in the agent id of the provenance. + async fn sign( + &self, + cell_id: &CellId, + provenance: AgentPubKey, + data_to_sign: Arc<[u8]>, + ) -> Result; + + fn get_provenance(&self, cell_id: &CellId) -> Option; + + /// Get the capability secret for the given `cell_id` if it exists. + fn get_cap_secret(&self, cell_id: &CellId) -> Option; +} + +/// Signs an unsigned zome call using the provided signing implementation +pub(crate) async fn sign_zome_call( + zome_call_unsigned: ZomeCallUnsigned, + signer: Arc>, +) -> Result { + let pub_key = zome_call_unsigned.provenance.clone(); + + let data_to_sign = zome_call_unsigned.data_to_sign().map_err(|e| { + anyhow::anyhow!("Failed to get data to sign from unsigned zome call: {}", e) + })?; + + let signature = signer + .sign(&zome_call_unsigned.cell_id, pub_key, data_to_sign) + .await?; + + Ok(ZomeCall { + cell_id: zome_call_unsigned.cell_id, + zome_name: zome_call_unsigned.zome_name, + fn_name: zome_call_unsigned.fn_name, + payload: zome_call_unsigned.payload, + cap_secret: zome_call_unsigned.cap_secret, + provenance: zome_call_unsigned.provenance, + nonce: zome_call_unsigned.nonce, + expires_at: zome_call_unsigned.expires_at, + signature, + }) +} diff --git a/src/signing/client_signing.rs b/src/signing/client_signing.rs new file mode 100644 index 0000000..2f8570b --- /dev/null +++ b/src/signing/client_signing.rs @@ -0,0 +1,76 @@ +use super::AgentSigner; +use async_trait::async_trait; +use ed25519_dalek::Signer; +use holo_hash::AgentPubKey; +use holochain_zome_types::{ + capability::CapSecret, cell::CellId, dependencies::holochain_integrity_types::Signature, +}; +use parking_lot::RwLock; +use std::{collections::HashMap, sync::Arc}; + +pub struct SigningCredentials { + pub signing_agent_key: holo_hash::AgentPubKey, + pub keypair: ed25519_dalek::SigningKey, + pub cap_secret: CapSecret, +} + +/// Custom debug implementation which won't attempt to print the `cap_secret` or `keypair` +impl std::fmt::Debug for SigningCredentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SigningCredentials") + .field("signing_agent_key", &self.signing_agent_key) + .finish() + } +} + +#[derive(Debug, Clone, Default)] +pub struct ClientAgentSigner { + credentials: Arc>>, +} + +impl ClientAgentSigner { + pub fn new() -> Self { + Self { + credentials: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub fn add_credentials(&mut self, cell_id: CellId, credentials: SigningCredentials) { + self.credentials.write().insert(cell_id, credentials); + } +} + +#[async_trait] +impl AgentSigner for ClientAgentSigner { + async fn sign( + &self, + cell_id: &CellId, + _provenance: AgentPubKey, + data_to_sign: Arc<[u8]>, + ) -> Result { + let credentials_lock = self.credentials.read(); + let credentials = credentials_lock + .get(cell_id) + .ok_or_else(|| anyhow::anyhow!("No credentials found for cell: {:?}", cell_id))?; + let signature = credentials.keypair.try_sign(&data_to_sign)?; + Ok(Signature(signature.to_bytes())) + } + + fn get_provenance(&self, cell_id: &CellId) -> Option { + self.credentials + .read() + .get(cell_id) + .map(|c| c.signing_agent_key.clone()) + } + + fn get_cap_secret(&self, cell_id: &CellId) -> Option { + self.credentials.read().get(cell_id).map(|c| c.cap_secret) + } +} + +/// Convert the ClientAgentSigner into an `Arc>` +impl From for Arc> { + fn from(cas: ClientAgentSigner) -> Self { + Arc::new(Box::new(cas)) + } +} diff --git a/tests/admin.rs b/tests/admin.rs index 4c5e18d..324c0d9 100644 --- a/tests/admin.rs +++ b/tests/admin.rs @@ -1,10 +1,12 @@ use holochain::test_utils::itertools::Itertools; use holochain::{prelude::AppBundleSource, sweettest::SweetConductor}; -use holochain_client::{AdminWebsocket, AppWebsocket, InstallAppPayload, InstalledAppId}; +use holochain_client::{ + AdminWebsocket, AppAgentWebsocket, AppWebsocket, AuthorizeSigningCredentialsPayload, + ClientAgentSigner, InstallAppPayload, InstalledAppId, +}; use holochain_conductor_api::{CellInfo, StorageBlob}; use holochain_zome_types::ExternIO; use std::{collections::HashMap, path::PathBuf}; -use utilities::{authorize_signing_credentials, sign_zome_call}; #[tokio::test(flavor = "multi_thread")] async fn app_interfaces() { @@ -43,7 +45,7 @@ async fn signed_zome_call() { let mut app_ws = AppWebsocket::connect(format!("ws://localhost:{}", app_ws_port)) .await .unwrap(); - let installed_app = app_ws.app_info(app_id).await.unwrap().unwrap(); + let installed_app = app_ws.app_info(app_id.clone()).await.unwrap().unwrap(); let cells = installed_app.cell_info.into_values().next().unwrap(); let cell_id = match cells[0].clone() { @@ -56,16 +58,29 @@ async fn signed_zome_call() { const TEST_ZOME_NAME: &str = "foo"; const TEST_FN_NAME: &str = "foo"; - let signing_credentials = authorize_signing_credentials(&mut admin_ws, &cell_id).await; - let signed_zome_call = sign_zome_call( - &cell_id, - &TEST_ZOME_NAME, - &TEST_FN_NAME, - &signing_credentials, - ) - .await; + let mut signer = ClientAgentSigner::default(); + let credentials = admin_ws + .authorize_signing_credentials(AuthorizeSigningCredentialsPayload { + cell_id: cell_id.clone(), + functions: None, + }) + .await + .unwrap(); + signer.add_credentials(cell_id.clone(), credentials); + + let mut app_ws = AppAgentWebsocket::from_existing(app_ws, app_id, signer.into()) + .await + .unwrap(); - let response = app_ws.call_zome(signed_zome_call).await.unwrap(); + let response = app_ws + .call_zome( + cell_id.into(), + TEST_ZOME_NAME.into(), + TEST_FN_NAME.into(), + ExternIO::encode(()).unwrap(), + ) + .await + .unwrap(); assert_eq!( ExternIO::decode::(&response).unwrap(), TEST_FN_NAME.to_string() diff --git a/tests/clone_cell.rs b/tests/clone_cell.rs index ca22c7a..789e48d 100644 --- a/tests/clone_cell.rs +++ b/tests/clone_cell.rs @@ -4,12 +4,15 @@ use holochain::{ prelude::{DeleteCloneCellPayload, DisableCloneCellPayload, EnableCloneCellPayload}, sweettest::SweetConductor, }; -use holochain_client::{AdminWebsocket, AppWebsocket, InstallAppPayload}; +use holochain_client::ConductorApiError; +use holochain_client::{ + AdminWebsocket, AppAgentWebsocket, AppWebsocket, AuthorizeSigningCredentialsPayload, + ClientAgentSigner, InstallAppPayload, +}; use holochain_types::prelude::{ AppBundleSource, CloneCellId, CloneId, CreateCloneCellPayload, DnaModifiersOpt, InstalledAppId, }; -use holochain_zome_types::RoleName; -use utilities::{authorize_signing_credentials, sign_zome_call}; +use holochain_zome_types::{ExternIO, RoleName}; #[tokio::test(flavor = "multi_thread")] async fn clone_cell_management() { @@ -32,7 +35,7 @@ async fn clone_cell_management() { .await .unwrap(); admin_ws.enable_app(app_id.clone()).await.unwrap(); - let app_api_port = admin_ws.attach_app_interface(30000).await.unwrap(); + let app_api_port = admin_ws.attach_app_interface(0).await.unwrap(); let mut app_ws = AppWebsocket::connect(format!("ws://localhost:{}", app_api_port)) .await .unwrap(); @@ -53,14 +56,33 @@ async fn clone_cell_management() { }; let cell_id = clone_cell.cell_id.clone(); - let signing_credentials = authorize_signing_credentials(&mut admin_ws, &cell_id).await; + let mut signer = ClientAgentSigner::default(); + let credentials = admin_ws + .authorize_signing_credentials(AuthorizeSigningCredentialsPayload { + cell_id: cell_id.clone(), + functions: None, + }) + .await + .unwrap(); + signer.add_credentials(cell_id.clone(), credentials); + + let mut app_ws = AppAgentWebsocket::from_existing(app_ws, app_id.clone(), signer.into()) + .await + .unwrap(); const TEST_ZOME_NAME: &str = "foo"; const TEST_FN_NAME: &str = "foo"; - let signed_zome_call = - sign_zome_call(&cell_id, TEST_ZOME_NAME, TEST_FN_NAME, &signing_credentials).await; + // call clone cell should succeed - let response = app_ws.call_zome(signed_zome_call).await.unwrap(); + let response = app_ws + .call_zome( + cell_id.clone().into(), + TEST_ZOME_NAME.into(), + TEST_FN_NAME.into(), + ExternIO::encode(()).unwrap(), + ) + .await + .unwrap(); assert_eq!(response.decode::().unwrap(), "foo"); // disable clone cell @@ -72,11 +94,15 @@ async fn clone_cell_management() { .await .unwrap(); - let signed_zome_call = - sign_zome_call(&cell_id, TEST_ZOME_NAME, TEST_FN_NAME, &signing_credentials).await; - // call disabled clone cell should fail - let response = app_ws.call_zome(signed_zome_call).await; + let response = app_ws + .call_zome( + cell_id.clone().into(), + TEST_ZOME_NAME.into(), + TEST_FN_NAME.into(), + ExternIO::encode(()).unwrap(), + ) + .await; assert!(response.is_err()); // enable clone cell @@ -89,11 +115,16 @@ async fn clone_cell_management() { .unwrap(); assert_eq!(enabled_cell, clone_cell); - let signed_zome_call = - sign_zome_call(&cell_id, TEST_ZOME_NAME, TEST_FN_NAME, &signing_credentials).await; - // call enabled clone cell should succeed - let response = app_ws.call_zome(signed_zome_call).await.unwrap(); + let response = app_ws + .call_zome( + cell_id.clone().into(), + TEST_ZOME_NAME.into(), + TEST_FN_NAME.into(), + ExternIO::encode(()).unwrap(), + ) + .await + .unwrap(); assert_eq!(response.decode::().unwrap(), "foo"); // disable clone cell again @@ -122,3 +153,94 @@ async fn clone_cell_management() { .await; assert!(enable_clone_cell_response.is_err()); } + +// Check that app info can be refreshed to allow zome calls to a clone cell identified by its clone cell id +#[tokio::test(flavor = "multi_thread")] +pub async fn app_info_refresh() { + let conductor = SweetConductor::from_standard_config().await; + let admin_port = conductor.get_arbitrary_admin_websocket_port().unwrap(); + let mut admin_ws = AdminWebsocket::connect(format!("ws://localhost:{}", admin_port)) + .await + .unwrap(); + let app_id: InstalledAppId = "test-app".into(); + let role_name: RoleName = "foo".into(); + + // Create our agent key + let agent_key = admin_ws.generate_agent_pub_key().await.unwrap(); + + // Install and enable an app + admin_ws + .install_app(InstallAppPayload { + agent_key: agent_key.clone(), + installed_app_id: Some(app_id.clone()), + membrane_proofs: HashMap::new(), + network_seed: None, + source: AppBundleSource::Path(PathBuf::from("./fixture/test.happ")), + }) + .await + .unwrap(); + admin_ws.enable_app(app_id.clone()).await.unwrap(); + + let mut signer = ClientAgentSigner::default(); + + // Create an app interface and connect an app agent to it + let app_api_port = admin_ws.attach_app_interface(0).await.unwrap(); + let mut app_agent_ws = AppAgentWebsocket::connect( + format!("ws://localhost:{}", app_api_port), + app_id.clone(), + signer.clone().into(), + ) + .await + .unwrap(); + + // Create a clone cell, AFTER the app agent has been created + let cloned_cell = app_agent_ws + .create_clone_cell(CreateCloneCellPayload { + app_id: app_id.clone(), + role_name: role_name.clone(), + modifiers: DnaModifiersOpt::none().with_network_seed("test seed".into()), + membrane_proof: None, + name: None, + }) + .await + .unwrap(); + + // Authorise signing credentials for the cloned cell + let credentials = admin_ws + .authorize_signing_credentials(AuthorizeSigningCredentialsPayload { + cell_id: cloned_cell.cell_id.clone(), + functions: None, + }) + .await + .unwrap(); + signer.add_credentials(cloned_cell.cell_id.clone(), credentials); + + // Call the zome function on the clone cell, expecting a failure + let err = app_agent_ws + .call_zome( + cloned_cell.clone_id.clone().into(), + "foo".into(), + "foo".into(), + ExternIO::encode(()).unwrap(), + ) + .await + .expect_err("Should fail because the client doesn't know the clone cell exists"); + match err { + ConductorApiError::CellNotFound => (), + _ => panic!("Unexpected error: {:?}", err), + } + + // Refresh the app info, which means the app agent will now know about the clone cell + app_agent_ws.refresh_app_info().await.unwrap(); + + // Call the zome function on the clone cell again, expecting success + app_agent_ws + .call_zome( + cloned_cell.clone_id.clone().into(), + "foo".into(), + "foo".into(), + ExternIO::encode(()).unwrap(), + ) + .await + .unwrap(); +} diff --git a/utilities/Cargo.toml b/utilities/Cargo.toml deleted file mode 100644 index 7407449..0000000 --- a/utilities/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -authors = ["Holochain Foundation"] -edition = "2021" -name = "utilities" -version = "0.1.0" - -[dependencies] -arbitrary = "1.2" -ed25519-dalek = "1" -holochain_client = { path = "../" } -holochain_state = "0.2.6" -holochain_zome_types = { workspace = true } -rand = "0.7" diff --git a/utilities/src/lib.rs b/utilities/src/lib.rs deleted file mode 100644 index 296f731..0000000 --- a/utilities/src/lib.rs +++ /dev/null @@ -1,85 +0,0 @@ -use arbitrary::Arbitrary; -use ed25519_dalek::{Keypair, Signer}; -pub use holochain_client::{AdminWebsocket, AgentPubKey, ZomeCall}; -use holochain_state::nonce::fresh_nonce; -use holochain_zome_types::{ - CapAccess, CapSecret, CellId, ExternIO, FunctionName, GrantZomeCallCapabilityPayload, - Signature, Timestamp, ZomeCallCapGrant, ZomeCallUnsigned, ZomeName, -}; -use std::collections::BTreeSet; - -pub struct SigningCredentials { - cap_secret: CapSecret, - keypair: Keypair, - signing_key: AgentPubKey, -} - -pub async fn authorize_signing_credentials( - admin_ws: &mut AdminWebsocket, - cell_id: &CellId, -) -> SigningCredentials { - let mut rng = rand::thread_rng(); - let keypair: Keypair = Keypair::generate(&mut rng); - let signing_key = AgentPubKey::from_raw_32(keypair.public.as_bytes().to_vec()); - - let mut buf = arbitrary::Unstructured::new(&[]); - let cap_secret = CapSecret::arbitrary(&mut buf).unwrap(); - - let mut assignees = BTreeSet::new(); - assignees.insert(signing_key.clone()); - - admin_ws - .grant_zome_call_capability(GrantZomeCallCapabilityPayload { - cell_id: cell_id.clone(), - cap_grant: ZomeCallCapGrant { - tag: "zome-call-signing-key".into(), - functions: holochain_zome_types::GrantedFunctions::All, - access: CapAccess::Assigned { - secret: cap_secret.clone(), - assignees: assignees.clone(), - }, - }, - }) - .await - .unwrap(); - - SigningCredentials { - cap_secret, - keypair, - signing_key, - } -} - -pub async fn sign_zome_call( - cell_id: &CellId, - zome_name: &str, - fn_name: &str, - signing_credentials: &SigningCredentials, -) -> ZomeCall { - let (nonce, expires_at) = fresh_nonce(Timestamp::now()).unwrap(); - let unsigned_zome_call_payload = ZomeCallUnsigned { - cap_secret: Some(signing_credentials.cap_secret), - cell_id: cell_id.clone(), - zome_name: ZomeName::from(zome_name), - fn_name: FunctionName::from(fn_name), - provenance: signing_credentials.signing_key.clone(), - payload: ExternIO::encode(()).unwrap(), - nonce, - expires_at, - }; - let hashed_zome_call = unsigned_zome_call_payload.data_to_sign().unwrap(); - - let signature = signing_credentials.keypair.sign(&hashed_zome_call); - - ZomeCall { - cap_secret: unsigned_zome_call_payload.cap_secret, - cell_id: unsigned_zome_call_payload.cell_id, - zome_name: unsigned_zome_call_payload.zome_name, - fn_name: unsigned_zome_call_payload.fn_name, - provenance: unsigned_zome_call_payload.provenance, - payload: unsigned_zome_call_payload.payload, - nonce: unsigned_zome_call_payload.nonce, - expires_at: unsigned_zome_call_payload.expires_at, - signature: Signature::from(signature.to_bytes()), - } -}