use base64::{engine::general_purpose::STANDARD as B64, Engine};
use bip32::secp256k1::ecdsa::{
signature::{Signer, Verifier},
Signature, SigningKey, VerifyingKey,
};
use crate::{cosmos, Error};
const BIND_TAG: &str = "cyber:migrate:v1:";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Claim {
pub address: String,
pub pubkey: [u8; 33],
pub neuron: [u8; 32],
pub signature: [u8; 64],
}
impl Claim {
pub fn encode(&self) -> String {
format!(
"{} {} {} {}",
self.address,
to_hex(&self.pubkey),
to_hex(&self.neuron),
to_hex(&self.signature)
)
}
pub fn decode(s: &str) -> Option<Claim> {
let mut it = s.split_whitespace();
let address = it.next()?.to_string();
let pubkey: [u8; 33] = from_hex(it.next()?)?.try_into().ok()?;
let neuron: [u8; 32] = from_hex(it.next()?)?.try_into().ok()?;
let signature: [u8; 64] = from_hex(it.next()?)?.try_into().ok()?;
if it.next().is_some() {
return None;
}
Some(Claim { address, pubkey, neuron, signature })
}
}
fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn from_hex(s: &str) -> Option<Vec<u8>> {
if s.len() % 2 != 0 {
return None;
}
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect()
}
pub fn neuron_of(compressed_pubkey: &[u8]) -> [u8; 32] {
*hemera::hash(compressed_pubkey).as_bytes()
}
pub fn bind_message(neuron: &[u8; 32]) -> Vec<u8> {
let mut s = String::with_capacity(BIND_TAG.len() + 64);
s.push_str(BIND_TAG);
for b in neuron {
s.push_str(&format!("{b:02x}"));
}
s.into_bytes()
}
pub fn adr036_doc(signer: &str, data: &[u8]) -> String {
let data_b64 = B64.encode(data);
format!(
"{{\"account_number\":\"0\",\"chain_id\":\"\",\"fee\":{{\"amount\":[],\"gas\":\"0\"}},\
\"memo\":\"\",\"msgs\":[{{\"type\":\"sign/MsgSignData\",\"value\":\
{{\"data\":\"{data_b64}\",\"signer\":\"{signer}\"}}}}],\"sequence\":\"0\"}}"
)
}
pub fn create(key: &SigningKey, hrp: &str, neuron: [u8; 32]) -> Result<Claim, Error> {
let pubkey = cosmos::compressed(key.verifying_key());
let address = cosmos::address(&pubkey, hrp)?;
let doc = adr036_doc(&address, &bind_message(&neuron));
let sig: Signature = key.sign(doc.as_bytes());
let mut signature = [0u8; 64];
signature.copy_from_slice(&sig.to_bytes());
Ok(Claim { address, pubkey, neuron, signature })
}
pub fn verify(claim: &Claim, hrp: &str) -> bool {
let Ok(derived) = cosmos::address(&claim.pubkey, hrp) else { return false };
if derived != claim.address {
return false;
}
let Ok(vk) = VerifyingKey::from_sec1_bytes(&claim.pubkey) else { return false };
let Ok(sig) = Signature::from_slice(&claim.signature) else { return false };
let doc = adr036_doc(&claim.address, &bind_message(&claim.neuron));
vk.verify(doc.as_bytes(), &sig).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::seed;
const MNEMONIC: &str =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
fn key() -> SigningKey {
seed::cosmos_key(MNEMONIC, "").unwrap()
}
#[test]
fn round_trip_claim_verifies() {
let k = key();
let neuron = neuron_of(&cosmos::compressed(k.verifying_key()));
let claim = create(&k, cosmos::PUSSY, neuron).unwrap();
assert!(verify(&claim, cosmos::PUSSY));
assert!(claim.address.starts_with("pussy1"));
}
#[test]
fn tampered_neuron_is_rejected() {
let k = key();
let neuron = neuron_of(&cosmos::compressed(k.verifying_key()));
let mut claim = create(&k, cosmos::PUSSY, neuron).unwrap();
claim.neuron[0] ^= 0x01; assert!(!verify(&claim, cosmos::PUSSY), "signature no longer covers the neuron");
}
#[test]
fn wrong_hrp_is_rejected() {
let k = key();
let neuron = neuron_of(&cosmos::compressed(k.verifying_key()));
let claim = create(&k, cosmos::PUSSY, neuron).unwrap();
assert!(!verify(&claim, cosmos::BOSTROM));
}
#[test]
fn swapped_pubkey_is_rejected() {
let k = key();
let neuron = neuron_of(&cosmos::compressed(k.verifying_key()));
let mut claim = create(&k, cosmos::PUSSY, neuron).unwrap();
let other = seed::cosmos_key(MNEMONIC, "other").unwrap();
claim.pubkey = cosmos::compressed(other.verifying_key());
assert!(!verify(&claim, cosmos::PUSSY));
}
#[test]
fn claim_encodes_and_decodes_round_trip() {
let k = key();
let neuron = neuron_of(&cosmos::compressed(k.verifying_key()));
let claim = create(&k, cosmos::PUSSY, neuron).unwrap();
let wire = claim.encode();
let back = Claim::decode(&wire).expect("decodes");
assert_eq!(claim, back);
assert!(verify(&back, cosmos::PUSSY));
}
#[test]
fn decode_rejects_malformed() {
assert!(Claim::decode("not enough fields").is_none());
assert!(Claim::decode("addr zz zz zz").is_none());
}
#[test]
fn neuron_of_is_deterministic() {
let k = key();
let pk = cosmos::compressed(k.verifying_key());
assert_eq!(neuron_of(&pk), neuron_of(&pk));
}
}