From 0ccabef3656e3c8239c8c75aedc19877c87534cb Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Fri, 12 Jul 2024 18:06:18 +0100 Subject: [PATCH] Support linking multiple auth principals to an OC account (#5852) --- Cargo.lock | 112 ++++++++++++- Cargo.toml | 2 + backend/canisters/identity/CHANGELOG.md | 1 + backend/canisters/identity/api/can.did | 31 +++- .../identity/api/src/lifecycle/init.rs | 1 + backend/canisters/identity/api/src/main.rs | 2 + .../api/src/updates/approve_identity_link.rs | 21 +++ .../api/src/updates/initiate_identity_link.rs | 17 ++ .../canisters/identity/api/src/updates/mod.rs | 2 + backend/canisters/identity/impl/Cargo.toml | 2 + backend/canisters/identity/impl/src/lib.rs | 28 ++++ .../identity/impl/src/lifecycle/init.rs | 1 + .../impl/src/model/identity_link_requests.rs | 59 +++++++ .../canisters/identity/impl/src/model/mod.rs | 1 + .../impl/src/model/user_principals.rs | 35 +++++ .../impl/src/updates/approve_identity_link.rs | 63 ++++++++ .../impl/src/updates/create_identity.rs | 21 +-- .../src/updates/initiate_identity_link.rs | 42 +++++ .../identity/impl/src/updates/mod.rs | 2 + backend/integration_tests/Cargo.toml | 2 + .../integration_tests/src/client/identity.rs | 50 ++++++ backend/integration_tests/src/client/mod.rs | 1 + .../src/client/sign_in_with_email.rs | 23 +++ .../integration_tests/src/identity_tests.rs | 148 ++++++++++++++++++ backend/integration_tests/src/lib.rs | 2 + backend/integration_tests/src/setup.rs | 12 ++ backend/integration_tests/src/wasms.rs | 1 + backend/tools/canister_installer/src/lib.rs | 1 + 28 files changed, 662 insertions(+), 21 deletions(-) create mode 100644 backend/canisters/identity/api/src/updates/approve_identity_link.rs create mode 100644 backend/canisters/identity/api/src/updates/initiate_identity_link.rs create mode 100644 backend/canisters/identity/impl/src/model/identity_link_requests.rs create mode 100644 backend/canisters/identity/impl/src/updates/approve_identity_link.rs create mode 100644 backend/canisters/identity/impl/src/updates/initiate_identity_link.rs create mode 100644 backend/integration_tests/src/client/sign_in_with_email.rs create mode 100644 backend/integration_tests/src/identity_tests.rs diff --git a/Cargo.lock b/Cargo.lock index eeb5c30b3a..789ac26ae9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -929,6 +929,39 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cached" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b0116662497bc24e4b177c90eaf8870e39e2714c3fcfa296327a93f593fc21" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.14.3", + "instant", + "once_cell", + "thiserror", +] + +[[package]] +name = "cached_proc_macro" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c878c71c2821aa2058722038a59a67583a4240524687c6028571c9b395ded61f" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + [[package]] name = "camino" version = "1.1.7" @@ -1853,6 +1886,16 @@ dependencies = [ "darling_macro 0.13.4", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + [[package]] name = "darling" version = "0.20.8" @@ -1877,6 +1920,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + [[package]] name = "darling_core" version = "0.20.8" @@ -1902,6 +1959,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.20.8" @@ -3446,6 +3514,19 @@ dependencies = [ "sha3", ] +[[package]] +name = "ic-cbor" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bc917070b8fc4bd88e3199746372e44d507f54c93a9b191787e1caefca1eba" +dependencies = [ + "candid", + "ic-certification 2.5.0", + "leb128", + "nom", + "thiserror", +] + [[package]] name = "ic-cdk" version = "0.12.0" @@ -3540,6 +3621,25 @@ dependencies = [ "slotmap", ] +[[package]] +name = "ic-certificate-verification" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "769142849e241e6cf7f5611f9b04983e958729495ea67d2de95e5d9a9c687d9b" +dependencies = [ + "cached 0.47.0", + "candid", + "ic-cbor", + "ic-certification 2.5.0", + "lazy_static", + "leb128", + "miracl_core_bls12381", + "nom", + "parking_lot", + "sha2 0.10.8", + "thiserror", +] + [[package]] name = "ic-certification" version = "0.9.0" @@ -4384,8 +4484,10 @@ dependencies = [ "canister_tracing_macros", "http_request", "ic-captcha", + "ic-cbor", "ic-cdk 0.14.0", "ic-cdk-timers", + "ic-certificate-verification", "ic-certification 2.5.0", "ic-stable-structures 0.6.4", "identity_canister", @@ -4636,9 +4738,11 @@ dependencies = [ "serde", "serde_bytes", "serial_test", + "sign_in_with_email_canister", "storage_bucket_canister", "storage_index_canister", "test-case", + "test_utils", "testing", "translations_canister", "types", @@ -5203,6 +5307,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "miracl_core_bls12381" +version = "4.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07cbe42e2a8dd41df582fb8e00fc24d920b5561cc301fcb6d14e2e0434b500f" + [[package]] name = "modclub_canister" version = "0.1.0" @@ -6267,7 +6377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.65", diff --git a/Cargo.toml b/Cargo.toml index c66493d5a9..8078b28ada 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -181,9 +181,11 @@ hex = "0.4.3" hmac-sha256 = { version = "1.1.7", features = ["traits010"] } ic-agent = "0.35.0" ic-captcha = "1.0.0" +ic-cbor = "2.5.0" ic-cdk = "0.14.0" ic-cdk-macros = "0.14.0" ic-cdk-timers = "0.8.0" +ic-certificate-verification = "2.4.0" ic-certification = "2.5.0" ic-ledger-types = "0.11.0" ic-stable-structures = "0.6.4" diff --git a/backend/canisters/identity/CHANGELOG.md b/backend/canisters/identity/CHANGELOG.md index 34e4ccba4a..1cced95c5a 100644 --- a/backend/canisters/identity/CHANGELOG.md +++ b/backend/canisters/identity/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - Sync userIds to Identity canister ([#6027](https://github.com/open-chat-labs/open-chat/pull/6027)) +- Support linking multiple auth principals to an OC account ([#5852](https://github.com/open-chat-labs/open-chat/pull/5852)) ## [[2.0.1209](https://github.com/open-chat-labs/open-chat/releases/tag/v2.0.1209-identity)] - 2024-06-20 diff --git a/backend/canisters/identity/api/can.did b/backend/canisters/identity/api/can.did index 0db0f3b6f8..8c0b87f383 100644 --- a/backend/canisters/identity/api/can.did +++ b/backend/canisters/identity/api/can.did @@ -34,6 +34,21 @@ type GenerateChallengeResponse = variant { Throttled; }; +type ApproveIdentityLinkArgs = record { + delegation : SignedDelegation; + public_key : blob; + link_initiated_by : principal; +}; + +type ApproveIdentityLinkResponse = variant { + Success; + CallerNotRecognised; + LinkRequestNotFound; + MalformedSignature : text; + InvalidSignature; + DelegationTooOld; +}; + type CreateIdentityArgs = record { public_key : PublicKey; session_key : PublicKey; @@ -52,6 +67,18 @@ type CreateIdentityResponse = variant { ChallengeFailed; }; +type InitiateIdentityLinkArgs = record { + public_key : blob; + link_to_principal : principal; +}; + +type InitiateIdentityLinkResponse = variant { + Success; + AlreadyRegistered; + TargetUserNotFound; + PublicKeyInvalid : text; +}; + type PrepareDelegationArgs = record { session_key : PublicKey; max_time_to_live : opt Nanoseconds; @@ -70,7 +97,9 @@ type PrepareDelegationSuccess = record { service : { check_auth_principal : (record {}) -> (CheckAuthPrincipalResponse) query; get_delegation : (GetDelegationArgs) -> (GetDelegationResponse) query; - generate_challenge : (record {}) -> (GenerateChallengeResponse); + approve_identity_link : (ApproveIdentityLinkArgs) -> (ApproveIdentityLinkResponse); create_identity : (CreateIdentityArgs) -> (CreateIdentityResponse); + generate_challenge : (record {}) -> (GenerateChallengeResponse); + initiate_identity_link : (InitiateIdentityLinkArgs) -> (InitiateIdentityLinkResponse); prepare_delegation : (PrepareDelegationArgs) -> (PrepareDelegationResponse); } diff --git a/backend/canisters/identity/api/src/lifecycle/init.rs b/backend/canisters/identity/api/src/lifecycle/init.rs index fbfa6fbba6..ca8482950a 100644 --- a/backend/canisters/identity/api/src/lifecycle/init.rs +++ b/backend/canisters/identity/api/src/lifecycle/init.rs @@ -8,6 +8,7 @@ pub struct Args { pub user_index_canister_id: CanisterId, pub cycles_dispenser_canister_id: CanisterId, pub skip_captcha_whitelist: Vec, + pub ic_root_key: Vec, pub wasm_version: BuildVersion, pub test_mode: bool, } diff --git a/backend/canisters/identity/api/src/main.rs b/backend/canisters/identity/api/src/main.rs index 2f0a4b89b0..c6b0655b57 100644 --- a/backend/canisters/identity/api/src/main.rs +++ b/backend/canisters/identity/api/src/main.rs @@ -4,8 +4,10 @@ fn main() { generate_candid_method!(identity, check_auth_principal, query); generate_candid_method!(identity, get_delegation, query); + generate_candid_method!(identity, approve_identity_link, update); generate_candid_method!(identity, create_identity, update); generate_candid_method!(identity, generate_challenge, update); + generate_candid_method!(identity, initiate_identity_link, update); generate_candid_method!(identity, prepare_delegation, update); candid::export_service!(); diff --git a/backend/canisters/identity/api/src/updates/approve_identity_link.rs b/backend/canisters/identity/api/src/updates/approve_identity_link.rs new file mode 100644 index 0000000000..6ce8c481e5 --- /dev/null +++ b/backend/canisters/identity/api/src/updates/approve_identity_link.rs @@ -0,0 +1,21 @@ +use crate::SignedDelegation; +use candid::{CandidType, Deserialize, Principal}; +use serde::Serialize; + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub struct Args { + pub delegation: SignedDelegation, + #[serde(with = "serde_bytes")] + pub public_key: Vec, + pub link_initiated_by: Principal, +} + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub enum Response { + Success, + CallerNotRecognised, + LinkRequestNotFound, + MalformedSignature(String), + InvalidSignature, + DelegationTooOld, +} diff --git a/backend/canisters/identity/api/src/updates/initiate_identity_link.rs b/backend/canisters/identity/api/src/updates/initiate_identity_link.rs new file mode 100644 index 0000000000..e9e83d46a2 --- /dev/null +++ b/backend/canisters/identity/api/src/updates/initiate_identity_link.rs @@ -0,0 +1,17 @@ +use candid::{CandidType, Deserialize, Principal}; +use serde::Serialize; + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub struct Args { + #[serde(with = "serde_bytes")] + pub public_key: Vec, + pub link_to_principal: Principal, +} + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub enum Response { + Success, + AlreadyRegistered, + TargetUserNotFound, + PublicKeyInvalid(String), +} diff --git a/backend/canisters/identity/api/src/updates/mod.rs b/backend/canisters/identity/api/src/updates/mod.rs index 42593b3fe6..de741d74f8 100644 --- a/backend/canisters/identity/api/src/updates/mod.rs +++ b/backend/canisters/identity/api/src/updates/mod.rs @@ -1,4 +1,6 @@ +pub mod approve_identity_link; pub mod c2c_set_user_ids; pub mod create_identity; pub mod generate_challenge; +pub mod initiate_identity_link; pub mod prepare_delegation; diff --git a/backend/canisters/identity/impl/Cargo.toml b/backend/canisters/identity/impl/Cargo.toml index 5842dc8572..130dba405e 100644 --- a/backend/canisters/identity/impl/Cargo.toml +++ b/backend/canisters/identity/impl/Cargo.toml @@ -18,8 +18,10 @@ canister_state_macros = { path = "../../../libraries/canister_state_macros" } canister_tracing_macros = { path = "../../../libraries/canister_tracing_macros" } http_request = { path = "../../../libraries/http_request" } ic-captcha = { workspace = true } +ic-cbor = { workspace = true } ic-cdk = { workspace = true } ic-cdk-timers = { workspace = true } +ic-certificate-verification = { workspace = true } ic-certification = { workspace = true } ic-stable-structures = { workspace = true } identity_canister = { path = "../api" } diff --git a/backend/canisters/identity/impl/src/lib.rs b/backend/canisters/identity/impl/src/lib.rs index fedfa43ae5..a074e2e02f 100644 --- a/backend/canisters/identity/impl/src/lib.rs +++ b/backend/canisters/identity/impl/src/lib.rs @@ -1,4 +1,5 @@ use crate::model::challenges::Challenges; +use crate::model::identity_link_requests::IdentityLinkRequests; use crate::model::salt::Salt; use crate::model::user_principals::UserPrincipals; use candid::Principal; @@ -12,7 +13,9 @@ use sha256::sha256; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use types::{BuildVersion, CanisterId, Cycles, Hash, TimestampMillis, Timestamped}; +use utils::consts::IC_ROOT_KEY; use utils::env::Environment; +use x509_parser::prelude::{FromDer, SubjectPublicKeyInfo}; mod guards; mod hash; @@ -87,20 +90,29 @@ struct Data { cycles_dispenser_canister_id: CanisterId, skip_captcha_whitelist: HashSet, user_principals: UserPrincipals, + #[serde(default)] + identity_link_requests: IdentityLinkRequests, #[serde(skip)] signature_map: SignatureMap, + #[serde(with = "serde_bytes", default = "ic_root_key")] + ic_root_key: Vec, salt: Salt, rng_seed: [u8; 32], challenges: Challenges, test_mode: bool, } +fn ic_root_key() -> Vec { + IC_ROOT_KEY.to_vec() +} + impl Data { pub fn new( governance_principals: HashSet, user_index_canister_id: CanisterId, cycles_dispenser_canister_id: CanisterId, skip_captcha_whitelist: Vec, + ic_root_key: Vec, test_mode: bool, ) -> Data { Data { @@ -109,7 +121,9 @@ impl Data { cycles_dispenser_canister_id, skip_captcha_whitelist: skip_captcha_whitelist.into_iter().collect(), user_principals: UserPrincipals::default(), + identity_link_requests: IdentityLinkRequests::default(), signature_map: SignatureMap::default(), + ic_root_key, salt: Salt::default(), rng_seed: [0; 32], challenges: Challenges::default(), @@ -151,6 +165,20 @@ fn delegation_signature_msg_hash(d: &Delegation) -> Hash { hash::hash_with_domain(b"ic-request-auth-delegation", &map_hash) } +fn extract_originating_canister(caller: Principal, public_key: &[u8]) -> Result { + let key_info = SubjectPublicKeyInfo::from_der(public_key).map_err(|e| format!("{e:?}"))?.1; + let canister_id_length = key_info.subject_public_key.data[0]; + + let canister_id = CanisterId::from_slice(&key_info.subject_public_key.data[1..=(canister_id_length as usize)]); + + let expected_caller = Principal::self_authenticating(public_key); + if caller == expected_caller { + Ok(canister_id) + } else { + Err("PublicKey does not match caller".to_string()) + } +} + #[derive(Serialize, Debug)] pub struct Metrics { pub now: TimestampMillis, diff --git a/backend/canisters/identity/impl/src/lifecycle/init.rs b/backend/canisters/identity/impl/src/lifecycle/init.rs index 6326048798..b17c328059 100644 --- a/backend/canisters/identity/impl/src/lifecycle/init.rs +++ b/backend/canisters/identity/impl/src/lifecycle/init.rs @@ -18,6 +18,7 @@ fn init(args: Args) { args.user_index_canister_id, args.cycles_dispenser_canister_id, args.skip_captcha_whitelist, + args.ic_root_key, args.test_mode, ); diff --git a/backend/canisters/identity/impl/src/model/identity_link_requests.rs b/backend/canisters/identity/impl/src/model/identity_link_requests.rs new file mode 100644 index 0000000000..45f50792a6 --- /dev/null +++ b/backend/canisters/identity/impl/src/model/identity_link_requests.rs @@ -0,0 +1,59 @@ +use candid::Principal; +use serde::{Deserialize, Serialize}; +use std::collections::hash_map::Entry::Occupied; +use std::collections::HashMap; +use types::{CanisterId, TimestampMillis}; +use utils::time::MINUTE_IN_MS; + +#[derive(Serialize, Deserialize, Default)] +pub struct IdentityLinkRequests { + map: HashMap, // Key is the caller who made the request +} + +#[derive(Serialize, Deserialize)] +struct IdentityLinkRequest { + link_to_principal: Principal, + originating_canister: CanisterId, + created: TimestampMillis, +} + +impl IdentityLinkRequests { + pub fn push( + &mut self, + caller: Principal, + originating_canister: CanisterId, + link_to_principal: Principal, + now: TimestampMillis, + ) { + self.prune_expired(now); + + self.map.insert( + caller, + IdentityLinkRequest { + link_to_principal, + originating_canister, + created: now, + }, + ); + } + + pub fn take(&mut self, caller: Principal, link_initiated_by: Principal, now: TimestampMillis) -> Option { + self.prune_expired(now); + + if let Occupied(e) = self.map.entry(link_initiated_by) { + let entry = e.get(); + if entry.link_to_principal == caller { + let originating_canister = entry.originating_canister; + e.remove(); + return Some(originating_canister); + } + } + + None + } + + fn prune_expired(&mut self, now: TimestampMillis) { + let cutoff = now.saturating_sub(5 * MINUTE_IN_MS); + self.map.retain(|_, v| v.created > cutoff) + } +} diff --git a/backend/canisters/identity/impl/src/model/mod.rs b/backend/canisters/identity/impl/src/model/mod.rs index fd2b2bf4d5..a0405e2160 100644 --- a/backend/canisters/identity/impl/src/model/mod.rs +++ b/backend/canisters/identity/impl/src/model/mod.rs @@ -1,3 +1,4 @@ pub mod challenges; +pub mod identity_link_requests; pub mod salt; pub mod user_principals; diff --git a/backend/canisters/identity/impl/src/model/user_principals.rs b/backend/canisters/identity/impl/src/model/user_principals.rs index ff560011bb..707b6022dd 100644 --- a/backend/canisters/identity/impl/src/model/user_principals.rs +++ b/backend/canisters/identity/impl/src/model/user_principals.rs @@ -15,6 +15,7 @@ pub struct UserPrincipal { pub index: u32, pub principal: Principal, pub auth_principals: Vec, + pub user_id: Option, } #[derive(Serialize, Deserialize)] @@ -55,6 +56,21 @@ impl UserPrincipals { *self.originating_canisters.entry(originating_canister).or_default() += 1; } + pub fn link_auth_principal_with_existing_user( + &mut self, + new_principal: Principal, + originating_canister: CanisterId, + user_principal_index: u32, + ) { + self.auth_principals.insert( + new_principal, + AuthPrincipalInternal { + originating_canister, + user_principal_index, + }, + ); + } + pub fn next_index(&self) -> u32 { self.user_principals.len().try_into().unwrap() } @@ -65,6 +81,10 @@ impl UserPrincipals { .and_then(|a| self.user_principal_by_index(a.user_principal_index)) } + pub fn get_auth_principal(&self, auth_principal: &Principal) -> Option { + self.auth_principals.get(auth_principal).map(|a| a.into()) + } + pub fn user_principals_count(&self) -> u32 { self.user_principals.len() as u32 } @@ -95,6 +115,21 @@ impl UserPrincipals { index: user_principal_index, principal: u.principal, auth_principals: u.auth_principals.clone(), + user_id: u.user_id, }) } } + +pub struct AuthPrincipal { + pub originating_canister: CanisterId, + pub user_principal_index: u32, +} + +impl From<&AuthPrincipalInternal> for AuthPrincipal { + fn from(value: &AuthPrincipalInternal) -> Self { + AuthPrincipal { + originating_canister: value.originating_canister, + user_principal_index: value.user_principal_index, + } + } +} diff --git a/backend/canisters/identity/impl/src/updates/approve_identity_link.rs b/backend/canisters/identity/impl/src/updates/approve_identity_link.rs new file mode 100644 index 0000000000..f565d2314d --- /dev/null +++ b/backend/canisters/identity/impl/src/updates/approve_identity_link.rs @@ -0,0 +1,63 @@ +use crate::{mutate_state, RuntimeState}; +use canister_tracing_macros::trace; +use ic_cbor::{parse_cbor, CborValue, CertificateToCbor}; +use ic_cdk::update; +use ic_certificate_verification::VerifyCertificate; +use ic_certification::Certificate; +use identity_canister::approve_identity_link::{Response::*, *}; +use utils::time::{MINUTE_IN_MS, NANOS_PER_MILLISECOND}; + +#[update] +#[trace] +fn approve_identity_link(args: Args) -> Response { + mutate_state(|state| approve_identity_link_impl(args, state)) +} + +fn approve_identity_link_impl(args: Args, state: &mut RuntimeState) -> Response { + let caller = state.env.caller(); + + let Some(auth_principal) = state.data.user_principals.get_auth_principal(&caller) else { + return CallerNotRecognised; + }; + + let now = state.env.now(); + let now_nanos = (now * NANOS_PER_MILLISECOND) as u128; + let five_minutes = (5 * MINUTE_IN_MS * NANOS_PER_MILLISECOND) as u128; + + let Ok(cbor) = parse_cbor(&args.delegation.signature) else { + return MalformedSignature("Unable to parse signature as CBOR".to_string()); + }; + let CborValue::Map(map) = cbor else { + return MalformedSignature("Expected CBOR map".to_string()); + }; + let Some(CborValue::ByteString(certificate_bytes)) = map.get("certificate") else { + return MalformedSignature("Couldn't find certificate".to_string()); + }; + let Ok(certificate) = Certificate::from_cbor(certificate_bytes) else { + return MalformedSignature("Unable to parse certificate".to_string()); + }; + if certificate + .verify( + auth_principal.originating_canister.as_slice(), + state.data.ic_root_key.as_slice(), + ) + .is_err() + { + return InvalidSignature; + } + if ic_certificate_verification::validate_certificate_time(&certificate, &now_nanos, &five_minutes).is_err() { + return DelegationTooOld; + } + + if let Some(originating_canister) = state.data.identity_link_requests.take(caller, args.link_initiated_by, now) { + state.data.user_principals.link_auth_principal_with_existing_user( + args.link_initiated_by, + originating_canister, + auth_principal.user_principal_index, + ); + + Success + } else { + LinkRequestNotFound + } +} diff --git a/backend/canisters/identity/impl/src/updates/create_identity.rs b/backend/canisters/identity/impl/src/updates/create_identity.rs index 545905343b..e5278312d8 100644 --- a/backend/canisters/identity/impl/src/updates/create_identity.rs +++ b/backend/canisters/identity/impl/src/updates/create_identity.rs @@ -1,11 +1,8 @@ use crate::updates::prepare_delegation::prepare_delegation_inner; -use crate::{mutate_state, RuntimeState}; -use candid::Principal; +use crate::{extract_originating_canister, mutate_state, RuntimeState}; use canister_tracing_macros::trace; use ic_cdk::update; use identity_canister::create_identity::{Response::*, *}; -use types::CanisterId; -use x509_parser::prelude::{FromDer, SubjectPublicKeyInfo}; #[update] #[trace] @@ -20,7 +17,7 @@ fn create_identity_impl(args: Args, state: &mut RuntimeState) -> Response { return AlreadyRegistered; } - let originating_canister = match validate_public_key(caller, &args.public_key) { + let originating_canister = match extract_originating_canister(caller, &args.public_key) { Ok(c) => c, Err(error) => return PublicKeyInvalid(error), }; @@ -44,17 +41,3 @@ fn create_identity_impl(args: Args, state: &mut RuntimeState) -> Response { expiration: result.expiration, }) } - -fn validate_public_key(caller: Principal, public_key: &[u8]) -> Result { - let key_info = SubjectPublicKeyInfo::from_der(public_key).map_err(|e| format!("{e:?}"))?.1; - let canister_id_length = key_info.subject_public_key.data[0]; - - let canister_id = CanisterId::from_slice(&key_info.subject_public_key.data[1..=(canister_id_length as usize)]); - - let expected_caller = Principal::self_authenticating(public_key); - if caller == expected_caller { - Ok(canister_id) - } else { - Err("PublicKey does not match caller".to_string()) - } -} diff --git a/backend/canisters/identity/impl/src/updates/initiate_identity_link.rs b/backend/canisters/identity/impl/src/updates/initiate_identity_link.rs new file mode 100644 index 0000000000..a4d1837802 --- /dev/null +++ b/backend/canisters/identity/impl/src/updates/initiate_identity_link.rs @@ -0,0 +1,42 @@ +use crate::{extract_originating_canister, mutate_state, RuntimeState}; +use candid::Principal; +use canister_tracing_macros::trace; +use ic_cdk::update; +use identity_canister::initiate_identity_link::{Response::*, *}; + +#[update] +#[trace] +fn initiate_identity_link(args: Args) -> Response { + mutate_state(|state| initiate_identity_link_impl(args, state)) +} + +fn initiate_identity_link_impl(args: Args, state: &mut RuntimeState) -> Response { + let caller = state.env.caller(); + + if is_registered_as_user(&caller, state) { + return AlreadyRegistered; + } + if !is_registered_as_user(&args.link_to_principal, state) { + return TargetUserNotFound; + } + + let originating_canister = match extract_originating_canister(caller, &args.public_key) { + Ok(c) => c, + Err(error) => return PublicKeyInvalid(error), + }; + + state + .data + .identity_link_requests + .push(caller, originating_canister, args.link_to_principal, state.env.now()); + + Success +} + +fn is_registered_as_user(auth_principal: &Principal, state: &RuntimeState) -> bool { + state + .data + .user_principals + .get_by_auth_principal(auth_principal) + .is_some_and(|u| u.user_id.is_some()) +} diff --git a/backend/canisters/identity/impl/src/updates/mod.rs b/backend/canisters/identity/impl/src/updates/mod.rs index 42593b3fe6..de741d74f8 100644 --- a/backend/canisters/identity/impl/src/updates/mod.rs +++ b/backend/canisters/identity/impl/src/updates/mod.rs @@ -1,4 +1,6 @@ +pub mod approve_identity_link; pub mod c2c_set_user_ids; pub mod create_identity; pub mod generate_challenge; +pub mod initiate_identity_link; pub mod prepare_delegation; diff --git a/backend/integration_tests/Cargo.toml b/backend/integration_tests/Cargo.toml index d80dfbd48e..b4f3e71ea9 100644 --- a/backend/integration_tests/Cargo.toml +++ b/backend/integration_tests/Cargo.toml @@ -36,6 +36,8 @@ registry_canister = { path = "../canisters/registry/api" } serde = { workspace = true } serde_bytes = { workspace = true } serial_test = "2.0.0" +sign_in_with_email_canister = { workspace = true } +sign_in_with_email_canister_test_utils = { workspace = true } storage_bucket_canister = { path = "../canisters/storage_bucket/api" } storage_index_canister = { path = "../canisters/storage_index/api" } testing = { path = "../libraries/testing" } diff --git a/backend/integration_tests/src/client/identity.rs b/backend/integration_tests/src/client/identity.rs index d93d4dfa6a..19861eed73 100644 --- a/backend/integration_tests/src/client/identity.rs +++ b/backend/integration_tests/src/client/identity.rs @@ -6,7 +6,9 @@ generate_query_call!(check_auth_principal); generate_query_call!(get_delegation); // Updates +generate_update_call!(approve_identity_link); generate_update_call!(create_identity); +generate_update_call!(initiate_identity_link); generate_update_call!(prepare_delegation); pub mod happy_path { @@ -81,4 +83,52 @@ pub mod happy_path { response => panic!("'get_delegation' error: {response:?}"), } } + + pub fn initiate_identity_link( + env: &mut PocketIc, + sender: Principal, + identity_canister_id: CanisterId, + public_key: Vec, + link_to_principal: Principal, + ) { + let response = super::initiate_identity_link( + env, + sender, + identity_canister_id, + &identity_canister::initiate_identity_link::Args { + public_key, + link_to_principal, + }, + ); + + match response { + identity_canister::initiate_identity_link::Response::Success => (), + response => panic!("'initiate_identity_link' error: {response:?}"), + } + } + + pub fn approve_identity_link( + env: &mut PocketIc, + sender: Principal, + identity_canister_id: CanisterId, + delegation: SignedDelegation, + public_key: Vec, + link_initiated_by: Principal, + ) { + let response = super::approve_identity_link( + env, + sender, + identity_canister_id, + &identity_canister::approve_identity_link::Args { + delegation, + public_key, + link_initiated_by, + }, + ); + + match response { + identity_canister::approve_identity_link::Response::Success => (), + response => panic!("'approve_identity_link' error: {response:?}"), + } + } } diff --git a/backend/integration_tests/src/client/mod.rs b/backend/integration_tests/src/client/mod.rs index 5c2563cff7..f75ee12525 100644 --- a/backend/integration_tests/src/client/mod.rs +++ b/backend/integration_tests/src/client/mod.rs @@ -24,6 +24,7 @@ pub mod notifications; pub mod notifications_index; pub mod online_users; pub mod registry; +pub mod sign_in_with_email; pub mod storage_bucket; pub mod storage_index; pub mod user; diff --git a/backend/integration_tests/src/client/sign_in_with_email.rs b/backend/integration_tests/src/client/sign_in_with_email.rs new file mode 100644 index 0000000000..42e8199349 --- /dev/null +++ b/backend/integration_tests/src/client/sign_in_with_email.rs @@ -0,0 +1,23 @@ +use crate::{generate_query_call, generate_update_call}; + +// Queries +generate_query_call!(get_delegation); + +// Updates +generate_update_call!(generate_magic_link); +generate_update_call!(handle_magic_link); + +mod generate_magic_link { + pub type Args = sign_in_with_email_canister::GenerateMagicLinkArgs; + pub type Response = sign_in_with_email_canister::GenerateMagicLinkResponse; +} + +mod get_delegation { + pub type Args = sign_in_with_email_canister::GetDelegationArgs; + pub type Response = sign_in_with_email_canister::GetDelegationResponse; +} + +mod handle_magic_link { + pub type Args = sign_in_with_email_canister::HandleMagicLinkArgs; + pub type Response = sign_in_with_email_canister::HandleMagicLinkResponse; +} diff --git a/backend/integration_tests/src/identity_tests.rs b/backend/integration_tests/src/identity_tests.rs new file mode 100644 index 0000000000..9de3e5a7b0 --- /dev/null +++ b/backend/integration_tests/src/identity_tests.rs @@ -0,0 +1,148 @@ +use crate::env::ENV; +use crate::{client, CanisterIds, TestEnv}; +use candid::Principal; +use identity_canister::{Delegation, SignedDelegation}; +use pocket_ic::PocketIc; +use rand::random; +use std::ops::Deref; +use std::time::Duration; +use test_case::test_case; +use testing::rng::{random_internet_identity_principal, random_string}; +use utils::time::NANOS_PER_MILLISECOND; + +#[test_case(false)] +#[test_case(true)] +fn link_auth_identities(delay: bool) { + let mut wrapper = ENV.deref().get(); + let TestEnv { env, canister_ids, .. } = wrapper.env(); + + let (auth_principal1, public_key1, delegation1) = sign_in_with_email(env, canister_ids); + let (auth_principal2, public_key2) = random_internet_identity_principal(); + + let session_key1 = random::<[u8; 32]>().to_vec(); + let session_key2 = random::<[u8; 32]>().to_vec(); + + let create_identity_result = client::identity::happy_path::create_identity( + env, + auth_principal1, + canister_ids.identity, + public_key1.clone(), + session_key1.clone(), + ); + + let oc_principal1 = Principal::self_authenticating(create_identity_result.user_key.clone()); + client::local_user_index::happy_path::register_user( + env, + oc_principal1, + canister_ids.local_user_index, + create_identity_result.user_key, + ); + + env.tick(); + + client::identity::happy_path::initiate_identity_link( + env, + auth_principal2, + canister_ids.identity, + public_key2, + auth_principal1, + ); + + if delay { + env.advance_time(Duration::from_secs(301)); + } + + let approve_identity_link_response = client::identity::approve_identity_link( + env, + auth_principal1, + canister_ids.identity, + &identity_canister::approve_identity_link::Args { + delegation: delegation1, + public_key: public_key1, + link_initiated_by: auth_principal2, + }, + ); + + match approve_identity_link_response { + identity_canister::approve_identity_link::Response::Success if !delay => { + let prepare_delegation_response = + client::identity::happy_path::prepare_delegation(env, auth_principal2, canister_ids.identity, session_key2); + + let oc_principal2 = Principal::self_authenticating(prepare_delegation_response.user_key); + + assert_eq!(oc_principal1, oc_principal2); + } + identity_canister::approve_identity_link::Response::DelegationTooOld if delay => {} + response => panic!("{response:?}"), + }; +} + +fn sign_in_with_email(env: &mut PocketIc, canister_ids: &CanisterIds) -> (Principal, Vec, SignedDelegation) { + let email = format!("{}@test.com", random_string()); + let session_key = random::<[u8; 32]>().to_vec(); + + let generate_magic_link_response = client::sign_in_with_email::generate_magic_link( + env, + Principal::anonymous(), + canister_ids.sign_in_with_email, + &sign_in_with_email_canister::GenerateMagicLinkArgs { + email: email.clone(), + session_key: session_key.clone(), + max_time_to_live: None, + }, + ); + + let sign_in_with_email_canister::GenerateMagicLinkResponse::Success(generate_magic_link_success) = + generate_magic_link_response + else { + panic!("{generate_magic_link_response:?}"); + }; + + let magic_link = sign_in_with_email_canister_test_utils::generate_magic_link( + &email, + session_key.clone(), + generate_magic_link_success.created * NANOS_PER_MILLISECOND, + generate_magic_link_success.expiration, + generate_magic_link_success.code, + ); + + let handle_magic_link_response = client::sign_in_with_email::handle_magic_link( + env, + Principal::anonymous(), + canister_ids.sign_in_with_email, + &sign_in_with_email_canister::HandleMagicLinkArgs { + link: format!("{}&c={}", magic_link.build_querystring(), magic_link.magic_link.code()), + }, + ); + assert!(matches!( + handle_magic_link_response, + sign_in_with_email_canister::HandleMagicLinkResponse::Success + )); + + let get_delegation_response = client::sign_in_with_email::get_delegation( + env, + Principal::anonymous(), + canister_ids.sign_in_with_email, + &sign_in_with_email_canister::GetDelegationArgs { + email: email.to_string(), + session_key, + expiration: generate_magic_link_success.expiration, + }, + ); + + let sign_in_with_email_canister::GetDelegationResponse::Success(delegation) = get_delegation_response else { + panic!("{get_delegation_response:?}"); + }; + + let principal = Principal::self_authenticating(&generate_magic_link_success.user_key); + let public_key = generate_magic_link_success.user_key; + let delegation = SignedDelegation { + delegation: Delegation { + pubkey: delegation.delegation.pubkey, + expiration: delegation.delegation.expiration, + }, + signature: delegation.signature, + }; + + (principal, public_key, delegation) +} diff --git a/backend/integration_tests/src/lib.rs b/backend/integration_tests/src/lib.rs index ae69bea0c0..881827df80 100644 --- a/backend/integration_tests/src/lib.rs +++ b/backend/integration_tests/src/lib.rs @@ -23,6 +23,7 @@ mod escrow_tests; mod fire_and_forget_handler_tests; mod freeze_group_tests; mod gated_group_tests; +mod identity_tests; mod join_group_tests; mod last_online_date_tests; mod mentions_tests; @@ -100,6 +101,7 @@ pub struct CanisterIds { pub translations: CanisterId, pub event_relay: CanisterId, pub event_store: CanisterId, + pub sign_in_with_email: CanisterId, pub icp_ledger: CanisterId, pub chat_ledger: CanisterId, pub cycles_minting_canister: CanisterId, diff --git a/backend/integration_tests/src/setup.rs b/backend/integration_tests/src/setup.rs index d153f40037..d24c28af42 100644 --- a/backend/integration_tests/src/setup.rs +++ b/backend/integration_tests/src/setup.rs @@ -116,6 +116,7 @@ fn install_canisters(env: &mut PocketIc, controller: Principal) -> CanisterIds { let online_users_canister_wasm = wasms::ONLINE_USERS.clone(); let proposals_bot_canister_wasm = wasms::PROPOSALS_BOT.clone(); let registry_canister_wasm = wasms::REGISTRY.clone(); + let sign_in_with_email_canister_wasm = wasms::SIGN_IN_WITH_EMAIL.clone(); let sns_wasm_canister_wasm = wasms::SNS_WASM.clone(); let storage_bucket_canister_wasm = wasms::STORAGE_BUCKET.clone(); let storage_index_canister_wasm = wasms::STORAGE_INDEX.clone(); @@ -198,6 +199,7 @@ fn install_canisters(env: &mut PocketIc, controller: Principal) -> CanisterIds { user_index_canister_id, cycles_dispenser_canister_id, skip_captcha_whitelist: vec![NNS_INTERNET_IDENTITY_CANISTER_ID, sign_in_with_email_canister_id], + ic_root_key: env.root_key().unwrap(), wasm_version: BuildVersion::min(), test_mode: true, }; @@ -370,6 +372,15 @@ fn install_canisters(env: &mut PocketIc, controller: Principal) -> CanisterIds { event_store_init_args, ); + let sign_in_with_email_init_args = sign_in_with_email_canister_test_utils::default_init_args(); + install_canister( + env, + controller, + sign_in_with_email_canister_id, + sign_in_with_email_canister_wasm, + sign_in_with_email_init_args, + ); + client::user_index::happy_path::upgrade_user_canister_wasm(env, controller, user_index_canister_id, user_canister_wasm); client::user_index::happy_path::upgrade_local_user_index_canister_wasm( env, @@ -488,6 +499,7 @@ fn install_canisters(env: &mut PocketIc, controller: Principal) -> CanisterIds { translations: translations_canister_id, event_relay: event_relay_canister_id, event_store: event_store_canister_id, + sign_in_with_email: sign_in_with_email_canister_id, icp_ledger: nns_ledger_canister_id, chat_ledger: chat_ledger_canister_id, cycles_minting_canister: cycles_minting_canister_id, diff --git a/backend/integration_tests/src/wasms.rs b/backend/integration_tests/src/wasms.rs index 6bac71cdd4..e5be9f95bf 100644 --- a/backend/integration_tests/src/wasms.rs +++ b/backend/integration_tests/src/wasms.rs @@ -23,6 +23,7 @@ lazy_static! { pub static ref ONLINE_USERS: CanisterWasm = get_canister_wasm("online_users"); pub static ref PROPOSALS_BOT: CanisterWasm = get_canister_wasm("proposals_bot"); pub static ref REGISTRY: CanisterWasm = get_canister_wasm("registry"); + pub static ref SIGN_IN_WITH_EMAIL: CanisterWasm = get_canister_wasm("sign_in_with_email"); pub static ref SNS_WASM: CanisterWasm = get_canister_wasm("sns_wasm"); pub static ref STORAGE_BUCKET: CanisterWasm = get_canister_wasm("storage_bucket"); pub static ref STORAGE_INDEX: CanisterWasm = get_canister_wasm("storage_index"); diff --git a/backend/tools/canister_installer/src/lib.rs b/backend/tools/canister_installer/src/lib.rs index dde916c076..daee0b3dd0 100644 --- a/backend/tools/canister_installer/src/lib.rs +++ b/backend/tools/canister_installer/src/lib.rs @@ -123,6 +123,7 @@ async fn install_service_canisters_impl( user_index_canister_id: canister_ids.user_index, cycles_dispenser_canister_id: canister_ids.cycles_dispenser, skip_captcha_whitelist: vec![canister_ids.nns_internet_identity, canister_ids.sign_in_with_email], + ic_root_key: agent.read_root_key(), wasm_version: version, test_mode, };