diff --git a/e2e/passkey/passkeyProxyV2.ethereum.test.ts b/e2e/passkey/passkeyProxyV2.ethereum.test.ts new file mode 100644 index 0000000000..d5ce1b3730 --- /dev/null +++ b/e2e/passkey/passkeyProxyV2.ethereum.test.ts @@ -0,0 +1,87 @@ +import '@frequency-chain/api-augment'; +import assert from 'assert'; +import { + createAndFundKeypair, + EcdsaSignature, + getBlockNumber, + getNonce, + Sr25519Signature, +} from '../scaffolding/helpers'; +import { KeyringPair } from '@polkadot/keyring/types'; +import { ExtrinsicHelper } from '../scaffolding/extrinsicHelpers'; +import { getFundingSource } from '../scaffolding/funding'; +import { getUnifiedPublicKey, getUnifiedAddress } from '../scaffolding/ethereum'; +import { createPassKeyAndSignAccount, createPassKeyCallV2, createPasskeyPayloadV2 } from '../scaffolding/P256'; +import { u8aToHex, u8aWrapBytes } from '@polkadot/util'; +const fundingSource = getFundingSource(import.meta.url); + +describe('Passkey Pallet Proxy V2 Ethereum Tests', function () { + describe('passkey ethereum tests', function () { + let fundedSr25519Keys: KeyringPair; + let fundedEthereumKeys: KeyringPair; + let receiverKeys: KeyringPair; + + before(async function () { + fundedSr25519Keys = await createAndFundKeypair(fundingSource, 300_000_000n); + fundedEthereumKeys = await createAndFundKeypair(fundingSource, 300_000_000n, undefined, undefined, 'ethereum'); + receiverKeys = await createAndFundKeypair(fundingSource); + }); + + it('should transfer via passkeys with root sr25519 key into an ethereum style account', async function () { + const accountPKey = getUnifiedPublicKey(fundedSr25519Keys); + const nonce = await getNonce(fundedSr25519Keys); + const transferCalls = ExtrinsicHelper.api.tx.balances.transferKeepAlive( + getUnifiedAddress(receiverKeys), + 55_000_000n + ); + const { passKeyPrivateKey, passKeyPublicKey } = createPassKeyAndSignAccount(accountPKey); + const accountSignature = fundedSr25519Keys.sign(u8aWrapBytes(passKeyPublicKey)); + const multiSignature: Sr25519Signature = { Sr25519: u8aToHex(accountSignature) }; + const passkeyCall = await createPassKeyCallV2(accountPKey, nonce, transferCalls); + const passkeyPayload = await createPasskeyPayloadV2( + multiSignature, + passKeyPrivateKey, + passKeyPublicKey, + passkeyCall, + false + ); + const passkeyProxy = ExtrinsicHelper.executePassKeyProxyV2(fundedSr25519Keys, passkeyPayload); + await assert.doesNotReject(passkeyProxy.fundAndSendUnsigned(fundingSource)); + await ExtrinsicHelper.waitForFinalization((await getBlockNumber()) + 2); + // adding some delay before fetching the nonce to ensure it is updated + await new Promise((resolve) => setTimeout(resolve, 1000)); + const nonceAfter = (await ExtrinsicHelper.getAccountInfo(fundedSr25519Keys)).nonce.toNumber(); + assert.equal(nonce + 1, nonceAfter); + }); + + it('should transfer via passkeys with root ethereum style key into another one', async function () { + const accountPKey = getUnifiedPublicKey(fundedEthereumKeys); + console.log(`accountPKey ${u8aToHex(accountPKey)}`); + const nonce = await getNonce(fundedEthereumKeys); + const transferCalls = ExtrinsicHelper.api.tx.balances.transferKeepAlive( + getUnifiedAddress(receiverKeys), + 66_000_000n + ); + const { passKeyPrivateKey, passKeyPublicKey } = createPassKeyAndSignAccount(accountPKey); + // ethereum keys should not have wrapping + const accountSignature = fundedEthereumKeys.sign(passKeyPublicKey); + console.log(`accountSignature ${u8aToHex(accountSignature)}`); + const multiSignature: EcdsaSignature = { Ecdsa: u8aToHex(accountSignature) }; + const passkeyCall = await createPassKeyCallV2(accountPKey, nonce, transferCalls); + const passkeyPayload = await createPasskeyPayloadV2( + multiSignature, + passKeyPrivateKey, + passKeyPublicKey, + passkeyCall, + false + ); + const passkeyProxy = ExtrinsicHelper.executePassKeyProxyV2(fundingSource, passkeyPayload); + await assert.doesNotReject(passkeyProxy.sendUnsigned()); + await ExtrinsicHelper.waitForFinalization((await getBlockNumber()) + 2); + // adding some delay before fetching the nonce to ensure it is updated + await new Promise((resolve) => setTimeout(resolve, 1000)); + const nonceAfter = (await ExtrinsicHelper.getAccountInfo(fundedEthereumKeys)).nonce.toNumber(); + assert.equal(nonce + 1, nonceAfter); + }); + }); +}); diff --git a/e2e/passkey/passkeyProxyV2.test.ts b/e2e/passkey/passkeyProxyV2.test.ts new file mode 100644 index 0000000000..d39a4fecfe --- /dev/null +++ b/e2e/passkey/passkeyProxyV2.test.ts @@ -0,0 +1,112 @@ +import '@frequency-chain/api-augment'; +import assert from 'assert'; +import { createAndFundKeypair, getBlockNumber, getNonce, Sr25519Signature } from '../scaffolding/helpers'; +import { KeyringPair } from '@polkadot/keyring/types'; +import { ExtrinsicHelper } from '../scaffolding/extrinsicHelpers'; +import { getFundingSource } from '../scaffolding/funding'; +import { u8aToHex, u8aWrapBytes } from '@polkadot/util'; +import { createPassKeyAndSignAccount, createPassKeyCallV2, createPasskeyPayloadV2 } from '../scaffolding/P256'; +import { getUnifiedPublicKey } from '../scaffolding/ethereum'; +const fundingSource = getFundingSource(import.meta.url); + +describe('Passkey Pallet Proxy V2 Tests', function () { + describe('proxy basic tests', function () { + let fundedKeys: KeyringPair; + let receiverKeys: KeyringPair; + + before(async function () { + fundedKeys = await createAndFundKeypair(fundingSource, 300_000_000n); + receiverKeys = await createAndFundKeypair(fundingSource); + }); + + it('should fail due to unsupported call', async function () { + const accountPKey = getUnifiedPublicKey(fundedKeys); + const nonce = await getNonce(fundedKeys); + + const remarksCalls = ExtrinsicHelper.api.tx.system.remark('passkey-test'); + const { passKeyPrivateKey, passKeyPublicKey, passkeySignature } = createPassKeyAndSignAccount(accountPKey); + const accountSignature = fundedKeys.sign(u8aWrapBytes(passKeyPublicKey)); + const multiSignature: Sr25519Signature = { Sr25519: u8aToHex(accountSignature) }; + const passkeyCall = await createPassKeyCallV2(accountPKey, nonce, remarksCalls); + const passkeyPayload = await createPasskeyPayloadV2( + multiSignature, + passKeyPrivateKey, + passKeyPublicKey, + passkeyCall, + false + ); + + const passkeyProxy = ExtrinsicHelper.executePassKeyProxyV2(fundedKeys, passkeyPayload); + await assert.rejects(passkeyProxy.fundAndSendUnsigned(fundingSource)); + }); + + it('should fail to transfer balance due to bad account ownership proof', async function () { + const accountPKey = getUnifiedPublicKey(fundedKeys); + const nonce = await getNonce(fundedKeys); + const transferCalls = ExtrinsicHelper.api.tx.balances.transferKeepAlive(getUnifiedPublicKey(receiverKeys), 0n); + const { passKeyPrivateKey, passKeyPublicKey, passkeySignature } = createPassKeyAndSignAccount(accountPKey); + const accountSignature = fundedKeys.sign('badPasskeyPublicKey'); + const multiSignature: Sr25519Signature = { Sr25519: u8aToHex(accountSignature) }; + const passkeyCall = await createPassKeyCallV2(accountPKey, nonce, transferCalls); + const passkeyPayload = await createPasskeyPayloadV2( + multiSignature, + passKeyPrivateKey, + passKeyPublicKey, + passkeyCall, + false + ); + + const passkeyProxy = ExtrinsicHelper.executePassKeyProxyV2(fundedKeys, passkeyPayload); + await assert.rejects(passkeyProxy.fundAndSendUnsigned(fundingSource)); + }); + + it('should fail to transfer balance due to bad passkey signature', async function () { + const accountPKey = getUnifiedPublicKey(fundedKeys); + const nonce = await getNonce(fundedKeys); + const transferCalls = ExtrinsicHelper.api.tx.balances.transferKeepAlive(getUnifiedPublicKey(receiverKeys), 0n); + const { passKeyPrivateKey, passKeyPublicKey, passkeySignature } = createPassKeyAndSignAccount(accountPKey); + const accountSignature = fundedKeys.sign(u8aWrapBytes(passKeyPublicKey)); + const multiSignature: Sr25519Signature = { Sr25519: u8aToHex(accountSignature) }; + const passkeyCall = await createPassKeyCallV2(accountPKey, nonce, transferCalls); + const passkeyPayload = await createPasskeyPayloadV2( + multiSignature, + passKeyPrivateKey, + passKeyPublicKey, + passkeyCall, + true + ); + + const passkeyProxy = ExtrinsicHelper.executePassKeyProxyV2(fundedKeys, passkeyPayload); + await assert.rejects(passkeyProxy.fundAndSendUnsigned(fundingSource)); + }); + + it('should transfer small balance from fundedKeys to receiverKeys', async function () { + const accountPKey = getUnifiedPublicKey(fundedKeys); + const nonce = await getNonce(fundedKeys); + const transferCalls = ExtrinsicHelper.api.tx.balances.transferKeepAlive( + getUnifiedPublicKey(receiverKeys), + 100_000_000n + ); + const { passKeyPrivateKey, passKeyPublicKey } = createPassKeyAndSignAccount(accountPKey); + const accountSignature = fundedKeys.sign(u8aWrapBytes(passKeyPublicKey)); + const multiSignature: Sr25519Signature = { Sr25519: u8aToHex(accountSignature) }; + const passkeyCall = await createPassKeyCallV2(accountPKey, nonce, transferCalls); + const passkeyPayload = await createPasskeyPayloadV2( + multiSignature, + passKeyPrivateKey, + passKeyPublicKey, + passkeyCall, + false + ); + const passkeyProxy = ExtrinsicHelper.executePassKeyProxyV2(fundedKeys, passkeyPayload); + await assert.doesNotReject(passkeyProxy.fundAndSendUnsigned(fundingSource)); + await ExtrinsicHelper.waitForFinalization((await getBlockNumber()) + 2); + const receiverBalance = await ExtrinsicHelper.getAccountInfo(receiverKeys); + // adding some delay before fetching the nonce to ensure it is updated + await new Promise((resolve) => setTimeout(resolve, 2000)); + const nonceAfter = (await ExtrinsicHelper.getAccountInfo(fundedKeys)).nonce.toNumber(); + assert.equal(nonce + 1, nonceAfter); + assert(receiverBalance.data.free.toBigInt() > 0n); + }); + }); +}); diff --git a/e2e/scaffolding/P256.ts b/e2e/scaffolding/P256.ts index 3d3fc3cbd6..3db0bbcf5f 100644 --- a/e2e/scaffolding/P256.ts +++ b/e2e/scaffolding/P256.ts @@ -30,6 +30,21 @@ export async function createPassKeyCall( return passkeyCall; } +export async function createPassKeyCallV2( + accountPKey: Uint8Array, + nonce: number, + call: SubmittableExtrinsic<'rxjs', ISubmittableResult> +) { + const ext_call_type = ExtrinsicHelper.api.registry.createType('Call', call); + const passkeyCall = { + accountId: accountPKey, + accountNonce: nonce, + call: ext_call_type, + }; + + return passkeyCall; +} + export async function createPasskeyPayload( passKeyPrivateKey: Uint8Array, passKeyPublicKey: Uint8Array, @@ -72,3 +87,48 @@ export async function createPasskeyPayload( return payload; } + +export async function createPasskeyPayloadV2( + accountSignature: MultiSignatureType, + passKeyPrivateKey: Uint8Array, + passKeyPublicKey: Uint8Array, + passkeyCallPayload: any = {}, + set_invalid_passkey_data: boolean = false +) { + const authenticatorDataRaw = 'WJ8JTNbivTWn-433ubs148A7EgWowi4SAcYBjLWfo1EdAAAAAA'; + const replacedClientDataRaw = + 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiI3JwbGMjIiwib3JpZ2luIjoiaHR0cHM6Ly9wYXNza2V5LmFtcGxpY2EuaW86ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZSwiYWxnIjoiSFMyNTYifQ'; + const challengeReplacer = '#rplc#'; + let clientData = base64UrlToUint8Array(replacedClientDataRaw); + let authenticatorData = base64UrlToUint8Array(authenticatorDataRaw); + + if (set_invalid_passkey_data) { + authenticatorData = new Uint8Array(0); + clientData = new Uint8Array(0); + } + const passkeyCallType = ExtrinsicHelper.api.createType('PalletPasskeyPasskeyCallV2', passkeyCallPayload); + + // Challenge is sha256(passkeyCallType) + const calculatedChallenge = sha256(passkeyCallType.toU8a()); + const calculatedChallengeBase64url = Buffer.from(calculatedChallenge).toString('base64url'); + // inject challenge inside clientData + const clientDataJSON = Buffer.from(clientData) + .toString('utf-8') + .replace(challengeReplacer, calculatedChallengeBase64url); + // prepare signing payload which is [authenticator || sha256(client_data_json)] + const passkeySha256 = sha256(new Uint8Array([...authenticatorData, ...sha256(Buffer.from(clientDataJSON))])); + const passKeySignature = secp256r1.sign(passkeySha256, passKeyPrivateKey).toDERRawBytes(); + const passkeyPayload = { + passkeyPublicKey: Array.from(passKeyPublicKey), + verifiablePasskeySignature: { + signature: Array.from(passKeySignature), + authenticatorData: Array.from(authenticatorData), + clientDataJson: Array.from(Buffer.from(clientDataJSON)), + }, + accountOwnershipProof: accountSignature, + passkeyCall: passkeyCallType, + }; + const payload = ExtrinsicHelper.api.createType('PalletPasskeyPasskeyPayloadV2', passkeyPayload); + + return payload; +} diff --git a/e2e/scaffolding/extrinsicHelpers.ts b/e2e/scaffolding/extrinsicHelpers.ts index 3c0231dabe..26454538d7 100644 --- a/e2e/scaffolding/extrinsicHelpers.ts +++ b/e2e/scaffolding/extrinsicHelpers.ts @@ -984,4 +984,12 @@ export class ExtrinsicHelper { ExtrinsicHelper.api.events.passkey.TransactionExecutionSuccess ); } + + public static executePassKeyProxyV2(keys: KeyringPair, payload: any) { + return new Extrinsic( + () => ExtrinsicHelper.api.tx.passkey.proxyV2(payload), + keys, + ExtrinsicHelper.api.events.passkey.TransactionExecutionSuccess + ); + } } diff --git a/pallets/passkey/README.md b/pallets/passkey/README.md index f3a930755d..48fb285497 100644 --- a/pallets/passkey/README.md +++ b/pallets/passkey/README.md @@ -18,6 +18,7 @@ The Passkey pallet provides for: ## Interactions ### Extrinsic verification + Because the Polkadot SDK currently lacks support for P256 signatures, we had to use an unsigned extrinsic to allow this custom verification before dispatching transactions. To achieve this, we added P256 signature verification within the `ValidateUnsigned` trait implementation for the pallet. @@ -27,10 +28,10 @@ checks within the ValidateUnsigned trait implementation to mitigate potential vu ### Extrinsics -| Name/Description | Caller | Payment | Key Events | Runtime Added | -|----------------------------------------|--------| ------------------ |-------------------------------------------------------------------------------------------------------------------------------------------|---------------| -| `proxy`
Proxies an extrinsic call | Anyone | Tokens | [`TransactionExecutionSuccess`](https://frequency-chain.github.io/frequency/pallet_passkey/module/enum.Event.html#variant.TransactionExecutionSuccess) | 92 | +| Name/Description | Caller | Payment | Key Events | Runtime Added | +| ------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | +| `proxy`
Proxies an extrinsic call | Anyone | Tokens | [`TransactionExecutionSuccess`](https://frequency-chain.github.io/frequency/pallet_passkey/module/enum.Event.html#variant.TransactionExecutionSuccess) | 92 | +| ------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | +| `proxy_v2`
Proxies an extrinsic call | Anyone | Tokens | [`TransactionExecutionSuccess`](https://frequency-chain.github.io/frequency/pallet_passkey/module/enum.Event.html#variant.TransactionExecutionSuccess) | 92 | See [Rust Docs](https://frequency-chain.github.io/frequency/pallet_passkey/module/struct.Pallet.html) for more details. - - diff --git a/pallets/passkey/src/benchmarking.rs b/pallets/passkey/src/benchmarking.rs index 176b930441..4d4ca9628f 100644 --- a/pallets/passkey/src/benchmarking.rs +++ b/pallets/passkey/src/benchmarking.rs @@ -28,7 +28,7 @@ mod app_sr25519 { type SignerId = app_sr25519::Public; -fn generate_payload() -> PasskeyPayload { +fn generate_payload() -> PasskeyPayloadV2 { let test_account_1_pk = SignerId::generate_pair(None); let test_account_1_account_id = T::AccountId::decode(&mut &test_account_1_pk.encode()[..]).unwrap(); @@ -47,22 +47,22 @@ fn generate_payload() -> PasskeyPayload { let inner_call: ::RuntimeCall = frame_system::Call::::remark { remark: vec![] }.into(); - let call: PasskeyCall = PasskeyCall { + let call: PasskeyCallV2 = PasskeyCallV2 { account_id: test_account_1_account_id, account_nonce: T::Nonce::zero(), - account_ownership_proof: signature, call: Box::new(inner_call), }; let passkey_signature = passkey_sign(&secret, &call.encode(), &client_data, &authenticator).unwrap(); - let payload = PasskeyPayload { + let payload = PasskeyPayloadV2 { passkey_public_key, verifiable_passkey_signature: VerifiablePasskeySignature { signature: passkey_signature, client_data_json: client_data.try_into().unwrap(), authenticator_data: authenticator.try_into().unwrap(), }, + account_ownership_proof: signature, passkey_call: call, }; payload @@ -77,13 +77,13 @@ benchmarks! { validate { let payload = generate_payload::(); }: { - assert_ok!(Passkey::validate_unsigned(TransactionSource::InBlock, &Call::proxy { payload })); + assert_ok!(Passkey::validate_unsigned(TransactionSource::InBlock, &Call::proxy_v2 { payload })); } pre_dispatch { let payload = generate_payload::(); }: { - assert_ok!(Passkey::pre_dispatch(&Call::proxy { payload })); + assert_ok!(Passkey::pre_dispatch(&Call::proxy_v2 { payload })); } impl_benchmark_test_suite!( diff --git a/pallets/passkey/src/lib.rs b/pallets/passkey/src/lib.rs index ee4a4a6796..d0cbbec65c 100644 --- a/pallets/passkey/src/lib.rs +++ b/pallets/passkey/src/lib.rs @@ -46,6 +46,8 @@ mod test_common; mod mock; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_v2; pub mod weights; pub use weights::*; @@ -125,6 +127,7 @@ pub mod module { /// Proxies an extrinsic call by changing the origin to `account_id` inside the payload. /// Since this is an unsigned extrinsic all the verification checks are performed inside /// `validate_unsigned` and `pre_dispatch` hooks. + #[deprecated(since = "1.15.2", note = "Use proxy_v2 instead")] #[pallet::call_index(0)] #[pallet::weight({ let dispatch_info = payload.passkey_call.call.get_dispatch_info(); @@ -132,9 +135,27 @@ pub mod module { let total = overhead.saturating_add(dispatch_info.weight); (total, dispatch_info.class) })] + #[allow(deprecated)] pub fn proxy( origin: OriginFor, payload: PasskeyPayload, + ) -> DispatchResultWithPostInfo { + Self::proxy_v2(origin, payload.into()) + } + + /// Proxies an extrinsic call by changing the origin to `account_id` inside the payload. + /// Since this is an unsigned extrinsic all the verification checks are performed inside + /// `validate_unsigned` and `pre_dispatch` hooks. + #[pallet::call_index(1)] + #[pallet::weight({ + let dispatch_info = payload.passkey_call.call.get_dispatch_info(); + let overhead = T::WeightInfo::pre_dispatch(); + let total = overhead.saturating_add(dispatch_info.weight); + (total, dispatch_info.class) + })] + pub fn proxy_v2( + origin: OriginFor, + payload: PasskeyPayloadV2, ) -> DispatchResultWithPostInfo { ensure_none(origin)?; let transaction_account_id = payload.passkey_call.account_id.clone(); @@ -165,7 +186,7 @@ pub mod module { /// The majority of these checks are the same as `SignedExtra` list in defined in runtime fn validate_unsigned(_source: TransactionSource, call: &Self::Call) -> TransactionValidity { let valid_tx = ValidTransaction::default(); - let payload = Self::filter_valid_calls(&call)?; + let (payload, is_legacy_call) = Self::filter_valid_calls(&call)?; let frame_system_validity = FrameSystemChecks(payload.passkey_call.account_id.clone(), call.clone()) @@ -178,7 +199,8 @@ pub mod module { ) .validate()?; // this is the last since it is the heaviest - let signature_validity = PasskeySignatureCheck::new(payload.clone()).validate()?; + let signature_validity = + PasskeySignatureCheck::new(payload.clone(), is_legacy_call).validate()?; let valid_tx = valid_tx .combine_with(frame_system_validity) @@ -192,7 +214,7 @@ pub mod module { /// Checking and executing a list of operations pre_dispatch /// The majority of these checks are the same as `SignedExtra` list in defined in runtime fn pre_dispatch(call: &Self::Call) -> Result<(), TransactionValidityError> { - let payload = Self::filter_valid_calls(&call)?; + let (payload, is_legacy_call) = Self::filter_valid_calls(&call)?; FrameSystemChecks(payload.passkey_call.account_id.clone(), call.clone()) .pre_dispatch()?; PasskeyNonceCheck::new(payload.passkey_call.clone()).pre_dispatch()?; @@ -200,7 +222,7 @@ pub mod module { ChargeTransactionPayment::(payload.passkey_call.account_id.clone(), call.clone()) .pre_dispatch()?; // this is the last since it is the heaviest - PasskeySignatureCheck::new(payload.clone()).pre_dispatch() + PasskeySignatureCheck::new(payload.clone(), is_legacy_call).pre_dispatch() } } } @@ -211,12 +233,18 @@ where ::RuntimeCall: From> + Dispatchable, { - /// Filtering the valid calls and extracting the payload from inside the call - fn filter_valid_calls(call: &Call) -> Result, TransactionValidityError> { + /// Filtering the valid calls and extracting the Payload V2 from inside the call and returning if this + /// is a legacy call or not + fn filter_valid_calls( + call: &Call, + ) -> Result<(PasskeyPayloadV2, bool), TransactionValidityError> { match call { Call::proxy { payload } if T::PasskeyCallFilter::contains(&payload.clone().passkey_call.call) => - return Ok(payload.clone()), + return Ok((payload.clone().into(), true)), + Call::proxy_v2 { payload } + if T::PasskeyCallFilter::contains(&payload.clone().passkey_call.call) => + return Ok((payload.clone(), false)), _ => return Err(InvalidTransaction::Call.into()), } } @@ -225,13 +253,13 @@ where /// Passkey specific nonce check which is a wrapper around `CheckNonce` extension #[derive(Encode, Decode, Clone, TypeInfo)] #[scale_info(skip_type_params(T))] -struct PasskeyNonceCheck(pub PasskeyCall); +struct PasskeyNonceCheck(pub PasskeyCallV2); impl PasskeyNonceCheck where ::RuntimeCall: Dispatchable, { - pub fn new(passkey_call: PasskeyCall) -> Self { + pub fn new(passkey_call: PasskeyCallV2) -> Self { Self(passkey_call) } @@ -261,26 +289,32 @@ where /// 2. Passkey P256 signature of the account public key #[derive(Encode, Decode, Clone, TypeInfo)] #[scale_info(skip_type_params(T))] -struct PasskeySignatureCheck(pub PasskeyPayload); +struct PasskeySignatureCheck { + payload: PasskeyPayloadV2, + is_legacy_payload: bool, +} impl PasskeySignatureCheck { - pub fn new(passkey_payload: PasskeyPayload) -> Self { - Self(passkey_payload) + pub fn new(passkey_payload: PasskeyPayloadV2, is_legacy_payload: bool) -> Self { + Self { payload: passkey_payload, is_legacy_payload } } pub fn validate(&self) -> TransactionValidity { // checking account signature to verify ownership of the account used - let signed_data = self.0.passkey_public_key.clone(); - let signature = self.0.passkey_call.account_ownership_proof.clone(); - let signer = &self.0.passkey_call.account_id; + let signed_data = self.payload.passkey_public_key.clone(); + let signature = self.payload.account_ownership_proof.clone(); + let signer = &self.payload.passkey_call.account_id; Self::check_account_signature(signer, &signed_data.inner().to_vec(), &signature) .map_err(|_e| TransactionValidityError::Invalid(InvalidTransaction::BadSigner))?; // checking the passkey signature to ensure access to the passkey - let p256_signed_data = self.0.passkey_call.encode(); - let p256_signature = self.0.verifiable_passkey_signature.clone(); - let p256_signer = self.0.passkey_public_key.clone(); + let p256_signed_data = match self.is_legacy_payload { + true => PasskeyPayload::from(self.payload.clone()).passkey_call.encode(), + false => self.payload.passkey_call.encode(), + }; + let p256_signature = self.payload.verifiable_passkey_signature.clone(); + let p256_signer = self.payload.passkey_public_key.clone(); p256_signature .try_verify(&p256_signed_data, &p256_signer) diff --git a/pallets/passkey/src/tests.rs b/pallets/passkey/src/tests.rs index 79edcb2e32..251cf0c8bf 100644 --- a/pallets/passkey/src/tests.rs +++ b/pallets/passkey/src/tests.rs @@ -116,6 +116,7 @@ impl TestPasskeyPayloadBuilder { } #[test] +#[allow(deprecated)] fn proxy_call_with_signed_origin_should_fail() { new_test_ext().execute_with(|| { // arrange @@ -134,6 +135,7 @@ fn proxy_call_with_signed_origin_should_fail() { } #[test] +#[allow(deprecated)] fn proxy_call_with_unsigned_origin_should_work() { new_test_ext().execute_with(|| { // arrange @@ -285,6 +287,7 @@ fn validate_unsigned_should_fee_removed_on_successful_validation() { } #[test] +#[allow(deprecated)] fn fee_withdrawn_for_failed_call() { new_test_ext().execute_with(|| { // arrange diff --git a/pallets/passkey/src/tests_v2.rs b/pallets/passkey/src/tests_v2.rs new file mode 100644 index 0000000000..fced748745 --- /dev/null +++ b/pallets/passkey/src/tests_v2.rs @@ -0,0 +1,525 @@ +//! Unit tests for the passkey module. +use super::*; +use crate::mock::Passkey; +use common_primitives::utils::wrap_binary_data; +use frame_support::{assert_err, assert_noop, assert_ok, dispatch::RawOrigin}; +use frame_system::{limits::BlockLength, Call as SystemCall}; +use mock::*; + +use crate::test_common::{ + constants::{AUTHENTICATOR_DATA, REPLACED_CLIENT_DATA_JSON}, + utilities::*, +}; +use pallet_balances::Call as BalancesCall; +use sp_core::{sr25519, sr25519::Public, Pair}; +use sp_runtime::{traits::One, DispatchError::BadOrigin}; + +struct TestPasskeyPayloadBuilder { + secret: p256::SecretKey, + key_pair: sr25519::Pair, + passkey_public_key: PasskeyPublicKey, + payload_to_sign: Vec, + nonce: u32, + call: ::RuntimeCall, + invalid_passkey_signature: bool, +} + +impl TestPasskeyPayloadBuilder { + pub fn new() -> Self { + let (key_pair, _) = sr25519::Pair::generate(); + Self { + secret: p256::SecretKey::from_slice(&[ + 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8, + ]) + .unwrap(), + key_pair, + passkey_public_key: PasskeyPublicKey([0u8; 33]), + payload_to_sign: vec![], + nonce: 0u32.into(), + call: RuntimeCall::System(SystemCall::remark { remark: vec![1, 2, 3u8] }).into(), + invalid_passkey_signature: false, + } + } + + pub fn with_a_valid_passkey(mut self) -> Self { + self.passkey_public_key = get_p256_public_key(&self.secret).unwrap(); + self + } + + pub fn with_custom_payload(mut self, payload: Vec) -> Self { + self.payload_to_sign = payload; + self + } + + pub fn with_passkey_as_payload(mut self) -> Self { + self.payload_to_sign = self.passkey_public_key.inner().to_vec(); + self + } + + pub fn with_account_nonce(mut self, nonce: u32) -> Self { + self.nonce = nonce; + self + } + + pub fn with_call(mut self, call: ::RuntimeCall) -> Self { + self.call = call; + self + } + + pub fn with_invalid_passkey_signature(mut self) -> Self { + self.invalid_passkey_signature = true; + self + } + + pub fn with_funded_account(self, amount: u64) -> Self { + assert_ok!(Balances::force_set_balance( + RawOrigin::Root.into(), + self.key_pair.public().into(), + amount.into() + )); + self + } + + pub fn build(&self) -> (PasskeyPayloadV2, Public) { + let wrapped_binary = wrap_binary_data(self.payload_to_sign.clone()); + let signature: MultiSignature = self.key_pair.sign(wrapped_binary.as_slice()).into(); + let client_data = base64_url::decode(REPLACED_CLIENT_DATA_JSON).unwrap(); + let authenticator = base64_url::decode(AUTHENTICATOR_DATA).unwrap(); + let bad_authenticator = b"bad_auth".to_vec(); + let call: PasskeyCallV2 = PasskeyCallV2 { + account_id: self.key_pair.public().into(), + account_nonce: self.nonce.into(), + call: Box::new(self.call.clone()), + }; + let passkey_signature = passkey_sign( + &self.secret, + &call.encode(), + &client_data, + match self.invalid_passkey_signature { + true => &bad_authenticator, + false => &authenticator, + }, + ) + .unwrap(); + let payload = PasskeyPayloadV2 { + passkey_public_key: self.passkey_public_key.clone(), + verifiable_passkey_signature: VerifiablePasskeySignature { + signature: passkey_signature, + client_data_json: client_data.try_into().unwrap(), + authenticator_data: authenticator.try_into().unwrap(), + }, + account_ownership_proof: signature, + passkey_call: call, + }; + (payload, self.key_pair.public()) + } +} + +#[test] +fn proxy_call_with_signed_origin_should_fail() { + new_test_ext().execute_with(|| { + // arrange + let (test_account_2_key_pair, _) = sr25519::Pair::generate(); + let (payload, account_pk) = TestPasskeyPayloadBuilder::new() + .with_passkey_as_payload() + .with_call(RuntimeCall::Balances(BalancesCall::transfer_allow_death { + dest: test_account_2_key_pair.public().into(), + value: 100, + })) + .build(); + + // assert + assert_noop!( + Passkey::proxy_v2(RuntimeOrigin::signed(account_pk.into()), payload), + BadOrigin + ); + }); +} + +#[test] +fn proxy_call_with_unsigned_origin_should_work() { + new_test_ext().execute_with(|| { + // arrange + let (payload, _) = TestPasskeyPayloadBuilder::new() + .with_passkey_as_payload() + .with_call(RuntimeCall::System(SystemCall::remark { remark: vec![1, 2, 3u8] })) + .build(); + + // assert + assert_ok!(Passkey::proxy_v2(RuntimeOrigin::none(), payload)); + }); +} + +#[test] +fn validate_unsigned_with_bad_account_signature_should_fail() { + new_test_ext().execute_with(|| { + // arrange + let (payload, _) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_custom_payload("bad data".as_bytes().to_vec()) + .with_call(RuntimeCall::System(SystemCall::remark { remark: vec![1, 2, 3u8] })) + .with_funded_account(10000000000) + .build(); + + let res = + Passkey::validate_unsigned(TransactionSource::InBlock, &Call::proxy_v2 { payload }); + // assert + assert_eq!(res, InvalidTransaction::BadSigner.into()); + }); +} + +#[test] +fn validate_unsigned_with_bad_passkey_signature_should_fail() { + new_test_ext().execute_with(|| { + // arrange + let amount = 10000000000; + let (payload, _) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_passkey_as_payload() + .with_call(RuntimeCall::System(SystemCall::remark { remark: vec![1, 2, 3u8] })) + .with_funded_account(amount) + .with_invalid_passkey_signature() + .build(); + + let res = + Passkey::validate_unsigned(TransactionSource::InBlock, &Call::proxy_v2 { payload }); + // assert + assert_eq!(res, InvalidTransaction::BadSigner.into()); + }); +} + +#[test] +fn validate_unsigned_with_low_funds_should_fail() { + new_test_ext().execute_with(|| { + // arrange + let (payload, _) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_passkey_as_payload() + .with_call(RuntimeCall::System(SystemCall::remark { remark: vec![1, 2, 3u8] })) + .build(); + + // act + let res = + Passkey::validate_unsigned(TransactionSource::InBlock, &Call::proxy_v2 { payload }); + // assert + assert_eq!(res, InvalidTransaction::Payment.into()); + }); +} + +#[test] +fn validate_unsigned_with_funds_should_pass() { + new_test_ext().execute_with(|| { + // arrange + let (payload, _) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_passkey_as_payload() + .with_call(RuntimeCall::System(SystemCall::remark { remark: vec![1, 2, 3u8] })) + .with_funded_account(10000000000) + .build(); + + // act + let res = + Passkey::validate_unsigned(TransactionSource::InBlock, &Call::proxy_v2 { payload }); + + // assert + assert!(res.is_ok()); + }); +} + +#[test] +fn pre_dispatch_with_funds_should_pass() { + new_test_ext().execute_with(|| { + // arrange + let (payload, _) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_passkey_as_payload() + .with_call(RuntimeCall::System(SystemCall::remark { remark: vec![1, 2, 3u8] })) + .with_funded_account(10000000000) + .build(); + + // act + let res = Passkey::pre_dispatch(&Call::proxy_v2 { payload }); + + // assert + assert!(res.is_ok()); + }); +} + +#[test] +fn pre_dispatch_with_low_funds_should_fail() { + new_test_ext().execute_with(|| { + // arrange + let (payload, _) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_passkey_as_payload() + .with_call(RuntimeCall::System(SystemCall::remark { remark: vec![1, 2, 3u8] })) + .build(); + + // act + let res = Passkey::pre_dispatch(&Call::proxy_v2 { payload }); + + // assert + assert_err!(res, InvalidTransaction::Payment); + }); +} + +#[test] +fn validate_unsigned_should_fee_removed_on_successful_validation() { + new_test_ext().execute_with(|| { + // arrange + let (test_account_2_key_pair, _) = sr25519::Pair::generate(); + let (payload, account_pk) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_passkey_as_payload() + .with_call(RuntimeCall::Balances(BalancesCall::transfer_allow_death { + dest: test_account_2_key_pair.public().into(), + value: 100, + })) + .with_funded_account(10000000000) + .build(); + + let account_id: ::AccountId = account_pk.into(); + let initial_balance = Balances::free_balance(&account_id); + + // act + let res = + Passkey::validate_unsigned(TransactionSource::InBlock, &Call::proxy_v2 { payload }); + + // assert + assert!(res.is_ok()); + let final_balance = Balances::free_balance(&account_id); + assert!(final_balance < initial_balance); + }); +} + +#[test] +fn fee_withdrawn_for_failed_call() { + new_test_ext().execute_with(|| { + // arrange + let amount = 10000000000; + let (test_account_2_key_pair, _) = sr25519::Pair::generate(); + let (payload, account_pk) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_passkey_as_payload() + .with_call(RuntimeCall::Balances(BalancesCall::transfer_allow_death { + dest: test_account_2_key_pair.public().into(), + value: amount, + })) + .with_funded_account(amount) + .build(); + + let account_id: ::AccountId = account_pk.into(); + let initial_balance = Balances::free_balance(&account_id); + + // act + let validate_result = Passkey::validate_unsigned( + TransactionSource::InBlock, + &Call::proxy_v2 { payload: payload.clone() }, + ); + let extrinsic_result = Passkey::proxy_v2(RuntimeOrigin::none(), payload); + + // assert + assert!(validate_result.is_ok()); + assert!(extrinsic_result.is_err()); + let final_balance = Balances::free_balance(&account_id); + assert!(final_balance < initial_balance); + }); +} + +#[test] +fn validate_unsigned_with_unsupported_call_should_fail() { + new_test_ext().execute_with(|| { + // arrange + let (payload, _) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_passkey_as_payload() + // remark_with_event is an unsupported call + .with_call(RuntimeCall::System(SystemCall::remark_with_event { + remark: vec![1, 2, 3u8], + })) + .build(); + + // act + let v = Passkey::validate_unsigned(TransactionSource::InBlock, &Call::proxy_v2 { payload }); + + // assert + assert_err!(v, InvalidTransaction::Call); + }); +} + +#[test] +fn validate_unsigned_with_used_nonce_should_fail_with_stale() { + new_test_ext().execute_with(|| { + // arrange + let (test_account_2_key_pair, _) = sr25519::Pair::generate(); + let (payload, account_pk) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_passkey_as_payload() + .with_call(RuntimeCall::Balances(BalancesCall::transfer_allow_death { + dest: test_account_2_key_pair.public().into(), + value: 10000, + })) + .with_funded_account(10000000000) + .with_account_nonce(0) + .build(); + + let who: ::AccountId = account_pk.into(); + let mut account = frame_system::Account::::get(&who); + account.nonce += 1; + frame_system::Account::::insert(who, account); + + // act + let v = Passkey::validate_unsigned(TransactionSource::InBlock, &Call::proxy_v2 { payload }); + + // assert + assert_err!(v, InvalidTransaction::Stale); + }); +} + +#[test] +fn validate_unsigned_with_correct_nonce_should_work() { + new_test_ext().execute_with(|| { + // arrange + let (test_account_2_key_pair, _) = sr25519::Pair::generate(); + let (payload, account_pk) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_passkey_as_payload() + .with_call(RuntimeCall::Balances(BalancesCall::transfer_allow_death { + dest: test_account_2_key_pair.public().into(), + value: 10000, + })) + .with_funded_account(10000000000) + .with_account_nonce(2) + .build(); + + let who: ::AccountId = account_pk.into(); + let mut account = frame_system::Account::::get(&who); + account.nonce += 1; + frame_system::Account::::insert(who.clone(), account); + + // act + let v = Passkey::validate_unsigned(TransactionSource::InBlock, &Call::proxy_v2 { payload }); + + // assert + assert!(v.is_ok()); + assert!(v.clone().unwrap().priority > 0); + assert_eq!(v.clone().unwrap().requires, vec![Encode::encode(&(who.clone(), 1u64))]); + assert_eq!(v.clone().unwrap().provides, vec![Encode::encode(&(who, 2u64))]); + }); +} + +#[test] +fn validate_unsigned_with_exceeding_weights_should_fail() { + new_test_ext().execute_with(|| { + // arrange + let block_length = BlockLength::default(); + let max = block_length.max.get(DispatchClass::Normal); + let (payload, _) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_passkey_as_payload() + .with_call(RuntimeCall::System(SystemCall::remark { remark: vec![1u8; *max as usize] })) + .with_funded_account(10000000000) + .build(); + + // act + let v = Passkey::validate_unsigned(TransactionSource::InBlock, &Call::proxy_v2 { payload }); + + // assert + assert_err!(v, InvalidTransaction::ExhaustsResources); + }); +} + +#[test] +fn pre_dispatch_unsigned_with_used_nonce_should_fail_with_stale() { + new_test_ext().execute_with(|| { + // arrange + let (test_account_2_key_pair, _) = sr25519::Pair::generate(); + let (payload, account_pk) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_passkey_as_payload() + .with_call(RuntimeCall::Balances(BalancesCall::transfer_allow_death { + dest: test_account_2_key_pair.public().into(), + value: 10000, + })) + .with_funded_account(10000000000) + .with_account_nonce(0) + .build(); + let who: ::AccountId = account_pk.into(); + let mut account = frame_system::Account::::get(&who); + account.nonce += 1; + frame_system::Account::::insert(who, account); + + // act + let v = Passkey::pre_dispatch(&Call::proxy_v2 { payload }); + + // assert + assert_err!(v, InvalidTransaction::Stale); + }); +} + +#[test] +fn pre_dispatch_unsigned_with_future_nonce_should_fail_with_future() { + new_test_ext().execute_with(|| { + // arrange + let (test_account_2_key_pair, _) = sr25519::Pair::generate(); + let (payload, _) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_passkey_as_payload() + .with_call(RuntimeCall::Balances(BalancesCall::transfer_allow_death { + dest: test_account_2_key_pair.public().into(), + value: 10000, + })) + .with_funded_account(10000000000) + // setting a future nonce + .with_account_nonce(2) + .build(); + + // act + let v = Passkey::pre_dispatch(&Call::proxy_v2 { payload }); + + // assert + assert_err!(v, InvalidTransaction::Future); + }); +} + +#[test] +fn pre_dispatch_unsigned_should_increment_nonce_on_success() { + new_test_ext().execute_with(|| { + // arrange + let (payload, account_pk) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_passkey_as_payload() + .with_call(RuntimeCall::System(SystemCall::remark { remark: vec![1u8; 3usize] })) + .with_funded_account(10000000000) + .build(); + let account_1_pk: ::AccountId = account_pk.into(); + + // act + assert_ok!(Passkey::pre_dispatch(&Call::proxy_v2 { payload })); + + // assert + let account = frame_system::Account::::get(&account_1_pk); + assert_eq!(account.nonce, ::Nonce::one()); + }); +} + +#[test] +fn pre_dispatch_with_exceeding_weight_should_fail() { + new_test_ext().execute_with(|| { + // arrange + let block_length = BlockLength::default(); + let max = block_length.max.get(DispatchClass::Normal); + let (payload, _) = TestPasskeyPayloadBuilder::new() + .with_a_valid_passkey() + .with_passkey_as_payload() + .with_call(RuntimeCall::System(SystemCall::remark { remark: vec![1u8; *max as usize] })) + .with_funded_account(10000000000) + .build(); + + // act + let v = Passkey::pre_dispatch(&Call::proxy_v2 { payload }); + + // assert + assert_err!(v, InvalidTransaction::ExhaustsResources); + }); +} diff --git a/pallets/passkey/src/types.rs b/pallets/passkey/src/types.rs index 7103d1dfd5..ce42c26174 100644 --- a/pallets/passkey/src/types.rs +++ b/pallets/passkey/src/types.rs @@ -43,6 +43,20 @@ pub struct PasskeyPayload { pub passkey_call: PasskeyCall, } +/// Passkey Payload V2 +#[derive(Encode, Decode, TypeInfo, MaxEncodedLen, PartialEq, RuntimeDebugNoBound, Clone)] +#[scale_info(skip_type_params(T))] +pub struct PasskeyPayloadV2 { + /// passkey public key + pub passkey_public_key: PasskeyPublicKey, + /// a self-contained verifiable passkey signature with all required metadata + pub verifiable_passkey_signature: VerifiablePasskeySignature, + /// passkey_public_key signed by account_id's private key + pub account_ownership_proof: MultiSignature, + /// PassKey Call + pub passkey_call: PasskeyCallV2, +} + /// A verifiable Pass key contains all the required information to verify a passkey signature #[derive(Encode, Decode, TypeInfo, MaxEncodedLen, PartialEq, RuntimeDebugNoBound, Clone)] pub struct VerifiablePasskeySignature { @@ -68,6 +82,48 @@ pub struct PasskeyCall { pub call: Box<::RuntimeCall>, } +/// Inner Passkey call V2 +#[derive(Encode, Decode, TypeInfo, MaxEncodedLen, PartialEq, RuntimeDebugNoBound, Clone)] +#[scale_info(skip_type_params(T))] +pub struct PasskeyCallV2 { + /// account id which is the origin of this call + pub account_id: T::AccountId, + /// account nonce + pub account_nonce: T::Nonce, + /// Extrinsic call + pub call: Box<::RuntimeCall>, +} + +impl From> for PasskeyPayloadV2 { + fn from(payload: PasskeyPayload) -> Self { + PasskeyPayloadV2 { + passkey_public_key: payload.passkey_public_key, + verifiable_passkey_signature: payload.verifiable_passkey_signature, + account_ownership_proof: payload.passkey_call.account_ownership_proof, + passkey_call: PasskeyCallV2 { + account_id: payload.passkey_call.account_id, + account_nonce: payload.passkey_call.account_nonce, + call: payload.passkey_call.call, + }, + } + } +} + +impl From> for PasskeyPayload { + fn from(payload: PasskeyPayloadV2) -> Self { + PasskeyPayload { + passkey_public_key: payload.passkey_public_key, + verifiable_passkey_signature: payload.verifiable_passkey_signature, + passkey_call: PasskeyCall { + account_id: payload.passkey_call.account_id, + account_ownership_proof: payload.account_ownership_proof, + account_nonce: payload.passkey_call.account_nonce, + call: payload.passkey_call.call, + }, + } + } +} + impl PasskeySignature { /// returns the inner raw data as a vector pub fn to_vec(&self) -> Vec { diff --git a/runtime/frequency/src/lib.rs b/runtime/frequency/src/lib.rs index be470706dc..d519d3da6a 100644 --- a/runtime/frequency/src/lib.rs +++ b/runtime/frequency/src/lib.rs @@ -399,7 +399,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: create_runtime_str!("frequency"), impl_name: create_runtime_str!("frequency"), authoring_version: 1, - spec_version: 137, + spec_version: 139, impl_version: 0, apis: RUNTIME_API_VERSIONS, transaction_version: 1, @@ -413,7 +413,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: create_runtime_str!("frequency-testnet"), impl_name: create_runtime_str!("frequency"), authoring_version: 1, - spec_version: 137, + spec_version: 139, impl_version: 0, apis: RUNTIME_API_VERSIONS, transaction_version: 1,