Comprehensive security model of ChronoVault including transaction-bound proofs and multi-layer protection.
- Threat Model
- Core Security Features
- Transaction Commitment Binding
- Attack Scenarios & Protections
- Security Layers
- Replay Attack Prevention
β
Your private key (compromised seed phrase/keystore)
β
Access to blockchain data (all transactions are public)
β
Mempool visibility (can see pending transactions)
β
Front-running capability (can submit higher gas transactions)
β
Contract code knowledge (smart contracts and circuits are public)
β Your TOTP secret (stored securely on your authenticator device)
β Your authenticator app (separate physical device)
β Ability to generate valid ZK proofs (requires the secret)
- Prevent unauthorized transactions even with compromised private key
- Prevent front-running attacks on legitimate transactions
- Prevent parameter tampering (changing destination/amount)
- Prevent replay attacks using old proofs
- Maintain privacy of the TOTP secret
- Ensure two-factor authentication with proper device separation
Every transaction requires a ZK proof demonstrating:
β Knowledge of the TOTP secret
β Correct TOTP code for current time
β Fresh timestamp (within 5 minutes)
β Commitment to specific transaction parameters β KEY INNOVATION
Circuit Verification:
// Verify secret knowledge
Poseidon(secret) === secretHash
// Verify TOTP code generation
totpCode === Poseidon(secret, timeCounter) % 1000000
// Transaction commitment is part of public signals
publicSignals = [totpCode, timeCounter, secretHash, txCommitment]The proof is cryptographically bound to transaction parameters:
txCommitment = keccak256(abi.encodePacked(
to, // Destination address
value, // ETH amount to send
keccak256(data), // Call data hash
nonce // Transaction nonce
)) % FIELD_PRIMEThis commitment becomes part of the ZK proof:
- Included in proof generation as public input
- Verified by the ZK circuit
- Checked by smart contract against actual transaction
Critical Implication:
Proof is valid ONLY for this exact transaction.
Changing ANY parameter β Proof becomes invalid.
Layer 1: TOTP Time Windows (30 seconds)
- TOTP code changes every 30 seconds
- Each time window has unique code
- Based on
timeCounter = floor(timestamp / 30)
Layer 2: Proof Freshness (5 minutes)
uint256 currentTime = block.timestamp;
uint256 proofTime = timeCounter * 30;
// Proof must be within 5 minutes
require(
currentTime >= proofTime &&
currentTime - proofTime <= MAX_TIME_DIFFERENCE,
"Timestamp too old or in future"
);Layer 3: One-Time Use Protection
// Each time counter can only be used ONCE
require(
timeCounter > lastUsedTimeCounter,
"Time counter already used"
);
// After successful verification
lastUsedTimeCounter = timeCounter;Combined Protection:
- Even within 5-minute window, each 30-second slot is one-time use
- Prevents replay attacks with intercepted proofs
- Provides 10 unique time windows per 5-minute period
uint256 public nonce;
function executeWithProof(...) external {
// Nonce is part of txCommitment calculation
uint256 commitment = keccak256(abi.encodePacked(
to, value, keccak256(data), nonce
));
// After successful execution
nonce++;
}Properties:
- Transactions execute in order
- Each transaction has unique nonce
- Cannot skip nonces
- Combined with time counter for double protection
Step 1: Frontend Calculates Commitment
// When preparing transaction
const txCommitment = calculateTxCommitment({
to: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
value: 1000000000000000000n, // 1 ETH
data: "0x",
nonce: 5n,
});
// Example: txCommitment = 12345...789 (BN254 field element)Step 2: Commitment Included in Proof
// Generate ZK proof WITH commitment
const { proof, publicSignals } = await generateZKProof(
secret,
timestamp,
txCommitment // β Bound to transaction
);
// publicSignals[3] = txCommitmentStep 3: Contract Verifies Match
function executeWithProof(
address to,
uint256 value,
bytes calldata data,
uint[2] calldata pA,
uint[2][2] calldata pB,
uint[2] calldata pC,
uint[4] calldata publicSignals
) external {
// Calculate expected commitment from actual parameters
uint256 expected = _calculateTxCommitment(to, value, data, nonce);
// Verify proof's commitment matches
if (publicSignals[3] != expected) {
revert TxCommitmentMismatch();
}
// Now verify ZK proof
_verifyZKProofInternal(pA, pB, pC, publicSignals);
// Execute transaction
nonce++;
(bool success, ) = to.call{value: value}(data);
}// BN254 is the elliptic curve used by Groth16
uint256 public constant FIELD_PRIME =
21888242871839275222246405745257275088548364400416034343698204186575808495617;
// Reduce commitment to field element
uint256 commitment = uint256(keccak256(...)) % FIELD_PRIME;Reason: ZK circuits operate in finite fields. The commitment must be a valid field element to be included in the proof.
Scenario:
Attacker steals seed phrase β Controls owner EOA β Tries to drain wallet
Protection:
// Old vulnerable approach (NOT our implementation):
function execute(address to, uint256 value, bytes calldata data)
external onlyOwner {
// β Just checks msg.sender == owner
// Attacker with private key can drain wallet!
}
// Our secure approach:
function executeWithProof(
address to,
uint256 value,
bytes calldata data,
uint[2] calldata pA, // Requires ZK proof
uint[2][2] calldata pB,
uint[2] calldata pC,
uint[4] calldata publicSignals
) external onlyOwner {
// β
Must provide valid ZK proof
// β
Proof requires TOTP secret
// β
Attacker doesn't have TOTP secret
}Result: β Attack FAILS. Transaction reverts without valid TOTP proof.
Scenario:
Attacker monitors mempool β Sees legitimate proof β Tries to reuse for different transaction
Attempt 1: Change Transaction Parameters
// Original transaction
executeWithProof(
to: 0xAlice,
value: 1 ETH,
data: 0x,
proof: [pA, pB, pC, publicSignals] // Contains commitment to 0xAlice + 1 ETH
)
// Attacker tries to change destination
executeWithProof(
to: 0xAttacker, // β Changed
value: 1 ETH,
data: 0x,
proof: [pA, pB, pC, publicSignals] // Same proof
)
// Contract calculates:
expected = hash(0xAttacker, 1 ETH, 0x, nonce)
actual = publicSignals[3] // Contains hash(0xAlice, 1 ETH, 0x, nonce)
// expected != actual β revert TxCommitmentMismatch()Result: β Attack FAILS. Commitment mismatch detected.
Attempt 2: Replay Exact Transaction
// Wait for original transaction to mine
// Try to submit again with same proof
executeWithProof(
to: 0xAlice, // Same
value: 1 ETH, // Same
data: 0x, // Same
proof: [...] // Same proof, same timeCounter
)
// Contract checks:
if (timeCounter <= lastUsedTimeCounter) {
revert TimeCounterAlreadyUsed();
}Result: β Attack FAILS. Time counter already used.
Scenario:
Attacker sees transaction in mempool β Submits with higher gas β Tries to execute first
Attempt:
// Legitimate transaction in mempool
executeWithProof(to: 0xAlice, value: 1 ETH, ..., proof)
// Attacker front-runs with higher gas
executeWithProof(to: 0xAttacker, value: 1 ETH, ..., proof)Protection:
- Transaction commitment binding prevents changing parameters
- Even if front-run, attacker cannot modify destination
- Legitimate transaction will still execute correctly
- Attacker wastes gas on failed transaction
Result: β Attack FAILS. Cannot change committed parameters.
Scenario:
Attacker saves old proof β Tries to use it days/weeks later
Protection 1: Time Freshness
uint256 proofTime = timeCounter * 30;
uint256 currentTime = block.timestamp;
if (currentTime - proofTime > MAX_TIME_DIFFERENCE) {
revert TimestampTooOld(); // Max 5 minutes
}Protection 2: One-Time Use
if (timeCounter <= lastUsedTimeCounter) {
revert TimeCounterAlreadyUsed();
}Result: β Attack FAILS. Proof too old AND time counter already used.
Scenario:
Attacker tries to create fake proof without knowing secret
Why It Fails:
- Zero-Knowledge Property: Cannot derive secret from publicSignals
- Cryptographic Hardness: Groth16 proofs are computationally infeasible to forge
- Secret Hash Verification: Proof must match
ownerSecretHashon-chain
// Smart contract verification
if (publicSignals[2] != ownerSecretHash) {
revert SecretHashMismatch();
}
// Groth16 verification (cryptographically secure)
bool valid = _verifier.verifyProof(pA, pB, pC, publicSignals);Result: β Attack FAILS. Cannot forge valid proof without secret.
Scenario:
Attacker tries to guess TOTP code (6 digits = 1,000,000 combinations)
Protection:
- Must also know secret: Cannot generate valid proof with just TOTP code
- Time window: Only 30 seconds per code
- One attempt per timeCounter: Contract tracks used time counters
- Circuit verification: Must prove
Poseidon(secret, timeCounter) % 1000000 === totpCode
Result: β Attack FAILS. Requires secret to generate valid proof.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Layer 1: Device Separation β
β β’ Transaction device has NO secret β
β β’ Authenticator device generates proofs offline β
β β’ QR-based air-gapped communication β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Layer 2: Transaction Commitment Binding β
β β’ Proof cryptographically bound to tx parameters β
β β’ Cannot change destination, amount, or data β
β β’ Calculated: hash(to, value, data, nonce) % FIELD β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Layer 3: Zero-Knowledge Proof β
β β’ Proves secret knowledge without revealing it β
β β’ Groth16: ~128-bit security β
β β’ Verifies: Poseidon(secret, time) % 1M = code β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Layer 4: Time-Based Freshness β
β β’ Proofs valid for 5 minutes β
β β’ TOTP changes every 30 seconds β
β β’ Old proofs automatically rejected β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Layer 5: One-Time Use Protection β
β β’ Each timeCounter can only be used ONCE β
β β’ Contract tracks lastUsedTimeCounter β
β β’ Prevents replay within freshness window β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Layer 6: Nonce-Based Ordering β
β β’ Sequential transaction execution β
β β’ Nonce part of commitment calculation β
β β’ Cannot skip or reorder transactions β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Within the 5-minute freshness window, there are 10 potential TOTP time windows (30 seconds each). Without additional protection, a captured proof could potentially be replayed.
contract TOTPWallet {
// Track the last used time counter
uint256 public lastUsedTimeCounter;
function _verifyZKProofInternal(
uint[2] calldata pA,
uint[2][2] calldata pB,
uint[2] calldata pC,
uint[4] calldata publicSignals
) internal returns (bool) {
uint256 timeCounter = publicSignals[1];
// 1. Check time counter hasn't been used
if (timeCounter <= lastUsedTimeCounter) {
revert TimeCounterAlreadyUsed();
}
// 2. Verify ZK proof
bool valid = _verifier.verifyProof(pA, pB, pC, publicSignals);
// 3. Mark time counter as used
if (valid) {
lastUsedTimeCounter = timeCounter;
}
return valid;
}
}Scenario: Attacker intercepts proof in mempool
Time: 12:00:00 β timeCounter = 43200
User generates proof for timeCounter 43200
Attacker sees proof in mempool
Option 1: Front-run with same proof
β Contract: timeCounter 43200 > lastUsedTimeCounter (0)
β Proof verifies, transaction executes
β lastUsedTimeCounter = 43200
β User's transaction: timeCounter 43200 <= lastUsedTimeCounter (43200)
β Reverts with TimeCounterAlreadyUsed
Option 2: Try to replay after user's transaction
β Contract: timeCounter 43200 <= lastUsedTimeCounter (43200)
β Reverts immediately
Result: First transaction using each timeCounter succeeds, all subsequent attempts fail.
12:00:00 | timeCounter: 43200 | User generates proof
12:00:05 | timeCounter: 43200 | User submits (β Success)
| lastUsedTimeCounter = 43200
12:00:10 | timeCounter: 43200 | Attacker tries same proof (β Fails)
12:00:30 | timeCounter: 43201 | New time window begins
12:00:35 | timeCounter: 43201 | User generates new proof (β Success)
| lastUsedTimeCounter = 43201
12:01:00 | timeCounter: 43202 | New time window begins
β
Immediate Protection: Proof becomes useless after first use
β
No Grace Period: No time window where replay is possible
β
Strictly Increasing: Must use monotonically increasing timeCounters
β
Front-Run Resistant: First transaction wins, others fail
β
Simple Implementation: Single state variable tracks protection
-
Protect Your TOTP Secret
- Store on secure authenticator device
- Never share or upload online
- Use hardware authenticators when possible
-
Verify Transaction Details
- Check destination address on authenticator device
- Verify amount before generating proof
- Review data if calling contracts
-
Use Separate Devices
- Transaction device for browsing/preparing
- Authenticator device for proof generation
- Keep authenticator offline when possible
-
Monitor Wallet Activity
- Check transaction history regularly
- Verify timeCounter increases correctly
- Report any suspicious activity
-
Always Include Transaction Commitment
- Never generate proofs without commitment
- Verify commitment matches in contract
- Use consistent hashing (keccak256)
-
Implement Time Checks
- Enforce 5-minute freshness window
- Check timeCounter strictly increases
- Validate timestamp against block.timestamp
-
Secure Circuit Implementation
- Use Poseidon for ZK-friendly hashing
- Include all necessary constraints
- Audit circuit logic thoroughly
-
Test Attack Scenarios
- Test replay attempts
- Test parameter tampering
- Test front-running scenarios
- Test time window edge cases
-
Circuit Logic (
totp_verifier.circom)- Poseidon hash implementation
- TOTP code calculation
- Transaction commitment handling
- Constraint completeness
-
Smart Contract (
TOTPWallet.sol)- Commitment calculation correctness
- Time validation logic
- One-time use enforcement
- Re-entrancy protection
-
Frontend Proof Generation (
zk-proof.ts)- Commitment calculation matches contract
- Proper field reduction (% FIELD_PRIME)
- Secure random number generation
- Input validation
-
QR Code Security
- No secret leakage in QR codes
- Proper transaction binding
- Multi-part proof integrity
- Unit tests for all security functions
- Integration tests for end-to-end flow
- Fuzz testing for edge cases
- Gas optimization without security compromise
- Multi-device testing scenarios
- Time-based replay attack tests
Their Approach:
β Pre-compute all future TOTP codes
β Hash into Merkle tree at wallet creation
β Limited lifespan (tree exhaustion)
β Vulnerable to brute-force if client compromised
β No transaction binding
Our Approach:
β
On-demand TOTP calculation
β
Zero-knowledge proofs
β
Unlimited lifespan
β
Transaction-bound proofs
β
No pre-computed data to compromise
Multi-Sig:
β’ Requires multiple parties
β’ Complex coordination
β’ No privacy (all signers visible)
β’ High gas costs
ChronoVault:
β’ Single user with 2FA
β’ Simple UX with QR codes
β’ Privacy-preserving (ZK proofs)
β’ Moderate gas costs
Hardware Wallets:
β’ Requires special device
β’ Limited programmability
β’ No 2FA beyond device itself
β’ Physical device can be lost
ChronoVault:
β’ Works with any authenticator app
β’ Fully programmable (smart contract)
β’ True 2FA (key + TOTP)
β’ Secret can be backed up securely
- Immediate: Attacker cannot drain wallet (needs TOTP secret)
- Short-term: Transfer wallet ownership to new key using TOTP proof
- Long-term: Deploy new wallet with new keys
- Immediate: Update
ownerSecretHashusing current proof - Alternative: Transfer funds to new wallet
- Prevention: Use hardware authenticators
- Immediate: Funds at risk, transfer ASAP
- Mitigation: Social recovery (future feature)
- Prevention: Never store both on same device
- Social Recovery: ZK-based guardian recovery system
- Spending Limits: Daily/weekly limits with different auth levels
- Biometric Integration: Combine with device biometrics
- Hardware TOTP: Support for hardware authenticators
- Multi-Factor: Additional factors beyond TOTP
- Emergency Timelock: Delay large transactions for review
- ARCHITECTURE.md - Technical architecture and implementation
- ZK_TOTP_EXPLANATION.md - ELI5 guide to ZK-TOTP
- ../README.md - Project overview