Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions pallets/pass/src/benchmarking.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use super::*;
use crate::{DeviceOf, Pallet};
use crate::{filter::DeviceFilter, AuthenticatedDevice, DeviceOf, Pallet};

use frame_benchmarking::v2::*;
use frame_support::{
Expand Down Expand Up @@ -138,7 +138,7 @@ mod benchmarks {
pub fn add_device() -> Result<(), BenchmarkError> {
// Setup code
let user_id = hash::<T>(b"my-account");
do_register::<T, I>(user_id)?;
let admin_device_id = do_register::<T, I>(user_id)?;

let address = Pallet::<T, I>::address_for(user_id);
let attestation = T::BenchmarkHelper::device_attestation(&address.encode());
Expand All @@ -148,8 +148,15 @@ mod benchmarks {
Footprint::from_parts(2, DeviceOf::<T, I>::max_encoded_len()),
);

// Set the authenticated device for no-escalation check
AuthenticatedDevice::<T, I>::put(admin_device_id);

#[extrinsic_call]
_(RawOrigin::Signed(address.clone()), attestation);
_(
RawOrigin::Signed(address.clone()),
attestation,
DeviceFilter::Admin,
);

// Verification code
assert_has_event::<T, I>(
Expand All @@ -167,7 +174,7 @@ mod benchmarks {
pub fn remove_device() -> Result<(), BenchmarkError> {
// Setup code
let user_id = hash::<T>(b"my-account");
do_register::<T, I>(user_id)?;
let admin_device_id = do_register::<T, I>(user_id)?;

let address = Pallet::<T, I>::address_for(user_id);
let attestation = T::BenchmarkHelper::device_attestation(&address.encode());
Expand All @@ -177,7 +184,12 @@ mod benchmarks {
Footprint::from_parts(2, DeviceOf::<T, I>::max_encoded_len()),
);

Pallet::<T, I>::add_device(RawOrigin::Signed(address.clone()).into(), attestation)?;
AuthenticatedDevice::<T, I>::put(admin_device_id);
Pallet::<T, I>::add_device(
RawOrigin::Signed(address.clone()).into(),
attestation,
DeviceFilter::Admin,
)?;

#[extrinsic_call]
_(RawOrigin::Signed(address.clone()), new_device_id);
Expand All @@ -198,7 +210,7 @@ mod benchmarks {
pub fn add_session_key() -> Result<(), BenchmarkError> {
// Setup code
let user_id = hash::<T>(b"my-account");
do_register::<T, I>(user_id)?;
let admin_device_id = do_register::<T, I>(user_id)?;

let address = Pallet::<T, I>::address_for(user_id);
let new_session_key: T::AccountId = account("session-key", 0, 0);
Expand All @@ -207,11 +219,20 @@ mod benchmarks {
Footprint::from_parts(2, T::AccountId::max_encoded_len()),
);

// Need a non-Admin filter for session keys
let filter: crate::DeviceFilterOf<T, I> = DeviceFilter::Pallets(
alloc::collections::BTreeSet::from([0u8])
.try_into()
.expect("within bounds"),
);
AuthenticatedDevice::<T, I>::put(admin_device_id);

#[extrinsic_call]
_(
RawOrigin::Signed(address.clone()),
T::Lookup::unlookup(new_session_key.clone()),
None,
filter,
);

// Verification code
Expand All @@ -230,7 +251,7 @@ mod benchmarks {
pub fn remove_session_key() -> Result<(), BenchmarkError> {
// Setup code
let user_id = hash::<T>(b"my-account");
do_register::<T, I>(user_id)?;
let admin_device_id = do_register::<T, I>(user_id)?;

let address = Pallet::<T, I>::address_for(user_id);
let session_key: T::AccountId = account("session-key", 0, 0);
Expand All @@ -239,10 +260,17 @@ mod benchmarks {
Footprint::from_parts(2, T::AccountId::max_encoded_len()),
);

let filter: crate::DeviceFilterOf<T, I> = DeviceFilter::Pallets(
alloc::collections::BTreeSet::from([0u8])
.try_into()
.expect("within bounds"),
);
AuthenticatedDevice::<T, I>::put(admin_device_id);
Pallet::<T, I>::add_session_key(
RawOrigin::Signed(address.clone()).into(),
T::Lookup::unlookup(session_key.clone()),
None,
filter,
)?;

#[extrinsic_call]
Expand Down
102 changes: 76 additions & 26 deletions pallets/pass/src/extensions.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
use crate::{Config, CredentialOf, Pallet, WeightInfo};
use crate::{
AuthenticatedDevice, Config, CredentialOf, DeviceFilters, Pallet, SpendMatcher, WeightInfo,
};
use codec::{Decode, DecodeWithMemTracking, Encode};
use fc_traits_authn::DeviceId;
use frame_support::{
dispatch::RawOrigin,
pallet_prelude::{TransactionValidityError, Weight},
pallet_prelude::{DispatchResult, TransactionValidityError, Weight},
CloneNoBound, DebugNoBound, DefaultNoBound, EqNoBound, PartialEqNoBound,
};
use frame_system::{ensure_signed, pallet_prelude::RuntimeCallFor};
use scale_info::TypeInfo;
use sp_core::blake2_256;
use sp_runtime::{
traits::{DispatchInfoOf, DispatchOriginOf, Implication, TransactionExtension, ValidateResult},
traits::{
DispatchInfoOf, DispatchOriginOf, Implication, PostDispatchInfoOf, TransactionExtension,
ValidateResult,
},
transaction_validity::{InvalidTransaction, TransactionSource, ValidTransaction},
};

/// Extract (pallet_index, call_index) from a SCALE-encoded RuntimeCall.
fn call_indices<C: Encode>(call: &C) -> (u8, u8) {
call.using_encoded(|bytes| {
if bytes.len() >= 2 {
(bytes[0], bytes[1])
} else {
(0, 0)
}
})
}

/// Handles the authentication of a Pass account. If the authentication is successful, a signed
/// origin associated to the device would be set.
///
Expand Down Expand Up @@ -68,8 +84,9 @@
{
const IDENTIFIER: &'static str = "PassAuthenticate";
type Implicit = ();
type Val = ();
type Pre = ();
/// The authenticated device_id, if any.
type Val = Option<DeviceId>;
type Pre = Option<DeviceId>;

fn weight(&self, _call: &RuntimeCallFor<T>) -> Weight {
T::WeightInfo::authenticate()
Expand All @@ -78,51 +95,84 @@
fn validate(
&self,
origin: DispatchOriginOf<RuntimeCallFor<T>>,
_call: &RuntimeCallFor<T>,
call: &RuntimeCallFor<T>,
_info: &DispatchInfoOf<RuntimeCallFor<T>>,
_len: usize,
_self_implicit: Self::Implicit,
inherited_implication: &impl Implication,
_source: TransactionSource,
) -> ValidateResult<Self::Val, RuntimeCallFor<T>> {
let origin = if let Some(params) = &self.0 {
Pallet::<T, I>::authenticate(
let (device_id, origin) = if let Some(params) = &self.0 {
let address = Pallet::<T, I>::authenticate(
&params.device_id,
&params.credential,
&inherited_implication.using_encoded(blake2_256),
)
.map(|address| RawOrigin::Signed(address).into())
.map_err(|e| {
log::error!(target: "pallet_pass", "Authentication failed: {:?}", e);
InvalidTransaction::BadSigner.into()
})
} else {
// If we're not attempting to authenticate, let's check if the origin is signed, and is
// maybe an existing session key. Given that, we'll pass the actual `pass_account_id`.
//
// Otherwise, just pass the previous origin to the rest of the extensions pipeline.
TransactionValidityError::from(InvalidTransaction::BadSigner)
})?;

Ok::<_, TransactionValidityError>(if let Ok(who) = ensure_signed(origin.clone()) {
Pallet::<T, I>::pass_account_from_session_key(&who)
.or(Some(who))
.map(|who| RawOrigin::Signed(who).into())
.unwrap()
// Check the device's call filter (missing filter = denied)
let filter = DeviceFilters::<T, I>::get(&address, &params.device_id)

Check warning on line 117 in pallets/pass/src/extensions.rs

View workflow job for this annotation

GitHub Actions / clippy

the borrowed expression implements the required traits

warning: the borrowed expression implements the required traits --> pallets/pass/src/extensions.rs:117:63 | 117 | let filter = DeviceFilters::<T, I>::get(&address, &params.device_id) | ^^^^^^^^^^^^^^^^^ help: change this to: `params.device_id` | = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.94.0/index.html#needless_borrows_for_generic_args = note: `#[warn(clippy::needless_borrows_for_generic_args)]` on by default
.ok_or(TransactionValidityError::from(InvalidTransaction::Call))?;
if !filter.allows(call_indices(call), T::SpendMatcher::spending_amount(call)) {
log::error!(target: "pallet_pass", "Device filter rejected call");
return Err(InvalidTransaction::Call.into());
}

Ok::<_, TransactionValidityError>((
Some(params.device_id),
RawOrigin::Signed(address).into(),
))
} else {
// Check if the origin is signed by a session key.
// Otherwise, pass the origin through unchanged.
if let Ok(who) = ensure_signed(origin.clone()) {
if let Some((account, filter)) = Pallet::<T, I>::pass_account_from_session_key(&who)
{
if !filter.allows(call_indices(call), T::SpendMatcher::spending_amount(call)) {
return Err(InvalidTransaction::Call.into());
}
Ok((None, RawOrigin::Signed(account).into()))
} else {
Ok((None, RawOrigin::Signed(who).into()))
}
} else {
origin
})
Ok((None, origin))
}
}?;

Ok((ValidTransaction::default(), (), origin))
Ok((ValidTransaction::default(), device_id, origin))
}

fn prepare(
self,
_val: Self::Val,
val: Self::Val,
_origin: &DispatchOriginOf<RuntimeCallFor<T>>,
_call: &RuntimeCallFor<T>,
_info: &DispatchInfoOf<RuntimeCallFor<T>>,
_len: usize,
) -> Result<Self::Pre, TransactionValidityError> {
Ok(())
// Store the authenticated device_id so extrinsics can read it
// for no-escalation checks.
if let Some(device_id) = val {
AuthenticatedDevice::<T, I>::put(device_id);
}
Ok(val)
}

fn post_dispatch_details(
pre: Self::Pre,
_info: &DispatchInfoOf<RuntimeCallFor<T>>,
_post_info: &PostDispatchInfoOf<RuntimeCallFor<T>>,
_len: usize,
_result: &DispatchResult,
) -> Result<Weight, TransactionValidityError> {
// Clear transient storage regardless of success/failure
if pre.is_some() {
AuthenticatedDevice::<T, I>::kill();
}
Ok(Weight::zero())
}
}
118 changes: 118 additions & 0 deletions pallets/pass/src/filter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
use core::fmt::Debug;

use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen};
use frame_support::{pallet_prelude::*, CloneNoBound, DebugNoBound, EqNoBound, PartialEqNoBound};
use scale_info::TypeInfo;
use sp_runtime::BoundedBTreeSet;

/// Compact per-device call filter.
///
/// Determines what a device is authorized to do. Follows the principle of
/// least privilege: the default should be restrictive, and only the first
/// admin device gets unrestricted access.
#[derive(
CloneNoBound,
Encode,
Decode,
DecodeWithMemTracking,
MaxEncodedLen,
TypeInfo,
DebugNoBound,
PartialEqNoBound,
EqNoBound,
)]
#[scale_info(skip_type_params(MaxCalls, MaxAssets))]
#[codec(mel_bound(AssetId: MaxEncodedLen, Balance: MaxEncodedLen))]
pub enum DeviceFilter<
AssetId: Ord + Clone + Debug + Eq,
Balance: Clone + Debug + Eq,
MaxCalls: Get<u32>,
MaxAssets: Get<u32>,
> {
/// Unrestricted access. Reserved for the first/admin/recovery device.
Admin,
/// Can call anything in the listed pallets (by pallet index).
Pallets(BoundedBTreeSet<u8, MaxCalls>),
/// Can call only the listed (pallet_index, call_index) pairs.
Calls(BoundedBTreeSet<(u8, u8), MaxCalls>),
/// Spend-only: can transfer listed assets, each up to a per-tx limit.
/// Non-spending calls are rejected.
Spend(BoundedVec<AssetSpendLimit<AssetId, Balance>, MaxAssets>),
}

/// A single asset spend limit.
#[derive(
Clone, Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo, Debug, PartialEq, Eq,
)]
pub struct AssetSpendLimit<AssetId: Clone + Debug + Eq, Balance: Clone + Debug + Eq> {

Check warning on line 47 in pallets/pass/src/filter.rs

View workflow job for this annotation

GitHub Actions / clippy

bound is defined in more than one place

warning: bound is defined in more than one place --> pallets/pass/src/filter.rs:47:57 | 47 | pub struct AssetSpendLimit<AssetId: Clone + Debug + Eq, Balance: Clone + Debug + Eq> { | ^^^^^^^ 48 | pub asset: AssetId, 49 | pub max_amount: Balance, | ^^^^^^^ | = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.94.0/index.html#multiple_bound_locations

Check warning on line 47 in pallets/pass/src/filter.rs

View workflow job for this annotation

GitHub Actions / clippy

bound is defined in more than one place

warning: bound is defined in more than one place --> pallets/pass/src/filter.rs:47:28 | 47 | pub struct AssetSpendLimit<AssetId: Clone + Debug + Eq, Balance: Clone + Debug + Eq> { | ^^^^^^^ 48 | pub asset: AssetId, | ^^^^^^^ | = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.94.0/index.html#multiple_bound_locations = note: `#[warn(clippy::multiple_bound_locations)]` on by default
pub asset: AssetId,
pub max_amount: Balance,
}

impl<
AssetId: Ord + Clone + Debug + Eq,
Balance: Ord + Clone + Debug + Eq,
MaxCalls: Get<u32>,
MaxAssets: Get<u32>,
> DeviceFilter<AssetId, Balance, MaxCalls, MaxAssets>
{
/// Check whether `self` is at least as permissive as `other`.
/// Used for the no-escalation invariant: a device can only grant
/// permissions it already has.
pub fn is_superset_of(&self, other: &Self) -> bool {
match (self, other) {
(Self::Admin, _) => true,
(_, Self::Admin) => false,
(Self::Pallets(a), Self::Pallets(b)) => b.iter().all(|p| a.contains(p)),
(Self::Pallets(a), Self::Calls(b)) => b.iter().all(|(p, _)| a.contains(p)),
(Self::Calls(a), Self::Calls(b)) => b.iter().all(|c| a.contains(c)),
(Self::Spend(a), Self::Spend(b)) => b.iter().all(|bl| {
a.iter()
.any(|al| al.asset == bl.asset && al.max_amount >= bl.max_amount)
}),
// More permissive scopes can delegate down to Spend
(Self::Pallets(_) | Self::Calls(_), Self::Spend(_)) => true,
// Spend can't grant call/pallet access
(Self::Spend(_), _) => false,
// Calls can't grant pallet-wide access
(Self::Calls(_), Self::Pallets(_)) => false,
}
}

/// Check if a call is allowed by this filter.
///
/// `call_index` is `(pallet_index, call_index)` — the first two bytes
/// of the SCALE-encoded `RuntimeCall`.
///
/// `spend_amount` is provided by the runtime's `SpendMatcher` and returns
/// `Some((asset, amount))` for spending calls.
pub fn allows(&self, call_index: (u8, u8), spend_amount: Option<(AssetId, Balance)>) -> bool {
match self {
Self::Admin => true,
Self::Pallets(set) => set.contains(&call_index.0),
Self::Calls(set) => set.contains(&call_index),
Self::Spend(limits) => {
let Some((asset, amount)) = spend_amount else {
return false;
};
limits
.iter()
.any(|l| l.asset == asset && amount <= l.max_amount)
}
}
}
}

/// Trait to extract spending information from a runtime call.
/// Implemented by the runtime to support `Spend` device filters.
pub trait SpendMatcher<Call, AssetId, Balance> {
/// If the call is a spending operation (transfer, payment, etc.),
/// returns the `(asset_id, amount)`. Otherwise returns `None`.
fn spending_amount(call: &Call) -> Option<(AssetId, Balance)>;
}

impl<C, A, B> SpendMatcher<C, A, B> for () {
fn spending_amount(_: &C) -> Option<(A, B)> {
None
}
}
Loading
Loading