use crate::error::{Error, Result};
use crate::pqc::{
build_hybrid_signature_extension, pqc_keypair_from_seed, pqc_sign, PqcKeypair,
ALGORITHM_DILITHIUM5, HYBRID_SIG_TYPE_URL,
};
use crate::unified::{unified_account_from_seed, UnifiedAccount};
use cosmrs::proto::cosmos::tx::signing::v1beta1::SignMode;
use cosmrs::proto::cosmos::tx::v1beta1::{
mode_info::{Single, Sum},
AuthInfo, Fee as ProtoFee, ModeInfo, SignDoc, SignerInfo, TxBody, TxRaw,
};
use cosmrs::proto::traits::Message as ProstMessage;
use cosmrs::Any;
use k256::ecdsa::signature::hazmat::PrehashSigner;
use k256::ecdsa::{Signature, SigningKey};
use sha3::{Digest, Keccak256, Shake256};
pub use crate::tx::{Coin, Fee};
pub const ETHSECP256K1_PUBKEY_TYPE: &str = "/cosmos.evm.crypto.v1.ethsecp256k1.PubKey";
#[derive(Debug, Clone)]
pub struct EthSignParams {
pub private_key: [u8; 32],
pub public_key: Vec<u8>,
pub messages: Vec<Any>,
pub chain_id: String,
pub account_number: u64,
pub sequence: u64,
pub fee: Fee,
pub memo: String,
pub timeout_height: u64,
}
#[derive(Debug, Clone)]
pub struct EthBuiltTx {
pub tx_raw_bytes: Vec<u8>,
pub auth_info_bytes: Vec<u8>,
pub body_bytes: Vec<u8>,
pub pqc_signed_message: Vec<u8>,
pub pqc_signature: Vec<u8>,
}
pub fn sign_classical_eth(params: EthSignParams) -> Result<EthBuiltTx> {
let auth_info_bytes =
build_eth_auth_info_bytes(¶ms.public_key, params.sequence, ¶ms.fee)?;
let body = TxBody {
messages: params.messages,
memo: params.memo,
timeout_height: params.timeout_height,
extension_options: vec![],
non_critical_extension_options: vec![],
};
let body_bytes = body.encode_to_vec();
let classical = eth_sign_direct(
¶ms.private_key,
&body_bytes,
&auth_info_bytes,
¶ms.chain_id,
params.account_number,
)?;
let tx_raw = TxRaw {
body_bytes: body_bytes.clone(),
auth_info_bytes: auth_info_bytes.clone(),
signatures: vec![classical],
};
Ok(EthBuiltTx {
tx_raw_bytes: tx_raw.encode_to_vec(),
auth_info_bytes,
body_bytes,
pqc_signed_message: vec![],
pqc_signature: vec![],
})
}
pub fn sign_hybrid_eth(
params: EthSignParams,
pqc: &PqcKeypair,
include_pqc_public_key: bool,
) -> Result<EthBuiltTx> {
let auth_info_bytes =
build_eth_auth_info_bytes(¶ms.public_key, params.sequence, ¶ms.fee)?;
let b0_body = TxBody {
messages: params.messages.clone(),
memo: params.memo.clone(),
timeout_height: params.timeout_height,
extension_options: vec![],
non_critical_extension_options: vec![],
};
let b0 = b0_body.encode_to_vec();
let pqc_signed_message = frame_sign_bytes(&b0, &auth_info_bytes);
let pqc_signature = pqc_sign(&pqc.secret_key, &pqc_signed_message)?;
let public_key: Option<&[u8]> = if include_pqc_public_key {
Some(pqc.public_key.as_slice())
} else {
None
};
let ext = build_hybrid_signature_extension(ALGORITHM_DILITHIUM5, &pqc_signature, public_key)?;
let ext_value = ext.encode_to_vec();
let ext_any = Any {
type_url: HYBRID_SIG_TYPE_URL.to_string(),
value: ext_value,
};
let final_body = TxBody {
messages: params.messages,
memo: params.memo,
timeout_height: params.timeout_height,
extension_options: vec![ext_any],
non_critical_extension_options: vec![],
};
let body_bytes = final_body.encode_to_vec();
let classical = eth_sign_direct(
¶ms.private_key,
&body_bytes,
&auth_info_bytes,
¶ms.chain_id,
params.account_number,
)?;
let tx_raw = TxRaw {
body_bytes: body_bytes.clone(),
auth_info_bytes: auth_info_bytes.clone(),
signatures: vec![classical],
};
Ok(EthBuiltTx {
tx_raw_bytes: tx_raw.encode_to_vec(),
auth_info_bytes,
body_bytes,
pqc_signed_message,
pqc_signature,
})
}
pub fn unified_account_from_phantom_signature(sig: &[u8]) -> Result<UnifiedAccount> {
let mut hasher = Shake256::default();
sha3::digest::Update::update(&mut hasher, sig);
let mut reader = sha3::digest::ExtendableOutput::finalize_xof(hasher);
let mut seed = [0u8; 32];
sha3::digest::XofReader::read(&mut reader, &mut seed);
unified_account_from_seed(seed)
}
pub fn is_ethsecp256k1_pubkey_type(type_url: &str) -> bool {
type_url == ETHSECP256K1_PUBKEY_TYPE
}
pub fn parse_ethsecp256k1_pubkey(any: &Any) -> Result<Vec<u8>> {
if !is_ethsecp256k1_pubkey_type(&any.type_url) {
return Err(Error::Derivation(format!(
"unexpected pubkey type: {}",
any.type_url
)));
}
let pk = cosmrs::proto::cosmos::crypto::secp256k1::PubKey::decode(any.value.as_slice())
.map_err(|e| Error::Derivation(format!("decode eth_secp256k1 pubkey: {e}")))?;
Ok(pk.key)
}
pub fn pqc_from_seed(seed: &[u8; 32]) -> PqcKeypair {
pqc_keypair_from_seed(seed)
}
fn frame_sign_bytes(b0: &[u8], a: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + b0.len() + a.len());
out.extend_from_slice(&(b0.len() as u32).to_be_bytes());
out.extend_from_slice(b0);
out.extend_from_slice(&(a.len() as u32).to_be_bytes());
out.extend_from_slice(a);
out
}
fn ethsecp256k1_pubkey_any(compressed: &[u8]) -> Any {
let pubkey = cosmrs::proto::cosmos::crypto::secp256k1::PubKey {
key: compressed.to_vec(),
};
Any {
type_url: ETHSECP256K1_PUBKEY_TYPE.to_string(),
value: pubkey.encode_to_vec(),
}
}
fn fee_to_proto(fee: &Fee) -> Result<ProtoFee> {
let amount = fee
.amount
.iter()
.map(|c| cosmrs::proto::cosmos::base::v1beta1::Coin {
denom: c.denom.clone(),
amount: c.amount.clone(),
})
.collect();
let gas_limit = if fee.gas.is_empty() {
0
} else {
fee.gas
.parse::<u64>()
.map_err(|_| Error::Denom(format!("invalid gas: {:?}", fee.gas)))?
};
Ok(ProtoFee {
amount,
gas_limit,
payer: fee.payer.clone(),
granter: fee.granter.clone(),
})
}
fn build_eth_auth_info_bytes(public_key: &[u8], sequence: u64, fee: &Fee) -> Result<Vec<u8>> {
let auth_info = AuthInfo {
signer_infos: vec![SignerInfo {
public_key: Some(ethsecp256k1_pubkey_any(public_key)),
mode_info: Some(ModeInfo {
sum: Some(Sum::Single(Single {
mode: SignMode::Direct as i32,
})),
}),
sequence,
}],
fee: Some(fee_to_proto(fee)?),
..Default::default()
};
Ok(auth_info.encode_to_vec())
}
fn eth_sign_direct(
private_key: &[u8; 32],
body_bytes: &[u8],
auth_info_bytes: &[u8],
chain_id: &str,
account_number: u64,
) -> Result<Vec<u8>> {
let sign_doc = SignDoc {
body_bytes: body_bytes.to_vec(),
auth_info_bytes: auth_info_bytes.to_vec(),
chain_id: chain_id.to_string(),
account_number,
};
let sign_bytes = sign_doc.encode_to_vec();
let hash = Keccak256::digest(&sign_bytes);
let signing = SigningKey::from_slice(private_key)
.map_err(|e| Error::Derivation(format!("invalid signing key: {e}")))?;
let sig: Signature = signing
.sign_prehash(&hash)
.map_err(|e| Error::Derivation(format!("eth_secp256k1 sign: {e}")))?;
Ok(sig.to_bytes().to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::unified::unified_account_from_seed;
use k256::ecdsa::signature::hazmat::PrehashVerifier;
use k256::ecdsa::VerifyingKey;
fn test_params(acct: &UnifiedAccount) -> EthSignParams {
EthSignParams {
private_key: acct.private_key,
public_key: acct.public_key.clone(),
messages: vec![Any {
type_url: "/cosmos.bank.v1beta1.MsgSend".to_string(),
value: vec![1, 2, 3],
}],
chain_id: "qorechain-vladi".to_string(),
account_number: 7,
sequence: 3,
fee: Fee {
amount: vec![Coin {
denom: "uqor".into(),
amount: "100".into(),
}],
gas: "200000".into(),
granter: String::new(),
payer: String::new(),
},
memo: String::new(),
timeout_height: 0,
}
}
fn verify_eth_sig(pubkey: &[u8], body: &[u8], auth: &[u8], built: &EthBuiltTx) -> bool {
let sign_doc = SignDoc {
body_bytes: body.to_vec(),
auth_info_bytes: auth.to_vec(),
chain_id: "qorechain-vladi".to_string(),
account_number: 7,
};
let sign_bytes = sign_doc.encode_to_vec();
let hash = Keccak256::digest(&sign_bytes);
let tx_raw = TxRaw::decode(built.tx_raw_bytes.as_slice()).unwrap();
let sig = Signature::from_slice(&tx_raw.signatures[0]).unwrap();
let vk = VerifyingKey::from_sec1_bytes(pubkey).unwrap();
vk.verify_prehash(&hash, &sig).is_ok()
}
#[test]
fn classical_sig_verifies_over_keccak() {
let acct = unified_account_from_seed([0x01u8; 32]).unwrap();
let built = sign_classical_eth(test_params(&acct)).unwrap();
assert!(built.pqc_signature.is_empty());
assert!(verify_eth_sig(
&acct.public_key,
&built.body_bytes,
&built.auth_info_bytes,
&built
));
}
#[test]
fn hybrid_b0_excludes_extension() {
let acct = unified_account_from_seed([0x01u8; 32]).unwrap();
let built = sign_hybrid_eth(test_params(&acct), &acct.pqc, false).unwrap();
assert!(!built.pqc_signature.is_empty());
let final_body = TxBody::decode(built.body_bytes.as_slice()).unwrap();
assert_eq!(final_body.extension_options.len(), 1);
let ext_value = &final_body.extension_options[0].value;
assert_eq!(ext_value[0], 0x08);
assert_ne!(ext_value[0], 0x7b);
let ext_msg = crate::proto::qorechain::pqc::v1::PqcHybridSignature::decode(
ext_value.as_slice(),
)
.unwrap();
assert_eq!(ext_msg.algorithm_id, ALGORITHM_DILITHIUM5);
assert_eq!(ext_msg.pqc_signature, built.pqc_signature);
assert!(ext_msg.pqc_public_key.is_empty());
let b0_len =
u32::from_be_bytes(built.pqc_signed_message[0..4].try_into().unwrap()) as usize;
let b0 = &built.pqc_signed_message[4..4 + b0_len];
let b0_body = TxBody::decode(b0).unwrap();
assert_eq!(b0_body.extension_options.len(), 0);
assert!(crate::pqc::pqc_verify(
&acct.pqc.public_key,
&built.pqc_signed_message,
&built.pqc_signature
));
assert!(verify_eth_sig(
&acct.public_key,
&built.body_bytes,
&built.auth_info_bytes,
&built
));
}
#[test]
fn eth_pubkey_any_roundtrips() {
let acct = unified_account_from_seed([0x01u8; 32]).unwrap();
let any = ethsecp256k1_pubkey_any(&acct.public_key);
assert_eq!(any.type_url, ETHSECP256K1_PUBKEY_TYPE);
assert!(is_ethsecp256k1_pubkey_type(&any.type_url));
let parsed = parse_ethsecp256k1_pubkey(&any).unwrap();
assert_eq!(parsed, acct.public_key);
}
#[test]
fn phantom_signature_account_is_deterministic() {
let sig = b"phantom-ed25519-signature-bytes";
let a = unified_account_from_phantom_signature(sig).unwrap();
let b = unified_account_from_phantom_signature(sig).unwrap();
assert_eq!(a.cosmos, b.cosmos);
assert_eq!(a.address_bytes, b.address_bytes);
}
}