Skip to main content

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(&current, &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(&current.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}