diff --git a/Cargo.lock b/Cargo.lock index cc44e8b..b935576 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -485,6 +485,7 @@ dependencies = [ "candid_parser", "canister_sig_util", "canister_tests", + "ciborium", "hex", "ic-cdk", "ic-cdk-macros", diff --git a/scripts/upgrade-civic.sh b/scripts/upgrade-civic.sh new file mode 100755 index 0000000..96d1b3c --- /dev/null +++ b/scripts/upgrade-civic.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# source .env.local + +######### +# USAGE # +######### + +function title() { + echo "Provisioning Civic canister" >&2 +} + +function usage() { + cat >&2 << EOF + +Usage: + $0 [--ii-canister-id CANISTER_ID] [--dfx-network NETWORK] + +Options: + --ii-canister-id CANISTER_ID The canister ID to use as IDP, defaults to the local internet_identity canister + --dfx-network NETWORK The network to use (typically "local" or "ic"), defaults to "local" + --issuer-canister CANISTER The canister to configure (name or canister ID), defaults to "issuer" +EOF +} + +function help() { + cat >&2 << EOF + +The issuer canister needs some information to operate correctly. This reads data +from the replica to ensure the issuer is provisioned correctly. +EOF +} + +II_CANISTER_ID= +DFX_NETWORK= +ADMIN_PRINCIPAL_ID=tglqb-kbqlj-to66e-3w5sg-kkz32-c6ffi-nsnta-vj2gf-vdcc5-5rzjk-jae + +while [[ $# -gt 0 ]] +do + case "$1" in + -h|--help) + title + usage + help + exit 0 + ;; + --ii-canister-id) + II_CANISTER_ID="${2:?missing value for '--ii-canister-id'}" + shift; # shift past --ii-canister-id & value + shift; + ;; + --dfx-network) + DFX_NETWORK="${2:?missing value for '--dfx-network'}" + shift; # shift past --dfx-network & value + shift; + ;; + --issuer-canister) + ISSUER_CANISTER_ID="${2:?missing value for '--issuer-canister-id'}" + shift; # shift past --issuer-canister & value + shift; + ;; + *) + echo "ERROR: unknown argument $1" + usage + echo + echo "Use '$0 --help' for more information" + exit 1 + ;; + esac +done + +# Make sure we always run from the repo's root +SCRIPTS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPTS_DIR/.." + +DFX_NETWORK="${DFX_NETWORK:-local}" +II_CANISTER_ID="${II_CANISTER_ID:-$(dfx canister id internet_identity --network "$DFX_NETWORK")}" +ISSUER_CANISTER_ID="${ISSUER_CANISTER_ID:-$(dfx canister id civic_canister_backend --network "$DFX_NETWORK")}" +CIVIC_FRONTEND_CANISTER_ID="${CIVIC_FRONTEND_CANISTER_ID:-$(dfx canister id civic_canister_frontend --network "$DFX_NETWORK")}" +if [ "$DFX_NETWORK" = "local" ]; then + REPLICA_SERVER_PORT=$(dfx info webserver-port) + ISSUER_DERIVATION_ORIGIN="http://${ISSUER_CANISTER_ID}.localhost:${REPLICA_SERVER_PORT}" + ISSUER_FRONTEND_HOSTNAME="http://${ISSUER_CANISTER_ID}.localhost:${REPLICA_SERVER_PORT}" +fi +if [ "$DFX_NETWORK" = "ic" ]; then + ISSUER_DERIVATION_ORIGIN="https://${ISSUER_CANISTER_ID}.icp0.io" + ISSUER_FRONTEND_HOSTNAME="https://${ISSUER_CANISTER_ID}.icp0.io" +fi + + +echo "Using DFX network: $DFX_NETWORK" >&2 +echo "Using II canister: $II_CANISTER_ID" >&2 +echo "Using issuer canister: $ISSUER_CANISTER_ID" >&2 +echo "Using derivation origin: $ISSUER_DERIVATION_ORIGIN" >&2 +echo "Using frontend hostname: $ISSUER_FRONTEND_HOSTNAME" >&2 + +# At the time of writing dfx outputs incorrect JSON with dfx ping (commas between object +# entries are missing). +# In order to read the root key we grab the array from the '"root_key": [...]' bit, the brackets +# to match what candid expects ({}), replace the commas between array entries to match +# what candid expects (semicolon) and annotate the numbers with their type (otherwise dfx assumes 'nat' +# instead of 'nat8'). +rootkey_did=$(dfx ping "$DFX_NETWORK" \ + | sed -n 's/.*"root_key": \[\(.*\)\].*/{\1}/p' \ + | sed 's/\([0-9][0-9]*\)/\1:nat8/g' \ + | sed 's/,/;/g') + +echo "Parsed rootkey: ${rootkey_did:0:20}..." >&2 + +# Add also dev server to alternative origins when deploying locally +if [ "$DFX_NETWORK" = "local" ]; then + ALTERNATIVE_ORIGINS="\"http://$CIVIC_FRONTEND_CANISTER_ID.localhost:4943\"" + else + ALTERNATIVE_ORIGINS="\"https://$CIVIC_FRONTEND_CANISTER_ID.icp0.io\"" +fi + +echo "Using Alternative Origin: $ALTERNATIVE_ORIGINS $ISSUER_FRONTEND_HOSTNAME" + +# Adjust issuer's .well-known/ii-alternative-origins to contain FE-hostname of local/dev deployments. +# We had a problem with `sed` command in CI. This is a hack to make it work locally and in CI. +mv src/civic_canister_backend/dist/.well-known/ii-alternative-origins ./ii-alternative-origins-template +cat ./ii-alternative-origins-template | sed "s+ISSUER_FE_HOSTNAME_PLACEHOLDER+$ALTERNATIVE_ORIGINS+g" > src/civic_canister_backend/dist/.well-known/ii-alternative-origins +rm ./ii-alternative-origins-template + +dfx deploy --upgrade-unchanged civic_canister_backend --network "$DFX_NETWORK" --argument '( + opt record { + idp_canister_ids = vec { principal "'"$II_CANISTER_ID"'" }; + ic_root_key_der = vec '"$rootkey_did"'; + derivation_origin = "'"$ISSUER_DERIVATION_ORIGIN"'"; + frontend_hostname = "'"$ISSUER_FRONTEND_HOSTNAME"'"; + admin = principal "'"$ADMIN_PRINCIPAL_ID"'"; + authorized_issuers = vec { principal "'"$ADMIN_PRINCIPAL_ID"'" }; + } +)' +# Revert changes +git checkout src/civic_canister_backend/dist/.well-known/ii-alternative-origins \ No newline at end of file diff --git a/src/civic_canister_backend/Cargo.toml b/src/civic_canister_backend/Cargo.toml index 88cd4b8..3937a65 100644 --- a/src/civic_canister_backend/Cargo.toml +++ b/src/civic_canister_backend/Cargo.toml @@ -42,6 +42,7 @@ lazy_static = "1.4" mockall = "0.10.2" serde_json.workspace = true sha2.workspace = true +ciborium = "0.2.2" diff --git a/src/civic_canister_backend/dist/.well-known/ii-alternative-origins b/src/civic_canister_backend/dist/.well-known/ii-alternative-origins index ed77f7d..c5ee6bd 100644 --- a/src/civic_canister_backend/dist/.well-known/ii-alternative-origins +++ b/src/civic_canister_backend/dist/.well-known/ii-alternative-origins @@ -1,3 +1,3 @@ { - "alternativeOrigins": [ISSUER_FE_HOSTNAME_PLACEHOLDER] + "alternativeOrigins": ["http://bd3sg-teaaa-aaaaa-qaaba-cai.localhost:4943"] } \ No newline at end of file diff --git a/src/civic_canister_backend/src/config.rs b/src/civic_canister_backend/src/config.rs index 3eba2bd..f319c4d 100644 --- a/src/civic_canister_backend/src/config.rs +++ b/src/civic_canister_backend/src/config.rs @@ -6,7 +6,7 @@ //! - Managing assets and their certification. //! - Handling HTTP requests with CORS support. -use crate::credential::{update_root_hash, StoredCredential}; +use crate::credential::{update_root_hash, CredentialList, CANISTER_SIG_SEED}; use asset_util::{collect_assets, CertifiedAssets}; use candid::{candid_method, CandidType, Deserialize, Principal}; use canister_sig_util::signature_map::{SignatureMap, LABEL_SIG}; @@ -15,34 +15,50 @@ use ic_cdk::api; use ic_cdk_macros::{init, post_upgrade, query, update}; use ic_certification::{labeled_hash, pruned}; use ic_stable_structures::storable::Bound; -use ic_stable_structures::{DefaultMemoryImpl, RestrictedMemory, StableCell, Storable}; +use ic_stable_structures::{ + memory_manager::{MemoryId, MemoryManager, VirtualMemory}, + DefaultMemoryImpl, StableBTreeMap, StableCell, StableVec, Storable, +}; use include_dir::{include_dir, Dir}; use serde_bytes::ByteBuf; use std::borrow::Cow; use std::cell::RefCell; -use std::collections::HashMap; use vc_util::issuer_api::{DerivationOriginData, DerivationOriginError, DerivationOriginRequest}; const PROD_II_CANISTER_ID: &str = "rdmx6-jaaaa-aaaaa-aaadq-cai"; +// A memory for config, where data from the heap can be serialized/deserialized. +const CONF: MemoryId = MemoryId::new(0); +// A memory for Signatures, where data from the heap can be serialized/deserialized. +const SIG: MemoryId = MemoryId::new(1); + +// A memory for the Credential data +const CREDENTIAL: MemoryId = MemoryId::new(2); + +type ConfigCell = StableCell>; + thread_local! { - /// Static configuration of the canister set by init() or post_upgrade(). - pub(crate) static CONFIG: RefCell = RefCell::new(ConfigCell::init(config_memory(), IssuerConfig::default()).expect("failed to initialize stable cell")); + static MEMORY_MANAGER: RefCell> = + RefCell::new(MemoryManager::init(DefaultMemoryImpl::default())); + + pub(crate) static CONFIG: RefCell = RefCell::new(ConfigCell::init(MEMORY_MANAGER.with(|m| m.borrow().get(CONF)), IssuerConfig::default()).expect("failed to initialize stable cell")); pub(crate) static SIGNATURES : RefCell = RefCell::new(SignatureMap::default()); - pub static CREDENTIALS : RefCell>> = RefCell::new(HashMap::new()); + pub(crate) static CREDENTIALS: RefCell>> = RefCell::new( + StableBTreeMap::init( + MEMORY_MANAGER.with(|m| m.borrow().get(CREDENTIAL)) + ) + ); // Assets for the management app - pub static ASSETS: RefCell = RefCell::new(CertifiedAssets::default()); -} + pub(crate) static ASSETS: RefCell = RefCell::new(CertifiedAssets::default()); -/// We use restricted memory in order to ensure the separation between non-managed config memory (first page) -/// and the managed memory for potential other data of the canister. -type Memory = RestrictedMemory; -type ConfigCell = StableCell; -/// Reserve the first stable memory page for the configuration stable cell. -fn config_memory() -> Memory { - RestrictedMemory::new(DefaultMemoryImpl::default(), 0..1) + // Stable vector to restore the signatures when the canister is upgraded + pub(crate) static MSG_HASHES: RefCell>> = RefCell::new( + StableVec::init( + MEMORY_MANAGER.with(|m| m.borrow().get(SIG)) + ).expect("failed to initialize stable vector") + ); } #[cfg(target_arch = "wasm32")] @@ -132,7 +148,7 @@ fn init(init_arg: Option) { let default_config = IssuerConfig::default(); CONFIG.with(|config_cell| { let mut config = config_cell.borrow_mut(); - *config = ConfigCell::init(config_memory(), default_config) + *config = ConfigCell::init(MEMORY_MANAGER.with(|m| m.borrow().get(CONF)), default_config) .expect("Failed to initialize config"); }); } @@ -194,7 +210,20 @@ fn get_admin() -> Principal { /// Called when the canister is upgraded. #[post_upgrade] fn post_upgrade(init_arg: Option) { + // Initialize the CONFIG init(init_arg); + + // Restore the signatures + SIGNATURES.with(|sigs| { + let mut sigs = sigs.borrow_mut(); + MSG_HASHES.with(|hashes| { + hashes.borrow().iter().for_each(|hash| { + sigs.add_signature(&CANISTER_SIG_SEED, hash); + }) + }); + }); + + update_root_hash(); } /// Called when the canister is configured. diff --git a/src/civic_canister_backend/src/credential.rs b/src/civic_canister_backend/src/credential.rs index ad07555..c252ebf 100644 --- a/src/civic_canister_backend/src/credential.rs +++ b/src/civic_canister_backend/src/credential.rs @@ -4,10 +4,11 @@ //! including issuing, updating, and retrieving credentials. It also handles authorization //! and verification processes related to credential operations. +use std::borrow::Cow; use std::fmt; use std::collections::{HashMap, BTreeMap}; use std::iter::repeat; -use candid::{CandidType, Deserialize, Principal, candid_method}; +use candid::{CandidType, Deserialize, Principal, candid_method, Encode, Decode}; use serde::Serialize; use serde_json::Value; use identity_credential::credential::{CredentialBuilder, Subject}; @@ -26,10 +27,12 @@ use ic_certification::{Hash, fork_hash, labeled_hash}; use serde_bytes::ByteBuf; use lazy_static::lazy_static; use identity_core::common::Timestamp; +use ic_stable_structures::storable::{Storable, Bound}; + extern crate asset_util; -use crate::config::{CONFIG, CREDENTIALS, SIGNATURES, ASSETS}; +use crate::config::{CONFIG, CREDENTIALS, SIGNATURES, ASSETS, MSG_HASHES}; // The expiration of issued verifiable credentials. const MINUTE_NS: u64 = 60 * 1_000_000_000; @@ -39,7 +42,7 @@ const AUTHORIZED_PRINCIPAL: &str = "tglqb-kbqlj-to66e-3w5sg-kkz32-c6ffi-nsnta-vj lazy_static! { /// Seed and public key used for signing the credentials. - static ref CANISTER_SIG_SEED: Vec = hash_bytes("some_random_seed").to_vec(); + pub(crate) static ref CANISTER_SIG_SEED: Vec = hash_bytes("a_random_seed").to_vec(); static ref CANISTER_SIG_PK: CanisterSigPublicKey = CanisterSigPublicKey::new(ic_cdk::id(), CANISTER_SIG_SEED.clone()); } @@ -106,6 +109,30 @@ pub(crate) struct StoredCredential { pub(crate) claim: Vec, } +/// Define a wrapper type around a list of credentials so that we can store it inside Stable +#[derive(CandidType, Serialize, Deserialize, Debug, Clone)] +pub struct CredentialList(Vec); + +/// Implement the trait needed to use CredentialList inside a StableBTreeMap +impl Storable for CredentialList { + fn to_bytes(&self) -> Cow<[u8]> { + Cow::Owned(Encode!(&self.0).expect("Failed to encode StoredCredential")) + } + + fn from_bytes(bytes: Cow<[u8]>) -> Self { + CredentialList(Decode!(&bytes, Vec).expect("Failed to decode StoredCredential")) + } + + // this measures the size of the object in bytes + const BOUND: Bound = Bound::Unbounded; +} + +impl From for Vec { + fn from(val: CredentialList) -> Self { + val.0 + } +} + /// Enumerates potential errors that can occur during credential operations. #[derive(CandidType, Deserialize, Debug)] pub(crate) enum CredentialError { @@ -116,16 +143,28 @@ pub(crate) enum CredentialError { /// Adds new credentials to the canister for a given principal. #[update] #[candid_method] -async fn add_credentials(principal: Principal, new_credentials: Vec) -> Result { +async fn add_credentials(principal: Principal, new_credentials: CredentialList) -> Result { // Check if the caller is the authorized principal if caller().to_text() != AUTHORIZED_PRINCIPAL { return Err(CredentialError::UnauthorizedSubject("Unauthorized: You do not have permission to add credentials.".to_string())); } // Access the credentials storage and attempt to add the new credentials - CREDENTIALS.with_borrow_mut(|credentials| { - let entry = credentials.entry(principal).or_default(); - entry.extend(new_credentials.clone()); - }); + CREDENTIALS.with(|c| { + // get a mutable reference to the stable map + let mut credentials = c.borrow_mut(); + // check if there is already credentials stored under this principal + if credentials.contains_key(&principal) { + // if yes, extend the vector with the vector of new credentials + let mut existing_credentials: Vec = credentials.get(&principal).unwrap().into(); + existing_credentials.extend(>::from(new_credentials.clone())); + credentials.insert(principal, CredentialList(existing_credentials)); + } else { + // else insert the new entry + credentials.insert(principal, new_credentials.clone()); + } + + }); + let credential_info = format!("Added credentials: \n{:?}", new_credentials); Ok(credential_info) } @@ -140,8 +179,9 @@ async fn update_credential(principal: Principal, credential_id: String, updated_ return Err(CredentialError::UnauthorizedSubject("Unauthorized: You do not have permission to update credentials.".to_string())); } // Access the credentials storage and attempt to update the specified credential - CREDENTIALS.with_borrow_mut(|credentials| { - if let Some(creds) = credentials.get_mut(&principal) { + CREDENTIALS.with(|c| { + if let Some(creds) = c.borrow().get(&principal) { + let mut creds: Vec = creds.into(); if let Some(pos) = creds.iter().position(|c| c.id == credential_id) { creds[pos] = updated_credential.clone(); return Ok(format!("Credential updated successfully: {:?}", updated_credential)); @@ -156,13 +196,11 @@ async fn update_credential(principal: Principal, credential_id: String, updated_ #[query] #[candid_method(query)] fn get_all_credentials(principal: Principal) -> Result, CredentialError> { - if let Some(c) = CREDENTIALS.with_borrow(|credentials| { - credentials.get(&principal).cloned() - }) { - Ok(c) - } else { - Err(CredentialError::NoCredentialFound(format!("No credentials found for principal {}", principal.to_text()))) - } + if let Some(c) = CREDENTIALS.with(|c| c.borrow().get(&principal)) { + Ok(c.into()) +} else { + Err(CredentialError::NoCredentialFound(format!("No credentials found for the principal {}", principal.to_text()))) +} } @@ -185,13 +223,17 @@ async fn prepare_credential( }; // And sign the JWT let signing_input = - vc_signing_input(&credential_jwt, &CANISTER_SIG_PK).expect("failed getting signing_input"); + vc_signing_input(&credential_jwt, &CANISTER_SIG_PK).expect("Failed getting signing_input."); let msg_hash = vc_signing_input_hash(&signing_input); // Add the signed JWT to the signature storage SIGNATURES.with(|sigs| { let mut sigs = sigs.borrow_mut(); sigs.add_signature(&CANISTER_SIG_SEED, msg_hash); + // Add the msg hash to the stable storage to restore the signatures when the canister is upgraded + MSG_HASHES.with(|hashes| { + let _ = hashes.borrow_mut().push(&msg_hash); + }); }); update_root_hash(); // Return a prepared context that includes the signed JWT @@ -218,7 +260,7 @@ fn get_credential(req: GetCredentialRequest) -> Result context, None => { return Result::::Err(internal_error( - "missing prepared_context", + "Missing prepared_context", )) } }; @@ -226,7 +268,7 @@ fn get_credential(req: GetCredentialRequest) -> Result s, Err(_) => { return Result::::Err(internal_error( - "invalid prepared_context", + "Invalid prepared_context", )) } }; @@ -251,7 +293,7 @@ fn get_credential(req: GetCredentialRequest) -> Result::Err( IssueCredentialError::SignatureNotFound(format!( - "signature not prepared or expired: {}", + "Signature not prepared or expired: {}", e )), ); @@ -273,7 +315,7 @@ fn authorize_vc_request( CONFIG.with_borrow(|config| { let config = config.get(); - // heck if the ID alias is legitimate and was issued by the internet identity canister + // check if the ID alias is legitimate and was issued by the internet identity canister for idp_canister_id in &config.idp_canister_ids { if let Ok(alias_tuple) = get_verified_id_alias_from_jws( &alias.credential_jws, @@ -286,7 +328,7 @@ fn authorize_vc_request( } } Err(IssueCredentialError::InvalidIdAlias( - "id alias could not be verified".to_string(), + "Id alias could not be verified".to_string(), )) }) } @@ -298,12 +340,10 @@ fn verify_authorized_principal( alias_tuple: &AliasTuple, ) -> Result { // Get the credentials of this user - if let Some(credentials) = CREDENTIALS.with(|credentials| { - let credentials = credentials.borrow(); - credentials.get(&alias_tuple.id_dapp).cloned() - }) { + if let Some(credentials) = CREDENTIALS.with(|c|c.borrow().get(&alias_tuple.id_dapp)) { // Check if the user has a credential of the type and return it - for c in credentials { + let v: Vec = credentials.into(); + for c in v { if c.type_.contains(&credential_type.to_string()) { return Ok(c) } @@ -311,12 +351,12 @@ fn verify_authorized_principal( } // No (matching) credential found for this user println!( - "*** principal {} it is not authorized for credential type {:?}", + "*** Principal {} it is not authorized for credential type {:?}", alias_tuple.id_dapp.to_text(), credential_type ); Err(IssueCredentialError::UnauthorizedSubject(format!( - "unauthorized principal {}", + "Unauthorized principal {}", alias_tuple.id_dapp.to_text() ))) } @@ -416,7 +456,7 @@ fn build_credential_jwt(params: CredentialParams) -> String { let expiration_date = Timestamp::from_unix(params.expiration_timestamp_s as i64) .expect("internal: failed computing expiration timestamp"); - // Build the VC object + // Build the VC a let mut credential = CredentialBuilder::default() .id(Url::parse(params.credential_id_url).unwrap()) .issuer(Url::parse(params.issuer_url).unwrap())