From 168d45e3289b41e8053fc73b3a335b4c42ac064e Mon Sep 17 00:00:00 2001 From: Joy Wang <108701016+joyqvq@users.noreply.github.com> Date: Tue, 3 Dec 2024 23:06:31 -0500 Subject: [PATCH 1/2] feat: add passkey session authenticator --- .../sui-e2e-tests/tests/passkey_e2e_tests.rs | 51 +-- .../tests/passkey_session_e2e_tests.rs | 382 ++++++++++++++++++ crates/sui-e2e-tests/tests/passkey_util.rs | 55 +++ crates/sui-keys/src/key_derive.rs | 6 +- crates/sui-protocol-config/src/lib.rs | 15 + ...sui_protocol_config__test__version_69.snap | 1 + crates/sui-rosetta/src/types.rs | 4 + crates/sui-types/src/base_types.rs | 1 + crates/sui-types/src/crypto.rs | 17 +- crates/sui-types/src/lib.rs | 1 + crates/sui-types/src/passkey_authenticator.rs | 2 +- .../src/passkey_session_authenticator.rs | 313 ++++++++++++++ crates/sui-types/src/signature.rs | 9 + crates/sui-types/src/transaction.rs | 9 + .../unit_tests/passkey_authenticator_test.rs | 2 +- .../passkey_session_authenticator_test.rs | 238 +++++++++++ 16 files changed, 1055 insertions(+), 51 deletions(-) create mode 100644 crates/sui-e2e-tests/tests/passkey_session_e2e_tests.rs create mode 100644 crates/sui-e2e-tests/tests/passkey_util.rs create mode 100644 crates/sui-types/src/passkey_session_authenticator.rs create mode 100644 crates/sui-types/src/unit_tests/passkey_session_authenticator_test.rs diff --git a/crates/sui-e2e-tests/tests/passkey_e2e_tests.rs b/crates/sui-e2e-tests/tests/passkey_e2e_tests.rs index 30016d0d3a604..b22b812366312 100644 --- a/crates/sui-e2e-tests/tests/passkey_e2e_tests.rs +++ b/crates/sui-e2e-tests/tests/passkey_e2e_tests.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use fastcrypto::traits::ToFromBytes; use p256::pkcs8::DecodePublicKey; -use passkey_authenticator::{Authenticator, UserValidationMethod}; +use passkey_authenticator::Authenticator; use passkey_client::Client; use passkey_types::{ ctap2::Aaguid, @@ -16,13 +16,11 @@ use passkey_types::{ Bytes, Passkey, }; use shared_crypto::intent::{Intent, IntentMessage}; -use std::net::SocketAddr; -use sui_core::authority_client::AuthorityAPI; use sui_macros::sim_test; use sui_test_transaction_builder::TestTransactionBuilder; use sui_types::crypto::Signature; +use sui_types::error::SuiError; use sui_types::error::UserInputError; -use sui_types::error::{SuiError, SuiResult}; use sui_types::signature::GenericSignature; use sui_types::transaction::Transaction; use sui_types::{ @@ -34,48 +32,8 @@ use sui_types::{ use test_cluster::TestCluster; use test_cluster::TestClusterBuilder; use url::Url; - -struct MyUserValidationMethod {} -#[async_trait::async_trait] -impl UserValidationMethod for MyUserValidationMethod { - async fn check_user_presence(&self) -> bool { - true - } - - async fn check_user_verification(&self) -> bool { - true - } - - fn is_verification_enabled(&self) -> Option { - Some(true) - } - - fn is_presence_enabled(&self) -> bool { - true - } -} - -/// A helper struct for passkey response and transaction construction. -pub struct PasskeyResponse { - user_sig_bytes: Vec, - authenticator_data: Vec, - client_data_json: String, - intent_msg: IntentMessage, -} - -/// Submits a transaction to the test cluster and returns the result. -async fn execute_tx(tx: Transaction, test_cluster: &TestCluster) -> SuiResult { - test_cluster - .authority_aggregator() - .authority_clients - .values() - .next() - .unwrap() - .authority_client() - .handle_transaction(tx, Some(SocketAddr::new([127, 0, 0, 1].into(), 0))) - .await - .map(|_| ()) -} +pub mod passkey_util; +use passkey_util::{execute_tx, MyUserValidationMethod, PasskeyResponse}; /// Register a new passkey, derive its address, fund it with gas and create a test /// transaction, then get a response from the passkey from signing. @@ -202,6 +160,7 @@ async fn create_credential_and_sign_test_tx( let client_data_json = authenticated_cred.response.client_data_json.as_slice(); PasskeyResponse { + sender, user_sig_bytes, authenticator_data: authenticator_data.to_vec(), client_data_json: String::from_utf8_lossy(client_data_json).to_string(), diff --git a/crates/sui-e2e-tests/tests/passkey_session_e2e_tests.rs b/crates/sui-e2e-tests/tests/passkey_session_e2e_tests.rs new file mode 100644 index 0000000000000..3c4acc2b34bb0 --- /dev/null +++ b/crates/sui-e2e-tests/tests/passkey_session_e2e_tests.rs @@ -0,0 +1,382 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 +use fastcrypto::{ + ed25519::Ed25519KeyPair, + traits::{KeyPair, ToFromBytes}, +}; +use p256::pkcs8::DecodePublicKey; +use passkey_authenticator::Authenticator; +use passkey_client::Client; +use passkey_types::{ + ctap2::Aaguid, + rand::random_vec, + webauthn::{ + AttestationConveyancePreference, CredentialCreationOptions, CredentialRequestOptions, + PublicKeyCredentialCreationOptions, PublicKeyCredentialParameters, + PublicKeyCredentialRequestOptions, PublicKeyCredentialRpEntity, PublicKeyCredentialType, + PublicKeyCredentialUserEntity, UserVerificationRequirement, + }, + Bytes, Passkey, +}; +use rand::{rngs::StdRng, SeedableRng}; +use shared_crypto::intent::{Intent, IntentMessage}; +use sui_macros::sim_test; +use sui_test_transaction_builder::TestTransactionBuilder; +use sui_types::error::SuiError; +use sui_types::error::UserInputError; +use sui_types::signature::GenericSignature; +use sui_types::transaction::Transaction; +use sui_types::{ + base_types::SuiAddress, + crypto::{PublicKey, SignatureScheme}, + transaction::TransactionData, +}; +use sui_types::{ + crypto::{get_key_pair, Signature}, + passkey_session_authenticator::RawPasskeySessionAuthenticator, +}; +use test_cluster::TestCluster; +use test_cluster::TestClusterBuilder; +use url::Url; + +pub mod passkey_util; +use passkey_util::{execute_tx, MyUserValidationMethod, PasskeyResponse}; + +async fn make_passkey_session_tx( + test_cluster: &TestCluster, + eph_kp: Ed25519KeyPair, + max_epoch: u64, + register_msg: Option>, + wrong_register_sig: bool, + wrong_ephemeral_sig: bool, + wrong_sender: bool, +) -> Transaction { + let register_msg = match register_msg { + Some(msg) => msg, + None => { + let mut register_msg = vec![SignatureScheme::ED25519.flag()]; + register_msg.extend_from_slice(eph_kp.public().as_bytes()); + register_msg.extend_from_slice(&max_epoch.to_be_bytes()); + register_msg + } + }; + + let response = + create_credential_and_commit_ephemeral_pk(test_cluster, wrong_sender, register_msg).await; + let ephemeral_signature = Signature::new_secure(&response.intent_msg, &eph_kp); + + let passkey_session_authenticator = RawPasskeySessionAuthenticator { + authenticator_data: response.authenticator_data, + client_data_json: response.client_data_json, + passkey_signature: if wrong_register_sig { + let mut fake_signature = response.user_sig_bytes.clone(); + fake_signature[2] += 1; + Signature::from_bytes(&fake_signature).unwrap() + } else { + Signature::from_bytes(&response.user_sig_bytes).unwrap() + }, + max_epoch, + ephemeral_signature: if wrong_ephemeral_sig { + let mut fake_signature = ephemeral_signature.as_bytes().to_vec().clone(); + fake_signature[2] += 1; + Signature::from_bytes(&fake_signature).unwrap() + } else { + ephemeral_signature + }, + } + .try_into() + .unwrap(); + let sig = GenericSignature::PasskeySessionAuthenticator(passkey_session_authenticator); + Transaction::from_generic_sig_data(response.intent_msg.value, vec![sig]) +} + +/// Register a new passkey, derive its address, fund it with gas and create a test +/// transaction, then get a response from the passkey from signing. +async fn create_credential_and_commit_ephemeral_pk( + test_cluster: &TestCluster, + wrong_sender: bool, + passkey_challenge: Vec, +) -> PasskeyResponse { + // set up authenticator and client + let my_aaguid = Aaguid::new_empty(); + let user_validation_method = MyUserValidationMethod {}; + let store: Option = None; + let my_authenticator = Authenticator::new(my_aaguid, store, user_validation_method); + let mut my_client = Client::new(my_authenticator); + let origin = Url::parse("https://www.sui.io").unwrap(); + + // Create credential. + let challenge_bytes_from_rp: Bytes = random_vec(32).into(); + let user_entity = PublicKeyCredentialUserEntity { + id: random_vec(32).into(), + display_name: "Johnny Passkey".into(), + name: "jpasskey@example.org".into(), + }; + let request = CredentialCreationOptions { + public_key: PublicKeyCredentialCreationOptions { + rp: PublicKeyCredentialRpEntity { + id: None, // Leaving the ID as None means use the effective domain + name: origin.domain().unwrap().into(), + }, + user: user_entity, + challenge: challenge_bytes_from_rp, + pub_key_cred_params: vec![PublicKeyCredentialParameters { + ty: PublicKeyCredentialType::PublicKey, + alg: coset::iana::Algorithm::ES256, + }], + timeout: None, + exclude_credentials: None, + authenticator_selection: None, + hints: None, + attestation: AttestationConveyancePreference::None, + attestation_formats: None, + extensions: None, + }, + }; + let my_webauthn_credential = my_client.register(&origin, request, None).await.unwrap(); + let verifying_key = p256::ecdsa::VerifyingKey::from_public_key_der( + my_webauthn_credential + .response + .public_key + .unwrap() + .as_slice(), + ) + .unwrap(); + + // Derive compact pubkey from DER format. + let encoded_point = verifying_key.to_encoded_point(false); + let x = encoded_point.x(); + let y = encoded_point.y(); + let prefix = if y.unwrap()[31] % 2 == 0 { 0x02 } else { 0x03 }; + let mut pk_bytes = vec![prefix]; + pk_bytes.extend_from_slice(x.unwrap()); + let sender = if wrong_sender { + (&PublicKey::try_from_bytes(SignatureScheme::PasskeyAuthenticator, &pk_bytes).unwrap()) + .into() + } else { + (&PublicKey::try_from_bytes(SignatureScheme::PasskeySessionAuthenticator, &pk_bytes) + .unwrap()) + .into() + }; + // Request a signature from passkey with challenge set to passkey_digest. + let credential_request = CredentialRequestOptions { + public_key: PublicKeyCredentialRequestOptions { + challenge: Bytes::from(passkey_challenge), + timeout: None, + rp_id: Some(String::from(origin.domain().unwrap())), + allow_credentials: None, + user_verification: UserVerificationRequirement::default(), + attestation: Default::default(), + attestation_formats: None, + extensions: None, + hints: None, + }, + }; + + let authenticated_cred = my_client + .authenticate(&origin, credential_request, None) + .await + .unwrap(); + + // Parse signature from der format in response and normalize it to lower s. + let sig_bytes_der = authenticated_cred.response.signature.as_slice(); + let sig = p256::ecdsa::Signature::from_der(sig_bytes_der).unwrap(); + let sig_bytes = sig.normalize_s().unwrap_or(sig).to_bytes(); + + let mut user_sig_bytes = vec![SignatureScheme::Secp256r1.flag()]; + user_sig_bytes.extend_from_slice(&sig_bytes); + user_sig_bytes.extend_from_slice(&pk_bytes); + + // Parse authenticator_data and client_data_json from response. + let authenticator_data = authenticated_cred.response.authenticator_data.as_slice(); + let client_data_json = authenticated_cred.response.client_data_json.as_slice(); + + // fund gas and make a test transaction. + let rgp = test_cluster.get_reference_gas_price().await; + let gas = test_cluster + .fund_address_and_return_gas(rgp, Some(20000000000), sender) + .await; + let tx_data = TestTransactionBuilder::new(sender, gas, rgp) + .transfer_sui(None, SuiAddress::ZERO) + .build(); + let intent_msg = IntentMessage::new(Intent::sui_transaction(), tx_data); + + PasskeyResponse { + sender, + user_sig_bytes, + authenticator_data: authenticator_data.to_vec(), + client_data_json: String::from_utf8_lossy(client_data_json).to_string(), + intent_msg, + } +} + +#[sim_test] +async fn test_passkey_session_feature_deny() { + use sui_protocol_config::ProtocolConfig; + let _guard = ProtocolConfig::apply_overrides_for_testing(|_, mut config| { + config.set_passkey_session_auth_for_testing(false); + config + }); + let test_cluster = TestClusterBuilder::new().build().await; + + let kp: Ed25519KeyPair = get_key_pair().1; + let max_epoch = 2_u64; + let tx = make_passkey_session_tx(&test_cluster, kp, max_epoch, None, false, false, false).await; + let err = execute_tx(tx, &test_cluster).await.unwrap_err(); + assert!(matches!( + err, + SuiError::UserInputError { + error: UserInputError::Unsupported(..) + } + )); +} + +#[sim_test] +async fn test_passkey_authenticator_scenarios() { + use sui_protocol_config::ProtocolConfig; + let test_cluster = TestClusterBuilder::new() + .with_epoch_duration_ms(15000) + .with_default_jwks() + .build() + .await; + + let _guard = ProtocolConfig::apply_overrides_for_testing(|_, mut config| { + config.set_zklogin_max_epoch_upper_bound_delta_for_testing(Some(1)); + config + }); + let kp = Ed25519KeyPair::generate(&mut StdRng::from_seed([0u8; 32])); + let max_epoch = 1_u64; + + // case 1: sign a tx with passkey ephemeral sig and register sig, passes + let tx = make_passkey_session_tx( + &test_cluster, + kp.copy(), + max_epoch, + None, + false, + false, + false, + ) + .await; + let res = execute_tx(tx, &test_cluster).await; + assert!(res.is_ok()); + + // case 2: use a mismatched ephemeral key with what the register signature commits to, fails to verify. + let kp2: Ed25519KeyPair = get_key_pair().1; + let mut register_msg = vec![SignatureScheme::ED25519.flag()]; + register_msg.extend_from_slice(kp2.public().as_bytes()); + register_msg.extend_from_slice(&max_epoch.to_be_bytes()); + let tx = make_passkey_session_tx( + &test_cluster, + kp.copy(), + max_epoch, + Some(register_msg), + false, + false, + false, + ) + .await; + let res = execute_tx(tx, &test_cluster).await; + assert!(matches!( + res, + Err(SuiError::InvalidSignature { error }) if error == "Invalid parsed challenge" + )); + + // case 3: use a mismatched max_epoch with what the register signature commits to, fails to verify. + let mut register_msg = vec![SignatureScheme::ED25519.flag()]; + register_msg.extend_from_slice(kp.public().as_bytes()); + register_msg.extend_from_slice(&(max_epoch + 1).to_be_bytes()); + let tx = make_passkey_session_tx( + &test_cluster, + kp.copy(), + max_epoch, + Some(register_msg), + false, + false, + false, + ) + .await; + let res = execute_tx(tx, &test_cluster).await; + assert!(matches!( + res, + Err(SuiError::InvalidSignature { error }) if error == "Invalid parsed challenge" + )); + + // case 4: invalid register signature fails to verify. + let tx = make_passkey_session_tx( + &test_cluster, + kp.copy(), + max_epoch, + None, + true, + false, + false, + ) + .await; + let res = execute_tx(tx, &test_cluster).await; + assert!(matches!( + res, + Err(SuiError::InvalidSignature { error }) if error == "Fails to verify register sig" + )); + // case 5: invalid ephemeral signature fails to verify. + let tx = make_passkey_session_tx( + &test_cluster, + kp.copy(), + max_epoch, + None, + false, + true, + false, + ) + .await; + let res = execute_tx(tx, &test_cluster).await; + assert!(matches!( + res, + Err(SuiError::InvalidSignature { error }) if error == "Fails to verify ephemeral sig" + )); + // case 6: advance 2 epochs, the ephermal sig expires, fails to verify + test_cluster.trigger_reconfiguration().await; + test_cluster.trigger_reconfiguration().await; + + let tx = make_passkey_session_tx( + &test_cluster, + kp.copy(), + max_epoch, + None, + false, + false, + false, + ) + .await; + let res = execute_tx(tx, &test_cluster).await; + assert!(matches!( + res, + Err(SuiError::InvalidSignature { error }) if error == "Passkey session expired at epoch 1, current epoch 2" + )); + // case 4: max_epoch bound delta = 1, but max epoch (10) - current epoch (2) > 1, too large, fails + let tx = make_passkey_session_tx(&test_cluster, kp.copy(), 10, None, false, false, false).await; + let res = execute_tx(tx, &test_cluster).await; + assert!(matches!( + res, + Err(SuiError::InvalidSignature { error }) if error == "Passkey session max epoch too large 10, current epoch 2, max accepted: 3" + )); +} + +#[sim_test] +async fn test_passkey_fails_wrong_author() { + let test_cluster = TestClusterBuilder::new().build().await; + let kp = Ed25519KeyPair::generate(&mut StdRng::from_seed([0u8; 32])); + let max_epoch = 1_u64; + let tx = make_passkey_session_tx( + &test_cluster, + kp.copy(), + max_epoch, + None, + false, + false, + true, + ) + .await; + let err = execute_tx(tx, &test_cluster).await.unwrap_err(); + assert!(matches!(err, SuiError::SignerSignatureAbsent { .. })); +} diff --git a/crates/sui-e2e-tests/tests/passkey_util.rs b/crates/sui-e2e-tests/tests/passkey_util.rs new file mode 100644 index 0000000000000..a6062b61944fd --- /dev/null +++ b/crates/sui-e2e-tests/tests/passkey_util.rs @@ -0,0 +1,55 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 +use passkey_authenticator::UserValidationMethod; +use shared_crypto::intent::IntentMessage; +use std::net::SocketAddr; +use sui_core::authority_client::AuthorityAPI; +use sui_types::base_types::SuiAddress; +use sui_types::error::SuiResult; +use sui_types::transaction::Transaction; +use test_cluster::TestCluster; + +/// Helper struct to initialize passkey client. +pub struct MyUserValidationMethod {} +#[async_trait::async_trait] +impl UserValidationMethod for MyUserValidationMethod { + async fn check_user_presence(&self) -> bool { + true + } + + async fn check_user_verification(&self) -> bool { + true + } + + fn is_verification_enabled(&self) -> Option { + Some(true) + } + + fn is_presence_enabled(&self) -> bool { + true + } +} + +/// Response with fields from passkey authentication. +#[derive(Debug)] +pub struct PasskeyResponse { + pub user_sig_bytes: Vec, + pub authenticator_data: Vec, + pub client_data_json: String, + pub intent_msg: IntentMessage, + pub sender: SuiAddress, +} + +/// Submits a transaction to the test cluster and returns the result. +pub async fn execute_tx(tx: Transaction, test_cluster: &TestCluster) -> SuiResult { + test_cluster + .authority_aggregator() + .authority_clients + .values() + .next() + .unwrap() + .authority_client() + .handle_transaction(tx, Some(SocketAddr::new([127, 0, 0, 1].into(), 0))) + .await + .map(|_| ()) +} diff --git a/crates/sui-keys/src/key_derive.rs b/crates/sui-keys/src/key_derive.rs index b86e72901116d..185382de97951 100644 --- a/crates/sui-keys/src/key_derive.rs +++ b/crates/sui-keys/src/key_derive.rs @@ -64,7 +64,8 @@ pub fn derive_key_pair_from_path( SignatureScheme::BLS12381 | SignatureScheme::MultiSig | SignatureScheme::ZkLoginAuthenticator - | SignatureScheme::PasskeyAuthenticator => Err(SuiError::UnsupportedFeatureError { + | SignatureScheme::PasskeyAuthenticator + | SignatureScheme::PasskeySessionAuthenticator => Err(SuiError::UnsupportedFeatureError { error: format!("key derivation not supported {:?}", key_scheme), }), } @@ -162,7 +163,8 @@ pub fn validate_path( SignatureScheme::BLS12381 | SignatureScheme::MultiSig | SignatureScheme::ZkLoginAuthenticator - | SignatureScheme::PasskeyAuthenticator => Err(SuiError::UnsupportedFeatureError { + | SignatureScheme::PasskeyAuthenticator + | SignatureScheme::PasskeySessionAuthenticator => Err(SuiError::UnsupportedFeatureError { error: format!("key derivation not supported {:?}", key_scheme), }), } diff --git a/crates/sui-protocol-config/src/lib.rs b/crates/sui-protocol-config/src/lib.rs index 1c71aeb30d27c..ea00f52403ce2 100644 --- a/crates/sui-protocol-config/src/lib.rs +++ b/crates/sui-protocol-config/src/lib.rs @@ -538,6 +538,10 @@ struct FeatureFlags { #[serde(skip_serializing_if = "is_false")] passkey_auth: bool, + // Enable passkey session auth + #[serde(skip_serializing_if = "is_false")] + passkey_session_auth: bool, + // Use AuthorityCapabilitiesV2 #[serde(skip_serializing_if = "is_false")] authority_capabilities_v2: bool, @@ -1632,6 +1636,9 @@ impl ProtocolConfig { pub fn passkey_auth(&self) -> bool { self.feature_flags.passkey_auth } + pub fn passkey_session_auth(&self) -> bool { + self.feature_flags.passkey_session_auth + } pub fn authority_capabilities_v2(&self) -> bool { self.feature_flags.authority_capabilities_v2 @@ -2991,6 +2998,10 @@ impl ProtocolConfig { if chain != Chain::Mainnet { cfg.feature_flags.uncompressed_g1_group_elements = true; } + + if chain != Chain::Testnet && chain != Chain::Mainnet { + cfg.feature_flags.passkey_session_auth = true; + } } 70 => { if chain != Chain::Mainnet { @@ -3229,6 +3240,10 @@ impl ProtocolConfig { self.feature_flags.passkey_auth = val } + pub fn set_passkey_session_auth_for_testing(&mut self, val: bool) { + self.feature_flags.passkey_session_auth = val + } + pub fn set_consensus_distributed_vote_scoring_strategy_for_testing(&mut self, val: bool) { self.feature_flags .consensus_distributed_vote_scoring_strategy = val; diff --git a/crates/sui-protocol-config/src/snapshots/sui_protocol_config__test__version_69.snap b/crates/sui-protocol-config/src/snapshots/sui_protocol_config__test__version_69.snap index ccca747ea3a97..5b4dcaee9e702 100644 --- a/crates/sui-protocol-config/src/snapshots/sui_protocol_config__test__version_69.snap +++ b/crates/sui-protocol-config/src/snapshots/sui_protocol_config__test__version_69.snap @@ -63,6 +63,7 @@ feature_flags: soft_bundle: true enable_coin_deny_list_v2: true passkey_auth: true + passkey_session_auth: true authority_capabilities_v2: true rethrow_serialization_type_layout_errors: true consensus_distributed_vote_scoring_strategy: true diff --git a/crates/sui-rosetta/src/types.rs b/crates/sui-rosetta/src/types.rs index 2d499a494767f..698035a38ecf1 100644 --- a/crates/sui-rosetta/src/types.rs +++ b/crates/sui-rosetta/src/types.rs @@ -393,6 +393,10 @@ impl From for PublicKey { hex_bytes: Hex::from_bytes(&k.0), curve_type: CurveType::Secp256r1, }, + SuiPublicKey::PasskeySession(k) => PublicKey { + hex_bytes: Hex::from_bytes(&k.0), + curve_type: CurveType::Secp256r1, + }, } } } diff --git a/crates/sui-types/src/base_types.rs b/crates/sui-types/src/base_types.rs index 9c28ad12d1766..0f371f6a5e0e8 100644 --- a/crates/sui-types/src/base_types.rs +++ b/crates/sui-types/src/base_types.rs @@ -771,6 +771,7 @@ impl TryFrom<&GenericSignature> for SuiAddress { SuiAddress::try_from_unpadded(&zklogin.inputs) } GenericSignature::PasskeyAuthenticator(s) => Ok(SuiAddress::from(&s.get_pk()?)), + GenericSignature::PasskeySessionAuthenticator(s) => Ok(SuiAddress::from(&s.get_pk()?)), } } } diff --git a/crates/sui-types/src/crypto.rs b/crates/sui-types/src/crypto.rs index 97379e6fd964b..1f445f29bbed2 100644 --- a/crates/sui-types/src/crypto.rs +++ b/crates/sui-types/src/crypto.rs @@ -267,6 +267,7 @@ pub enum PublicKey { Secp256r1(Secp256r1PublicKeyAsBytes), ZkLogin(ZkLoginPublicIdentifier), Passkey(Secp256r1PublicKeyAsBytes), + PasskeySession(Secp256r1PublicKeyAsBytes), } /// A wrapper struct to retrofit in [enum PublicKey] for zkLogin. @@ -294,6 +295,7 @@ impl AsRef<[u8]> for PublicKey { PublicKey::Secp256r1(pk) => &pk.0, PublicKey::ZkLogin(z) => &z.0, PublicKey::Passkey(pk) => &pk.0, + PublicKey::PasskeySession(pk) => &pk.0, } } } @@ -331,6 +333,11 @@ impl EncodeDecodeBase64 for PublicKey { FastCryptoError::InputLengthWrong(Secp256r1PublicKey::LENGTH + 1), )?)?; Ok(PublicKey::Passkey((&pk).into())) + } else if x == &SignatureScheme::PasskeySessionAuthenticator.flag() { + let pk = Secp256r1PublicKey::from_bytes(bytes.get(1..).ok_or( + FastCryptoError::InputLengthWrong(Secp256r1PublicKey::LENGTH + 1), + )?)?; + Ok(PublicKey::PasskeySession((&pk).into())) } else { Err(FastCryptoError::InvalidInput) } @@ -362,6 +369,9 @@ impl PublicKey { SignatureScheme::PasskeyAuthenticator => Ok(PublicKey::Passkey( (&Secp256r1PublicKey::from_bytes(key_bytes)?).into(), )), + SignatureScheme::PasskeySessionAuthenticator => Ok(PublicKey::PasskeySession( + (&Secp256r1PublicKey::from_bytes(key_bytes)?).into(), + )), _ => Err(eyre!("Unsupported curve")), } } @@ -373,6 +383,7 @@ impl PublicKey { PublicKey::Secp256r1(_) => Secp256r1SuiSignature::SCHEME, PublicKey::ZkLogin(_) => SignatureScheme::ZkLoginAuthenticator, PublicKey::Passkey(_) => SignatureScheme::PasskeyAuthenticator, + PublicKey::PasskeySession(_) => SignatureScheme::PasskeySessionAuthenticator, } } @@ -1003,7 +1014,8 @@ impl SuiSignature for S { let (sig, pk) = &self.get_verification_inputs()?; match scheme { - SignatureScheme::ZkLoginAuthenticator => {} // Pass this check because zk login does not derive address from pubkey. + SignatureScheme::ZkLoginAuthenticator + | SignatureScheme::PasskeySessionAuthenticator => {} // Pass this check because these two schemes do not derive address from pubkey. _ => { let address = SuiAddress::from(pk); if author != address { @@ -1664,6 +1676,7 @@ pub enum SignatureScheme { MultiSig, ZkLoginAuthenticator, PasskeyAuthenticator, + PasskeySessionAuthenticator, } impl SignatureScheme { @@ -1676,6 +1689,7 @@ impl SignatureScheme { SignatureScheme::BLS12381 => 0x04, // This is currently not supported for user Sui Address. SignatureScheme::ZkLoginAuthenticator => 0x05, SignatureScheme::PasskeyAuthenticator => 0x06, + SignatureScheme::PasskeySessionAuthenticator => 0x07, } } @@ -1695,6 +1709,7 @@ impl SignatureScheme { 0x04 => Ok(SignatureScheme::BLS12381), 0x05 => Ok(SignatureScheme::ZkLoginAuthenticator), 0x06 => Ok(SignatureScheme::PasskeyAuthenticator), + 0x07 => Ok(SignatureScheme::PasskeySessionAuthenticator), _ => Err(SuiError::KeyConversionError( "Invalid key scheme".to_string(), )), diff --git a/crates/sui-types/src/lib.rs b/crates/sui-types/src/lib.rs index f9b6d0abf477e..359a54f009a12 100644 --- a/crates/sui-types/src/lib.rs +++ b/crates/sui-types/src/lib.rs @@ -70,6 +70,7 @@ pub mod multisig; pub mod multisig_legacy; pub mod object; pub mod passkey_authenticator; +pub mod passkey_session_authenticator; pub mod programmable_transaction_builder; pub mod quorum_driver_types; pub mod randomness_state; diff --git a/crates/sui-types/src/passkey_authenticator.rs b/crates/sui-types/src/passkey_authenticator.rs index 1c7a5dcd5f38f..38aefd447f128 100644 --- a/crates/sui-types/src/passkey_authenticator.rs +++ b/crates/sui-types/src/passkey_authenticator.rs @@ -27,7 +27,7 @@ use std::sync::Arc; #[cfg(test)] #[path = "unit_tests/passkey_authenticator_test.rs"] -mod passkey_authenticator_test; +pub mod passkey_authenticator_test; /// An passkey authenticator with parsed fields. See field defition below. Can be initialized from [struct RawPasskeyAuthenticator]. #[derive(Debug, Clone, JsonSchema)] diff --git a/crates/sui-types/src/passkey_session_authenticator.rs b/crates/sui-types/src/passkey_session_authenticator.rs new file mode 100644 index 0000000000000..13f1225b81a26 --- /dev/null +++ b/crates/sui-types/src/passkey_session_authenticator.rs @@ -0,0 +1,313 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 +use crate::crypto::PublicKey; +use crate::crypto::Secp256r1SuiSignature; +use crate::crypto::SuiSignatureInner; +use crate::signature_verification::VerifiedDigestCache; +use crate::{ + base_types::{EpochId, SuiAddress}, + crypto::{Signature, SignatureScheme, SuiSignature}, + digests::ZKLoginInputsDigest, + error::{SuiError, SuiResult}, + signature::{AuthenticatorTrait, VerifyParams}, +}; +use fastcrypto::hash::{HashFunction, Sha256}; +use fastcrypto::rsa::{Base64UrlUnpadded, Encoding}; +use fastcrypto::secp256r1::{Secp256r1PublicKey, Secp256r1Signature}; +use fastcrypto::traits::VerifyingKey; +use fastcrypto::{error::FastCryptoError, traits::ToFromBytes}; +use once_cell::sync::OnceCell; +use passkey_types::webauthn::{ClientDataType, CollectedClientData}; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize}; +use shared_crypto::intent::IntentMessage; +use std::hash::Hash; +use std::hash::Hasher; +use std::sync::Arc; + +#[cfg(test)] +#[path = "unit_tests/passkey_session_authenticator_test.rs"] +mod passkey_session_authenticator_test; + +/// An passkey session authenticator with parsed fields. See field defition below. Can be initialized from [struct RawPasskeySessionAuthenticator]. +#[derive(Debug, Clone, JsonSchema)] +pub struct PasskeySessionAuthenticator { + /// `authenticatorData` is a bytearray that encodes + /// [Authenticator Data](https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data) + /// structure returned by the authenticator attestation + /// response as is. + authenticator_data: Vec, + + /// `clientDataJSON` contains a JSON-compatible + /// UTF-8 encoded string of the client data which + /// is passed to the authenticator by the client + /// during the authentication request (see [CollectedClientData](https://www.w3.org/TR/webauthn-2/#dictdef-collectedclientdata)) + client_data_json: String, + + /// Normalized r1 signature returned by passkey. This signature commits to ephemral public key and max epoch. + /// Initialized from `passkey_signature` in `RawPasskeySessionAuthenticator`. + #[serde(skip)] + passkey_signature: Secp256r1Signature, + + /// Compact r1 public key of the passkey. + /// Initialized from `passkey_signature` in `RawPasskeySessionAuthenticator`. + #[serde(skip)] + passkey_pk: Secp256r1PublicKey, + + /// Ephemeral signature that commits to intent message of tx_data. + ephemeral_signature: Signature, + + /// challenge field parsed from clientDataJSON. This should be `eph_flag || eph_pk || max_epoch`. + parsed_challenge: Vec, + + /// Maximum epoch that the ephemeral signature is valid for. + max_epoch: EpochId, + + /// Initialization of bytes for passkey in serialized form. + #[serde(skip)] + bytes: OnceCell>, +} + +/// An raw passkey session authenticator struct used during deserialization. Can be converted to [struct RawPasskeySessionAuthenticator]. +#[derive(Serialize, Deserialize, Debug)] +pub struct RawPasskeySessionAuthenticator { + pub authenticator_data: Vec, + pub client_data_json: String, + pub passkey_signature: Signature, + pub max_epoch: EpochId, + pub ephemeral_signature: Signature, +} + +/// Convert [struct RawPasskeySessionAuthenticator] to [struct RawPasskeySessionAuthenticator] with validations. +impl TryFrom for PasskeySessionAuthenticator { + type Error = SuiError; + + fn try_from(raw: RawPasskeySessionAuthenticator) -> Result { + let client_data_json_parsed: CollectedClientData = + serde_json::from_str(&raw.client_data_json).map_err(|_| { + SuiError::InvalidSignature { + error: "Invalid client data json".to_string(), + } + })?; + + if client_data_json_parsed.ty != ClientDataType::Get { + return Err(SuiError::InvalidSignature { + error: "Invalid client data type".to_string(), + }); + }; + + let parsed_challenge = Base64UrlUnpadded::decode_vec(&client_data_json_parsed.challenge) + .map_err(|_| SuiError::InvalidSignature { + error: "Invalid encoded challenge".to_string(), + })?; + + if raw.passkey_signature.scheme() != SignatureScheme::Secp256r1 { + return Err(SuiError::InvalidSignature { + error: "Invalid signature scheme".to_string(), + }); + }; + + let passkey_pk = Secp256r1PublicKey::from_bytes(raw.passkey_signature.public_key_bytes()) + .map_err(|_| SuiError::InvalidSignature { + error: "Invalid r1 pk".to_string(), + })?; + + let passkey_signature = Secp256r1Signature::from_bytes( + raw.passkey_signature.signature_bytes(), + ) + .map_err(|_| SuiError::InvalidSignature { + error: "Invalid r1 sig".to_string(), + })?; + + Ok(PasskeySessionAuthenticator { + authenticator_data: raw.authenticator_data, + client_data_json: raw.client_data_json, + passkey_signature, + passkey_pk, + ephemeral_signature: raw.ephemeral_signature, + parsed_challenge, + max_epoch: raw.max_epoch, + bytes: OnceCell::new(), + }) + } +} + +impl<'de> Deserialize<'de> for PasskeySessionAuthenticator { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + + let serializable = RawPasskeySessionAuthenticator::deserialize(deserializer)?; + serializable + .try_into() + .map_err(|e: SuiError| Error::custom(e.to_string())) + } +} + +impl Serialize for PasskeySessionAuthenticator { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + let mut bytes = Vec::with_capacity(Secp256r1SuiSignature::LENGTH); + bytes.push(SignatureScheme::Secp256r1.flag()); + bytes.extend_from_slice(self.passkey_signature.as_ref()); + bytes.extend_from_slice(self.passkey_pk.as_ref()); + + let raw = RawPasskeySessionAuthenticator { + authenticator_data: self.authenticator_data.clone(), + client_data_json: self.client_data_json.clone(), + passkey_signature: Signature::Secp256r1SuiSignature( + Secp256r1SuiSignature::from_bytes(&bytes).unwrap(), // This is safe because we just created the valid bytes. + ), + max_epoch: self.max_epoch, + ephemeral_signature: self.ephemeral_signature.clone(), + }; + raw.serialize(serializer) + } +} +impl PasskeySessionAuthenticator { + /// Returns the public key of the passkey authenticator. + pub fn get_pk(&self) -> SuiResult { + Ok(PublicKey::PasskeySession((&self.passkey_pk).into())) + } +} + +/// Necessary trait for [struct SenderSignedData]. +impl PartialEq for PasskeySessionAuthenticator { + fn eq(&self, other: &Self) -> bool { + self.as_ref() == other.as_ref() + } +} + +/// Necessary trait for [struct SenderSignedData]. +impl Eq for PasskeySessionAuthenticator {} + +/// Necessary trait for [struct SenderSignedData]. +impl Hash for PasskeySessionAuthenticator { + fn hash(&self, state: &mut H) { + self.as_ref().hash(state); + } +} + +impl AuthenticatorTrait for PasskeySessionAuthenticator { + fn verify_user_authenticator_epoch( + &self, + epoch: EpochId, + max_epoch_upper_bound_delta: Option, + ) -> SuiResult { + // the checks here ensure that `current_epoch + passkey_session_max_epoch_upper_bound_delta >= self.max_epoch >= current_epoch`. + // 1. if the config for upper bound is set, ensure that the max epoch in signature is not larger than epoch + upper_bound. + if let Some(delta) = max_epoch_upper_bound_delta { + let max_epoch_upper_bound = + epoch.checked_add(delta).ok_or(SuiError::InvalidSignature { + error: "Max epoch upper bound delta overflow".to_string(), + })?; + if self.max_epoch > max_epoch_upper_bound { + return Err(SuiError::InvalidSignature { + error: format!( + "Passkey session max epoch too large {}, current epoch {}, max accepted: {}", + self.max_epoch, + epoch, + max_epoch_upper_bound + ), + }); + } + } + + // 2. ensure that max epoch in signature is greater than the current epoch. + if epoch > self.max_epoch { + return Err(SuiError::InvalidSignature { + error: format!( + "Passkey session expired at epoch {}, current epoch {}", + self.max_epoch, epoch + ), + }); + } + Ok(()) + } + + /// Verify an intent message of a transaction with a passkey session authenticator. + fn verify_claims( + &self, + intent_msg: &IntentMessage, + author: SuiAddress, + _aux_verify_data: &VerifyParams, + _zklogin_inputs_cache: Arc>, + ) -> SuiResult + where + T: Serialize, + { + // Check if the challenge field is consistent with the ephemeral public key registered and its max epoch. + let mut expected_register_msg = vec![self.ephemeral_signature.scheme().flag()]; + expected_register_msg.extend_from_slice(self.ephemeral_signature.public_key_bytes()); + expected_register_msg.extend_from_slice(&self.max_epoch.to_be_bytes()); + + if self.parsed_challenge != expected_register_msg { + return Err(SuiError::InvalidSignature { + error: "Invalid parsed challenge".to_string(), + }); + }; + + // Check if author is derived from the public key. + if author != SuiAddress::from(&self.get_pk()?) { + return Err(SuiError::InvalidSignature { + error: "Invalid author".to_string(), + }); + }; + + // Check if the ephemeral signature verifies against the transaction blake2b_hash(intent_message). + self.ephemeral_signature + .verify_secure( + intent_msg, + author, + SignatureScheme::PasskeySessionAuthenticator, + ) + .map_err(|_| SuiError::InvalidSignature { + error: "Fails to verify ephemeral sig".to_string(), + })?; + + // Construct msg = authenticator_data || sha256(client_data_json). + let mut message = self.authenticator_data.clone(); + let client_data_hash = Sha256::digest(self.client_data_json.as_bytes()).digest; + message.extend_from_slice(&client_data_hash); + + // Verify the passkey signature against pk and message. + self.passkey_pk + .verify(&message, &self.passkey_signature) + .map_err(|_| SuiError::InvalidSignature { + error: "Fails to verify register sig".to_string(), + }) + } +} + +impl ToFromBytes for PasskeySessionAuthenticator { + fn from_bytes(bytes: &[u8]) -> Result { + // The first byte matches the flag of PasskeySessionAuthenticator. + if bytes.first().ok_or(FastCryptoError::InvalidInput)? + != &SignatureScheme::PasskeySessionAuthenticator.flag() + { + return Err(FastCryptoError::InvalidInput); + } + let passkey: PasskeySessionAuthenticator = + bcs::from_bytes(&bytes[1..]).map_err(|_| FastCryptoError::InvalidSignature)?; + + Ok(passkey) + } +} + +impl AsRef<[u8]> for PasskeySessionAuthenticator { + fn as_ref(&self) -> &[u8] { + self.bytes + .get_or_try_init::<_, eyre::Report>(|| { + let as_bytes = bcs::to_bytes(self).expect("BCS serialization should not fail"); + let mut bytes = Vec::with_capacity(1 + as_bytes.len()); + bytes.push(SignatureScheme::PasskeySessionAuthenticator.flag()); + bytes.extend_from_slice(as_bytes.as_slice()); + Ok(bytes) + }) + .expect("OnceCell invariant violated") + } +} diff --git a/crates/sui-types/src/signature.rs b/crates/sui-types/src/signature.rs index 156961eebf384..988ce9326fa2d 100644 --- a/crates/sui-types/src/signature.rs +++ b/crates/sui-types/src/signature.rs @@ -9,6 +9,7 @@ use crate::digests::ZKLoginInputsDigest; use crate::error::SuiError; use crate::multisig_legacy::MultiSigLegacy; use crate::passkey_authenticator::PasskeyAuthenticator; +use crate::passkey_session_authenticator::PasskeySessionAuthenticator; use crate::signature_verification::VerifiedDigestCache; use crate::zk_login_authenticator::ZkLoginAuthenticator; use crate::{base_types::SuiAddress, crypto::Signature, error::SuiResult, multisig::MultiSig}; @@ -91,6 +92,7 @@ pub enum GenericSignature { Signature, ZkLoginAuthenticator, PasskeyAuthenticator, + PasskeySessionAuthenticator, } impl GenericSignature { @@ -237,6 +239,12 @@ impl ToFromBytes for GenericSignature { let passkey = PasskeyAuthenticator::from_bytes(bytes)?; Ok(GenericSignature::PasskeyAuthenticator(passkey)) } + SignatureScheme::PasskeySessionAuthenticator => { + let passkey_session = PasskeySessionAuthenticator::from_bytes(bytes)?; + Ok(GenericSignature::PasskeySessionAuthenticator( + passkey_session, + )) + } _ => Err(FastCryptoError::InvalidInput), }, Err(_) => Err(FastCryptoError::InvalidInput), @@ -253,6 +261,7 @@ impl AsRef<[u8]> for GenericSignature { GenericSignature::Signature(s) => s.as_ref(), GenericSignature::ZkLoginAuthenticator(s) => s.as_ref(), GenericSignature::PasskeyAuthenticator(s) => s.as_ref(), + GenericSignature::PasskeySessionAuthenticator(s) => s.as_ref(), } } } diff --git a/crates/sui-types/src/transaction.rs b/crates/sui-types/src/transaction.rs index 47efab07ee6cd..3bd7899420611 100644 --- a/crates/sui-types/src/transaction.rs +++ b/crates/sui-types/src/transaction.rs @@ -2355,6 +2355,15 @@ impl SenderSignedData { }); } } + GenericSignature::PasskeySessionAuthenticator(_) => { + if !config.passkey_session_auth() { + return Err(SuiError::UserInputError { + error: UserInputError::Unsupported( + "passkey session is not enabled on this network".to_string(), + ), + }); + } + } GenericSignature::Signature(_) | GenericSignature::MultiSigLegacy(_) => (), } } diff --git a/crates/sui-types/src/unit_tests/passkey_authenticator_test.rs b/crates/sui-types/src/unit_tests/passkey_authenticator_test.rs index 20e8b0f7fad63..8ef85443222b3 100644 --- a/crates/sui-types/src/unit_tests/passkey_authenticator_test.rs +++ b/crates/sui-types/src/unit_tests/passkey_authenticator_test.rs @@ -161,7 +161,7 @@ async fn create_credential_and_sign_test_tx( } } -fn make_credential_creation_option(origin: &Url) -> CredentialCreationOptions { +pub fn make_credential_creation_option(origin: &Url) -> CredentialCreationOptions { let challenge_bytes_from_rp: Bytes = random_vec(32).into(); let user_entity = PublicKeyCredentialUserEntity { id: random_vec(32).into(), diff --git a/crates/sui-types/src/unit_tests/passkey_session_authenticator_test.rs b/crates/sui-types/src/unit_tests/passkey_session_authenticator_test.rs new file mode 100644 index 0000000000000..336156af8d528 --- /dev/null +++ b/crates/sui-types/src/unit_tests/passkey_session_authenticator_test.rs @@ -0,0 +1,238 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use crate::committee::EpochId; +use crate::crypto::get_key_pair; +use crate::passkey_authenticator::passkey_authenticator_test::MyUserValidationMethod; +use crate::passkey_session_authenticator::{ + PasskeySessionAuthenticator, RawPasskeySessionAuthenticator, +}; +use crate::{ + base_types::{dbg_addr, ObjectID, SuiAddress}, + crypto::{PublicKey, Signature, SignatureScheme}, + error::SuiError, + object::Object, + signature::GenericSignature, + transaction::{TransactionData, TEST_ONLY_GAS_UNIT_FOR_TRANSFER}, +}; +use fastcrypto::ed25519::Ed25519KeyPair; +use passkey_types::webauthn::{CredentialRequestOptions, PublicKeyCredentialRequestOptions}; + +use fastcrypto::traits::KeyPair; +use fastcrypto::traits::ToFromBytes; +use p256::pkcs8::DecodePublicKey; +use passkey_types::webauthn::CredentialCreationOptions; + +use crate::passkey_authenticator::passkey_authenticator_test::make_credential_creation_option; +use passkey_authenticator::Authenticator; +use passkey_client::Client; +use passkey_types::{ctap2::Aaguid, webauthn::UserVerificationRequirement, Bytes, Passkey}; +use shared_crypto::intent::{Intent, IntentMessage}; +use url::Url; +/// Response with fields from passkey authentication. +#[derive(Debug)] +pub struct PasskeySessionResponse { + user_sig_bytes: Vec, + authenticator_data: Vec, + client_data_json: String, + kp: Ed25519KeyPair, + intent_msg: IntentMessage, +} + +/// Create a new passkey credential, derives its address +/// and request a signature from passkey for a test transaction. +async fn create_credential_and_commit_ephemeral_pk( + origin: &Url, + request: CredentialCreationOptions, + max_epoch: EpochId, +) -> PasskeySessionResponse { + // Set up authenticator and client. + let my_aaguid = Aaguid::new_empty(); + let user_validation_method = MyUserValidationMethod {}; + let store: Option = None; + let my_authenticator = Authenticator::new(my_aaguid, store, user_validation_method); + let mut my_client = Client::new(my_authenticator); + + // Create credential with the request option. + let my_webauthn_credential = my_client.register(origin, request, None).await.unwrap(); + let verifying_key = p256::ecdsa::VerifyingKey::from_public_key_der( + my_webauthn_credential + .response + .public_key + .unwrap() + .as_slice(), + ) + .unwrap(); + + // Derive its compact pubkey from DER format. + let encoded_point = verifying_key.to_encoded_point(false); + let x = encoded_point.x(); + let y = encoded_point.y(); + let prefix = if y.unwrap()[31] % 2 == 0 { 0x02 } else { 0x03 }; + let mut pk_bytes = vec![prefix]; + pk_bytes.extend_from_slice(x.unwrap()); + let pk = + PublicKey::try_from_bytes(SignatureScheme::PasskeySessionAuthenticator, &pk_bytes).unwrap(); + + // Derives its sui address and make a test transaction with it as sender. + let sender = SuiAddress::from(&pk); + let recipient = dbg_addr(2); + let object_id = ObjectID::ZERO; + let object = Object::immutable_with_id_for_testing(object_id); + let gas_price = 1000; + let tx_data = TransactionData::new_transfer_sui( + recipient, + sender, + None, + object.compute_object_reference(), + gas_price * TEST_ONLY_GAS_UNIT_FOR_TRANSFER, + gas_price, + ); + let intent_msg = IntentMessage::new(Intent::sui_transaction(), tx_data); + + // Compute the challenge as eph_flag || eph_pk || max_epoch. This is the challenge for the passkey to sign. + let kp: Ed25519KeyPair = get_key_pair().1; + let mut register_msg = vec![SignatureScheme::ED25519.flag()]; + register_msg.extend_from_slice(kp.public().as_bytes()); + register_msg.extend_from_slice(&max_epoch.to_be_bytes()); + + // Send the challenge to the passkey to sign with the rp_id. + let credential_request = CredentialRequestOptions { + public_key: PublicKeyCredentialRequestOptions { + challenge: Bytes::from(register_msg), + timeout: None, + rp_id: Some(String::from(origin.domain().unwrap())), + allow_credentials: None, + user_verification: UserVerificationRequirement::default(), + attestation: Default::default(), + attestation_formats: None, + extensions: None, + hints: None, + }, + }; + + let authenticated_cred = my_client + .authenticate(origin, credential_request, None) + .await + .unwrap(); + + // Parse the response, gets the signature from der format and normalize it to lower s. + let sig_bytes_der = authenticated_cred.response.signature.as_slice(); + let sig = p256::ecdsa::Signature::from_der(sig_bytes_der).unwrap(); + let sig_bytes = sig.normalize_s().unwrap_or(sig).to_bytes(); + + // Parse authenticator_data and client_data_json from response. + let authenticator_data = authenticated_cred.response.authenticator_data.as_slice(); + let client_data_json = authenticated_cred.response.client_data_json.as_slice(); + + // Prepare flag || sig || pk. + let mut user_sig_bytes = vec![SignatureScheme::Secp256r1.flag()]; + user_sig_bytes.extend_from_slice(&sig_bytes); + user_sig_bytes.extend_from_slice(&pk_bytes); + + PasskeySessionResponse:: { + user_sig_bytes, + authenticator_data: authenticator_data.to_vec(), + client_data_json: String::from_utf8_lossy(client_data_json).to_string(), + kp, + intent_msg, + } +} + +#[tokio::test] +async fn test_passkey_session_sig_serde() { + let origin = Url::parse("https://www.sui.io").unwrap(); + let request = make_credential_creation_option(&origin); + let max_epoch = 2; + let response = create_credential_and_commit_ephemeral_pk(&origin, request, max_epoch).await; + + let raw = RawPasskeySessionAuthenticator { + passkey_signature: Signature::from_bytes(&response.user_sig_bytes).unwrap(), + max_epoch, + ephemeral_signature: Signature::new_secure(&response.intent_msg, &response.kp), + authenticator_data: response.authenticator_data, + client_data_json: response.client_data_json, + }; + let passkey: PasskeySessionAuthenticator = raw.try_into().unwrap(); + let serialized = bcs::to_bytes(&passkey).unwrap(); + + // deser back to passkey authenticator is the same + let deserialized: PasskeySessionAuthenticator = bcs::from_bytes(&serialized).unwrap(); + assert_eq!(passkey, deserialized); + + // serde round trip for generic signature is the same + let signature = GenericSignature::PasskeySessionAuthenticator(passkey); + + let serialized_str = serde_json::to_string(&signature).unwrap(); + let deserialized: GenericSignature = serde_json::from_str(&serialized_str).unwrap(); + assert_eq!(deserialized.as_ref(), signature.as_ref()); +} + +#[tokio::test] +async fn test_passkey_fails_invalid_json() { + let origin = Url::parse("https://www.sui.io").unwrap(); + let request = make_credential_creation_option(&origin); + let response = create_credential_and_commit_ephemeral_pk(&origin, request, 10).await; + let client_data_json_missing_type = r#"{"challenge":"9-fH7nX8Nb1JvUynz77mv1kXOkGkg1msZb2qhvZssGI","origin":"http://localhost:5173","crossOrigin":false}"#; + let raw = RawPasskeySessionAuthenticator { + authenticator_data: response.authenticator_data.clone(), + client_data_json: client_data_json_missing_type.to_string(), + passkey_signature: Signature::from_bytes(&response.user_sig_bytes).unwrap(), + max_epoch: 10, + ephemeral_signature: Signature::new_secure(&response.intent_msg, &response.kp), + }; + let res: Result = raw.try_into(); + let err = res.unwrap_err(); + assert_eq!( + err, + SuiError::InvalidSignature { + error: "Invalid client data json".to_string() + } + ); +} + +#[tokio::test] +async fn test_passkey_fails_invalid_challenge() { + let origin = Url::parse("https://www.sui.io").unwrap(); + let request = make_credential_creation_option(&origin); + let response = create_credential_and_commit_ephemeral_pk(&origin, request, 10).await; + let fake_client_data_json = r#"{"type":"webauthn.get","challenge":"wrong_base64_encoding","origin":"http://localhost:5173","crossOrigin":false}"#; + let raw = RawPasskeySessionAuthenticator { + authenticator_data: response.authenticator_data, + client_data_json: fake_client_data_json.to_string(), + passkey_signature: Signature::from_bytes(&response.user_sig_bytes).unwrap(), + max_epoch: 10, + ephemeral_signature: Signature::new_secure(&response.intent_msg, &response.kp), + }; + let res: Result = raw.try_into(); + let err = res.unwrap_err(); + assert_eq!( + err, + SuiError::InvalidSignature { + error: "Invalid encoded challenge".to_string() + } + ); +} + +#[tokio::test] +async fn test_passkey_fails_wrong_client_data_type() { + let origin = Url::parse("https://www.sui.io").unwrap(); + let request = make_credential_creation_option(&origin); + let response = create_credential_and_commit_ephemeral_pk(&origin, request, 10).await; + let fake_client_data_json = r#"{"type":"webauthn.create","challenge":"9-fH7nX8Nb1JvUynz77mv1kXOkGkg1msZb2qhvZssGI","origin":"http://localhost:5173","crossOrigin":false}"#; + let raw = RawPasskeySessionAuthenticator { + authenticator_data: response.authenticator_data.clone(), + client_data_json: fake_client_data_json.to_string(), + passkey_signature: Signature::from_bytes(&response.user_sig_bytes).unwrap(), + max_epoch: 10, + ephemeral_signature: Signature::new_secure(&response.intent_msg, &response.kp), + }; + let res: Result = raw.try_into(); + let err = res.unwrap_err(); + assert_eq!( + err, + SuiError::InvalidSignature { + error: "Invalid client data type".to_string() + } + ); +} From 3c05da34bf4b967898d3b405f923fb1cc61b0c65 Mon Sep 17 00:00:00 2001 From: Joy Wang <108701016+joyqvq@users.noreply.github.com> Date: Wed, 4 Dec 2024 09:12:33 -0500 Subject: [PATCH 2/2] json rpc --- crates/sui-open-rpc/spec/openrpc.json | 74 +++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/crates/sui-open-rpc/spec/openrpc.json b/crates/sui-open-rpc/spec/openrpc.json index 3ebaf3efbb2a3..d236d21934974 100644 --- a/crates/sui-open-rpc/spec/openrpc.json +++ b/crates/sui-open-rpc/spec/openrpc.json @@ -1341,6 +1341,7 @@ "package_digest_hash_module": false, "package_upgrades": true, "passkey_auth": false, + "passkey_session_auth": false, "prepend_prologue_tx_in_consensus_commit_in_checkpoints": false, "random_beacon": false, "receive_objects": false, @@ -6568,6 +6569,18 @@ } }, "additionalProperties": false + }, + { + "type": "object", + "required": [ + "PasskeySessionAuthenticator" + ], + "properties": { + "PasskeySessionAuthenticator": { + "$ref": "#/components/schemas/PasskeySessionAuthenticator" + } + }, + "additionalProperties": false } ] }, @@ -7967,6 +7980,55 @@ } } }, + "PasskeySessionAuthenticator": { + "description": "An passkey session authenticator with parsed fields. See field defition below. Can be initialized from [struct RawPasskeySessionAuthenticator].", + "type": "object", + "required": [ + "authenticator_data", + "client_data_json", + "ephemeral_signature", + "max_epoch", + "parsed_challenge" + ], + "properties": { + "authenticator_data": { + "description": "`authenticatorData` is a bytearray that encodes [Authenticator Data](https://www.w3.org/TR/webauthn-2/#sctn-authenticator-data) structure returned by the authenticator attestation response as is.", + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + }, + "client_data_json": { + "description": "`clientDataJSON` contains a JSON-compatible UTF-8 encoded string of the client data which is passed to the authenticator by the client during the authentication request (see [CollectedClientData](https://www.w3.org/TR/webauthn-2/#dictdef-collectedclientdata))", + "type": "string" + }, + "ephemeral_signature": { + "description": "Ephemeral signature that commits to intent message of tx_data.", + "allOf": [ + { + "$ref": "#/components/schemas/Signature" + } + ] + }, + "max_epoch": { + "description": "Maximum epoch that the ephemeral signature is valid for.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "parsed_challenge": { + "description": "challenge field parsed from clientDataJSON. This should be `eph_flag || eph_pk || max_epoch`.", + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } + } + } + }, "ProtocolConfig": { "type": "object", "required": [ @@ -8135,6 +8197,18 @@ } }, "additionalProperties": false + }, + { + "type": "object", + "required": [ + "PasskeySession" + ], + "properties": { + "PasskeySession": { + "$ref": "#/components/schemas/Base64" + } + }, + "additionalProperties": false } ] },