diff --git a/dnas/trusted/zomes/coordinator/trusted/src/gpg_key_dist.rs b/dnas/trusted/zomes/coordinator/trusted/src/gpg_key_dist.rs index 2ce0a69..4a38845 100644 --- a/dnas/trusted/zomes/coordinator/trusted/src/gpg_key_dist.rs +++ b/dnas/trusted/zomes/coordinator/trusted/src/gpg_key_dist.rs @@ -38,8 +38,8 @@ pub fn distribute_gpg_key(request: DistributeGpgKeyRequest) -> ExternResult ExternResult ExternResult> { links.extend(email_links); links.extend(fingerprint_links.clone()); - tracing::info!("Found {} links and {} by fingerprint", links.len(), fingerprint_links.len()); - let mut out = Vec::with_capacity(links.len()); for target in links .into_iter() diff --git a/dnas/trusted/zomes/coordinator/trusted/src/key_collection.rs b/dnas/trusted/zomes/coordinator/trusted/src/key_collection.rs new file mode 100644 index 0000000..f76c75e --- /dev/null +++ b/dnas/trusted/zomes/coordinator/trusted/src/key_collection.rs @@ -0,0 +1,85 @@ +use hdk::prelude::*; +use trusted_integrity::prelude::*; + +#[hdk_extern] +pub fn create_key_collection(key_collection: KeyCollection) -> ExternResult { + check_key_collection_create(&key_collection)?; + + let entry = EntryTypes::KeyCollection(key_collection); + let action_hash = create_entry(entry)?; + + let record = get(action_hash.clone(), GetOptions::content())?.ok_or(wasm_error!( + WasmErrorInner::Guest(String::from( + "Could not find the newly created KeyCollection" + )) + ))?; + + let entry_hash = hash_entry( + record + .entry() + .as_option() + .ok_or_else(|| wasm_error!(WasmErrorInner::Guest(String::from("Missing entry hash"))))? + .clone(), + )?; + let my_agent_info = agent_info()?; + create_link( + my_agent_info.agent_latest_pubkey, + entry_hash, + LinkTypes::KeyCollection, + (), + )?; + + Ok(record) +} + +fn check_key_collection_create(key_collection: &KeyCollection) -> ExternResult<()> { + let existing_key_collections = query( + ChainQueryFilter::default() + .entry_type(EntryType::App(UnitEntryTypes::KeyCollection.try_into()?)), + )?; + + // This is enforced by validation, but checked here for faster feedback + if existing_key_collections.len() >= KEY_COLLECTION_LIMIT { + return Err(wasm_error!(WasmErrorInner::Guest(String::from( + "Exceeded the maximum number of key collections", + )))); + } + + let names: HashSet<_> = existing_key_collections.into_iter().filter_map(|kc| match kc.entry().as_option().and_then(|e| e.as_app_entry()) { + Some(entry_bytes) => { + let key_collection: KeyCollection = entry_bytes.clone().into_sb().try_into().ok()?; + Some(key_collection.name) + } + None => None + }).collect(); + + // Not checked by validation, other users do not care about your key collection names being unique. The entries are private + // so they can't actually see them! + if names.contains(&key_collection.name) { + return Err(wasm_error!(WasmErrorInner::Guest(String::from( + "Key collection with the same name already exists", + )))); + } + + Ok(()) +} + +#[hdk_extern] +pub fn my_key_collections(_: ()) -> ExternResult> { + let my_agent_info = agent_info()?; + let key_collection_links = get_links(GetLinksInputBuilder::try_new(my_agent_info.agent_latest_pubkey, LinkTypes::KeyCollection)?.build())?; + + let mut records = Vec::with_capacity(key_collection_links.len()); + for link in key_collection_links { + let entry_hash: EntryHash = link.target.clone().try_into().map_err(|_| { + wasm_error!(WasmErrorInner::Guest(String::from("Could not convert link target to EntryHash"))) + })?; + let record = get(entry_hash, GetOptions::content())?.ok_or(wasm_error!( + WasmErrorInner::Guest(String::from("Could not find the KeyCollection")) + ))?; + // No need to type check these records, validation ensures they are all KeyCollections + records.push(record); + } + + Ok(records) +} diff --git a/dnas/trusted/zomes/coordinator/trusted/src/lib.rs b/dnas/trusted/zomes/coordinator/trusted/src/lib.rs index 6088e79..cf42180 100644 --- a/dnas/trusted/zomes/coordinator/trusted/src/lib.rs +++ b/dnas/trusted/zomes/coordinator/trusted/src/lib.rs @@ -1,4 +1,5 @@ mod gpg_key_dist; +mod key_collection; mod gpg_util; use hdk::prelude::*; diff --git a/dnas/trusted/zomes/integrity/trusted/src/gpg_key_dist.rs b/dnas/trusted/zomes/integrity/trusted/src/gpg_key_dist.rs index 672b556..9b3a133 100644 --- a/dnas/trusted/zomes/integrity/trusted/src/gpg_key_dist.rs +++ b/dnas/trusted/zomes/integrity/trusted/src/gpg_key_dist.rs @@ -56,24 +56,20 @@ pub fn validate_create_gpg_key_dist_link( let entry = must_get_entry(entry_hash)?; match entry.as_app_entry() { Some(app_entry) => { - let _: crate::gpg_key_dist::GpgKeyDist = match app_entry.clone().into_sb().try_into() { - Ok(gpg_key) => gpg_key, - Err(_) => { - return Ok(ValidateCallbackResult::Invalid(format!( - "The target for {:?} must be a {}", - link_type, - std::any::type_name::() - ))); - } - }; - } - None => { - return Ok(ValidateCallbackResult::Invalid(format!( - "The target for {:?} must be an app entry", - link_type - ))); + match >::try_into( + app_entry.clone().into_sb(), + ) { + Ok(_) => Ok(ValidateCallbackResult::Valid), + Err(_) => Ok(ValidateCallbackResult::Invalid(format!( + "The target for {:?} must be a {}", + link_type, + std::any::type_name::() + ))), + } } + None => Ok(ValidateCallbackResult::Invalid(format!( + "The target for {:?} must be an app entry", + link_type + ))), } - - Ok(ValidateCallbackResult::Valid) } diff --git a/dnas/trusted/zomes/integrity/trusted/src/key_collection.rs b/dnas/trusted/zomes/integrity/trusted/src/key_collection.rs new file mode 100644 index 0000000..f50398f --- /dev/null +++ b/dnas/trusted/zomes/integrity/trusted/src/key_collection.rs @@ -0,0 +1,112 @@ +use hdi::prelude::*; + +use crate::LinkTypes; +use crate::UnitEntryTypes; + +pub const KEY_COLLECTION_LIMIT: usize = 10; +pub const KEY_COLLECTION_NAME_MIN_LENGTH: usize = 3; + +#[hdk_entry_helper] +pub struct KeyCollection { + pub name: String, +} + +pub fn validate_create_key_collection( + create_action: EntryCreationAction, + key_collection: KeyCollection, +) -> ExternResult { + if key_collection.name.len() < KEY_COLLECTION_NAME_MIN_LENGTH { + return Ok(ValidateCallbackResult::Invalid( + format!( + "Key collection name must be at least {} characters long", + KEY_COLLECTION_NAME_MIN_LENGTH + ) + .to_string(), + )); + } + + let entry_def: AppEntryDef = UnitEntryTypes::KeyCollection.try_into()?; + + let action: Action = create_action.clone().into(); + let action_hash = hash_action(action.clone())?; + let activity = must_get_agent_activity(action.author().clone(), ChainFilter::new(action_hash))?; + + // Find all key collection creates + let mut key_collection_creates: HashSet<_> = activity + .iter() + .filter_map(|activity| match activity.action.action() { + Action::Create(Create { + entry_type: EntryType::App(app_entry), + entry_hash, + .. + }) if app_entry == &entry_def => Some(entry_hash), + _ => None, + }) + .collect(); + + // Run through every delete and grab the entry hash that it deletes, then remove those entries from the key collection set + activity + .iter() + .filter_map(|activity| match activity.action.action() { + Action::Delete(Delete { + deletes_entry_address, + .. + }) => Some(deletes_entry_address), + _ => None, + }) + .for_each(|entry_address| { + key_collection_creates.remove(&entry_address); + }); + + // Now check the remaining number of key collections is under the limit + // Note that being at the limit is allowed because the newly created key collection is already in the agent activity. + if key_collection_creates.len() > KEY_COLLECTION_LIMIT { + return Ok(ValidateCallbackResult::Invalid( + "Exceeded the maximum number of key collections".to_string(), + )); + } + + Ok(ValidateCallbackResult::Valid) +} + +pub fn validate_key_collection_link( + action: CreateLink, + base_address: AnyLinkableHash, + target_address: AnyLinkableHash, + link_type: LinkTypes, +) -> ExternResult { + let action_author_anylinkable: AnyLinkableHash = action.author.clone().into(); + + if action_author_anylinkable != base_address { + return Ok(ValidateCallbackResult::Invalid( + "Key collection must be linked from the author".to_string(), + )); + } + + let entry_hash = match target_address.clone().try_into() { + Ok(entry_hash) => entry_hash, + Err(_) => { + return Ok(ValidateCallbackResult::Invalid(format!( + "The target address for {:?} must be an entry hash", + link_type + ))); + } + }; + let entry = must_get_entry(entry_hash)?; + match entry.as_app_entry() { + Some(app_entry) => { + match >::try_into(app_entry.clone().into_sb()) { + Ok(_) => Ok(ValidateCallbackResult::Valid), + Err(_) => Ok(ValidateCallbackResult::Invalid(format!( + "The target for {:?} must be a {}", + link_type, + std::any::type_name::() + ))), + } + } + None => Ok(ValidateCallbackResult::Invalid(format!( + "The target for {:?} must be an app entry", + link_type + ))), + } +} diff --git a/dnas/trusted/zomes/integrity/trusted/src/lib.rs b/dnas/trusted/zomes/integrity/trusted/src/lib.rs index 9622316..447d111 100644 --- a/dnas/trusted/zomes/integrity/trusted/src/lib.rs +++ b/dnas/trusted/zomes/integrity/trusted/src/lib.rs @@ -1,10 +1,11 @@ pub(crate) mod gpg_key_dist; +pub(crate) mod key_collection; use hdi::prelude::*; -use prelude::validate_create_gpg_key_dist_link; pub mod prelude { pub use crate::gpg_key_dist::*; + pub use crate::key_collection::*; pub use crate::LinkTypes; pub use crate::{EntryTypes, UnitEntryTypes}; } @@ -15,6 +16,8 @@ pub mod prelude { #[unit_enum(UnitEntryTypes)] pub enum EntryTypes { GpgKeyDist(gpg_key_dist::GpgKeyDist), + #[entry_type(visibility = "private")] + KeyCollection(key_collection::KeyCollection), } #[hdk_link_types] @@ -22,6 +25,7 @@ pub enum LinkTypes { UserIdToGpgKeyDist, EmailToGpgKeyDist, FingerprintToGpgKeyDist, + KeyCollection, } // Validation you perform during the genesis process. Nobody else on the network performs it, only you. @@ -69,6 +73,9 @@ pub fn validate(op: Op) -> ExternResult { EntryCreationAction::Create(action), gpg_key, ), + EntryTypes::KeyCollection(key_collection) => { + key_collection::validate_create_key_collection(EntryCreationAction::Create(action), key_collection) + } }, OpEntry::UpdateEntry { app_entry, action, .. @@ -77,6 +84,9 @@ pub fn validate(op: Op) -> ExternResult { EntryCreationAction::Update(action), gpg_key, ), + _ => { + Ok(ValidateCallbackResult::Invalid("todo: update entry".to_string())) + } }, _ => Ok(ValidateCallbackResult::Valid), }, @@ -95,6 +105,7 @@ pub fn validate(op: Op) -> ExternResult { original_gpg_key, ) } + _ => Ok(ValidateCallbackResult::Invalid("todo: register update".to_string())), }, _ => Ok(ValidateCallbackResult::Valid), }, @@ -107,19 +118,25 @@ pub fn validate(op: Op) -> ExternResult { EntryTypes::GpgKeyDist(gpg_key) => { gpg_key_dist::validate_delete_gpg_key_dist(action, original_action, gpg_key) } + _ => Ok(ValidateCallbackResult::Invalid("todo: register delete".to_string())), }, _ => Ok(ValidateCallbackResult::Valid), }, FlatOp::RegisterCreateLink { - link_type, + base_address, target_address, + link_type, + action, .. } => match link_type { LinkTypes::FingerprintToGpgKeyDist | LinkTypes::UserIdToGpgKeyDist | LinkTypes::EmailToGpgKeyDist => { - validate_create_gpg_key_dist_link(target_address, link_type) + gpg_key_dist::validate_create_gpg_key_dist_link(target_address, link_type) } + LinkTypes::KeyCollection => { + key_collection::validate_key_collection_link(action, base_address, target_address, link_type) + }, }, FlatOp::RegisterDeleteLink { .. } => Ok(ValidateCallbackResult::Invalid(String::from( "There are no link types in this integrity zome", @@ -134,6 +151,7 @@ pub fn validate(op: Op) -> ExternResult { EntryCreationAction::Create(action), gpg_key, ), + _ => Ok(ValidateCallbackResult::Invalid("todo: store record".to_string())), }, // Complementary validation to the `RegisterUpdate` Op, in which the record itself is validated // If you want to optimize performance, you can remove the validation for an entry type here and keep it in `StoreEntry` and in `RegisterUpdate` @@ -189,6 +207,7 @@ pub fn validate(op: Op) -> ExternResult { Ok(result) } } + _ => Ok(ValidateCallbackResult::Invalid("todo".to_string())), } } // Complementary validation to the `RegisterDelete` Op, in which the record itself is validated @@ -255,20 +274,26 @@ pub fn validate(op: Op) -> ExternResult { original_gpg_key, ) } + _ => Ok(ValidateCallbackResult::Invalid("todo".to_string())), } } // Complementary validation to the `RegisterCreateLink` Op, in which the record itself is validated // If you want to optimize performance, you can remove the validation for an entry type here and keep it in `RegisterCreateLink` // Notice that doing so will cause `must_get_valid_record` for this record to return a valid record even if the `RegisterCreateLink` validation failed OpRecord::CreateLink { + base_address, target_address, link_type, + action, .. } => match link_type { LinkTypes::FingerprintToGpgKeyDist | LinkTypes::UserIdToGpgKeyDist | LinkTypes::EmailToGpgKeyDist => { - validate_create_gpg_key_dist_link(target_address, link_type) + gpg_key_dist::validate_create_gpg_key_dist_link(target_address, link_type) + } + LinkTypes::KeyCollection => { + key_collection::validate_key_collection_link(action, base_address, target_address, link_type) } }, // Complementary validation to the `RegisterDeleteLink` Op, in which the record itself is validated diff --git a/tests/src/trusted/trusted/common.ts b/tests/src/trusted/trusted/common.ts index dc29d21..19d4c6f 100644 --- a/tests/src/trusted/trusted/common.ts +++ b/tests/src/trusted/trusted/common.ts @@ -28,3 +28,11 @@ export async function distributeGpgKey(cell: CallableCell, gpgKey: string): Prom }, }); } + +export async function createKeyCollection(cell: CallableCell, name: string): Promise { + return cell.callZome({ + zome_name: "trusted", + fn_name: "create_key_collection", + payload: { name }, + }); +} diff --git a/tests/src/trusted/trusted/gpg-key.test.ts b/tests/src/trusted/trusted/gpg-key-dist.test.ts similarity index 100% rename from tests/src/trusted/trusted/gpg-key.test.ts rename to tests/src/trusted/trusted/gpg-key-dist.test.ts diff --git a/tests/src/trusted/trusted/key-collection.test.ts b/tests/src/trusted/trusted/key-collection.test.ts new file mode 100644 index 0000000..e960663 --- /dev/null +++ b/tests/src/trusted/trusted/key-collection.test.ts @@ -0,0 +1,86 @@ +import { assert, test } from "vitest"; + +import { runScenario, dhtSync, CallableCell } from '@holochain/tryorama'; +import { NewEntryAction, ActionHash, Record, AppBundleSource, fakeDnaHash, fakeActionHash, fakeAgentPubKey, fakeEntryHash } from '@holochain/client'; +import { decode } from '@msgpack/msgpack'; + +import { createKeyCollection, distributeGpgKey, sampleGpgKey } from './common.js'; + +test('Create key collection', async () => { + await runScenario(async scenario => { + const testAppPath = process.cwd() + '/../workdir/hWOT.happ'; + const appSource = { appBundleSource: { path: testAppPath } }; + + const [alice] = await scenario.addPlayersWithApps([appSource]); + + // Alice creates a key collection + const record: Record = await createKeyCollection(alice.cells[0], "a test"); + assert.ok(record); + }); +}); + +test('Create key collection limit', async () => { + await runScenario(async scenario => { + const testAppPath = process.cwd() + '/../workdir/hWOT.happ'; + const appSource = { appBundleSource: { path: testAppPath } }; + + const [alice] = await scenario.addPlayersWithApps([appSource]); + + // Alice creates the allowed number of key collections + for (let i = 0; i < 10; i++) { + const record: Record = await createKeyCollection(alice.cells[0], `a test ${i}`); + assert.ok(record); + } + + let failed = false; + try { + await createKeyCollection(alice.cells[0], `a test too many`); + } catch { + failed = true; + } + assert.ok(failed); + }); +}); + +test('Get my key collections', async () => { + await runScenario(async scenario => { + const testAppPath = process.cwd() + '/../workdir/hWOT.happ'; + const appSource = { appBundleSource: { path: testAppPath } }; + + const [alice] = await scenario.addPlayersWithApps([appSource]); + + // Alice creates some key collections + for (let i = 0; i < 2; i++) { + const record: Record = await createKeyCollection(alice.cells[0], `a test ${i}`); + assert.ok(record); + } + + const key_collections: Record[] = await alice.cells[0].callZome({ + zome_name: "trusted", + fn_name: "my_key_collections", + payload: null, + }); + + assert.equal(2, key_collections.length); + }); +}); + +test.skip('Remote validation', async () => { + await runScenario(async scenario => { + const testAppPath = process.cwd() + '/../workdir/hWOT.happ'; + const appSource = { appBundleSource: { path: testAppPath } }; + + const [alice, bob] = await scenario.addPlayersWithApps([appSource, appSource]); + + await scenario.shareAllAgents(); + + // Alice creates the allowed number of key collections + for (let i = 0; i < 10; i++) { + const record: Record = await createKeyCollection(alice.cells[0], `a test ${i}`); + assert.ok(record); + } + + // The DHT shouldn't sync if the remote validation fails + await dhtSync([alice, bob], alice.cells[0].cell_id[0]); + }); +});