Skip to main content

qorechain/
authenticator.rs

1//! v3.1.85 authenticator sign-bytes and key-rotation helpers.
2//!
3//! These mirror `@qorechain/wallet-adapter`'s `authenticator.js` byte-for-byte
4//! and rebuild the exact digests the chain re-derives in
5//! `x/abstractaccount/types/{evm,cosmos}_sign.go` and `x/pqc` key rotation. A
6//! linked external key (a Phantom ed25519 key, or a secp256k1 key) can spend from
7//! the ONE unified PQC-required account under least-privilege, spend-limited,
8//! revocable terms — via a relayer — WITHOUT that external key ever producing an
9//! ML-DSA co-signature:
10//!
11//! - **EVM lane** ([`evm_auth_sign_bytes`]) — `MsgExecuteEVM`: an EVM
12//!   call/transfer from the account's `0x` address.
13//! - **Native lane** ([`cosmos_auth_sign_bytes`]) — `MsgExecuteCosmos`: a bank
14//!   send from the account (Cosmos).
15//!
16//! The relayer submits + pays fees (its own hybrid-PQC signature satisfies the
17//! ante on the envelope); the authenticator's signature over the
18//! domain-separated, replay-bound sign-bytes IS the authorization. A digest
19//! mismatch is rejected on-chain (codespace `abstractaccount`, code 11 replay /
20//! 10 permission / 5 spending-limit / 6 session-expired).
21//!
22//! **NONCE.** EVM = the account's CURRENT EVM nonce
23//! (`eth_getTransactionCount(account0x)`); because the relayer is a DIFFERENT
24//! account than the owner, the relayer envelope does NOT bump the account's nonce,
25//! so use the current value as-is (no `+1`). Cosmos = the per-authenticator
26//! sequence for `(account, pubkey)` — a store counter distinct from the account's
27//! own sequence, incremented on each successful Native-lane spend.
28
29use crate::error::{Error, Result};
30use crate::msg::pqc as pqc_msg;
31use crate::pqc::{pqc_keypair_from_seed, pqc_sign, PqcKeypair};
32use crate::proto::qorechain::pqc::v1 as pb;
33use sha2::{Digest, Sha256};
34use sha3::digest::{ExtendableOutput, Update, XofReader};
35use sha3::Shake256;
36
37// ---- byte helpers (match the chain's binary.BigEndian + length-prefix framing) ----
38
39/// `BE64(n)` — `n` as 8 big-endian bytes.
40fn be64(n: u64) -> [u8; 8] {
41    n.to_be_bytes()
42}
43
44/// `LP(b)` — length-prefixed field: `BE64(len(b)) ‖ b`.
45fn lp(out: &mut Vec<u8>, bytes: &[u8]) {
46    out.extend_from_slice(&be64(bytes.len() as u64));
47    out.extend_from_slice(bytes);
48}
49
50fn sha256_32(bytes: &[u8]) -> [u8; 32] {
51    let mut hasher = Sha256::new();
52    Digest::update(&mut hasher, bytes);
53    hasher.finalize().into()
54}
55
56fn shake256_32(data: &[u8]) -> [u8; 32] {
57    let mut hasher = Shake256::default();
58    Update::update(&mut hasher, data);
59    let mut reader = hasher.finalize_xof();
60    let mut out = [0u8; 32];
61    reader.read(&mut out);
62    out
63}
64
65fn to_hex_lower(bytes: &[u8]) -> String {
66    hex::encode(bytes)
67}
68
69// ---- sign-bytes (the digest an authenticator signs) ----
70
71/// Rebuilds the 32-byte digest the chain re-derives for a `MsgExecuteEVM`
72/// (`types.EVMAuthSignBytes`):
73///
74/// ```text
75/// sha256( "qorechain-evm-auth-v1"
76///         ‖ LP(chain_id) ‖ LP(account) ‖ LP(pubkey)
77///         ‖ LP(to) ‖ LP(value) ‖ LP(data) ‖ BE64(nonce) )
78/// ```
79///
80/// `to` is the `0x`-hex recipient string, `value` the decimal wei (aqor) string,
81/// `data` the raw calldata, `pubkey` the authenticator's raw public key (32 bytes
82/// for ed25519). Returns the 32 bytes the wallet signs.
83pub fn evm_auth_sign_bytes(
84    chain_id: &str,
85    account: &str,
86    pubkey: &[u8],
87    to: &str,
88    value: &str,
89    data: &[u8],
90    nonce: u64,
91) -> [u8; 32] {
92    let mut body = Vec::new();
93    body.extend_from_slice(b"qorechain-evm-auth-v1");
94    lp(&mut body, chain_id.as_bytes());
95    lp(&mut body, account.as_bytes());
96    lp(&mut body, pubkey);
97    lp(&mut body, to.as_bytes());
98    lp(&mut body, value.as_bytes());
99    lp(&mut body, data);
100    body.extend_from_slice(&be64(nonce));
101    sha256_32(&body)
102}
103
104/// Rebuilds the 32-byte digest the chain re-derives for a `MsgExecuteCosmos`
105/// (`types.CosmosAuthSignBytes`):
106///
107/// ```text
108/// sha256( "qorechain-cosmos-auth-v1"
109///         ‖ LP(chain_id) ‖ LP(account) ‖ LP(pubkey)
110///         ‖ LP(to) ‖ LP(amount) ‖ BE64(nonce) )
111/// ```
112///
113/// `to` is the bech32 recipient, `amount` the CANONICAL `sdk.Coins` string
114/// (sorted, e.g. `"100uqor"`). Returns the 32 bytes the wallet signs.
115pub fn cosmos_auth_sign_bytes(
116    chain_id: &str,
117    account: &str,
118    pubkey: &[u8],
119    to: &str,
120    amount: &str,
121    nonce: u64,
122) -> [u8; 32] {
123    let mut body = Vec::new();
124    body.extend_from_slice(b"qorechain-cosmos-auth-v1");
125    lp(&mut body, chain_id.as_bytes());
126    lp(&mut body, account.as_bytes());
127    lp(&mut body, pubkey);
128    lp(&mut body, to.as_bytes());
129    lp(&mut body, amount.as_bytes());
130    body.extend_from_slice(&be64(nonce));
131    sha256_32(&body)
132}
133
134/// Returns the domain-separated STRING both the old and the new key sign for a
135/// `MsgRotatePQCKey` (`types.RotationSignBytes`):
136///
137/// ```text
138/// "qorechain-pqc-rotate-v1|<chain_id>|<algorithm_id>|<account>|<old_hex>|<new_hex>"
139/// ```
140///
141/// `old_hex`/`new_hex` are lowercase hex of the public keys. Sign the UTF-8 bytes
142/// of this string.
143pub fn rotation_sign_bytes(
144    chain_id: &str,
145    algorithm_id: u32,
146    account: &str,
147    old_pub: &[u8],
148    new_pub: &[u8],
149) -> String {
150    format!(
151        "qorechain-pqc-rotate-v1|{chain_id}|{algorithm_id}|{account}|{}|{}",
152        to_hex_lower(old_pub),
153        to_hex_lower(new_pub),
154    )
155}
156
157// ---- key rotation (legacy → canonical migration) ----
158
159/// The LEGACY derivation scheme: `shake256(mnemonic)` (chain-bridge / faucet-api).
160pub const DERIVATION_LEGACY: &str = "bridge";
161/// The CANONICAL derivation scheme: `shake256("qorechain:pqc:v1|<account>|<mnemonic>")`
162/// (SDK / wallet-adapter, address-bound).
163pub const DERIVATION_CANONICAL: &str = "adapter";
164
165/// Derives the LEGACY (chain-bridge) ML-DSA-87 keypair for a mnemonic:
166/// `keygen(shake256(mnemonic, 32))`. This is the key a backend (chain-bridge /
167/// faucet-api) registered before the address-bound derivation existed.
168pub fn derive_pqc_legacy(mnemonic: &str) -> PqcKeypair {
169    let seed = shake256_32(mnemonic.as_bytes());
170    pqc_keypair_from_seed(&seed)
171}
172
173/// Derives the CANONICAL (SDK / wallet-adapter) address-bound ML-DSA-87 keypair:
174/// `keygen(shake256("qorechain:pqc:v1|" + account + "|" + mnemonic, 32))`.
175pub fn derive_pqc_canonical(account: &str, mnemonic: &str) -> PqcKeypair {
176    let mut msg = Vec::new();
177    msg.extend_from_slice(b"qorechain:pqc:v1|");
178    msg.extend_from_slice(account.as_bytes());
179    msg.push(b'|');
180    msg.extend_from_slice(mnemonic.as_bytes());
181    let seed = shake256_32(&msg);
182    pqc_keypair_from_seed(&seed)
183}
184
185fn derive_pqc_by_scheme(scheme: &str, account: &str, mnemonic: &str) -> Result<PqcKeypair> {
186    match scheme {
187        DERIVATION_LEGACY | "mnemonic-only" => Ok(derive_pqc_legacy(mnemonic)),
188        DERIVATION_CANONICAL | "" => Ok(derive_pqc_canonical(account, mnemonic)),
189        other => Err(Error::Pqc(format!(
190            "unknown derivation \"{other}\" (use adapter|bridge)"
191        ))),
192    }
193}
194
195/// The result of [`rotate_pqc_key_msg_from_mnemonic`]: the dual-signed
196/// `MsgRotatePQCKey` plus both derived keypairs (so the caller can broadcast the
197/// message hybrid-cosigned with the OLD key, still registered until the rotation
198/// lands).
199#[derive(Debug, Clone)]
200pub struct RotateFromMnemonic {
201    /// The dual-signed `MsgRotatePQCKey`.
202    pub msg: pb::MsgRotatePqcKey,
203    /// The OLD-derivation keypair (the currently-registered key).
204    pub old_keypair: PqcKeypair,
205    /// The NEW-derivation keypair (the key being rotated to).
206    pub new_keypair: PqcKeypair,
207}
208
209/// Options for [`rotate_pqc_key_msg_from_mnemonic`].
210#[derive(Debug, Clone)]
211pub struct RotateFromMnemonicOptions<'a> {
212    /// The account (bech32) whose PQC key is rotated; also `sender` of the msg.
213    pub account: &'a str,
214    /// The mnemonic both derivations share.
215    pub mnemonic: &'a str,
216    /// The chain-id bound into the rotation sign-bytes.
217    pub chain_id: &'a str,
218    /// The algorithm id (1 = ML-DSA-87 / Dilithium-5). Defaults via [`Default`].
219    pub algorithm_id: u32,
220    /// The derivation to rotate FROM. Defaults to [`DERIVATION_LEGACY`].
221    pub old_derivation: &'a str,
222    /// The derivation to rotate TO. Defaults to [`DERIVATION_CANONICAL`].
223    pub new_derivation: &'a str,
224}
225
226impl<'a> RotateFromMnemonicOptions<'a> {
227    /// A default legacy→canonical rotation for `account`/`mnemonic`/`chain_id`
228    /// (algorithm 1, `bridge` → `adapter`).
229    pub fn new(account: &'a str, mnemonic: &'a str, chain_id: &'a str) -> Self {
230        Self {
231            account,
232            mnemonic,
233            chain_id,
234            algorithm_id: 1,
235            old_derivation: DERIVATION_LEGACY,
236            new_derivation: DERIVATION_CANONICAL,
237        }
238    }
239}
240
241/// Builds a dual-signed `MsgRotatePQCKey` that rotates an account's ML-DSA-87 key
242/// (SAME algorithm) from one derivation to another.
243///
244/// The canonical use is migrating a LEGACY chain-bridge key (`shake256(mnemonic)`)
245/// to the canonical address-bound key
246/// (`shake256("qorechain:pqc:v1|addr|mnemonic")`), so a wallet whose key was
247/// registered by a backend can move to the standard derivation. Both keys
248/// dual-sign the domain-separated rotation bytes (deterministic ML-DSA-87). The
249/// returned message must be broadcast BY the account, hybrid-cosigned with the OLD
250/// key (still the registered key until the rotation lands).
251///
252/// Fails if the two derivations produce the same public key (a no-op rotation).
253pub fn rotate_pqc_key_msg_from_mnemonic(
254    opts: &RotateFromMnemonicOptions<'_>,
255) -> Result<RotateFromMnemonic> {
256    let old_kp = derive_pqc_by_scheme(opts.old_derivation, opts.account, opts.mnemonic)?;
257    let new_kp = derive_pqc_by_scheme(opts.new_derivation, opts.account, opts.mnemonic)?;
258    if old_kp.public_key == new_kp.public_key {
259        return Err(Error::Pqc(
260            "old and new derivations produce the same key — rotation would be a no-op".into(),
261        ));
262    }
263    let sb = rotation_sign_bytes(
264        opts.chain_id,
265        opts.algorithm_id,
266        opts.account,
267        &old_kp.public_key,
268        &new_kp.public_key,
269    );
270    let old_signature = pqc_sign(&old_kp.secret_key, sb.as_bytes())?;
271    let new_signature = pqc_sign(&new_kp.secret_key, sb.as_bytes())?;
272    let msg = pqc_msg::rotate_pqc_key(
273        opts.account,
274        old_kp.public_key.clone(),
275        new_kp.public_key.clone(),
276        old_signature,
277        new_signature,
278    );
279    Ok(RotateFromMnemonic {
280        msg,
281        old_keypair: old_kp,
282        new_keypair: new_kp,
283    })
284}