Cryptographic Protection for Authenticator Inputs
Account Abstraction is currently a release candidate and, therefore, only available on Testnet and Devnet.
If you test it out, please share your feedback with us in the IOTA Builders Discord.
This how-to explains the importance of cryptographically protecting all authenticator input parameters, not just the transaction digest, to prevent attackers from tampering with inputs after authentication.
The Vulnerability
The content of a transaction can be cryptographically verified using the transaction digest (see Function digest). However, when this digest is created, the authenticator inputs are not included. When implementing custom authenticator functions that accept additional input parameters (beyond just verifying the transaction), it's critical to include all inputs in the cryptographic verification. If an authenticator only verifies the transaction digest but doesn't cryptographically bind other input parameters (such as shared objects or raw values) to the signature, an attacker can swap those inputs after the signature was created.
Attack Scenario
Consider an authenticator that uses a shared Blacklist object to reject transactions from blocked accounts. The Blacklist stores account addresses that are not allowed to authenticate transactions, and the authenticator checks the current account against that object before allowing the transaction to proceed.
If the Blacklist object is passed as an authenticator input, the authenticator must verify that it is the intended blacklist. Otherwise, if the signature only covers the transaction digest, an attacker can:
- Create a legitimate transaction from the account with specific input parameters
- Sign the transaction with the authenticator (signature covers transaction digest)
- Swap the blacklist shared object with a different one (e.g., an empty blacklist)
- Or change the raw value parameter to a different value
- Submit the modified transaction, which will pass signature verification because the transaction digest hasn't changed
Secure Implementation
Here's an example of a secure authenticator that properly protects its inputs:
/// Authenticates access for the `Account`.
/// Verifies the provided Ed25519 signature against the stored public key.
/// Additionally, checks if the sender's account is blacklisted using the provided `blacklist` shared object.
///
/// The authenticator computes a message hash that includes the transaction digest, the blacklist object ID, and a raw value argument.
/// This provides cryptographic protection for the authenticator inputs, ensuring that the signature is bound to specific transaction data
/// and the inputs.
#[authenticator]
public fun authenticate(
account: &Account,
blacklist: &Blacklist,
raw_value: u64,
signature: vector<u8>,
_: &AuthContext,
ctx: &TxContext,
) {
assert!(
ed25519::ed25519_verify(
&decode(signature),
account.public_key(),
&compute_message(blacklist, raw_value, ctx),
),
EEd25519VerificationFailed,
);
assert!(!blacklist.is_blacklisted(ctx.sender()), EAccountIsBlacklisted);
}
Why this is secure: The authenticator computes a message hash that includes:
- The transaction digest (
ctx.digest()) - The blacklist object ID (
object::id(blacklist)) - The raw value parameter
The helper that combines these inputs into the message hash used for signature verification:
/// Helper function to compute the message that should be signed for authentication.
fun compute_message(blacklist: &Blacklist, raw_value: u64, ctx: &TxContext): vector<u8> {
let mut message = vector::empty();
message.append(*ctx.digest());
message.append(object::id(blacklist).id_to_bytes());
message.append(bcs::to_bytes(&raw_value));
hash::sha2_256(message)
}
Exploitation Example (Why Protection is Necessary)
Without proper input protection, an attacker could swap the blacklist:
/// Swaps the blacklist shared object in the transaction with a new one.
pub fn swap_blacklist_in_transaction(
mut transaction: Transaction,
new_blacklist_ref: &ObjectRef,
) -> Transaction {
let new_blacklist_ref_call_arg = CallArg::Shared(SharedObjectRef {
object_id: new_blacklist_ref.object_id,
initial_shared_version: new_blacklist_ref.version,
mutable: false,
});
let new_sig = match &transaction.inner_mut().tx_signatures[0] {
GenericSignature::MoveAuthenticator(move_authenticator) => {
let raw_value_call_arg = move_authenticator.call_args()[1].clone();
let signature_call_arg = move_authenticator.call_args()[2].clone();
let account_call_arg = move_authenticator.object_to_authenticate().clone();
GenericSignature::MoveAuthenticator(MoveAuthenticator::new_v1(
vec![
new_blacklist_ref_call_arg,
raw_value_call_arg,
signature_call_arg,
],
vec![],
account_call_arg,
))
}
_ => panic!("Expected MoveAuthenticator signature"),
};
transaction.inner_mut().tx_signatures[0] = new_sig;
transaction
}
Or swap the raw value parameter:
/// Swaps the raw value in the transaction with a new one.
pub fn swap_raw_value_in_transaction(
mut transaction: Transaction,
new_raw_value: u64,
) -> Transaction {
let new_raw_value_call_arg = CallArg::Pure(bcs::to_bytes(&new_raw_value).unwrap());
let new_sig = match &transaction.inner_mut().tx_signatures[0] {
GenericSignature::MoveAuthenticator(move_authenticator) => {
let blacklist_call_arg = move_authenticator.call_args()[0].clone();
let signature_call_arg = move_authenticator.call_args()[2].clone();
let account_call_arg = move_authenticator.object_to_authenticate().clone();
GenericSignature::MoveAuthenticator(MoveAuthenticator::new_v1(
vec![
blacklist_call_arg,
new_raw_value_call_arg,
signature_call_arg,
],
vec![],
account_call_arg,
))
}
_ => panic!("Expected MoveAuthenticator signature"),
};
transaction.inner_mut().tx_signatures[0] = new_sig;
transaction
}
However, because the secure implementation cryptographically binds all inputs, these attacks will fail during signature verification.
Implementation Pattern
When creating authenticators with additional input parameters, always follow this pattern:
Step 1: Define Your Inputs
Identify all parameters your authenticator needs to verify:
#[authenticator]
public fun authenticate(
account: &Account,
blacklist: &Blacklist, // Shared object input
raw_value: u64, // Raw value input
signature: vector<u8>, // Signature covering ALL the inputs
_: &AuthContext,
ctx: &TxContext,
) {
// Authentication logic
}
Step 2: Compute Message Hash Including All Inputs
Create a helper function that combines all inputs into the message to be signed:
fun compute_message(blacklist: &Blacklist, raw_value: u64, ctx: &TxContext): vector<u8> {
let mut message = vector::empty();
// 1. Include transaction digest
message.append(*ctx.digest());
// 2. Include shared object ID
message.append(object::id(blacklist).id_to_bytes());
// 3. Include raw value
message.append(bcs::to_bytes(&raw_value));
// Hash the combined message
hash::sha2_256(message)
}
Step 3: Verify Signature Against Complete Message
Use the computed message in signature verification:
assert!(
ed25519::ed25519_verify(
&decode(signature),
account.public_key(),
&compute_message(blacklist, raw_value, ctx), // Verify against ALL the inputs
),
EEd25519VerificationFailed,
);
Step 4: Client-Side Signature Creation
When creating transactions on the client side, sign the same message:
// Prepare the message with all inputs
let mut message = vec![];
message.extend_from_slice(tx_digest.as_ref());
message.extend_from_slice(&blacklist_ref.0.into_bytes());
message.extend_from_slice(bcs::to_bytes(&raw_value)?.as_slice());
let message_hash = Sha256::digest(message.as_slice()).digest;
// Sign the complete message
let signature = keystore.sign_hashed(&signer, &message_hash)?;
Key Takeaways
✅ Protect all inputs: Every authenticator parameter that could be tampered with must be included in the cryptographic signature
✅ Include object identities: For shared objects, include their object IDs in the signed message to prevent swapping
✅ Include raw values: For primitive parameters (u64, bool, etc.), include their values in the signed message
✅ Include transaction digest: Including a transaction digest in the cryptographic signature allows to protect the transaction data and the authenticator inputs with one signature
✅ Use deterministic encoding: When combining inputs into a message, use deterministic encoding (like BCS) to ensure consistent hashing
✅ Hash the combined message: Use a cryptographic hash function (SHA-256) to create the final message digest
✅ Client-side consistency: Ensure client-side signature creation uses the exact same message construction as the on-chain authenticator
✅ Document the message format: Clearly document what's included in the signed message for client developers
❌ Don't rely on type safety alone: Having the right type doesn't mean it's the right instance
❌ Don't assume inputs are immutable: Any input not cryptographically protected can be swapped