Skip to main content

bitrouter_core/jwt/
keys.rs

1//! Multi-chain key management for BitRouter JWT authentication.
2//!
3//! A `MasterKeypair` wraps a 32-byte seed from which both Ed25519 (Solana)
4//! and secp256k1 (EVM) keypairs are derived. Addresses are formatted per
5//! CAIP-10 for cross-chain identity.
6//!
7//! Key storage: `BITROUTER_HOME/.keys/<prefix>/master.json`
8
9use alloy_primitives::Address;
10use alloy_signer::SignerSync;
11use alloy_signer_local::PrivateKeySigner;
12use base64::Engine;
13use base64::engine::general_purpose::URL_SAFE_NO_PAD;
14use serde::{Deserialize, Serialize};
15use solana_keypair::{Keypair as SolanaKeypair, Signer as SolanaSigner};
16use solana_pubkey::Pubkey;
17
18use crate::jwt::JwtError;
19use crate::jwt::chain::{Caip10, Chain};
20
21/// A master keypair for signing BitRouter JWTs across chains.
22///
23/// Stores a 32-byte seed that deterministically derives:
24/// - Ed25519 keypair (Solana) via `solana-keypair`
25/// - secp256k1 keypair (EVM) via `alloy-signer-local`
26#[derive(Clone)]
27pub struct MasterKeypair {
28    seed: [u8; 32],
29}
30
31impl MasterKeypair {
32    /// Generate a new random 32-byte seed.
33    pub fn generate() -> Self {
34        Self {
35            seed: rand::random(),
36        }
37    }
38
39    /// Construct from a 32-byte seed.
40    pub fn from_seed(seed: [u8; 32]) -> Self {
41        Self { seed }
42    }
43
44    /// Return the raw 32-byte seed.
45    pub fn seed(&self) -> &[u8; 32] {
46        &self.seed
47    }
48
49    // ── Ed25519 (Solana) ──────────────────────────────────────
50
51    /// Build a Solana `Keypair` from this seed.
52    fn solana_keypair(&self) -> SolanaKeypair {
53        SolanaKeypair::new_from_array(self.seed)
54    }
55
56    /// Solana public key as a base58-encoded string.
57    pub fn solana_pubkey_b58(&self) -> String {
58        self.solana_keypair().pubkey().to_string()
59    }
60
61    // ── secp256k1 (EVM) ───────────────────────────────────────
62
63    /// Construct an alloy `PrivateKeySigner` from the seed.
64    pub fn evm_signer(&self) -> Result<PrivateKeySigner, JwtError> {
65        PrivateKeySigner::from_slice(&self.seed).map_err(|e| JwtError::Secp256k1(e.to_string()))
66    }
67
68    /// Derive the EVM address (checksummed hex with `0x` prefix).
69    pub fn evm_address(&self) -> Result<Address, JwtError> {
70        Ok(self.evm_signer()?.address())
71    }
72
73    /// EVM address as a checksummed hex string (e.g. `"0xAb5801..."`).
74    pub fn evm_address_string(&self) -> Result<String, JwtError> {
75        Ok(self.evm_address()?.to_checksum(None))
76    }
77
78    // ── CAIP-10 ───────────────────────────────────────────────
79
80    /// Derive the CAIP-10 account identifier for a given chain.
81    pub fn caip10(&self, chain: &Chain) -> Result<Caip10, JwtError> {
82        let address = match chain {
83            Chain::Solana { .. } => self.solana_pubkey_b58(),
84            Chain::Evm { .. } => self.evm_address_string()?,
85        };
86        Ok(Caip10 {
87            chain: chain.clone(),
88            address,
89        })
90    }
91
92    // ── Display / prefix ──────────────────────────────────────
93
94    /// A short prefix for display and directory naming.
95    ///
96    /// Uses the first 16 characters of the Solana base58 public key.
97    pub fn public_key_prefix(&self) -> String {
98        let b58 = self.solana_pubkey_b58();
99        b58[..16.min(b58.len())].to_string()
100    }
101
102    // ── Signing helpers ───────────────────────────────────────
103
104    /// Sign a byte slice using Ed25519 (Solana / SOL_EDDSA) via the Solana SDK.
105    ///
106    /// Returns the 64-byte Ed25519 signature.
107    pub fn sign_ed25519(&self, message: &[u8]) -> Vec<u8> {
108        self.solana_keypair()
109            .sign_message(message)
110            .as_ref()
111            .to_vec()
112    }
113
114    /// Sign a byte slice using EIP-191 prefixed secp256k1 (EVM / EIP191K).
115    ///
116    /// The alloy signer applies the `"\x19Ethereum Signed Message:\n{len}"`
117    /// prefix, hashes with keccak256, and signs with secp256k1 ECDSA.
118    ///
119    /// Returns the 65-byte signature (r[32] + s[32] + v[1]).
120    pub fn sign_eip191(&self, message: &[u8]) -> Result<Vec<u8>, JwtError> {
121        let signer = self.evm_signer()?;
122        let sig = signer
123            .sign_message_sync(message)
124            .map_err(|e| JwtError::Signing(e.to_string()))?;
125        Ok(sig.as_bytes().to_vec())
126    }
127
128    // ── Serialization ─────────────────────────────────────────
129
130    /// Serialize to the JSON format stored in `master.json`.
131    pub fn to_json(&self) -> MasterKeyJson {
132        MasterKeyJson {
133            algorithm: "web3".to_string(),
134            seed: URL_SAFE_NO_PAD.encode(self.seed),
135        }
136    }
137
138    /// Deserialize from the JSON format stored in `master.json`.
139    pub fn from_json(json: &MasterKeyJson) -> Result<Self, JwtError> {
140        if json.algorithm != "web3" {
141            return Err(JwtError::InvalidKeypair);
142        }
143        let bytes = URL_SAFE_NO_PAD
144            .decode(&json.seed)
145            .map_err(|_| JwtError::InvalidKeypair)?;
146        let seed: [u8; 32] = bytes.try_into().map_err(|_| JwtError::InvalidKeypair)?;
147        Ok(Self::from_seed(seed))
148    }
149}
150
151// ── Verification helpers (no private key needed) ──────────────
152
153/// Decode a base58-encoded Solana public key into a `Pubkey`.
154pub fn decode_solana_pubkey(b58: &str) -> Result<Pubkey, JwtError> {
155    b58.parse::<Pubkey>()
156        .map_err(|_| JwtError::InvalidPublicKey)
157}
158
159/// JSON-serializable format for the master key file.
160///
161/// Stored at `BITROUTER_HOME/.keys/<prefix>/master.json`.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct MasterKeyJson {
164    /// Algorithm identifier. `"web3"` for multi-chain seed.
165    pub algorithm: String,
166    /// The 32-byte seed, base64url-encoded (no padding).
167    pub seed: String,
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn roundtrip_seed() {
176        let kp = MasterKeypair::generate();
177        let seed = *kp.seed();
178        let kp2 = MasterKeypair::from_seed(seed);
179        assert_eq!(kp.solana_pubkey_b58(), kp2.solana_pubkey_b58());
180    }
181
182    #[test]
183    fn roundtrip_json() {
184        let kp = MasterKeypair::generate();
185        let json = kp.to_json();
186        let kp2 = MasterKeypair::from_json(&json).expect("valid json");
187        assert_eq!(kp.solana_pubkey_b58(), kp2.solana_pubkey_b58());
188    }
189
190    #[test]
191    fn same_seed_same_addresses() {
192        let kp1 = MasterKeypair::generate();
193        let kp2 = MasterKeypair::from_seed(*kp1.seed());
194        assert_eq!(kp1.solana_pubkey_b58(), kp2.solana_pubkey_b58());
195        assert_eq!(
196            kp1.evm_address_string().expect("evm"),
197            kp2.evm_address_string().expect("evm")
198        );
199    }
200
201    #[test]
202    fn solana_and_evm_addresses_differ() {
203        let kp = MasterKeypair::generate();
204        let sol = kp.solana_pubkey_b58();
205        let evm = kp.evm_address_string().expect("evm");
206        assert_ne!(sol, evm);
207    }
208
209    #[test]
210    fn public_key_prefix_length() {
211        let kp = MasterKeypair::generate();
212        assert_eq!(kp.public_key_prefix().len(), 16);
213    }
214
215    #[test]
216    fn caip10_solana() {
217        let kp = MasterKeypair::generate();
218        let chain = Chain::solana_mainnet();
219        let id = kp.caip10(&chain).expect("caip10");
220        assert!(
221            id.format()
222                .starts_with("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:")
223        );
224        assert_eq!(id.address, kp.solana_pubkey_b58());
225    }
226
227    #[test]
228    fn caip10_evm() {
229        let kp = MasterKeypair::generate();
230        let chain = Chain::base();
231        let id = kp.caip10(&chain).expect("caip10");
232        assert!(id.format().starts_with("eip155:8453:0x"));
233        assert_eq!(id.address, kp.evm_address_string().expect("evm"));
234    }
235
236    #[test]
237    fn decode_solana_pubkey_roundtrip() {
238        let kp = MasterKeypair::generate();
239        let b58 = kp.solana_pubkey_b58();
240        let pk = decode_solana_pubkey(&b58).expect("decode");
241        assert_eq!(bs58::encode(pk.to_bytes()).into_string(), b58);
242    }
243
244    #[test]
245    fn sign_ed25519_produces_64_bytes() {
246        let kp = MasterKeypair::generate();
247        let sig = kp.sign_ed25519(b"test message");
248        assert_eq!(sig.len(), 64);
249    }
250
251    #[test]
252    fn sign_eip191_produces_65_bytes() {
253        let kp = MasterKeypair::generate();
254        let sig = kp.sign_eip191(b"test message").expect("sign");
255        assert_eq!(sig.len(), 65);
256    }
257
258    #[test]
259    fn from_json_rejects_wrong_algorithm() {
260        let kp = MasterKeypair::generate();
261        let mut json = kp.to_json();
262        json.algorithm = "rsa".to_string();
263        assert!(MasterKeypair::from_json(&json).is_err());
264    }
265
266    #[test]
267    fn eip191_signature_recovers_correct_address() {
268        use alloy_primitives::Signature as EvmSignature;
269
270        let kp = MasterKeypair::generate();
271        let message = b"hello web3";
272        let sig_bytes = kp.sign_eip191(message).expect("sign");
273
274        let sig = EvmSignature::try_from(sig_bytes.as_slice()).expect("parse sig");
275        let recovered = sig.recover_address_from_msg(message).expect("recover");
276        assert_eq!(recovered, kp.evm_address().expect("address"));
277    }
278}