rotateToPublicKey only transfers Bridge ownership, leaving KeyLogRegistry desynchronized and old owner privileged
rotateToPublicKey transfers ownership only on the Bridge, leaving KeyLogRegistry owned by the old key. The old owner retains setAuthorizedCaller and upgrade rights and can later brick or upgrade the registry maliciously.
Description
Both Bridge and KeyLogRegistry independently track and transfer
ownership during key rotation. In the normal flow via
registerKeyPairWithTransfer, both contracts transfer ownership atomically
in the same transaction: KeyLogRegistry does so internally in
registerKeyLogPair, and Bridge does so explicitly afterward. The same
applies to upgradeWithKeyRotation.
However, rotateToPublicKey only transfers Bridge ownership and does not
trigger any ownership update on KeyLogRegistry:
function rotateToPublicKey(bytes memory existingOwnerPublicKey) external {// ...feeCollector = latest.prerotatedKeyHash;transferOwnership(latest.prerotatedKeyHash);}
After this call, the two contracts are permanently desynchronized:
Bridge.owner()= new rotated addressKeyLogRegistry.owner()= old address
Vulnerable Scenario
- The owner calls
rotateToPublicKey()to rotate to the next key in the pre-rotation chain. - Bridge ownership transfers to the new pre-committed address.
KeyLogRegistryownership remains at the old address.- The old address retains full control over
KeyLogRegistry, includingsetAuthorizedCaller()and_authorizeUpgrade(). - If the old key is later compromised, the attacker can call
setAuthorizedCaller(attacker)onKeyLogRegistry, revoking the Bridge's authorization and permanently bricking all key registration operations (registerKeyLogandregisterKeyLogPairareonlyAuthorized). - The attacker can also upgrade
KeyLogRegistryto a malicious implementation via_authorizeUpgrade, since they are still its owner.
This is especially concerning given the protocol's core purpose is
protection against key theft. The pre-rotation scheme guarantees that a
compromised key cannot steal funds, but rotateToPublicKey breaks this
guarantee for KeyLogRegistry governance.
Impact
After rotateToPublicKey, the old owner retains full administrative
control over KeyLogRegistry, including the ability to revoke Bridge
authorization (bricking all operations) and upgrade KeyLogRegistry to a
malicious implementation. This undermines the pre-rotation security model
that the protocol is built upon.
Recommendation
Remove rotateToPublicKey entirely and force all ownership changes through
registerKeyPairWithTransfer, which keeps both contracts in sync
atomically. This function is the only ownership rotation path that causes
desynchronization, and it is also the source of other issues (unchecked
exists return value, forced fee collector overwrite). If the function
must be preserved, add a corresponding KeyLogRegistry ownership transfer:
function rotateToPublicKey(bytes memory existingOwnerPublicKey) external {// ... existing checks ...(KeyLogEntry memory latest, bool exists) =keyLogRegistry.getLatestChainEntry(existingOwnerPublicKey);require(exists, "Key log not initialized");feeCollector = latest.prerotatedKeyHash;transferOwnership(latest.prerotatedKeyHash);// Sync KeyLogRegistry ownershipkeyLogRegistry.transferOwnership(latest.prerotatedKeyHash);}
Resolution
YadaCoin, Confirmed. Added KeyLogRegistry ownership synchronization
in rotateToPublicKey and introduced a transferOwnershipForKeyRotation
wrapper to handle atomic ownership transfer across both contracts.
Zealynx, Fixed. Verified that ownership is now transferred atomically
to both Bridge and KeyLogRegistry, preventing the desynchronization
vector.

