Skip to content

Commit 8b43bae

Browse files
committed
Harden key authorization attachment
1 parent e47e5f0 commit 8b43bae

3 files changed

Lines changed: 279 additions & 55 deletions

File tree

crates/tempo-common/src/keys/signer.rs

Lines changed: 200 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
//! resolves a network's key entry into a ready-to-use [`Signer`]
55
//! (private key signer + signing mode + effective `from` address).
66
7-
use alloy::{primitives::Address, signers::local::PrivateKeySigner};
7+
use alloy::{
8+
primitives::{Address, TxKind},
9+
signers::local::PrivateKeySigner,
10+
};
811
use mpp::client::tempo::signing::{KeychainVersion, TempoSigningMode};
9-
use tempo_primitives::transaction::SignedKeyAuthorization;
12+
use tempo_primitives::transaction::{Call, CallScope, SelectorRule, SignedKeyAuthorization};
1013

1114
use crate::{
1215
error::{ConfigError, KeyError, TempoError},
@@ -78,6 +81,23 @@ impl Signer {
7881
})
7982
}
8083

84+
/// Returns a copy whose signing mode includes the stored key authorization,
85+
/// after verifying that authorization is valid for this transaction.
86+
///
87+
/// This prevents stale or unrelated local authorizations from being attached
88+
/// to provisioning retries for a different chain, key, or scoped call set.
89+
pub fn with_key_authorization_for_transaction(
90+
&self,
91+
chain_id: u64,
92+
calls: &[Call],
93+
) -> Result<Option<Self>, TempoError> {
94+
let Some(auth) = self.stored_key_authorization.as_deref() else {
95+
return Ok(None);
96+
};
97+
validate_key_authorization_for_transaction(auth, self.signer.address(), chain_id, calls)?;
98+
Ok(self.with_key_authorization())
99+
}
100+
81101
/// Whether this signer has a stored key authorization available for
82102
/// provisioning retries.
83103
#[must_use]
@@ -86,6 +106,94 @@ impl Signer {
86106
}
87107
}
88108

