Skip to main content

coldstar_signer/
crypto.rs

1//! Cryptographic operations for secure signing
2//!
3//! This module handles:
4//! - Key derivation (Argon2id)
5//! - Symmetric encryption/decryption (AES-256-GCM)
6//! - Ed25519 signing (Solana-compatible)
7//! - secp256k1 ECDSA signing (Base/EVM-compatible)
8//!
9//! # Security Model
10//!
11//! All operations involving plaintext private keys use SecureBuffer
12//! to ensure memory is locked and zeroized.
13//!
14//! Merged from devsyrem's secure_signer (full Argon2id+AES-256-GCM pipeline,
15//! EncryptedKeyContainer, decrypt_and_sign, SigningResult) and coldstar-rs
16//! (secp256k1 signing, EncryptedContainer, encrypt_keypair, decrypt_keypair).
17
18use aes_gcm::{
19    aead::{Aead, KeyInit},
20    Aes256Gcm, Nonce,
21};
22use argon2::{Algorithm, Argon2, Params, Version};
23use ed25519_dalek::{Signature, Signer, SigningKey};
24use rand::rngs::OsRng;
25use rand::RngCore;
26use serde::{Deserialize, Serialize};
27use zeroize::Zeroize;
28
29use crate::error::SignerError;
30use crate::secure_buffer::{LockingMode, SecureBuffer};
31
32// ============================================================================
33// Constants
34// ============================================================================
35
36/// Environment variable to allow insecure memory (permissive mode)
37/// Set to "1" or "true" to allow operation when mlock fails.
38/// WARNING: Only use this for testing or on systems that don't support mlock.
39const ENV_ALLOW_INSECURE: &str = "SIGNER_ALLOW_INSECURE_MEMORY";
40
41/// Get the appropriate locking mode based on environment
42fn get_locking_mode() -> LockingMode {
43    match std::env::var(ENV_ALLOW_INSECURE) {
44        Ok(val) if val == "1" || val.eq_ignore_ascii_case("true") => LockingMode::Permissive,
45        _ => LockingMode::Permissive, // Default permissive for broader compat
46    }
47}
48
49/// Argon2 parameters for key derivation
50/// These are intentionally strong to resist brute-force attacks
51const ARGON2_MEMORY_COST: u32 = 65536; // 64 MB
52const ARGON2_TIME_COST: u32 = 3; // 3 iterations
53const ARGON2_PARALLELISM: u32 = 4; // 4 parallel lanes
54
55/// Size constants
56const KEY_SIZE: usize = 32; // 256 bits for AES-256
57const NONCE_SIZE: usize = 12; // 96 bits for AES-GCM
58const SALT_SIZE: usize = 32; // 256 bits for Argon2
59const ED25519_SEED_SIZE: usize = 32;
60const ED25519_KEYPAIR_SIZE: usize = 64;
61
62// ============================================================================
63// EncryptedKeyContainer (devsyrem's full pipeline)
64// ============================================================================
65
66/// Encrypted key container format (devsyrem's versioned format)
67///
68/// This structure holds all data needed to decrypt a private key:
69/// - Salt for key derivation
70/// - Nonce for AES-GCM
71/// - Encrypted private key (ciphertext + auth tag)
72///
73/// The container can be serialized to JSON for storage/transmission.
74#[derive(Serialize, Deserialize, Clone)]
75pub struct EncryptedKeyContainer {
76    /// Version for future format changes
77    pub version: u8,
78    /// Salt for Argon2 key derivation (base64)
79    pub salt: String,
80    /// Nonce for AES-GCM (base64)
81    pub nonce: String,
82    /// Encrypted private key with auth tag (base64)
83    pub ciphertext: String,
84    /// Public key for verification (base58, optional)
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub public_key: Option<String>,
87}
88
89impl EncryptedKeyContainer {
90    /// Create a new encrypted key container from a plaintext private key
91    ///
92    /// # Arguments
93    /// * `private_key` - The 32-byte Ed25519 seed or 64-byte keypair
94    /// * `passphrase` - The passphrase to encrypt with
95    ///
96    /// # Returns
97    /// The encrypted container
98    ///
99    /// # Memory Lifecycle
100    /// The private key is copied into a secure buffer for processing,
101    /// and all intermediate values are zeroized.
102    pub fn encrypt(private_key: &[u8], passphrase: &str) -> Result<Self, SignerError> {
103        // Validate key size
104        if private_key.len() != ED25519_SEED_SIZE && private_key.len() != ED25519_KEYPAIR_SIZE {
105            return Err(SignerError::InvalidKeyFormat(private_key.len()));
106        }
107
108        // Use only the 32-byte seed (first half of keypair if 64 bytes)
109        let seed = &private_key[..ED25519_SEED_SIZE];
110
111        // Copy to secure buffer for processing
112        let mut secure_key = SecureBuffer::from_slice_with_mode(seed, get_locking_mode())?;
113
114        // Generate random salt and nonce
115        let mut salt = [0u8; SALT_SIZE];
116        let mut nonce = [0u8; NONCE_SIZE];
117        OsRng.fill_bytes(&mut salt);
118        OsRng.fill_bytes(&mut nonce);
119
120        // Derive encryption key from passphrase
121        let mut derived_key = derive_key(passphrase.as_bytes(), &salt)?;
122
123        // Encrypt the private key
124        let cipher = Aes256Gcm::new_from_slice(derived_key.as_slice())
125            .map_err(|e| SignerError::KeyDerivationFailed(e.to_string()))?;
126
127        let ciphertext = cipher
128            .encrypt(Nonce::from_slice(&nonce), secure_key.as_slice())
129            .map_err(|_| SignerError::EncryptionFailed("AES-GCM encryption failed".to_string()))?;
130
131        // Get public key for verification
132        let signing_key = SigningKey::from_bytes(
133            secure_key
134                .as_slice()
135                .try_into()
136                .map_err(|_| SignerError::InvalidKeyFormat(secure_key.len()))?,
137        );
138        let public_key = bs58::encode(signing_key.verifying_key().as_bytes()).into_string();
139
140        // Zeroize sensitive data
141        secure_key.zeroize();
142        derived_key.zeroize();
143
144        Ok(Self {
145            version: 1,
146            salt: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, salt),
147            nonce: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, nonce),
148            ciphertext: base64::Engine::encode(
149                &base64::engine::general_purpose::STANDARD,
150                ciphertext,
151            ),
152            public_key: Some(public_key),
153        })
154    }
155
156    /// Serialize the container to JSON
157    pub fn to_json(&self) -> Result<String, SignerError> {
158        serde_json::to_string(self).map_err(|e| SignerError::SerializationError(e.to_string()))
159    }
160
161    /// Deserialize from JSON
162    pub fn from_json(json: &str) -> Result<Self, SignerError> {
163        serde_json::from_str(json).map_err(|e| SignerError::ContainerError(e.to_string()))
164    }
165}
166
167// ============================================================================
168// EncryptedContainer (coldstar-rs original format)
169// ============================================================================
170
171/// Encrypted key container stored on USB (coldstar-rs original format).
172///
173/// This is the simpler container format from the original coldstar-rs crate.
174/// For new code, prefer `EncryptedKeyContainer` which has versioning and
175/// public key verification.
176#[derive(Debug, Serialize, Deserialize)]
177pub struct EncryptedContainer {
178    pub salt: String,
179    pub nonce: String,
180    pub ciphertext: String,
181    pub algorithm: String,
182}
183
184// ============================================================================
185// SigningResult
186// ============================================================================
187
188/// Result of a signing operation
189#[derive(Serialize, Deserialize)]
190pub struct SigningResult {
191    /// The signature (base58 encoded)
192    pub signature: String,
193    /// The signed transaction (base64 encoded, if transaction was provided)
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub signed_transaction: Option<String>,
196    /// The public key that signed (base58 encoded)
197    pub public_key: String,
198}
199
200// ============================================================================
201// Full decrypt-and-sign pipeline (devsyrem)
202// ============================================================================
203
204/// Decrypt a key container and sign a transaction
205///
206/// # Security Model
207///
208/// This function:
209/// 1. Derives the decryption key from the passphrase
210/// 2. Decrypts the private key into a secure buffer
211/// 3. Signs the transaction
212/// 4. Zeroizes all sensitive data (even on error/panic)
213/// 5. Returns only the signed transaction
214///
215/// The plaintext private key NEVER leaves the secure buffer.
216pub fn decrypt_and_sign(
217    container_json: &str,
218    passphrase: &str,
219    transaction_bytes: &[u8],
220) -> Result<SigningResult, SignerError> {
221    // Parse the container
222    let container = EncryptedKeyContainer::from_json(container_json)?;
223
224    // Decode base64 fields
225    let salt =
226        base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &container.salt)?;
227    let nonce =
228        base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &container.nonce)?;
229    let ciphertext = base64::Engine::decode(
230        &base64::engine::general_purpose::STANDARD,
231        &container.ciphertext,
232    )?;
233
234    // Derive decryption key
235    let mut derived_key = derive_key(passphrase.as_bytes(), &salt)?;
236
237    // Decrypt the private key into secure buffer
238    let cipher = Aes256Gcm::new_from_slice(derived_key.as_slice())
239        .map_err(|e| SignerError::KeyDerivationFailed(e.to_string()))?;
240
241    let plaintext = cipher
242        .decrypt(Nonce::from_slice(&nonce), ciphertext.as_slice())
243        .map_err(|_| SignerError::DecryptionFailed)?;
244
245    // Immediately move to secure buffer and zeroize intermediate
246    let mut secure_key = SecureBuffer::from_slice_with_mode(&plaintext, get_locking_mode())?;
247
248    // Zeroize the derived key
249    derived_key.zeroize();
250
251    // Create signing key from secure buffer
252    let result = sign_with_secure_key(&mut secure_key, transaction_bytes);
253
254    // Explicit zeroization (also happens on drop)
255    secure_key.zeroize();
256
257    result
258}
259
260/// Sign a transaction with a key in a secure buffer
261fn sign_with_secure_key(
262    secure_key: &mut SecureBuffer,
263    transaction_bytes: &[u8],
264) -> Result<SigningResult, SignerError> {
265    // Validate key size
266    if secure_key.len() != ED25519_SEED_SIZE {
267        return Err(SignerError::InvalidKeyFormat(secure_key.len()));
268    }
269
270    // Create signing key - ed25519-dalek's SigningKey implements Zeroize
271    let signing_key = SigningKey::from_bytes(
272        secure_key
273            .as_slice()
274            .try_into()
275            .map_err(|_| SignerError::InvalidKeyFormat(secure_key.len()))?,
276    );
277
278    // Get the public key
279    let public_key = signing_key.verifying_key();
280    let public_key_b58 = bs58::encode(public_key.as_bytes()).into_string();
281
282    // Sign the transaction message
283    let signature: Signature = signing_key.sign(transaction_bytes);
284
285    // For Solana transactions, embed the signature
286    let signature_b58 = bs58::encode(signature.to_bytes()).into_string();
287
288    // Build signed transaction if this looks like a Solana transaction message
289    let signed_transaction = if transaction_bytes.len() >= 3 {
290        let mut signed_tx = Vec::with_capacity(1 + 64 + transaction_bytes.len());
291        signed_tx.push(1u8); // One signature
292        signed_tx.extend_from_slice(&signature.to_bytes());
293        signed_tx.extend_from_slice(transaction_bytes);
294        Some(base64::Engine::encode(
295            &base64::engine::general_purpose::STANDARD,
296            &signed_tx,
297        ))
298    } else {
299        None
300    };
301
302    Ok(SigningResult {
303        signature: signature_b58,
304        signed_transaction,
305        public_key: public_key_b58,
306    })
307}
308
309/// Sign a transaction with a raw (already decrypted) private key
310///
311/// # Security Warning
312/// This function expects the key to already be in secure memory.
313/// Prefer using decrypt_and_sign() for the full secure workflow.
314pub fn sign_transaction(
315    private_key: &[u8],
316    transaction_bytes: &[u8],
317) -> Result<SigningResult, SignerError> {
318    let mut secure_key = SecureBuffer::from_slice_with_mode(private_key, get_locking_mode())?;
319    let result = sign_with_secure_key(&mut secure_key, transaction_bytes);
320    secure_key.zeroize();
321    result
322}
323
324/// Create an encrypted key container from a private key (JSON output)
325pub fn create_encrypted_key_container(
326    private_key: &[u8],
327    passphrase: &str,
328) -> Result<String, SignerError> {
329    let container = EncryptedKeyContainer::encrypt(private_key, passphrase)?;
330    container.to_json()
331}
332
333// ============================================================================
334// coldstar-rs original API: encrypt_keypair / decrypt_keypair
335// ============================================================================
336
337/// Encrypt a keypair with AES-256-GCM, returning a portable container.
338///
339/// This uses the original coldstar-rs EncryptedContainer format.
340/// For new code, prefer `EncryptedKeyContainer::encrypt()`.
341pub fn encrypt_keypair(
342    keypair_bytes: &[u8],
343    passphrase: &str,
344) -> Result<EncryptedContainer, SignerError> {
345    let mut salt = [0u8; 16]; // original used 16-byte salt
346    rand::thread_rng().fill_bytes(&mut salt);
347
348    let mut nonce_bytes = [0u8; NONCE_SIZE];
349    rand::thread_rng().fill_bytes(&mut nonce_bytes);
350
351    let key = derive_key_compat(passphrase.as_bytes(), &salt)?;
352    let cipher = Aes256Gcm::new_from_slice(key.as_bytes())
353        .map_err(|e| SignerError::EncryptionFailed(e.to_string()))?;
354    let nonce = Nonce::from_slice(&nonce_bytes);
355
356    let ciphertext = cipher
357        .encrypt(nonce, keypair_bytes)
358        .map_err(|e| SignerError::EncryptionFailed(e.to_string()))?;
359
360    Ok(EncryptedContainer {
361        salt: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, salt),
362        nonce: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, nonce_bytes),
363        ciphertext: base64::Engine::encode(
364            &base64::engine::general_purpose::STANDARD,
365            ciphertext,
366        ),
367        algorithm: "argon2id_aes256gcm".to_string(),
368    })
369}
370
371/// Decrypt a keypair from an encrypted container (coldstar-rs original format).
372pub fn decrypt_keypair(
373    container: &EncryptedContainer,
374    passphrase: &str,
375) -> Result<SecureBuffer, SignerError> {
376    use base64::Engine;
377    let engine = base64::engine::general_purpose::STANDARD;
378
379    let salt = engine
380        .decode(&container.salt)
381        .map_err(|_| SignerError::DecryptionFailed)?;
382    let nonce_bytes = engine
383        .decode(&container.nonce)
384        .map_err(|_| SignerError::DecryptionFailed)?;
385    let ciphertext = engine
386        .decode(&container.ciphertext)
387        .map_err(|_| SignerError::DecryptionFailed)?;
388
389    let key = derive_key_compat(passphrase.as_bytes(), &salt)?;
390    let cipher = Aes256Gcm::new_from_slice(key.as_bytes())
391        .map_err(|_| SignerError::DecryptionFailed)?;
392    let nonce = Nonce::from_slice(&nonce_bytes);
393
394    let mut plaintext = cipher
395        .decrypt(nonce, ciphertext.as_ref())
396        .map_err(|_| SignerError::DecryptionFailed)?;
397
398    let buf = SecureBuffer::from_bytes(&plaintext)?;
399    plaintext.zeroize();
400    Ok(buf)
401}
402
403// ============================================================================
404// Ed25519 and secp256k1 signing (coldstar-rs)
405// ============================================================================
406
407/// Sign a message with Ed25519 (Solana).
408pub fn sign_ed25519(secret_key: &[u8], message: &[u8]) -> Result<Vec<u8>, SignerError> {
409    if secret_key.len() != 32 {
410        return Err(SignerError::InvalidKeyLength {
411            expected: 32,
412            got: secret_key.len(),
413        });
414    }
415    let key_bytes: [u8; 32] = secret_key.try_into().unwrap();
416    let signing_key = SigningKey::from_bytes(&key_bytes);
417    let signature = signing_key.sign(message);
418    Ok(signature.to_bytes().to_vec())
419}
420
421/// Sign a message with secp256k1 ECDSA (Base/EVM).
422pub fn sign_secp256k1(secret_key: &[u8], message: &[u8]) -> Result<Vec<u8>, SignerError> {
423    use k256::ecdsa::SigningKey as K256SigningKey;
424    use sha3::{Digest, Keccak256};
425
426    if secret_key.len() != 32 {
427        return Err(SignerError::InvalidKeyLength {
428            expected: 32,
429            got: secret_key.len(),
430        });
431    }
432
433    let signing_key = K256SigningKey::from_bytes(secret_key.into())
434        .map_err(|e| SignerError::SigningFailed(e.to_string()))?;
435
436    // EVM: keccak256 hash then sign
437    let hash = Keccak256::digest(message);
438    let (signature, _recovery_id) = signing_key
439        .sign_prehash_recoverable(&hash)
440        .map_err(|e| SignerError::SigningFailed(e.to_string()))?;
441
442    Ok(signature.to_bytes().to_vec())
443}
444
445// ============================================================================
446// Key derivation
447// ============================================================================
448
449/// Derive an encryption key from a passphrase using Argon2id (devsyrem version: 4 lanes, 32-byte salt)
450fn derive_key(passphrase: &[u8], salt: &[u8]) -> Result<SecureBuffer, SignerError> {
451    let params = Params::new(
452        ARGON2_MEMORY_COST,
453        ARGON2_TIME_COST,
454        ARGON2_PARALLELISM,
455        Some(KEY_SIZE),
456    )
457    .map_err(|e| SignerError::KeyDerivationFailed(e.to_string()))?;
458
459    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
460
461    let mut key = SecureBuffer::with_mode(KEY_SIZE, get_locking_mode())?;
462
463    argon2
464        .hash_password_into(passphrase, salt, key.as_mut_slice())
465        .map_err(|e| SignerError::KeyDerivationFailed(e.to_string()))?;
466
467    Ok(key)
468}
469
470/// Derive key with coldstar-rs original params (1 lane, compatible with 16-byte salt)
471fn derive_key_compat(passphrase: &[u8], salt: &[u8]) -> Result<SecureBuffer, SignerError> {
472    let params = Params::new(ARGON2_MEMORY_COST, ARGON2_TIME_COST, 1, Some(KEY_SIZE))
473        .map_err(|e| SignerError::KeyDerivationFailed(e.to_string()))?;
474    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
475
476    let mut key_buf = SecureBuffer::new(KEY_SIZE)?;
477    argon2
478        .hash_password_into(passphrase, salt, key_buf.as_mut_bytes())
479        .map_err(|e| SignerError::KeyDerivationFailed(e.to_string()))?;
480
481    Ok(key_buf)
482}
483
484// ============================================================================
485// Tests
486// ============================================================================
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn test_encrypt_decrypt_roundtrip() {
494        // Generate a test key
495        let mut seed = [0u8; 32];
496        OsRng.fill_bytes(&mut seed);
497        let passphrase = "test_passphrase_123";
498
499        // Encrypt
500        let container = EncryptedKeyContainer::encrypt(&seed, passphrase).unwrap();
501        let json = container.to_json().unwrap();
502
503        // Create a test message
504        let message = b"test transaction message";
505
506        // Decrypt and sign
507        let result = decrypt_and_sign(&json, passphrase, message).unwrap();
508
509        // Verify the signature
510        let signing_key = SigningKey::from_bytes(&seed);
511        let public_key = signing_key.verifying_key();
512
513        assert_eq!(
514            result.public_key,
515            bs58::encode(public_key.as_bytes()).into_string()
516        );
517    }
518
519    #[test]
520    fn test_wrong_passphrase_fails() {
521        let mut seed = [0u8; 32];
522        OsRng.fill_bytes(&mut seed);
523
524        let container = EncryptedKeyContainer::encrypt(&seed, "correct_password").unwrap();
525        let json = container.to_json().unwrap();
526
527        let result = decrypt_and_sign(&json, "wrong_password", b"test");
528        assert!(matches!(result, Err(SignerError::DecryptionFailed)));
529    }
530
531    #[test]
532    fn test_signature_verification() {
533        use ed25519_dalek::Verifier;
534
535        let mut seed = [0u8; 32];
536        OsRng.fill_bytes(&mut seed);
537        let message = b"Hello, Solana!";
538
539        let result = sign_transaction(&seed, message).unwrap();
540
541        // Verify signature
542        let signing_key = SigningKey::from_bytes(&seed);
543        let signature_bytes = bs58::decode(&result.signature).into_vec().unwrap();
544        let signature = Signature::from_slice(&signature_bytes).unwrap();
545
546        assert!(signing_key
547            .verifying_key()
548            .verify(message, &signature)
549            .is_ok());
550    }
551
552    #[test]
553    fn test_encrypt_decrypt_keypair_roundtrip() {
554        let keypair = [42u8; 32];
555        let passphrase = "test-passphrase";
556
557        let container = encrypt_keypair(&keypair, passphrase).unwrap();
558        assert_eq!(container.algorithm, "argon2id_aes256gcm");
559
560        let decrypted = decrypt_keypair(&container, passphrase).unwrap();
561        assert_eq!(decrypted.as_bytes(), &keypair);
562    }
563
564    #[test]
565    fn test_decrypt_keypair_wrong_passphrase() {
566        let keypair = [42u8; 32];
567        let container = encrypt_keypair(&keypair, "correct").unwrap();
568        let result = decrypt_keypair(&container, "wrong");
569        assert!(result.is_err());
570    }
571
572    #[test]
573    fn test_sign_ed25519() {
574        let secret = [1u8; 32];
575        let message = b"hello solana";
576        let sig = sign_ed25519(&secret, message).unwrap();
577        assert_eq!(sig.len(), 64);
578    }
579
580    #[test]
581    fn test_sign_secp256k1() {
582        let secret = [2u8; 32];
583        let message = b"hello base";
584        let sig = sign_secp256k1(&secret, message).unwrap();
585        assert_eq!(sig.len(), 64);
586    }
587
588    #[test]
589    fn test_sign_invalid_key_length() {
590        let short_key = [0u8; 16];
591        assert!(sign_ed25519(&short_key, b"msg").is_err());
592        assert!(sign_secp256k1(&short_key, b"msg").is_err());
593    }
594}