diff --git a/Cargo.lock b/Cargo.lock index 1206c6ac97..4a944cbdca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3945,13 +3945,13 @@ dependencies = [ "canister_tracing_macros", "http_request", "ic-captcha", - "ic-cbor", "ic-cdk 0.16.0", "ic-cdk-timers", "ic-certificate-verification", "ic-certification", "ic-stable-structures", "identity_canister", + "identity_utils", "msgpack", "rand 0.8.5", "serde", @@ -4050,6 +4050,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "identity_utils" +version = "0.1.0" +dependencies = [ + "ic-cbor", + "ic-certificate-verification", + "ic-certification", + "types", +] + [[package]] name = "identity_verification" version = "1.3.1" @@ -8313,12 +8323,14 @@ dependencies = [ "http_request", "ic-cdk 0.16.0", "ic-cdk-timers", + "ic-certificate-verification", "ic-ledger-types", "ic-stable-structures", "icpswap_client", "icrc-ledger-types", "icrc_ledger_canister", "icrc_ledger_canister_c2c_client", + "identity_utils", "itertools 0.13.0", "ledger_utils", "local_user_index_canister", diff --git a/Cargo.toml b/Cargo.toml index b99bf44689..bac7193973 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -134,6 +134,7 @@ members = [ "backend/libraries/human_readable_derive", "backend/libraries/icdex_client", "backend/libraries/icpswap_client", + "backend/libraries/identity_utils", "backend/libraries/index_store", "backend/libraries/instruction_counts_log", "backend/libraries/json", diff --git a/backend/canisters/identity/CHANGELOG.md b/backend/canisters/identity/CHANGELOG.md index c4a3aa8ae0..de336407fa 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/). ### Changed - Increase max stable memory read / write buffer size ([#6440](https://github.com/open-chat-labs/open-chat/pull/6440)) +- Extract certificate handling code into `identity_utils` ([#6459](https://github.com/open-chat-labs/open-chat/pull/6459)) ## [[2.0.1344](https://github.com/open-chat-labs/open-chat/releases/tag/v2.0.1344-identity)] - 2024-09-10 diff --git a/backend/canisters/identity/api/src/lib.rs b/backend/canisters/identity/api/src/lib.rs index a70e56fcfb..fe9920a09c 100644 --- a/backend/canisters/identity/api/src/lib.rs +++ b/backend/canisters/identity/api/src/lib.rs @@ -1,6 +1,5 @@ use candid::{CandidType, Deserialize}; use serde::Serialize; -use types::TimestampNanos; mod lifecycle; mod queries; @@ -10,20 +9,6 @@ pub use lifecycle::*; pub use queries::*; pub use updates::*; -#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] -pub struct Delegation { - #[serde(with = "serde_bytes")] - pub pubkey: Vec, - pub expiration: TimestampNanos, -} - -#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] -pub struct SignedDelegation { - pub delegation: Delegation, - #[serde(with = "serde_bytes")] - pub signature: Vec, -} - pub type ChallengeKey = u32; #[derive(CandidType, Serialize, Deserialize, Debug)] diff --git a/backend/canisters/identity/api/src/queries/get_delegation.rs b/backend/canisters/identity/api/src/queries/get_delegation.rs index d43c8cc846..93d01d30c3 100644 --- a/backend/canisters/identity/api/src/queries/get_delegation.rs +++ b/backend/canisters/identity/api/src/queries/get_delegation.rs @@ -1,7 +1,6 @@ -use crate::SignedDelegation; use candid::CandidType; use serde::{Deserialize, Serialize}; -use types::TimestampNanos; +use types::{SignedDelegation, TimestampNanos}; #[derive(CandidType, Serialize, Deserialize, Debug)] pub struct Args { diff --git a/backend/canisters/identity/api/src/updates/approve_identity_link.rs b/backend/canisters/identity/api/src/updates/approve_identity_link.rs index 8725be28aa..f869857672 100644 --- a/backend/canisters/identity/api/src/updates/approve_identity_link.rs +++ b/backend/canisters/identity/api/src/updates/approve_identity_link.rs @@ -1,6 +1,6 @@ -use crate::SignedDelegation; use candid::{CandidType, Deserialize, Principal}; use serde::Serialize; +use types::SignedDelegation; #[derive(CandidType, Serialize, Deserialize, Debug)] pub struct Args { diff --git a/backend/canisters/identity/impl/Cargo.toml b/backend/canisters/identity/impl/Cargo.toml index d733d90b6f..0e90a7b197 100644 --- a/backend/canisters/identity/impl/Cargo.toml +++ b/backend/canisters/identity/impl/Cargo.toml @@ -18,13 +18,13 @@ 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" } +identity_utils = { path = "../../../libraries/identity_utils" } msgpack = { path = "../../../libraries/msgpack" } rand = { workspace = true } serde = { workspace = true } diff --git a/backend/canisters/identity/impl/src/lib.rs b/backend/canisters/identity/impl/src/lib.rs index 6f4dfd1e61..c3430f017b 100644 --- a/backend/canisters/identity/impl/src/lib.rs +++ b/backend/canisters/identity/impl/src/lib.rs @@ -7,12 +7,11 @@ use canister_sig_util::signature_map::{SignatureMap, LABEL_SIG}; use canister_sig_util::CanisterSigPublicKey; use canister_state_macros::canister_state; use ic_cdk::api::set_certified_data; -use identity_canister::Delegation; use serde::{Deserialize, Serialize}; use sha256::sha256; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; -use types::{BuildVersion, CanisterId, Cycles, Hash, TimestampMillis, Timestamped}; +use types::{BuildVersion, CanisterId, Cycles, Delegation, Hash, TimestampMillis, Timestamped}; use utils::consts::IC_ROOT_KEY; use utils::env::Environment; use x509_parser::prelude::{FromDer, SubjectPublicKeyInfo}; diff --git a/backend/canisters/identity/impl/src/queries/get_delegation.rs b/backend/canisters/identity/impl/src/queries/get_delegation.rs index 9f46e3bf5d..a0d2421b2a 100644 --- a/backend/canisters/identity/impl/src/queries/get_delegation.rs +++ b/backend/canisters/identity/impl/src/queries/get_delegation.rs @@ -1,7 +1,7 @@ use crate::{delegation_signature_msg_hash, read_state, RuntimeState}; use ic_cdk::query; use identity_canister::get_delegation::{Response::*, *}; -use identity_canister::{Delegation, SignedDelegation}; +use types::{Delegation, SignedDelegation}; #[query] fn get_delegation(args: Args) -> Response { diff --git a/backend/canisters/identity/impl/src/updates/approve_identity_link.rs b/backend/canisters/identity/impl/src/updates/approve_identity_link.rs index b174c4d3a9..8d5a8f120f 100644 --- a/backend/canisters/identity/impl/src/updates/approve_identity_link.rs +++ b/backend/canisters/identity/impl/src/updates/approve_identity_link.rs @@ -1,10 +1,9 @@ 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 identity_utils::extract_certificate; use utils::time::{MINUTE_IN_MS, NANOS_PER_MILLISECOND}; #[update] @@ -20,21 +19,9 @@ fn approve_identity_link_impl(args: Args, state: &mut RuntimeState) -> Response 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()); + let certificate = match extract_certificate(&args.delegation.signature) { + Ok(c) => c, + Err(e) => return MalformedSignature(e), }; if certificate .verify( @@ -45,6 +32,11 @@ fn approve_identity_link_impl(args: Args, state: &mut RuntimeState) -> Response { return InvalidSignature; } + + 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; + if ic_certificate_verification::validate_certificate_time(&certificate, &now_nanos, &five_minutes).is_err() { return DelegationTooOld; } diff --git a/backend/canisters/user/CHANGELOG.md b/backend/canisters/user/CHANGELOG.md index 832a05c79d..c0ce7f3ce5 100644 --- a/backend/canisters/user/CHANGELOG.md +++ b/backend/canisters/user/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] +### Added + +- Allow changing PIN number if signed in within last 5 minutes ([#6459](https://github.com/open-chat-labs/open-chat/pull/6459)) + ### Changed - Increase max stable memory read / write buffer size ([#6440](https://github.com/open-chat-labs/open-chat/pull/6440)) diff --git a/backend/canisters/user/api/can.did b/backend/canisters/user/api/can.did index 52a6bfa346..5d529c9453 100644 --- a/backend/canisters/user/api/can.did +++ b/backend/canisters/user/api/can.did @@ -635,8 +635,20 @@ type CancelMessageReminderResponse = variant { }; type SetPinNumberArgs = record { - current : opt text; new : opt text; + verification : variant { + None; + PIN : text; + Delegation : SignedDelegation; + }; +}; + +type SignedDelegation = record { + delegation : record { + pubkey : blob; + expiration : TimestampNanos; + }; + signature : blob; }; type SetPinNumberResponse = variant { @@ -646,6 +658,8 @@ type SetPinNumberResponse = variant { PinRequired; PinIncorrect : Milliseconds; TooManyFailedPinAttempts : Milliseconds; + MalformedSignature : text; + DelegationTooOld; }; type SendMessageWithTransferToChannelArgs = record { diff --git a/backend/canisters/user/api/src/updates/set_pin_number.rs b/backend/canisters/user/api/src/updates/set_pin_number.rs index c0f12e902f..599d9afc5e 100644 --- a/backend/canisters/user/api/src/updates/set_pin_number.rs +++ b/backend/canisters/user/api/src/updates/set_pin_number.rs @@ -2,13 +2,21 @@ use candid::CandidType; use serde::{Deserialize, Serialize}; use std::fmt::Debug; use ts_export::ts_export; -use types::{FieldTooLongResult, FieldTooShortResult, Milliseconds}; +use types::{FieldTooLongResult, FieldTooShortResult, Milliseconds, SignedDelegation}; #[ts_export(user, set_pin_number)] #[derive(CandidType, Serialize, Deserialize, Debug)] pub struct Args { - pub current: Option, pub new: Option, + pub verification: PinNumberVerification, +} + +#[ts_export(user, set_pin_number)] +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub enum PinNumberVerification { + None, + PIN(String), + Delegation(SignedDelegation), } #[ts_export(user, set_pin_number)] @@ -20,4 +28,6 @@ pub enum Response { PinRequired, PinIncorrect(Milliseconds), TooManyFailedPinAttempts(Milliseconds), + MalformedSignature(String), + DelegationTooOld, } diff --git a/backend/canisters/user/impl/Cargo.toml b/backend/canisters/user/impl/Cargo.toml index 7b46f7ef71..11efe880f3 100644 --- a/backend/canisters/user/impl/Cargo.toml +++ b/backend/canisters/user/impl/Cargo.toml @@ -38,12 +38,14 @@ group_index_canister_c2c_client = { path = "../../group_index/c2c_client" } http_request = { path = "../../../libraries/http_request" } ic-cdk = { workspace = true } ic-cdk-timers = { workspace = true } +ic-certificate-verification = { workspace = true } ic-ledger-types = { workspace = true } ic-stable-structures = { workspace = true } icpswap_client = { path = "../../../libraries/icpswap_client" } icrc_ledger_canister_c2c_client = { path = "../../../external_canisters/icrc_ledger/c2c_client" } icrc_ledger_canister = { path = "../../../external_canisters/icrc_ledger/api" } icrc-ledger-types = { workspace = true } +identity_utils = { path = "../../../libraries/identity_utils" } itertools = { workspace = true } ledger_utils = { path = "../../../libraries/ledger_utils" } local_user_index_canister = { path = "../../local_user_index/api" } diff --git a/backend/canisters/user/impl/src/updates/set_pin_number.rs b/backend/canisters/user/impl/src/updates/set_pin_number.rs index aab542a950..ffd7ad13b2 100644 --- a/backend/canisters/user/impl/src/updates/set_pin_number.rs +++ b/backend/canisters/user/impl/src/updates/set_pin_number.rs @@ -3,8 +3,10 @@ use crate::model::pin_number::VerifyPinError; use crate::{mutate_state, run_regular_jobs, RuntimeState}; use canister_api_macros::update; use canister_tracing_macros::trace; +use identity_utils::extract_certificate; use types::{FieldTooLongResult, FieldTooShortResult}; use user_canister::set_pin_number::{Response::*, *}; +use utils::time::{MINUTE_IN_MS, NANOS_PER_MILLISECOND}; const MIN_LENGTH: usize = 4; const MAX_LENGTH: usize = 20; @@ -20,30 +22,50 @@ fn set_pin_number(args: Args) -> Response { fn set_pin_number_impl(args: Args, state: &mut RuntimeState) -> Response { let now = state.env.now(); - if let Err(error) = state.data.pin_number.verify(args.current.as_deref(), now) { - match error { - VerifyPinError::PinRequired => PinRequired, - VerifyPinError::PinIncorrect(delay) => PinIncorrect(delay), - VerifyPinError::TooManyFailedAttempted(delay) => TooManyFailedPinAttempts(delay), - } - } else { - if let Some(new) = args.new.as_ref() { - let length = new.len(); - if length < MIN_LENGTH { - return TooShort(FieldTooShortResult { - length_provided: length as u32, - min_length: MIN_LENGTH as u32, - }); + if state.data.pin_number.enabled() { + match args.verification { + PinNumberVerification::None => return PinRequired, + PinNumberVerification::PIN(attempt) => { + if let Err(error) = state.data.pin_number.verify(Some(&attempt), now) { + return match error { + VerifyPinError::PinRequired => PinRequired, + VerifyPinError::PinIncorrect(delay) => PinIncorrect(delay), + VerifyPinError::TooManyFailedAttempted(delay) => TooManyFailedPinAttempts(delay), + }; + } } - if length > MAX_LENGTH { - return TooLong(FieldTooLongResult { - length_provided: length as u32, - max_length: MAX_LENGTH as u32, - }); + PinNumberVerification::Delegation(delegation) => { + let certificate = match extract_certificate(&delegation.signature) { + Ok(c) => c, + Err(e) => return MalformedSignature(e), + }; + + let now_nanos = (now * NANOS_PER_MILLISECOND) as u128; + let five_minutes = (5 * MINUTE_IN_MS * NANOS_PER_MILLISECOND) as u128; + + if ic_certificate_verification::validate_certificate_time(&certificate, &now_nanos, &five_minutes).is_err() { + return DelegationTooOld; + }; } } + } - state.data.pin_number.set(args.new, now); - Success + if let Some(new) = args.new.as_ref() { + let length = new.len(); + if length < MIN_LENGTH { + return TooShort(FieldTooShortResult { + length_provided: length as u32, + min_length: MIN_LENGTH as u32, + }); + } + if length > MAX_LENGTH { + return TooLong(FieldTooLongResult { + length_provided: length as u32, + max_length: MAX_LENGTH as u32, + }); + } } + + state.data.pin_number.set(args.new, now); + Success } diff --git a/backend/integration_tests/src/client/identity.rs b/backend/integration_tests/src/client/identity.rs index 6dce60f2eb..f59021651d 100644 --- a/backend/integration_tests/src/client/identity.rs +++ b/backend/integration_tests/src/client/identity.rs @@ -15,9 +15,8 @@ generate_update_call!(prepare_delegation); pub mod happy_path { use candid::Principal; use identity_canister::auth_principals::UserPrincipal; - use identity_canister::SignedDelegation; use pocket_ic::PocketIc; - use types::{CanisterId, TimestampMillis}; + use types::{CanisterId, SignedDelegation, TimestampMillis}; pub fn create_identity( env: &mut PocketIc, diff --git a/backend/integration_tests/src/client/mod.rs b/backend/integration_tests/src/client/mod.rs index dfc6a3c4a6..5c09fd0821 100644 --- a/backend/integration_tests/src/client/mod.rs +++ b/backend/integration_tests/src/client/mod.rs @@ -8,7 +8,7 @@ use serde::de::DeserializeOwned; use serde::Serialize; use std::time::Duration; use testing::rng::random_internet_identity_principal; -use types::{CanisterId, CanisterWasm, DiamondMembershipPlanDuration}; +use types::{CanisterId, CanisterWasm, DiamondMembershipPlanDuration, SignedDelegation}; mod macros; @@ -123,18 +123,13 @@ pub fn register_user(env: &mut PocketIc, canister_ids: &CanisterIds) -> User { } pub fn register_user_with_referrer(env: &mut PocketIc, canister_ids: &CanisterIds, referral_code: Option) -> User { - let (auth_principal, public_key) = random_internet_identity_principal(); - let session_key = random::<[u8; 32]>().to_vec(); - let create_identity_result = - identity::happy_path::create_identity(env, auth_principal, canister_ids.identity, public_key, session_key, true); + let (user, _) = register_user_internal(env, canister_ids, referral_code, false); + user +} - local_user_index::happy_path::register_user_with_referrer( - env, - Principal::self_authenticating(&create_identity_result.user_key), - canister_ids.local_user_index, - create_identity_result.user_key, - referral_code, - ) +pub fn register_user_and_include_delegation(env: &mut PocketIc, canister_ids: &CanisterIds) -> (User, SignedDelegation) { + let (user, delegation) = register_user_internal(env, canister_ids, None, true); + (user, delegation.unwrap()) } pub fn register_diamond_user(env: &mut PocketIc, canister_ids: &CanisterIds, controller: Principal) -> User { @@ -157,6 +152,46 @@ pub fn upgrade_user( tick_many(env, 4); } +fn register_user_internal( + env: &mut PocketIc, + canister_ids: &CanisterIds, + referral_code: Option, + get_delegation: bool, +) -> (User, Option) { + let (auth_principal, public_key) = random_internet_identity_principal(); + let session_key = random::<[u8; 32]>().to_vec(); + let create_identity_result = identity::happy_path::create_identity( + env, + auth_principal, + canister_ids.identity, + public_key, + session_key.clone(), + true, + ); + + let user = local_user_index::happy_path::register_user_with_referrer( + env, + Principal::self_authenticating(&create_identity_result.user_key), + canister_ids.local_user_index, + create_identity_result.user_key, + referral_code, + ); + + let delegation = if get_delegation { + Some(identity::happy_path::get_delegation( + env, + auth_principal, + canister_ids.identity, + session_key, + create_identity_result.expiration, + )) + } else { + None + }; + + (user, delegation) +} + fn unwrap_response(response: Result) -> R { match response.unwrap() { WasmResult::Reply(bytes) => candid::decode_one(&bytes).unwrap(), diff --git a/backend/integration_tests/src/client/user.rs b/backend/integration_tests/src/client/user.rs index 9339e859c3..6c556c6e2c 100644 --- a/backend/integration_tests/src/client/user.rs +++ b/backend/integration_tests/src/client/user.rs @@ -50,6 +50,7 @@ pub mod happy_path { CanisterId, Chat, ChatId, CommunityId, Cryptocurrency, Empty, EventIndex, EventsResponse, MessageContentInitial, MessageId, Milliseconds, Reaction, Rules, TextContent, TimestampMillis, UserId, VideoCallType, }; + use user_canister::set_pin_number::PinNumberVerification; pub fn send_text_message( env: &mut PocketIc, @@ -380,11 +381,12 @@ pub mod happy_path { } pub fn set_pin_number(env: &mut PocketIc, user: &User, current: Option, new: Option) { + let verification = current.map(PinNumberVerification::PIN).unwrap_or(PinNumberVerification::None); let response = super::set_pin_number( env, user.principal, user.canister(), - &user_canister::set_pin_number::Args { current, new }, + &user_canister::set_pin_number::Args { verification, new }, ); assert!(matches!(response, user_canister::set_pin_number::Response::Success)); diff --git a/backend/integration_tests/src/identity_tests.rs b/backend/integration_tests/src/identity_tests.rs index 56f501e4fd..64480a2061 100644 --- a/backend/integration_tests/src/identity_tests.rs +++ b/backend/integration_tests/src/identity_tests.rs @@ -1,13 +1,13 @@ 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 types::{Delegation, SignedDelegation}; use utils::time::NANOS_PER_MILLISECOND; #[test_case(false)] diff --git a/backend/integration_tests/src/pin_number_tests.rs b/backend/integration_tests/src/pin_number_tests.rs index 1e1fd8d32a..96eaaa9ca7 100644 --- a/backend/integration_tests/src/pin_number_tests.rs +++ b/backend/integration_tests/src/pin_number_tests.rs @@ -7,10 +7,11 @@ use std::time::Duration; use test_case::test_case; use testing::rng::random_message_id; use types::{CryptoContent, CryptoTransaction, Cryptocurrency, MessageContentInitial}; +use user_canister::set_pin_number::PinNumberVerification; use utils::time::MINUTE_IN_MS; #[test] -fn can_set_pin_number() { +fn can_set_pin_number_by_providing_current() { let mut wrapper = ENV.deref().get(); let TestEnv { env, canister_ids, .. } = wrapper.env(); @@ -31,6 +32,51 @@ fn can_set_pin_number() { assert!(initial_state2.pin_number_settings.is_none()); } +#[test_case(true)] +#[test_case(false)] +fn can_set_pin_number_by_providing_recent_delegation(within_5_minutes: bool) { + let mut wrapper = ENV.deref().get(); + let TestEnv { env, canister_ids, .. } = wrapper.env(); + + let (user, delegation) = client::register_user_and_include_delegation(env, canister_ids); + + client::user::happy_path::set_pin_number(env, &user, None, Some("1000".to_string())); + + let initial_state1 = client::user::happy_path::initial_state(env, &user); + + assert!(initial_state1.pin_number_settings.is_some()); + assert!(initial_state1.pin_number_settings.unwrap().attempts_blocked_until.is_none()); + + let advance_by = if within_5_minutes { Duration::from_secs(299) } else { Duration::from_secs(301) }; + env.advance_time(advance_by); + + let set_pin_number_response = client::user::set_pin_number( + env, + user.principal, + user.canister(), + &user_canister::set_pin_number::Args { + new: None, + verification: PinNumberVerification::Delegation(delegation), + }, + ); + + let initial_state2 = client::user::happy_path::initial_state(env, &user); + + if within_5_minutes { + assert!(matches!( + set_pin_number_response, + user_canister::set_pin_number::Response::Success + )); + assert!(initial_state2.pin_number_settings.is_none()); + } else { + assert!(matches!( + set_pin_number_response, + user_canister::set_pin_number::Response::DelegationTooOld + )); + assert!(initial_state2.pin_number_settings.is_some()); + } +} + #[test] fn attempts_blocked_after_incorrect_attempts() { let mut wrapper = ENV.deref().get(); @@ -46,7 +92,7 @@ fn attempts_blocked_after_incorrect_attempts() { user.principal, user.canister(), &user_canister::set_pin_number::Args { - current: Some(format!("100{i}")), + verification: PinNumberVerification::PIN(format!("100{i}")), new: Some("2000".to_string()), }, ); diff --git a/backend/libraries/identity_utils/Cargo.toml b/backend/libraries/identity_utils/Cargo.toml new file mode 100644 index 0000000000..384645ad77 --- /dev/null +++ b/backend/libraries/identity_utils/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "identity_utils" +version = "0.1.0" +edition = "2021" + +[dependencies] +ic-cbor = { workspace = true } +ic-certificate-verification = { workspace = true } +ic-certification = { workspace = true } +types = { path = "../types" } \ No newline at end of file diff --git a/backend/libraries/identity_utils/src/lib.rs b/backend/libraries/identity_utils/src/lib.rs new file mode 100644 index 0000000000..de2d9ee6c4 --- /dev/null +++ b/backend/libraries/identity_utils/src/lib.rs @@ -0,0 +1,15 @@ +use ic_cbor::{parse_cbor, CborValue, CertificateToCbor}; +use ic_certification::Certificate; + +pub fn extract_certificate(signature: &[u8]) -> Result { + let Ok(cbor) = parse_cbor(signature) else { + return Err("Unable to parse signature as CBOR".to_string()); + }; + let CborValue::Map(map) = cbor else { + return Err("Expected CBOR map".to_string()); + }; + let Some(CborValue::ByteString(certificate_bytes)) = map.get("certificate") else { + return Err("Couldn't find certificate".to_string()); + }; + Certificate::from_cbor(certificate_bytes).map_err(|_| "Unable to parse certificate".to_string()) +} diff --git a/backend/libraries/types/src/delegation.rs b/backend/libraries/types/src/delegation.rs new file mode 100644 index 0000000000..7dcfbea881 --- /dev/null +++ b/backend/libraries/types/src/delegation.rs @@ -0,0 +1,20 @@ +use crate::TimestampNanos; +use candid::{CandidType, Deserialize}; +use serde::Serialize; +use ts_export::ts_export; + +#[ts_export] +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct Delegation { + #[serde(with = "serde_bytes")] + pub pubkey: Vec, + pub expiration: TimestampNanos, +} + +#[ts_export] +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct SignedDelegation { + pub delegation: Delegation, + #[serde(with = "serde_bytes")] + pub signature: Vec, +} diff --git a/backend/libraries/types/src/lib.rs b/backend/libraries/types/src/lib.rs index 80a7f8f582..5844ddde8c 100644 --- a/backend/libraries/types/src/lib.rs +++ b/backend/libraries/types/src/lib.rs @@ -22,6 +22,7 @@ mod community_roles; mod community_summary; mod cryptocurrency; mod cycles; +mod delegation; mod deleted_group_info; mod diamond_membership; mod error; @@ -94,6 +95,7 @@ pub use community_roles::*; pub use community_summary::*; pub use cryptocurrency::*; pub use cycles::*; +pub use delegation::*; pub use deleted_group_info::*; pub use diamond_membership::*; pub use error::*; diff --git a/frontend/openchat-agent/src/typebox.ts b/frontend/openchat-agent/src/typebox.ts index 17b42f1e35..e1537c802a 100644 --- a/frontend/openchat-agent/src/typebox.ts +++ b/frontend/openchat-agent/src/typebox.ts @@ -129,12 +129,6 @@ export const UserTokenSwapStatusTokenSwapStatus = Type.Object({ success: Type.Optional(Type.Boolean()), }); -export type UserSetPinNumberArgs = Static; -export const UserSetPinNumberArgs = Type.Object({ - current: Type.Optional(Type.String()), - new: Type.Optional(Type.String()), -}); - export type UserSwapTokensSuccessResult = Static; export const UserSwapTokensSuccessResult = Type.Object({ amount_out: Type.BigInt(), @@ -2118,6 +2112,10 @@ export const UserSetPinNumberResponse = Type.Union([ Type.Object({ TooManyFailedPinAttempts: Type.BigInt(), }), + Type.Object({ + MalformedSignature: Type.String(), + }), + Type.Literal("DelegationTooOld"), ]); export type UserSwapTokensICPSwapArgs = Static; @@ -2345,7 +2343,7 @@ export const AccountICRC1 = Type.Object({ Type.Number(), Type.Number(), Type.Number(), - ]) + ]), ), }); @@ -2371,6 +2369,12 @@ export const SnsNeuronGate = Type.Object({ min_dissolve_delay: Type.Optional(Type.BigInt()), }); +export type Delegation = Static; +export const Delegation = Type.Object({ + pubkey: TSBytes, + expiration: Type.BigInt(), +}); + export type MessagePermissions = Static; export const MessagePermissions = Type.Object({ default: GroupPermissionRole, @@ -3483,7 +3487,7 @@ export const UserTokenSwapsTokenSwap = Type.Object({ Type.Object({ Err: Type.String(), }), - ]) + ]), ), notified_dex: Type.Optional( Type.Union([ @@ -3493,7 +3497,7 @@ export const UserTokenSwapsTokenSwap = Type.Object({ Type.Object({ Err: Type.String(), }), - ]) + ]), ), amount_swapped: Type.Optional( Type.Union([ @@ -3510,7 +3514,7 @@ export const UserTokenSwapsTokenSwap = Type.Object({ Type.Object({ Err: Type.String(), }), - ]) + ]), ), withdrawn_from_dex: Type.Optional( Type.Union([ @@ -3520,7 +3524,7 @@ export const UserTokenSwapsTokenSwap = Type.Object({ Type.Object({ Err: Type.String(), }), - ]) + ]), ), success: Type.Optional(Type.Boolean()), }); @@ -3739,6 +3743,12 @@ export const GroupSubtype = Type.Object({ GovernanceProposals: GovernanceProposalsSubtype, }); +export type SignedDelegation = Static; +export const SignedDelegation = Type.Object({ + delegation: Delegation, + signature: TSBytes, +}); + export type P2PSwapReserved = Static; export const P2PSwapReserved = Type.Object({ reserved_by: UserId, @@ -3820,6 +3830,8 @@ export const PrizeContent = Type.Object({ prizes_remaining: Type.Number(), prizes_pending: Type.Number(), winners: Type.Array(UserId), + winner_count: Type.Number(), + user_is_winner: Type.Boolean(), token: Cryptocurrency, end_date: Type.BigInt(), caption: Type.Optional(Type.String()), @@ -3996,7 +4008,7 @@ export const UsersUnblocked = Type.Object({ export type Tips = Static; export const Tips = Type.Array( - Type.Tuple([TSBytes, Type.Array(Type.Tuple([UserId, Type.BigInt()]))]) + Type.Tuple([TSBytes, Type.Array(Type.Tuple([UserId, Type.BigInt()]))]), ); export type CallParticipant = Static; @@ -4453,6 +4465,25 @@ export const UserCreateGroupResponse = Type.Union([ Type.Literal("InternalError"), ]); +export type UserSetPinNumberPinNumberVerification = Static< + typeof UserSetPinNumberPinNumberVerification +>; +export const UserSetPinNumberPinNumberVerification = Type.Union([ + Type.Literal("None"), + Type.Object({ + PIN: Type.String(), + }), + Type.Object({ + Delegation: SignedDelegation, + }), +]); + +export type UserSetPinNumberArgs = Static; +export const UserSetPinNumberArgs = Type.Object({ + new: Type.Optional(Type.String()), + verification: UserSetPinNumberPinNumberVerification, +}); + export type UserTipMessageArgs = Static; export const UserTipMessageArgs = Type.Object({ chat: Chat, @@ -5596,7 +5627,7 @@ export const CommunityCanisterChannelSummaryUpdates = Type.Object({ member_count: Type.Optional(Type.Number()), permissions_v2: Type.Optional(GroupPermissions), updated_events: Type.Array( - Type.Tuple([Type.Union([MessageIndex, Type.Null()]), EventIndex, Type.BigInt()]) + Type.Tuple([Type.Union([MessageIndex, Type.Null()]), EventIndex, Type.BigInt()]), ), metrics: Type.Optional(ChatMetrics), date_last_pinned: Type.Optional(Type.BigInt()), @@ -5713,7 +5744,7 @@ export const GroupCanisterGroupChatSummaryUpdates = Type.Object({ wasm_version: Type.Optional(BuildVersion), permissions_v2: Type.Optional(GroupPermissions), updated_events: Type.Array( - Type.Tuple([Type.Union([MessageIndex, Type.Null()]), EventIndex, Type.BigInt()]) + Type.Tuple([Type.Union([MessageIndex, Type.Null()]), EventIndex, Type.BigInt()]), ), metrics: Type.Optional(ChatMetrics), my_metrics: Type.Optional(ChatMetrics), diff --git a/tsBindings/shared/Delegation.ts b/tsBindings/shared/Delegation.ts new file mode 100644 index 0000000000..caa75f2d3a --- /dev/null +++ b/tsBindings/shared/Delegation.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TSBytes } from "./TSBytes"; + +export type Delegation = { pubkey: TSBytes, expiration: bigint, }; diff --git a/tsBindings/shared/PrizeContent.ts b/tsBindings/shared/PrizeContent.ts index fff5e45816..6f18fbed9e 100644 --- a/tsBindings/shared/PrizeContent.ts +++ b/tsBindings/shared/PrizeContent.ts @@ -2,4 +2,4 @@ import type { Cryptocurrency } from "./Cryptocurrency"; import type { UserId } from "./UserId"; -export type PrizeContent = { prizes_remaining: number, prizes_pending: number, winners: Array, token: Cryptocurrency, end_date: bigint, caption?: string, diamond_only: boolean, }; +export type PrizeContent = { prizes_remaining: number, prizes_pending: number, winners: Array, winner_count: number, user_is_winner: boolean, token: Cryptocurrency, end_date: bigint, caption?: string, diamond_only: boolean, }; diff --git a/tsBindings/shared/SignedDelegation.ts b/tsBindings/shared/SignedDelegation.ts new file mode 100644 index 0000000000..7378bcb552 --- /dev/null +++ b/tsBindings/shared/SignedDelegation.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Delegation } from "./Delegation"; +import type { TSBytes } from "./TSBytes"; + +export type SignedDelegation = { delegation: Delegation, signature: TSBytes, }; diff --git a/tsBindings/types.d.ts b/tsBindings/types.d.ts index f0ef764843..27b1d43668 100644 --- a/tsBindings/types.d.ts +++ b/tsBindings/types.d.ts @@ -9,7 +9,6 @@ export type UserBioResponse = { "Success": string }; export type UserJoinVideoCallResponse = "Success" | "MessageNotFound" | "AlreadyEnded" | "UserSuspended" | "UserBlocked" | "ChatNotFound"; export type UserTokenSwapStatusArgs = { swap_id: bigint, }; export type UserTokenSwapStatusTokenSwapStatus = { started: bigint, deposit_account: { Ok : null } | { Err : string } | null, transfer: { Ok : bigint } | { Err : string } | null, notify_dex: { Ok : null } | { Err : string } | null, amount_swapped: { Ok : { Ok : bigint } | { Err : string } } | { Err : string } | null, withdraw_from_dex: { Ok : bigint } | { Err : string } | null, success?: boolean, }; -export type UserSetPinNumberArgs = { current?: string, new?: string, }; export type UserSwapTokensSuccessResult = { amount_out: bigint, }; export type UserUnblockUserResponse = "Success" | "UserSuspended"; export type UserAddHotGroupExclusionsResponse = "Success"; @@ -211,7 +210,7 @@ export type GroupSendMessageSuccessResult = { event_index: EventIndex, message_i export type GroupSendMessageResponse = { "Success": GroupSendMessageSuccessResult } | "ThreadMessageNotFound" | "MessageEmpty" | { "TextTooLong": number } | { "InvalidPoll": InvalidPollReason } | "NotAuthorized" | "CallerNotInGroup" | "UserSuspended" | { "InvalidRequest": string } | "ChatFrozen" | "RulesNotAccepted"; export type UserSavedCryptoAccountsResponse = { "Success": Array }; export type UserTokenSwapStatusResponse = { "Success": UserTokenSwapStatusTokenSwapStatus } | "NotFound"; -export type UserSetPinNumberResponse = "Success" | { "TooShort": FieldTooShortResult } | { "TooLong": FieldTooLongResult } | "PinRequired" | { "PinIncorrect": bigint } | { "TooManyFailedPinAttempts": bigint }; +export type UserSetPinNumberResponse = "Success" | { "TooShort": FieldTooShortResult } | { "TooLong": FieldTooLongResult } | "PinRequired" | { "PinIncorrect": bigint } | { "TooManyFailedPinAttempts": bigint } | { "MalformedSignature": string } | "DelegationTooOld"; export type UserSwapTokensICPSwapArgs = { swap_canister_id: TSBytes, zero_for_one: boolean, }; export type UserSwapTokensResponse = { "Success": UserSwapTokensSuccessResult } | "SwapFailed" | "PinRequired" | { "PinIncorrect": bigint } | { "TooManyFailedPinAttempts": bigint } | { "InternalError": string }; export type UserSetAvatarResponse = "Success" | { "AvatarTooBig": FieldTooLongResult } | "UserSuspended"; @@ -236,6 +235,7 @@ export type AccountICRC1 = { owner: TSBytes, subaccount?: [number, number, numbe export type CommunityMembershipUpdates = { role?: CommunityRole, rules_accepted?: boolean, display_name: OptionUpdateString, }; export type GiphyContent = { caption?: string, title: string, desktop: GiphyImageVariant, mobile: GiphyImageVariant, }; export type SnsNeuronGate = { governance_canister_id: TSBytes, min_stake_e8s?: bigint, min_dissolve_delay?: bigint, }; +export type Delegation = { pubkey: TSBytes, expiration: bigint, }; export type MessagePermissions = { default: GroupPermissionRole, text?: GroupPermissionRole, image?: GroupPermissionRole, video?: GroupPermissionRole, audio?: GroupPermissionRole, file?: GroupPermissionRole, poll?: GroupPermissionRole, crypto?: GroupPermissionRole, giphy?: GroupPermissionRole, prize?: GroupPermissionRole, p2p_swap?: GroupPermissionRole, video_call?: GroupPermissionRole, custom: Array, }; export type ChatId = TSBytes; export type CryptoAccountICRC1 = "Mint" | { "Account": AccountICRC1 }; @@ -380,6 +380,7 @@ export type UserReportMessageArgs = { them: UserId, thread_root_message_index?: export type VideoContent = { width: number, height: number, thumbnail_data: ThumbnailData, caption?: string, mime_type: string, image_blob_reference?: BlobReference, video_blob_reference?: BlobReference, }; export type GroupPermissions = { change_roles: GroupPermissionRole, update_group: GroupPermissionRole, add_members: GroupPermissionRole, invite_users: GroupPermissionRole, remove_members: GroupPermissionRole, delete_messages: GroupPermissionRole, pin_messages: GroupPermissionRole, react_to_messages: GroupPermissionRole, mention_all_members: GroupPermissionRole, start_video_call: GroupPermissionRole, message_permissions: MessagePermissions, thread_permissions?: MessagePermissions, }; export type GroupSubtype = { "GovernanceProposals": GovernanceProposalsSubtype }; +export type SignedDelegation = { delegation: Delegation, signature: TSBytes, }; export type P2PSwapReserved = { reserved_by: UserId, }; export type UserSummary = { user_id: UserId, username: string, display_name?: string, avatar_id?: bigint, is_bot: boolean, suspended: boolean, diamond_member: boolean, diamond_membership_status: DiamondMembershipStatus, total_chit_earned: number, chit_balance: number, streak: number, is_unique_person: boolean, }; export type CompletedCryptoTransactionICRC2 = { ledger: TSBytes, token: Cryptocurrency, amount: bigint, spender: UserId, from: CryptoAccountICRC1, to: CryptoAccountICRC1, fee: bigint, memo?: TSBytes, created: bigint, block_index: bigint, }; @@ -389,7 +390,7 @@ export type SwapStatusErrorReserved = { reserved_by: UserId, }; export type MessagePinned = { message_index: MessageIndex, pinned_by: UserId, }; export type P2PSwapContentInitial = { token0: TokenInfo, token0_amount: bigint, token1: TokenInfo, token1_amount: bigint, expires_in: bigint, caption?: string, }; export type GroupDescriptionChanged = { new_description: string, previous_description: string, changed_by: UserId, }; -export type PrizeContent = { prizes_remaining: number, prizes_pending: number, winners: Array, token: Cryptocurrency, end_date: bigint, caption?: string, diamond_only: boolean, }; +export type PrizeContent = { prizes_remaining: number, prizes_pending: number, winners: Array, winner_count: number, user_is_winner: boolean, token: Cryptocurrency, end_date: bigint, caption?: string, diamond_only: boolean, }; export type GroupRulesChanged = { enabled: boolean, prev_enabled: boolean, changed_by: UserId, }; export type GroupCreated = { name: string, description: string, created_by: UserId, }; export type HydratedMention = { thread_root_message_index?: MessageIndex, message_id: MessageId, message_index: MessageIndex, event_index: EventIndex, mentioned_by: UserId, }; @@ -454,6 +455,8 @@ export type ProposalsBotCommonProposalToSubmit = { title: string, summary: strin export type OnlineUsersLastOnlineResponse = { "Success": Array }; export type UserManageFavouriteChatsArgs = { to_add: Array, to_remove: Array, }; export type UserCreateGroupResponse = { "Success": UserCreateGroupSuccessResult } | { "NameTooShort": FieldTooShortResult } | { "NameTooLong": FieldTooLongResult } | "NameReserved" | { "DescriptionTooLong": FieldTooLongResult } | { "RulesTooShort": FieldTooShortResult } | { "RulesTooLong": FieldTooLongResult } | { "AvatarTooBig": FieldTooLongResult } | "AccessGateInvalid" | { "MaxGroupsCreated": number } | "NameTaken" | "Throttled" | "UserSuspended" | "UnauthorizedToCreatePublicGroup" | "InternalError"; +export type UserSetPinNumberPinNumberVerification = "None" | { "PIN": string } | { "Delegation": SignedDelegation }; +export type UserSetPinNumberArgs = { new?: string, verification: UserSetPinNumberPinNumberVerification, }; export type UserTipMessageArgs = { chat: Chat, recipient: UserId, thread_root_message_index?: MessageIndex, message_id: MessageId, ledger: TSBytes, token: Cryptocurrency, amount: bigint, fee: bigint, decimals: number, pin?: string, }; export type UserTipMessageResponse = "Success" | "ChatNotFound" | "MessageNotFound" | "CannotTipSelf" | "NotAuthorized" | "TransferCannotBeZero" | "TransferNotToMessageSender" | { "TransferFailed": string } | "ChatFrozen" | "PinRequired" | { "PinIncorrect": bigint } | { "TooManyFailedPinAttempts": bigint } | "UserSuspended" | { "Retrying": string } | { "InternalError": [string, CompletedCryptoTransaction] }; export type UserChatInList = { "Direct": ChatId } | { "Group": ChatId } | { "Favourite": Chat } | { "Community": [CommunityId, bigint] }; diff --git a/tsBindings/user/setPinNumber/UserSetPinNumberArgs.ts b/tsBindings/user/setPinNumber/UserSetPinNumberArgs.ts index 9485b2e758..288baf2863 100644 --- a/tsBindings/user/setPinNumber/UserSetPinNumberArgs.ts +++ b/tsBindings/user/setPinNumber/UserSetPinNumberArgs.ts @@ -1,3 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UserSetPinNumberPinNumberVerification } from "./UserSetPinNumberPinNumberVerification"; -export type UserSetPinNumberArgs = { current?: string, new?: string, }; +export type UserSetPinNumberArgs = { new?: string, verification: UserSetPinNumberPinNumberVerification, }; diff --git a/tsBindings/user/setPinNumber/UserSetPinNumberPinNumberVerification.ts b/tsBindings/user/setPinNumber/UserSetPinNumberPinNumberVerification.ts new file mode 100644 index 0000000000..eb523fd4cc --- /dev/null +++ b/tsBindings/user/setPinNumber/UserSetPinNumberPinNumberVerification.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SignedDelegation } from "../../shared/SignedDelegation"; + +export type UserSetPinNumberPinNumberVerification = "None" | { "PIN": string } | { "Delegation": SignedDelegation }; diff --git a/tsBindings/user/setPinNumber/UserSetPinNumberResponse.ts b/tsBindings/user/setPinNumber/UserSetPinNumberResponse.ts index 45c83add1d..91dad8aac1 100644 --- a/tsBindings/user/setPinNumber/UserSetPinNumberResponse.ts +++ b/tsBindings/user/setPinNumber/UserSetPinNumberResponse.ts @@ -2,4 +2,4 @@ import type { FieldTooLongResult } from "../../shared/FieldTooLongResult"; import type { FieldTooShortResult } from "../../shared/FieldTooShortResult"; -export type UserSetPinNumberResponse = "Success" | { "TooShort": FieldTooShortResult } | { "TooLong": FieldTooLongResult } | "PinRequired" | { "PinIncorrect": bigint } | { "TooManyFailedPinAttempts": bigint }; +export type UserSetPinNumberResponse = "Success" | { "TooShort": FieldTooShortResult } | { "TooLong": FieldTooLongResult } | "PinRequired" | { "PinIncorrect": bigint } | { "TooManyFailedPinAttempts": bigint } | { "MalformedSignature": string } | "DelegationTooOld";