aptos_sdk/account/rotation.rs
1//! Authentication-key rotation.
2//!
3//! Rotating an account's authentication key to a new key pair requires proving
4//! ownership of *both* the current key and the new key. This is done by signing
5//! a [`RotationProofChallenge`] with each key and submitting both signatures to
6//! `0x1::account::rotate_authentication_key`.
7//!
8//! Use [`build_rotate_auth_key_payload`] to construct the signed payload, or the
9//! higher-level `Aptos::rotate_auth_key` helper which also fetches the sequence
10//! number and submits the transaction.
11//!
12//! # On-wire format
13//!
14//! The challenge is BCS-serialized and includes a fixed domain prefix
15//! (`0x1::account::RotationProofChallenge`) so it cannot be replayed as any
16//! other signed message. The layout is pinned to Aptos core:
17//!
18//! ```text
19//! account_address: address // 0x1
20//! module_name: String // "account"
21//! struct_name: String // "RotationProofChallenge"
22//! sequence_number: u64
23//! originator: address // the account being rotated
24//! current_auth_key:address // current authentication key, as an address
25//! new_public_key: vector<u8> // new public key bytes
26//! ```
27
28use crate::account::Account;
29use crate::error::AptosResult;
30use crate::transaction::{InputEntryFunctionData, TransactionPayload};
31use crate::types::AccountAddress;
32use serde::{Deserialize, Serialize};
33
34/// The challenge signed (by both the current and new keys) to authorize an
35/// authentication-key rotation.
36///
37/// The field order and the leading domain fields (`account_address`,
38/// `module_name`, `struct_name`) are part of the BCS-serialized bytes and must
39/// match `0x1::account::RotationProofChallenge` exactly.
40#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
41pub struct RotationProofChallenge {
42 /// Address of the module defining the challenge (`0x1`).
43 pub account_address: AccountAddress,
44 /// Module name (`"account"`).
45 pub module_name: String,
46 /// Struct name (`"RotationProofChallenge"`).
47 pub struct_name: String,
48 /// The current sequence number of the account being rotated.
49 pub sequence_number: u64,
50 /// The account whose key is being rotated.
51 pub originator: AccountAddress,
52 /// The account's current authentication key, as an address.
53 pub current_auth_key: AccountAddress,
54 /// The new public key bytes to rotate to.
55 #[serde(with = "serde_bytes")]
56 pub new_public_key: Vec<u8>,
57}
58
59impl RotationProofChallenge {
60 /// Builds the challenge for rotating `originator` (with the given
61 /// `sequence_number` and `current_auth_key`) to `new_public_key`.
62 pub fn new(
63 sequence_number: u64,
64 originator: AccountAddress,
65 current_auth_key: AccountAddress,
66 new_public_key: Vec<u8>,
67 ) -> Self {
68 Self {
69 account_address: AccountAddress::ONE,
70 module_name: "account".to_string(),
71 struct_name: "RotationProofChallenge".to_string(),
72 sequence_number,
73 originator,
74 current_auth_key,
75 new_public_key,
76 }
77 }
78
79 /// Serializes the challenge to its BCS bytes (the message that both keys
80 /// sign).
81 ///
82 /// # Errors
83 ///
84 /// Returns an error if BCS serialization fails.
85 pub fn to_bcs(&self) -> AptosResult<Vec<u8>> {
86 aptos_bcs::to_bytes(self).map_err(crate::error::AptosError::bcs)
87 }
88}
89
90/// Builds a signed `0x1::account::rotate_authentication_key` payload that
91/// rotates `current`'s authentication key to `new_account`'s key.
92///
93/// Both accounts sign a [`RotationProofChallenge`]: `current` proves it controls
94/// the account today, and `new_account` proves it controls the key being rotated
95/// to (preventing an attacker from pointing an account at a key they don't own).
96///
97/// `sequence_number` must be the account's current sequence number.
98///
99/// # Scheme support
100///
101/// This targets the classic rotation path, which supports Ed25519
102/// (scheme `0`) and MultiEd25519 (scheme `1`) keys for both the source and
103/// destination. `current` and `new_account`'s
104/// [`signature_scheme`](Account::signature_scheme) /
105/// [`public_key_bytes`](Account::public_key_bytes) are forwarded verbatim.
106///
107/// # Errors
108///
109/// Returns an error if either signature cannot be produced, the challenge cannot
110/// be BCS-serialized, or the payload cannot be built.
111pub fn build_rotate_auth_key_payload<A: Account, B: Account>(
112 current: &A,
113 new_account: &B,
114 sequence_number: u64,
115) -> AptosResult<TransactionPayload> {
116 let challenge = RotationProofChallenge::new(
117 sequence_number,
118 current.address(),
119 current.authentication_key().into(),
120 new_account.public_key_bytes(),
121 );
122 let message = challenge.to_bcs()?;
123
124 // `cap_rotate_key`: current key signs the challenge (proves current control).
125 let cap_rotate_key = current.sign(&message)?;
126 // `cap_update_table`: new key signs the challenge (proves ownership of the
127 // key being rotated to).
128 let cap_update_table = new_account.sign(&message)?;
129
130 InputEntryFunctionData::rotate_authentication_key(
131 current.signature_scheme(),
132 current.public_key_bytes(),
133 new_account.signature_scheme(),
134 new_account.public_key_bytes(),
135 cap_rotate_key,
136 cap_update_table,
137 )
138}
139
140#[cfg(all(test, feature = "ed25519"))]
141mod tests {
142 use super::*;
143 use crate::account::Ed25519Account;
144 use crate::crypto::{Ed25519PublicKey, Ed25519Signature};
145 use crate::transaction::TransactionPayload;
146
147 #[test]
148 fn challenge_sets_domain_fields() {
149 let challenge =
150 RotationProofChallenge::new(7, AccountAddress::ONE, AccountAddress::ONE, vec![1, 2, 3]);
151 assert_eq!(challenge.account_address, AccountAddress::ONE);
152 assert_eq!(challenge.module_name, "account");
153 assert_eq!(challenge.struct_name, "RotationProofChallenge");
154 assert_eq!(challenge.sequence_number, 7);
155 }
156
157 #[test]
158 fn challenge_bcs_layout_is_pinned() {
159 // 0x1 originator/auth-key keep the fixture compact and deterministic.
160 let challenge = RotationProofChallenge::new(
161 0,
162 AccountAddress::ONE,
163 AccountAddress::ONE,
164 vec![0xaa, 0xbb],
165 );
166 let hex = const_hex::encode(challenge.to_bcs().unwrap());
167
168 // Domain prefix, built from parts:
169 // account_address 0x1 (32B) || "account" (len 07 + bytes) ||
170 // "RotationProofChallenge" (len 16 + bytes)
171 let mut expected_prefix = Vec::new();
172 expected_prefix.extend_from_slice(AccountAddress::ONE.as_bytes());
173 expected_prefix.push(7);
174 expected_prefix.extend_from_slice(b"account");
175 expected_prefix.push(22);
176 expected_prefix.extend_from_slice(b"RotationProofChallenge");
177 assert!(
178 hex.starts_with(&const_hex::encode(&expected_prefix)),
179 "challenge domain-prefix BCS drifted: {hex}",
180 );
181
182 // Trailer: sequence_number 0 (8B) || originator 0x1 (32B) ||
183 // current_auth_key 0x1 (32B) || new_public_key vec (len 02 + aabb).
184 assert!(
185 hex.ends_with("02aabb"),
186 "new_public_key encoding drifted: {hex}"
187 );
188 }
189
190 #[test]
191 fn rotate_payload_signs_challenge_with_both_keys() {
192 let current = Ed25519Account::generate();
193 let new_account = Ed25519Account::generate();
194
195 let payload = build_rotate_auth_key_payload(¤t, &new_account, 5).unwrap();
196 let TransactionPayload::EntryFunction(ef) = payload else {
197 panic!("expected entry function");
198 };
199 assert_eq!(ef.module.name.as_str(), "account");
200 assert_eq!(ef.function, "rotate_authentication_key");
201 assert_eq!(ef.args.len(), 6);
202
203 // Reconstruct the exact challenge and verify both signatures over it.
204 let challenge = RotationProofChallenge::new(
205 5,
206 current.address(),
207 current.authentication_key().into(),
208 new_account.public_key_bytes(),
209 );
210 let message = challenge.to_bcs().unwrap();
211
212 // args: [from_scheme, from_pk, to_scheme, to_pk, cap_rotate_key, cap_update_table]
213 // Each is individually BCS-encoded; scheme is a u8, keys/sigs are byte vectors.
214 let from_scheme: u8 = aptos_bcs::from_bytes(&ef.args[0]).unwrap();
215 let to_scheme: u8 = aptos_bcs::from_bytes(&ef.args[2]).unwrap();
216 assert_eq!(from_scheme, 0, "ed25519 scheme");
217 assert_eq!(to_scheme, 0, "ed25519 scheme");
218
219 let cap_rotate_key: Vec<u8> = aptos_bcs::from_bytes(&ef.args[4]).unwrap();
220 let cap_update_table: Vec<u8> = aptos_bcs::from_bytes(&ef.args[5]).unwrap();
221
222 let current_pk = Ed25519PublicKey::from_bytes(¤t.public_key_bytes()).unwrap();
223 let new_pk = Ed25519PublicKey::from_bytes(&new_account.public_key_bytes()).unwrap();
224
225 current_pk
226 .verify(
227 &message,
228 &Ed25519Signature::from_bytes(&cap_rotate_key).unwrap(),
229 )
230 .expect("current key must sign the challenge");
231 new_pk
232 .verify(
233 &message,
234 &Ed25519Signature::from_bytes(&cap_update_table).unwrap(),
235 )
236 .expect("new key must sign the challenge");
237
238 // Cross-check: the new key must NOT verify the current key's signature.
239 assert!(
240 new_pk
241 .verify(
242 &message,
243 &Ed25519Signature::from_bytes(&cap_rotate_key).unwrap()
244 )
245 .is_err(),
246 );
247 }
248}