109+
fn validate_key_authorization_for_transaction(
110+
auth: &SignedKeyAuthorization,
111+
signer_address: Address,
112+
chain_id: u64,
113+
calls: &[Call],
114+
) -> Result<(), TempoError> {
115+
if auth.authorization.key_id != signer_address {
116+
return Err(KeyError::SigningOperation {
117+
operation: "key authorization validation",
118+
reason: format!(
119+
"authorization key {:#x} does not match signer {:#x}",
120+
auth.authorization.key_id, signer_address
121+
),
122+
}
123+
.into());
124+
}
125+
126+
if auth.authorization.chain_id != chain_id {
127+
return Err(KeyError::SigningOperation {
128+
operation: "key authorization validation",
129+
reason: format!(
130+
"authorization chain {} does not match transaction chain {}",
131+
auth.authorization.chain_id, chain_id
132+
),
133+
}
134+
.into());
135+
}
136+
137+
if let Some(expiry) = auth.authorization.expiry {
138+
let now = std::time::SystemTime::now()
139+
.duration_since(std::time::UNIX_EPOCH)
140+
.unwrap_or_default()
141+
.as_secs();
142+
if expiry.get() <= now {
143+
return Err(KeyError::SigningOperation {
144+
operation: "key authorization validation",
145+
reason: "authorization is expired".to_string(),
146+
}
147+
.into());
148+
}
149+
}
150+
151+
let Some(scopes) = auth.authorization.allowed_calls.as_deref() else {
152+
return Ok(());
153+
};
154+
if calls.is_empty() || !calls.iter().all(|call| call_matches_scopes(call, scopes)) {
155+
return Err(KeyError::SigningOperation {
156+
operation: "key authorization validation",
157+
reason: "authorization scopes do not cover transaction calls".to_string(),
158+
}
159+
.into());
160+
}
161+
162+
Ok(())
163+
}
164+
165+
fn call_matches_scopes(call: &Call, scopes: &[CallScope]) -> bool {
166+
let TxKind::Call(target) = call.to else {
167+
return false;
168+
};
169+
scopes.iter().any(|scope| {
170+
scope.target == target && call_matches_scope_rules(call, &scope.selector_rules)
171+
})
172+
}
173+
174+
fn call_matches_scope_rules(call: &Call, rules: &[SelectorRule]) -> bool {
175+
if rules.is_empty() {
176+
return true;
177+
}
178+
let Some(selector) = call.input.get(..4) else {
179+
return false;
180+
};
181+
rules.iter().any(|rule| {
182+
rule.selector == selector
183+
&& (rule.recipients.is_empty()
184+
|| first_abi_address_argument(call)
185+
.is_some_and(|recipient| rule.recipients.contains(&recipient)))
186+
})
187+
}
188+
189+
fn first_abi_address_argument(call: &Call) -> Option<Address> {
190+
let arg = call.input.get(4..36)?;
191+
if arg[..12].iter().any(|byte| *byte != 0) {
192+
return None;
193+
}
194+
Some(Address::from_slice(&arg[12..]))
195+
}
196+
89197
impl Keystore {
90198
/// Resolve the wallet signer for a network.
91199
///
@@ -158,6 +266,9 @@ impl Keystore {
158266
#[cfg(test)]
159267
mod tests {
160268
use super::*;
269+
use alloy::primitives::{Bytes, U256};
270+
use alloy::signers::SignerSync;
271+
use tempo_primitives::transaction::{KeyAuthorization, PrimitiveSignature, SignatureType};
161272
use zeroize::Zeroizing;
162273

163274
use crate::keys::KeyEntry;
@@ -292,6 +403,52 @@ mod tests {
292403
);
293404
}
294405

406+
#[test]
407+
fn test_key_authorization_for_transaction_rejects_chain_mismatch() {
408+
let signer = signer_with_auth(|auth| auth);
409+
410+
let result = signer
411+
.with_key_authorization_for_transaction(4218, &[call_to(Address::repeat_byte(0x11))]);
412+
assert!(result.is_err());
413+
let err = result.err().unwrap().to_string();
414+
assert!(err.contains("authorization chain 4217"));
415+
assert!(err.contains("transaction chain 4218"));
416+
}
417+
418+
#[test]
419+
fn test_key_authorization_for_transaction_rejects_uncovered_scope() {
420+
let allowed = Address::repeat_byte(0x11);
421+
let denied = Address::repeat_byte(0x22);
422+
let signer = signer_with_auth(|auth| {
423+
auth.with_allowed_calls(vec![CallScope {
424+
target: allowed,
425+
selector_rules: vec![],
426+
}])
427+
});
428+
429+
let result = signer.with_key_authorization_for_transaction(4217, &[call_to(denied)]);
430+
assert!(result.is_err());
431+
let err = result.err().unwrap().to_string();
432+
assert!(err.contains("authorization scopes do not cover transaction calls"));
433+
}
434+
435+
#[test]
436+
fn test_key_authorization_for_transaction_accepts_covered_scope() {
437+
let allowed = Address::repeat_byte(0x11);
438+
let signer = signer_with_auth(|auth| {
439+
auth.with_allowed_calls(vec![CallScope {
440+
target: allowed,
441+
selector_rules: vec![],
442+
}])
443+
});
444+
445+
let provisioning = signer
446+
.with_key_authorization_for_transaction(4217, &[call_to(allowed)])
447+
.unwrap()
448+
.expect("covered auth should attach");
449+
assert!(provisioning.signing_mode.key_authorization().is_some());
450+
}
451+
295452
#[test]
296453
fn test_signer_direct_has_no_stored_auth() {
297454
let keys = Keystore::from_private_key(TEST_PRIVATE_KEY).unwrap();
@@ -300,6 +457,47 @@ mod tests {
300457
assert!(signer.with_key_authorization().is_none());
301458
}
302459

460+
fn signer_with_auth(update: impl FnOnce(KeyAuthorization) -> KeyAuthorization) -> Signer {
461+
let wallet_signer = parse_private_key_signer(
462+
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
463+
)
464+
.unwrap();
465+
let access_signer = parse_private_key_signer(TEST_PRIVATE_KEY).unwrap();
466+
let auth = KeyAuthorization {
467+
chain_id: 4217,
468+
key_type: SignatureType::Secp256k1,
469+
key_id: access_signer.address(),
470+
expiry: std::num::NonZero::new(9_999_999_999),
471+
limits: None,
472+
allowed_calls: None,
473+
};
474+
let auth = update(auth);
475+
let sig = wallet_signer
476+
.sign_hash_sync(&auth.signature_hash())
477+
.unwrap();
478+
479+
Signer {
480+
signer: access_signer,
481+
signing_mode: TempoSigningMode::Keychain {
482+
wallet: wallet_signer.address(),
483+
key_authorization: None,
484+
version: KeychainVersion::V2,
485+
},
486+
from: wallet_signer.address(),
487+
stored_key_authorization: Some(Box::new(
488+
auth.into_signed(PrimitiveSignature::Secp256k1(sig)),
489+
)),
490+
}
491+
}
492+
493+
fn call_to(to: Address) -> Call {
494+
Call {
495+
to: TxKind::Call(to),
496+
value: U256::ZERO,
497+
input: Bytes::from_static(&[0xaa, 0xbb, 0xcc, 0xdd]),
498+
}
499+
}
500+
303501
#[test]
304502
fn test_signer_no_key_for_network() {
305503
let keys = Keystore::default();

crates/tempo-common/src/payment/session/tx.rs

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -138,14 +138,13 @@ pub async fn resolve_and_sign_tx_with_fee_payer(
138138
let gas_limit = match gas_result {
139139
Ok(gas) => gas,
140140
Err(original) if wallet.has_stored_key_authorization() => {
141-
provisioning_signer =
142-
wallet
143-
.with_key_authorization()
144-
.ok_or_else(|| KeyError::SigningOperation {
145-
operation: "key provisioning",
146-
reason: "stored key authorization could not be applied to signing mode"
147-
.to_string(),
148-
})?;
141+
provisioning_signer = wallet
142+
.with_key_authorization_for_transaction(chain_id, &calls)?
143+
.ok_or_else(|| KeyError::SigningOperation {
144+
operation: "key provisioning",
145+
reason: "stored key authorization could not be applied to signing mode"
146+
.to_string(),
147+
})?;
149148
effective_wallet = &provisioning_signer;
150149
key_auth = effective_wallet.signing_mode.key_authorization();
151150
let mut gas_request = TempoTransactionRequest {
@@ -240,14 +239,13 @@ pub async fn submit_tempo_tx(
240239
match provider.send_raw_transaction(&tx_bytes).await {
241240
Ok(pending) => Ok(format!("{:#x}", pending.tx_hash())),
242241
Err(original) if wallet.has_stored_key_authorization() => {
243-
let provisioning_signer =
244-
wallet
245-
.with_key_authorization()
246-
.ok_or_else(|| KeyError::SigningOperation {
247-
operation: "key provisioning",
248-
reason: "stored key authorization could not be applied to signing mode"
249-
.to_string(),
250-
})?;
242+
let provisioning_signer = wallet
243+
.with_key_authorization_for_transaction(chain_id, &calls)?
244+
.ok_or_else(|| KeyError::SigningOperation {
245+
operation: "key provisioning",
246+
reason: "stored key authorization could not be applied to signing mode"
247+
.to_string(),
248+
})?;
251249
let retry_bytes = resolve_and_sign_tx(
252250
provider,
253251
&provisioning_signer,

0 commit comments

Comments
 (0)