diff --git a/rust/vetkeys/basic_bls_signing/backend/Cargo.toml b/rust/vetkeys/basic_bls_signing/backend/Cargo.toml index 35b7410dfa..678d588917 100644 --- a/rust/vetkeys/basic_bls_signing/backend/Cargo.toml +++ b/rust/vetkeys/basic_bls_signing/backend/Cargo.toml @@ -18,7 +18,7 @@ getrandom = { version = "0.2", features = ["custom"] } ic-cdk = "0.20.1" ic-cdk-management-canister = "0.1.1" ic-stable-structures = "0.6.8" -ic-vetkeys = "0.7.0" +ic-vetkeys = "0.8.1" serde = "1.0.217" serde_bytes = "0.11.15" serde_cbor = "0.11.2" diff --git a/rust/vetkeys/basic_bls_signing/frontend/package.json b/rust/vetkeys/basic_bls_signing/frontend/package.json index 0a68d864e4..16390882d0 100644 --- a/rust/vetkeys/basic_bls_signing/frontend/package.json +++ b/rust/vetkeys/basic_bls_signing/frontend/package.json @@ -10,7 +10,7 @@ "dependencies": { "@icp-sdk/auth": "^7.1.0", "@icp-sdk/core": "^5.4.0", - "@icp-sdk/vetkeys": "^0.5.0-beta.0" + "@icp-sdk/vetkeys": "^0.5.0" }, "devDependencies": { "@icp-sdk/bindgen": "~0.2.2", diff --git a/rust/vetkeys/basic_ibe/backend/Cargo.toml b/rust/vetkeys/basic_ibe/backend/Cargo.toml index 8acc73773e..f5f564b277 100644 --- a/rust/vetkeys/basic_ibe/backend/Cargo.toml +++ b/rust/vetkeys/basic_ibe/backend/Cargo.toml @@ -18,7 +18,7 @@ ic-cdk = "0.20.1" ic-cdk-management-canister = "0.1.1" ic-dummy-getrandom-for-wasm = "0.1.0" ic-stable-structures = "0.6.8" -ic-vetkeys = "0.7.0" +ic-vetkeys = "0.8.1" serde = "1.0.217" serde_bytes = "0.11.15" serde_cbor = "0.11.2" diff --git a/rust/vetkeys/basic_ibe/frontend/package.json b/rust/vetkeys/basic_ibe/frontend/package.json index 0a68d864e4..16390882d0 100644 --- a/rust/vetkeys/basic_ibe/frontend/package.json +++ b/rust/vetkeys/basic_ibe/frontend/package.json @@ -10,7 +10,7 @@ "dependencies": { "@icp-sdk/auth": "^7.1.0", "@icp-sdk/core": "^5.4.0", - "@icp-sdk/vetkeys": "^0.5.0-beta.0" + "@icp-sdk/vetkeys": "^0.5.0" }, "devDependencies": { "@icp-sdk/bindgen": "~0.2.2", diff --git a/rust/vetkeys/basic_timelock_ibe/backend/Cargo.toml b/rust/vetkeys/basic_timelock_ibe/backend/Cargo.toml index 5874fd7834..f0eca3233c 100644 --- a/rust/vetkeys/basic_timelock_ibe/backend/Cargo.toml +++ b/rust/vetkeys/basic_timelock_ibe/backend/Cargo.toml @@ -20,7 +20,7 @@ ic-cdk-management-canister = "0.1.1" ic-cdk-timers = "1.0.0" ic-dummy-getrandom-for-wasm = "0.1.0" ic-stable-structures = "0.6.8" -ic-vetkeys = "0.7.0" +ic-vetkeys = "0.8.1" serde = "1.0.217" serde_bytes = "0.11.15" serde_cbor = "0.11.2" diff --git a/rust/vetkeys/basic_timelock_ibe/frontend/package.json b/rust/vetkeys/basic_timelock_ibe/frontend/package.json index 399d5a6425..592ff0d587 100644 --- a/rust/vetkeys/basic_timelock_ibe/frontend/package.json +++ b/rust/vetkeys/basic_timelock_ibe/frontend/package.json @@ -26,6 +26,6 @@ "dependencies": { "@icp-sdk/auth": "^7.1.0", "@icp-sdk/core": "^5.4.0", - "@icp-sdk/vetkeys": "^0.5.0-beta.0" + "@icp-sdk/vetkeys": "^0.5.0" } } diff --git a/rust/vetkeys/basic_vetkd/frontend/package.json b/rust/vetkeys/basic_vetkd/frontend/package.json index 258c8684c1..7936b17ac1 100644 --- a/rust/vetkeys/basic_vetkd/frontend/package.json +++ b/rust/vetkeys/basic_vetkd/frontend/package.json @@ -10,7 +10,7 @@ "dependencies": { "@icp-sdk/auth": "^7.1.0", "@icp-sdk/core": "^5.4.0", - "@icp-sdk/vetkeys": "^0.5.0-beta.0" + "@icp-sdk/vetkeys": "^0.5.0" }, "devDependencies": { "@icp-sdk/bindgen": "~0.2.2", diff --git a/rust/vetkeys/encrypted_notes_app_vetkd/frontend/package.json b/rust/vetkeys/encrypted_notes_app_vetkd/frontend/package.json index a47e8cdc67..eced20f0c7 100644 --- a/rust/vetkeys/encrypted_notes_app_vetkd/frontend/package.json +++ b/rust/vetkeys/encrypted_notes_app_vetkd/frontend/package.json @@ -26,7 +26,7 @@ "dependencies": { "@icp-sdk/auth": "^7.1.0", "@icp-sdk/core": "^5.4.0", - "@icp-sdk/vetkeys": "^0.5.0-beta.0", + "@icp-sdk/vetkeys": "^0.5.0", "daisyui": "^1.25.4", "idb-keyval": "6.2.1", "isomorphic-dompurify": "^2.25.0", diff --git a/rust/vetkeys/password_manager/Cargo.toml b/rust/vetkeys/password_manager/Cargo.toml index 8450c49e2f..3164c69fb5 100644 --- a/rust/vetkeys/password_manager/Cargo.toml +++ b/rust/vetkeys/password_manager/Cargo.toml @@ -6,7 +6,7 @@ resolver = "2" ic-cdk = "0.20.1" ic-cdk-management-canister = "0.1.1" ic-stable-structures = "0.7.0" -ic-vetkeys = "0.7.0" +ic-vetkeys = "0.8.1" [profile.release] lto = true diff --git a/rust/vetkeys/password_manager/README.md b/rust/vetkeys/password_manager/README.md index 966be9e4bf..05dc04806a 100644 --- a/rust/vetkeys/password_manager/README.md +++ b/rust/vetkeys/password_manager/README.md @@ -58,12 +58,24 @@ icp network stop An **Encrypted Maps**-enabled Rust canister that securely stores passwords. -> **Note.** This backend is hand-written today. An upstream Rust macro that generates an entire Encrypted Maps canister in one line — `ic_vetkeys::export_encrypted_maps_canister!(...)` — is in progress ([dfinity/vetkeys#404](https://github.com/dfinity/vetkeys/pull/404)); it produces the same Candid interface with far less boilerplate. +> **Note.** The entire canister is generated by the `ic_vetkeys::export_encrypted_maps_canister!(...)` macro: the canister owns the `MemoryManager` and passes Encrypted Maps its four `Memory` instances, and the macro emits the `#[init]`/`#[post_upgrade]` plus every `#[query]`/`#[update]` endpoint. Because the macro is the single source of the endpoint set, the exposed Candid matches what the `@icp-sdk/vetkeys` frontend expects by construction. See [`backend/src/lib.rs`](backend/src/lib.rs). When you need to extend the standard endpoints with your own state or invariants, see the [`password_manager_with_metadata`](../password_manager_with_metadata/) example, which layers per-password metadata on hand-written endpoints. ### Frontend (`frontend/`) A **Svelte** application providing a user-friendly interface for managing vaults and passwords. It talks to the backend through the `@icp-sdk/vetkeys` Encrypted Maps client. +#### Derived-key caching + +`EncryptedMaps` derives and caches the vetKey material used to encrypt/decrypt values. As of `@icp-sdk/vetkeys` 0.5.0 this cache is **in memory by default** (discarded on page reload). This example opts into cross-reload persistence with `IndexedDbDerivedKeyMaterialCache`, giving the store a **per-identity namespace** (`vetkeys-`) so one identity's keys are never served to another on the same origin, and it calls `clearCache()` on the `EncryptedMaps` instance on logout to drop the cached material. + +**Verifying `clearCache()` on logout.** `clearCache()` *empties* the cache's object store; it does not delete the database, so an empty `vetkeys-` database remaining in the list is expected (an empty store holds no usable key material). To check it: + +1. Open DevTools → **Application → IndexedDB**. +2. Expand `vetkeys-` → `derived-key-material`. While logged in and after opening a vault it holds one or more entries; after logout it should hold **0**. +3. DevTools caches this view — click **Refresh database** after logging out, otherwise stale entries appear to linger. + +You will also see an unrelated `icp-sdk-` database (object store `subnetNodeKeys`): that is the agent's cache of public subnet node keys, not key material, and is safe to ignore. + ## Updating the Candid interface `backend/backend.did` defines the backend's public interface. If you change the backend's public API, regenerate it: diff --git a/rust/vetkeys/password_manager/backend/backend.did b/rust/vetkeys/password_manager/backend/backend.did index f30499fd14..0880c066ca 100644 --- a/rust/vetkeys/password_manager/backend/backend.did +++ b/rust/vetkeys/password_manager/backend/backend.did @@ -1,5 +1,17 @@ -type AccessRights = variant { Read; ReadWrite; ReadWriteManage }; +// Access rights of a user to a vetKey in [`crate::key_manager::KeyManager`] and/or an encrypted map in [`crate::encrypted_maps::EncryptedMaps`]. +type AccessRights = variant { + // User can retrieve the vetKey or encrypted map. + Read; + // User can update values in the encrypted map. + ReadWrite; + // User can view/share/revoke access to the vetKey or encrypted map. + ReadWriteManage; +}; +// Efficiently serializable and deserializable byte vector that is `Storable` with `ic_stable_structures`. +// See, e.g., [https://mmapped.blog/posts/01-effective-rust-canisters#serde-bytes](https://mmapped.blog/posts/01-effective-rust-canisters#serde-bytes) for more details regarding why `Vec` does not work out of the box. +// Also, we cannot use `serde_bytes::ByteBuf` directly because it is not `Storable`. type ByteBuf = record { inner : blob }; +// Represents the complete data for an encrypted map, including ownership, contents, and access control. type EncryptedMapData = record { access_control : vec record { principal; AccessRights }; keyvals : vec record { ByteBuf; ByteBuf }; diff --git a/rust/vetkeys/password_manager/backend/src/lib.rs b/rust/vetkeys/password_manager/backend/src/lib.rs index ca58ed57ef..4e0c7def57 100644 --- a/rust/vetkeys/password_manager/backend/src/lib.rs +++ b/rust/vetkeys/password_manager/backend/src/lib.rs @@ -1,311 +1,32 @@ -use std::cell::RefCell; - -use candid::Principal; -use ic_cdk_management_canister::{VetKDCurve, VetKDKeyId}; -use ic_cdk::{init, post_upgrade, query, update}; +// This canister is a thin wrapper around the `ic-vetkeys` EncryptedMaps +// library. The canister owns the single MemoryManager and hands EncryptedMaps +// the four Memory instances it needs; the `#[init]`/`#[post_upgrade]` and every +// `#[query]`/`#[update]` endpoint are generated by the library macro, which +// guarantees the exposed Candid matches what the `@icp-sdk/vetkeys` frontend +// expects. use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory}; -use ic_stable_structures::storable::Blob; -use ic_stable_structures::{Cell as StableCell, DefaultMemoryImpl}; -use ic_vetkeys::encrypted_maps::{EncryptedMapData, EncryptedMaps, VetKey, VetKeyVerificationKey}; -use ic_vetkeys::types::{AccessRights, ByteBuf, EncryptedMapValue, TransportKey}; +use ic_stable_structures::DefaultMemoryImpl; +use std::cell::RefCell; type Memory = VirtualMemory; -type MapId = (Principal, ByteBuf); thread_local! { static MEMORY_MANAGER: RefCell> = RefCell::new(MemoryManager::init(DefaultMemoryImpl::default())); - static ENCRYPTED_MAPS: RefCell>> = - const { RefCell::new(None) }; - static KEY_NAME: RefCell> = - RefCell::new(StableCell::init(id_to_memory(4), String::new())); -} - -#[init] -fn init(key_name: String) { - KEY_NAME.with_borrow_mut(|k| { k.set(key_name.clone()); }); - init_encrypted_maps(key_name); -} - -#[post_upgrade] -fn post_upgrade() { - let key_name = KEY_NAME.with_borrow(|k| k.get().clone()); - init_encrypted_maps(key_name); -} - -fn init_encrypted_maps(key_name: String) { - let key_id = VetKDKeyId { - curve: VetKDCurve::Bls12_381_G2, - name: key_name, - }; - ENCRYPTED_MAPS.with_borrow_mut(|encrypted_maps| { - encrypted_maps.replace(EncryptedMaps::init( - "encrypted_maps_app", - key_id, - id_to_memory(0), - id_to_memory(1), - id_to_memory(2), - id_to_memory(3), - )) - }); -} - -#[query] -fn get_accessible_shared_map_names() -> Vec<(Principal, ByteBuf)> { - ENCRYPTED_MAPS.with_borrow(|encrypted_maps| { - encrypted_maps - .as_ref() - .unwrap() - .get_accessible_shared_map_names(ic_cdk::api::msg_caller()) - .into_iter() - .map(|map_id| (map_id.0, ByteBuf::from(map_id.1.as_ref().to_vec()))) - .collect() - }) -} - -#[query] -fn get_shared_user_access_for_map( - key_owner: Principal, - key_name: ByteBuf, -) -> Result, String> { - let key_name = bytebuf_to_blob(key_name)?; - let key_id = (key_owner, key_name); - ENCRYPTED_MAPS.with_borrow(|encrypted_maps| { - encrypted_maps - .as_ref() - .unwrap() - .get_shared_user_access_for_map(ic_cdk::api::msg_caller(), key_id) - }) -} - -#[query] -fn get_encrypted_values_for_map( - map_owner: Principal, - map_name: ByteBuf, -) -> Result, String> { - let map_name = bytebuf_to_blob(map_name)?; - let map_id = (map_owner, map_name); - let result = ENCRYPTED_MAPS.with_borrow(|encrypted_maps| { - encrypted_maps - .as_ref() - .unwrap() - .get_encrypted_values_for_map(ic_cdk::api::msg_caller(), map_id) - }); - result.map(|map_values| { - map_values - .into_iter() - .map(|(key, value)| (ByteBuf::from(key.as_slice().to_vec()), value)) - .collect() - }) -} - -#[query] -fn get_all_accessible_encrypted_values() -> Vec<(MapId, Vec<(ByteBuf, EncryptedMapValue)>)> { - ENCRYPTED_MAPS - .with_borrow(|encrypted_maps| { - encrypted_maps - .as_ref() - .unwrap() - .get_all_accessible_encrypted_values(ic_cdk::api::msg_caller()) - }) - .into_iter() - .map(|((owner, map_name), encrypted_values)| { - ( - (owner, ByteBuf::from(map_name.as_ref().to_vec())), - encrypted_values - .into_iter() - .map(|(key, value)| (ByteBuf::from(key.as_ref().to_vec()), value)) - .collect(), - ) - }) - .collect() -} - -#[query] -fn get_all_accessible_encrypted_maps() -> Vec> { - ENCRYPTED_MAPS.with_borrow(|encrypted_maps| { - encrypted_maps - .as_ref() - .unwrap() - .get_all_accessible_encrypted_maps(ic_cdk::api::msg_caller()) - }) -} - -#[query] -fn get_encrypted_value( - map_owner: Principal, - map_name: ByteBuf, - map_key: ByteBuf, -) -> Result, String> { - let map_name = bytebuf_to_blob(map_name)?; - let map_id = (map_owner, map_name); - ENCRYPTED_MAPS.with_borrow(|encrypted_maps| { - encrypted_maps.as_ref().unwrap().get_encrypted_value( - ic_cdk::api::msg_caller(), - map_id, - bytebuf_to_blob(map_key)?, - ) - }) } -#[update] -fn remove_map_values( - map_owner: Principal, - map_name: ByteBuf, -) -> Result, String> { - let map_name = bytebuf_to_blob(map_name)?; - let map_id = (map_owner, map_name); - let result = ENCRYPTED_MAPS.with_borrow_mut(|encrypted_maps| { - encrypted_maps - .as_mut() - .unwrap() - .remove_map_values(ic_cdk::api::msg_caller(), map_id) - }); - result.map(|removed| { - removed - .into_iter() - .map(|key| ByteBuf::from(key.as_ref().to_vec())) - .collect() - }) -} - -#[query] -fn get_owned_non_empty_map_names() -> Vec { - ENCRYPTED_MAPS.with_borrow(|encrypted_maps| { - encrypted_maps - .as_ref() - .unwrap() - .get_owned_non_empty_map_names(ic_cdk::api::msg_caller()) - .into_iter() - .map(|map_name| ByteBuf::from(map_name.as_slice().to_vec())) - .collect() - }) -} - -#[update] -fn insert_encrypted_value( - map_owner: Principal, - map_name: ByteBuf, - map_key: ByteBuf, - value: EncryptedMapValue, -) -> Result, String> { - let map_name = bytebuf_to_blob(map_name)?; - let map_id = (map_owner, map_name); - ENCRYPTED_MAPS.with_borrow_mut(|encrypted_maps| { - encrypted_maps.as_mut().unwrap().insert_encrypted_value( - ic_cdk::api::msg_caller(), - map_id, - bytebuf_to_blob(map_key)?, - value, - ) - }) -} - -#[update] -fn remove_encrypted_value( - map_owner: Principal, - map_name: ByteBuf, - map_key: ByteBuf, -) -> Result, String> { - let map_name = bytebuf_to_blob(map_name)?; - let map_id = (map_owner, map_name); - ENCRYPTED_MAPS.with_borrow_mut(|encrypted_maps| { - encrypted_maps.as_mut().unwrap().remove_encrypted_value( - ic_cdk::api::msg_caller(), - map_id, - bytebuf_to_blob(map_key)?, - ) - }) -} - -#[update] -async fn get_vetkey_verification_key() -> VetKeyVerificationKey { - ENCRYPTED_MAPS - .with_borrow(|encrypted_maps| { - encrypted_maps - .as_ref() - .unwrap() - .get_vetkey_verification_key() - }) - .await -} - -#[update] -async fn get_encrypted_vetkey( - map_owner: Principal, - map_name: ByteBuf, - transport_key: TransportKey, -) -> Result { - let map_name = bytebuf_to_blob(map_name)?; - let map_id = (map_owner, map_name); - Ok(ENCRYPTED_MAPS - .with_borrow(|encrypted_maps| { - encrypted_maps.as_ref().unwrap().get_encrypted_vetkey( - ic_cdk::api::msg_caller(), - map_id, - transport_key, - ) - })? - .await) -} - -#[query] -fn get_user_rights( - map_owner: Principal, - map_name: ByteBuf, - user: Principal, -) -> Result, String> { - let map_name = bytebuf_to_blob(map_name)?; - let map_id = (map_owner, map_name); - ENCRYPTED_MAPS.with_borrow(|encrypted_maps| { - encrypted_maps - .as_ref() - .unwrap() - .get_user_rights(ic_cdk::api::msg_caller(), map_id, user) - }) -} - -#[update] -fn set_user_rights( - map_owner: Principal, - map_name: ByteBuf, - user: Principal, - access_rights: AccessRights, -) -> Result, String> { - let map_name = bytebuf_to_blob(map_name)?; - let map_id = (map_owner, map_name); - ENCRYPTED_MAPS.with_borrow_mut(|encrypted_maps| { - encrypted_maps.as_mut().unwrap().set_user_rights( - ic_cdk::api::msg_caller(), - map_id, - user, - access_rights, - ) - }) -} - -#[update] -fn remove_user( - map_owner: Principal, - map_name: ByteBuf, - user: Principal, -) -> Result, String> { - let map_name = bytebuf_to_blob(map_name)?; - let map_id = (map_owner, map_name); - ENCRYPTED_MAPS.with_borrow_mut(|encrypted_maps| { - encrypted_maps - .as_mut() - .unwrap() - .remove_user(ic_cdk::api::msg_caller(), map_id, user) - }) -} - -fn bytebuf_to_blob(buf: ByteBuf) -> Result, String> { - Blob::try_from(buf.as_ref()).map_err(|_| "too large input".to_string()) -} - -fn id_to_memory(id: u8) -> Memory { +fn memory(id: u8) -> Memory { MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(id))) } +// The first argument is the domain-separator string that isolates this +// application's derived keys; it must stay stable for the life of the canister. +// The four Memory instances back EncryptedMaps' own stable state, in the order +// it expects: config (which persists the domain separator and vetKD key id), +// access control, shared keys, and the encrypted values. +ic_vetkeys::export_encrypted_maps_canister!( + "encrypted_maps_app", + [memory(0), memory(1), memory(2), memory(3)], +); + ic_cdk::export_candid!(); diff --git a/rust/vetkeys/password_manager/frontend/package.json b/rust/vetkeys/password_manager/frontend/package.json index ba0e1f6b23..3d671be29a 100644 --- a/rust/vetkeys/password_manager/frontend/package.json +++ b/rust/vetkeys/password_manager/frontend/package.json @@ -20,7 +20,7 @@ "dependencies": { "@icp-sdk/auth": "^7.1.0", "@icp-sdk/core": "^5.4.0", - "@icp-sdk/vetkeys": "^0.5.0-beta.0", + "@icp-sdk/vetkeys": "^0.5.0", "@popperjs/core": "^2.11.8", "@sveltejs/vite-plugin-svelte": "^3.0.2", "@tailwindcss/postcss": "^4.0.6", diff --git a/rust/vetkeys/password_manager/frontend/src/lib/encrypted_maps.ts b/rust/vetkeys/password_manager/frontend/src/lib/encrypted_maps.ts index 9972d6965c..20dc6bcfe9 100644 --- a/rust/vetkeys/password_manager/frontend/src/lib/encrypted_maps.ts +++ b/rust/vetkeys/password_manager/frontend/src/lib/encrypted_maps.ts @@ -1,8 +1,9 @@ import "./init.ts"; -import { HttpAgent, type HttpAgentOptions } from "@icp-sdk/core/agent"; +import { HttpAgent, type Identity } from "@icp-sdk/core/agent"; import { DefaultEncryptedMapsClient, EncryptedMaps, + IndexedDbDerivedKeyMaterialCache, } from "@icp-sdk/vetkeys/encrypted_maps"; import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; @@ -11,7 +12,7 @@ const canisterEnv = safeGetCanisterEnv<{ }>(); export async function createEncryptedMaps( - agentOptions?: HttpAgentOptions, + identity: Identity, ): Promise { const canisterId = canisterEnv?.["PUBLIC_CANISTER_ID:backend"]; @@ -22,10 +23,23 @@ export async function createEncryptedMaps( } const agent = await HttpAgent.create({ - ...agentOptions, + identity, host: window.location.origin, rootKey: canisterEnv?.IC_ROOT_KEY, }); - return new EncryptedMaps(new DefaultEncryptedMapsClient(agent, canisterId)); + // Since v0.5.0 EncryptedMaps caches derived key material in memory only by + // default, so it is discarded on page reload. We opt back into persisting + // it across reloads with IndexedDbDerivedKeyMaterialCache, namespacing the + // store by the caller's principal so one identity's cached keys are never + // served to another on the same origin. Remember to call clearCache() on + // logout (see the auth store). + const cache = new IndexedDbDerivedKeyMaterialCache( + `vetkeys-${identity.getPrincipal().toText()}`, + ); + + return new EncryptedMaps( + new DefaultEncryptedMapsClient(agent, canisterId), + { cache }, + ); } diff --git a/rust/vetkeys/password_manager/frontend/src/store/auth.ts b/rust/vetkeys/password_manager/frontend/src/store/auth.ts index 54a75e50ae..9634492e55 100644 --- a/rust/vetkeys/password_manager/frontend/src/store/auth.ts +++ b/rust/vetkeys/password_manager/frontend/src/store/auth.ts @@ -79,6 +79,15 @@ export async function logout() { const currentAuth = get(auth); if (currentAuth.state === "initialized") { + // Best-effort: drop cached derived key material so a persisted + // (IndexedDB) handle can no longer be used to decrypt after logout. This + // must not block sign-out if IndexedDB is unavailable (private mode, + // quota, blocked storage), so failures are logged and swallowed. + try { + await currentAuth.encryptedMaps.clearCache(); + } catch (e) { + console.error("Failed to clear derived-key cache on logout:", e); + } await currentAuth.client.signOut(); auth.update(() => ({ state: "anonymous", @@ -93,7 +102,7 @@ export async function authenticate(client: AuthClient) { try { const identity = await client.getIdentity(); - const encryptedMaps = await createEncryptedMaps({ identity }); + const encryptedMaps = await createEncryptedMaps(identity); auth.update(() => ({ state: "initialized", diff --git a/rust/vetkeys/password_manager_with_metadata/README.md b/rust/vetkeys/password_manager_with_metadata/README.md index cd06c10aa5..c3348538fe 100644 --- a/rust/vetkeys/password_manager_with_metadata/README.md +++ b/rust/vetkeys/password_manager_with_metadata/README.md @@ -61,12 +61,24 @@ icp network stop An **Encrypted Maps**-enabled Rust canister that stores encrypted passwords together with unencrypted metadata (URLs, tags) in atomic update calls. -> **Note.** A plain Encrypted Maps canister can be generated by an upstream Rust macro — `ic_vetkeys::export_encrypted_maps_canister!(...)` — that is in progress ([dfinity/vetkeys#404](https://github.com/dfinity/vetkeys/pull/404)). This example instead hand-writes a custom canister so it can add the metadata-aware methods on top of the Encrypted Maps interface. +> **Note.** A plain Encrypted Maps canister can be generated in one line by the `ic_vetkeys::export_encrypted_maps_canister!(...)` macro — see the [`password_manager`](../password_manager/) example. This example deliberately hand-writes its canister instead: it maintains a custom invariant (every encrypted value has a matching metadata row) and exposes metadata-aware `*_with_metadata` methods. The macro emits a fixed endpoint set — including the plain `insert_encrypted_value`/`remove_encrypted_value` mutators that would bypass and desync the metadata — and offers no way to override or omit them, so it is not a fit here. Reach for the macro when the standard Encrypted Maps interface is enough, and hand-write (as here) when you need to layer your own state on top. ### Frontend (`frontend/`) A **Svelte** application for managing vaults and passwords. It uses the `@icp-sdk/vetkeys` Encrypted Maps client for the crypto operations and a canister actor (bindings generated from `backend/backend.did` by the `@icp-sdk/bindgen` Vite plugin) for the metadata methods. +#### Derived-key caching + +`EncryptedMaps` derives and caches the vetKey material used to encrypt/decrypt values. As of `@icp-sdk/vetkeys` 0.5.0 this cache is **in memory by default** (discarded on page reload). This example opts into cross-reload persistence with `IndexedDbDerivedKeyMaterialCache`, giving the store a **per-identity namespace** (`vetkeys-`) so one identity's keys are never served to another on the same origin, and it calls `clearCache()` on the `EncryptedMaps` instance on logout to drop the cached material. + +**Verifying `clearCache()` on logout.** `clearCache()` *empties* the cache's object store; it does not delete the database, so an empty `vetkeys-` database remaining in the list is expected (an empty store holds no usable key material). To check it: + +1. Open DevTools → **Application → IndexedDB**. +2. Expand `vetkeys-` → `derived-key-material`. While logged in and after opening a vault it holds one or more entries; after logout it should hold **0**. +3. DevTools caches this view — click **Refresh database** after logging out, otherwise stale entries appear to linger. + +You will also see an unrelated `icp-sdk-` database (object store `subnetNodeKeys`): that is the agent's cache of public subnet node keys, not key material, and is safe to ignore. + ## Updating the Candid interface `backend/backend.did` defines the backend's public interface; the frontend bindings are generated from it during the build. If you change the backend's public API, regenerate it: diff --git a/rust/vetkeys/password_manager_with_metadata/backend/Cargo.toml b/rust/vetkeys/password_manager_with_metadata/backend/Cargo.toml index 3480734795..75aac264ba 100644 --- a/rust/vetkeys/password_manager_with_metadata/backend/Cargo.toml +++ b/rust/vetkeys/password_manager_with_metadata/backend/Cargo.toml @@ -18,6 +18,6 @@ ic-cdk = "0.20.1" ic-cdk-management-canister = "0.1.1" ic-dummy-getrandom-for-wasm = "0.1.0" ic-stable-structures = "0.7.2" -ic-vetkeys = "0.7.0" +ic-vetkeys = "0.8.1" serde = "1.0.217" serde_cbor = "0.11.2" diff --git a/rust/vetkeys/password_manager_with_metadata/backend/src/lib.rs b/rust/vetkeys/password_manager_with_metadata/backend/src/lib.rs index 334d0509be..dffd736b0c 100644 --- a/rust/vetkeys/password_manager_with_metadata/backend/src/lib.rs +++ b/rust/vetkeys/password_manager_with_metadata/backend/src/lib.rs @@ -76,7 +76,10 @@ thread_local! { RefCell::new(MemoryManager::init(DefaultMemoryImpl::default())); static ENCRYPTED_MAPS: RefCell>> = const { RefCell::new(None) }; - static METADATA: RefCell = RefCell::new(StableBTreeMap::new( + // Use `init` (not `new`): after an upgrade the thread-local is re-created, + // and `new` would overwrite the existing map with an empty one, dropping all + // metadata. `init` loads the persisted map when the memory already holds one. + static METADATA: RefCell = RefCell::new(StableBTreeMap::init( MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(4))), )); static KEY_NAME: RefCell> = diff --git a/rust/vetkeys/password_manager_with_metadata/frontend/package.json b/rust/vetkeys/password_manager_with_metadata/frontend/package.json index adaf711ac4..d0944300aa 100644 --- a/rust/vetkeys/password_manager_with_metadata/frontend/package.json +++ b/rust/vetkeys/password_manager_with_metadata/frontend/package.json @@ -23,7 +23,7 @@ "dependencies": { "@icp-sdk/auth": "^7.1.0", "@icp-sdk/core": "^5.4.0", - "@icp-sdk/vetkeys": "^0.5.0-beta.0", + "@icp-sdk/vetkeys": "^0.5.0", "@sveltejs/vite-plugin-svelte": "^3.0.2", "daisyui": "^4.12.23", "svelte": "^4.2.19", diff --git a/rust/vetkeys/password_manager_with_metadata/frontend/src/lib/encrypted_maps.ts b/rust/vetkeys/password_manager_with_metadata/frontend/src/lib/encrypted_maps.ts index 6fc0287456..7fe62197eb 100644 --- a/rust/vetkeys/password_manager_with_metadata/frontend/src/lib/encrypted_maps.ts +++ b/rust/vetkeys/password_manager_with_metadata/frontend/src/lib/encrypted_maps.ts @@ -1,7 +1,9 @@ import { HttpAgent } from "@icp-sdk/core/agent"; +import type { Principal } from "@icp-sdk/core/principal"; import { DefaultEncryptedMapsClient, EncryptedMaps, + IndexedDbDerivedKeyMaterialCache, } from "@icp-sdk/vetkeys/encrypted_maps"; import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; @@ -9,7 +11,10 @@ const canisterEnv = safeGetCanisterEnv<{ "PUBLIC_CANISTER_ID:backend": string; }>(); -export function createEncryptedMaps(agent: HttpAgent): EncryptedMaps { +export function createEncryptedMaps( + agent: HttpAgent, + principal: Principal, +): EncryptedMaps { const canisterId = canisterEnv?.["PUBLIC_CANISTER_ID:backend"]; if (!canisterId) { @@ -18,5 +23,18 @@ export function createEncryptedMaps(agent: HttpAgent): EncryptedMaps { ); } - return new EncryptedMaps(new DefaultEncryptedMapsClient(agent, canisterId)); + // Since v0.5.0 EncryptedMaps caches derived key material in memory only by + // default, so it is discarded on page reload. We opt back into persisting + // it across reloads with IndexedDbDerivedKeyMaterialCache, namespacing the + // store by the caller's principal so one identity's cached keys are never + // served to another on the same origin. Remember to call clearCache() on + // logout (see the auth store). + const cache = new IndexedDbDerivedKeyMaterialCache( + `vetkeys-${principal.toText()}`, + ); + + return new EncryptedMaps( + new DefaultEncryptedMapsClient(agent, canisterId), + { cache }, + ); } diff --git a/rust/vetkeys/password_manager_with_metadata/frontend/src/lib/password_manager.ts b/rust/vetkeys/password_manager_with_metadata/frontend/src/lib/password_manager.ts index 131eacd51d..99f472a103 100644 --- a/rust/vetkeys/password_manager_with_metadata/frontend/src/lib/password_manager.ts +++ b/rust/vetkeys/password_manager_with_metadata/frontend/src/lib/password_manager.ts @@ -1,4 +1,9 @@ -import { Actor, HttpAgent, type ActorSubclass } from "@icp-sdk/core/agent"; +import { + Actor, + HttpAgent, + type ActorSubclass, + type Identity, +} from "@icp-sdk/core/agent"; import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; import type { Principal } from "@icp-sdk/core/principal"; import { EncryptedMaps } from "@icp-sdk/vetkeys/encrypted_maps"; @@ -151,9 +156,9 @@ export class PasswordManager { } } -export async function createPasswordManager(agentOptions?: { - identity?: HttpAgent["config"]["identity"]; -}): Promise { +export async function createPasswordManager( + identity: Identity, +): Promise { const canisterId = canisterEnv?.["PUBLIC_CANISTER_ID:backend"]; if (!canisterId) { @@ -163,12 +168,12 @@ export async function createPasswordManager(agentOptions?: { } const agent = await HttpAgent.create({ - ...agentOptions, + identity, host: window.location.origin, rootKey: canisterEnv?.IC_ROOT_KEY, }); - const encryptedMaps = createEncryptedMaps(agent); + const encryptedMaps = createEncryptedMaps(agent, identity.getPrincipal()); const canisterClient = Actor.createActor<_SERVICE>(idlFactory, { agent, canisterId, diff --git a/rust/vetkeys/password_manager_with_metadata/frontend/src/store/auth.ts b/rust/vetkeys/password_manager_with_metadata/frontend/src/store/auth.ts index 1f54fb574e..8beea52a6f 100644 --- a/rust/vetkeys/password_manager_with_metadata/frontend/src/store/auth.ts +++ b/rust/vetkeys/password_manager_with_metadata/frontend/src/store/auth.ts @@ -80,6 +80,15 @@ export async function logout() { const currentAuth = get(auth); if (currentAuth.state === "initialized") { + // Best-effort: drop cached derived key material so a persisted + // (IndexedDB) handle can no longer be used to decrypt after logout. This + // must not block sign-out if IndexedDB is unavailable (private mode, + // quota, blocked storage), so failures are logged and swallowed. + try { + await currentAuth.passwordManager.encryptedMaps.clearCache(); + } catch (e) { + console.error("Failed to clear derived-key cache on logout:", e); + } await currentAuth.client.signOut(); auth.update(() => ({ state: "anonymous", @@ -94,7 +103,7 @@ export async function authenticate(client: AuthClient) { try { const identity = await client.getIdentity(); - const passwordManager = await createPasswordManager({ identity }); + const passwordManager = await createPasswordManager(identity); auth.update(() => ({ state: "initialized",