Skip to main content

client_core/
crypto.rs

1//! AES-256-GCM encryption and X25519 ECDH key exchange.
2//! Wire format: base64url(nonce[12B] || ciphertext || GCM_tag[16B]).
3//!
4//! This module is the single source of truth for client-side crypto across
5//! the CLI and desktop. Wire format is bit-compatible with the Go relay /
6//! `cinch/internal/crypto/` Go side; do not change byte layout, HKDF info
7//! string (`cinch-key-xfer`), or nonce length without coordinated updates
8//! to all consumers and `testdata/crypto-vectors.json`.
9
10use aes_gcm::{
11    aead::{Aead, AeadCore, KeyInit, OsRng},
12    Aes256Gcm, Nonce,
13};
14use base64::engine::general_purpose::URL_SAFE_NO_PAD;
15use base64::Engine;
16use hkdf::Hkdf;
17use sha2::Sha256;
18use x25519_dalek::{PublicKey, StaticSecret};
19
20/// Encrypt plaintext with AES-256-GCM.
21/// Returns base64url(nonce[12] || ciphertext || tag[16]).
22pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<String, String> {
23    let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| format!("aes init: {}", e))?;
24    let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
25    let ciphertext = cipher
26        .encrypt(&nonce, plaintext)
27        .map_err(|e| format!("encrypt: {}", e))?;
28    let mut out = nonce.to_vec();
29    out.extend_from_slice(&ciphertext);
30    Ok(URL_SAFE_NO_PAD.encode(&out))
31}
32
33/// Decrypt a base64url-encoded AES-256-GCM payload.
34pub fn decrypt(key: &[u8; 32], encoded: &str) -> Result<Vec<u8>, String> {
35    let data = URL_SAFE_NO_PAD
36        .decode(encoded)
37        .map_err(|e| format!("base64 decode: {}", e))?;
38    if data.len() < 12 + 16 {
39        return Err(format!("ciphertext too short: {} bytes", data.len()));
40    }
41    let (nonce_bytes, ciphertext) = data.split_at(12);
42    let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| format!("aes init: {}", e))?;
43    let nonce = Nonce::from_slice(nonce_bytes);
44    cipher
45        .decrypt(nonce, ciphertext)
46        .map_err(|e| format!("decrypt: {}", e))
47}
48
49/// Derive a 32-byte AES key from X25519 ECDH shared secret via HKDF-SHA256.
50/// `local_priv_b64` and `remote_pub_b64` are base64url-encoded.
51pub fn derive_shared_key(local_priv_b64: &str, remote_pub_b64: &str) -> Result<[u8; 32], String> {
52    let priv_bytes = URL_SAFE_NO_PAD
53        .decode(local_priv_b64)
54        .map_err(|e| format!("decode private key: {}", e))?;
55    let pub_bytes = URL_SAFE_NO_PAD
56        .decode(remote_pub_b64)
57        .map_err(|e| format!("decode public key: {}", e))?;
58
59    if priv_bytes.len() != 32 {
60        return Err(format!(
61            "private key must be 32 bytes, got {}",
62            priv_bytes.len()
63        ));
64    }
65    if pub_bytes.len() != 32 {
66        return Err(format!(
67            "public key must be 32 bytes, got {}",
68            pub_bytes.len()
69        ));
70    }
71
72    let secret = StaticSecret::from(<[u8; 32]>::try_from(priv_bytes.as_slice()).unwrap());
73    let public = PublicKey::from(<[u8; 32]>::try_from(pub_bytes.as_slice()).unwrap());
74    let shared = secret.diffie_hellman(&public);
75
76    let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
77    let mut okm = [0u8; 32];
78    hk.expand(b"cinch-key-xfer", &mut okm)
79        .map_err(|e| format!("hkdf expand: {}", e))?;
80    Ok(okm)
81}
82
83/// Generate an ephemeral X25519 keypair for ECDH key exchange.
84/// Returns (private_key_b64, public_key_b64).
85pub fn generate_ephemeral_keypair() -> (String, String) {
86    let secret = StaticSecret::random_from_rng(OsRng);
87    let public = PublicKey::from(&secret);
88    let priv_b64 = URL_SAFE_NO_PAD.encode(secret.as_bytes());
89    let pub_b64 = URL_SAFE_NO_PAD.encode(public.as_bytes());
90    (priv_b64, pub_b64)
91}
92
93/// Generate a fresh 32-byte AES-256 key. Mirrors the Go side's
94/// `cinchcrypto.GenerateKey()` used by `cinch auth login` to seed the
95/// per-user clip-encryption key.
96pub fn generate_aes_key() -> [u8; 32] {
97    use aes_gcm::aead::rand_core::RngCore;
98    let mut key = [0u8; 32];
99    OsRng.fill_bytes(&mut key);
100    key
101}
102
103/// Generate a static X25519 keypair for the device's long-lived identity
104/// used in encrypted key-exchange bundles. Returns (private_b64, public_b64).
105pub fn generate_device_keypair() -> (String, String) {
106    generate_ephemeral_keypair()
107}
108
109/// Derive the X25519 public key (base64url) from a stored private key
110/// (base64url). Used at login completion to re-register a device's
111/// public key with the relay without needing the keypair generator's
112/// in-memory output.
113pub fn pub_from_priv(priv_b64: &str) -> Result<String, String> {
114    let raw = URL_SAFE_NO_PAD
115        .decode(priv_b64)
116        .map_err(|e| format!("decode private key: {}", e))?;
117    if raw.len() != 32 {
118        return Err(format!("invalid private key length: {}", raw.len()));
119    }
120    let mut buf = [0u8; 32];
121    buf.copy_from_slice(&raw);
122    let secret = StaticSecret::from(buf);
123    let public = PublicKey::from(&secret);
124    Ok(URL_SAFE_NO_PAD.encode(public.as_bytes()))
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_encrypt_decrypt_roundtrip() {
133        let key = [0x42u8; 32];
134        let plaintext = b"hello world";
135        let encoded = encrypt(&key, plaintext).unwrap();
136        let decrypted = decrypt(&key, &encoded).unwrap();
137        assert_eq!(decrypted, plaintext);
138    }
139
140    #[test]
141    fn test_nonce_uniqueness() {
142        let key = [0x42u8; 32];
143        let a = encrypt(&key, b"same").unwrap();
144        let b = encrypt(&key, b"same").unwrap();
145        assert_ne!(a, b);
146    }
147
148    #[test]
149    fn test_tamper_detection() {
150        let key = [0x42u8; 32];
151        let encoded = encrypt(&key, b"test").unwrap();
152        let mut data = URL_SAFE_NO_PAD.decode(&encoded).unwrap();
153        data[15] ^= 0xFF;
154        let tampered = URL_SAFE_NO_PAD.encode(&data);
155        assert!(decrypt(&key, &tampered).is_err());
156    }
157
158    #[test]
159    fn test_ecdh_symmetric() {
160        let (a_priv, a_pub) = generate_ephemeral_keypair();
161        let (b_priv, b_pub) = generate_ephemeral_keypair();
162        let key_ab = derive_shared_key(&a_priv, &b_pub).unwrap();
163        let key_ba = derive_shared_key(&b_priv, &a_pub).unwrap();
164        assert_eq!(key_ab, key_ba);
165    }
166
167    #[test]
168    fn test_wire_format_layout() {
169        let key = [0x42u8; 32];
170        let encoded = encrypt(&key, b"test").unwrap();
171        let data = URL_SAFE_NO_PAD.decode(&encoded).unwrap();
172        // nonce(12) + ciphertext(4) + tag(16) = 32
173        assert_eq!(data.len(), 32);
174    }
175
176    #[test]
177    fn pub_from_priv_matches_generate_device_keypair() {
178        let (priv_b64, expected_pub) = generate_device_keypair();
179        let derived = pub_from_priv(&priv_b64).unwrap();
180        assert_eq!(derived, expected_pub);
181    }
182
183    #[test]
184    fn pub_from_priv_rejects_bad_length() {
185        let bad = URL_SAFE_NO_PAD.encode([0u8; 16]);
186        assert!(pub_from_priv(&bad).is_err());
187    }
188
189    /// Pins the deterministic AES-256-GCM vector embedded in wire-vectors.json.
190    /// key=[0x00;32], nonce=[0x00;12], plaintext=b"hello".
191    /// Changing crypto.rs's wire format will break this; update the JSON vector too.
192    #[test]
193    fn deterministic_vector_decrypts_to_hello() {
194        let key = [0u8; 32];
195        let encoded = "AAAAAAAAAAAAAAAApsIsUSKLkI9_Yv_Opqkvq-85v02T";
196        let got = decrypt(&key, encoded).expect("decrypt");
197        assert_eq!(got, b"hello");
198    }
199
200    #[test]
201    fn test_decrypt_with_different_key_returns_err() {
202        let key_a = generate_aes_key();
203        let key_b = generate_aes_key();
204        assert_ne!(key_a, key_b, "fresh keys must differ");
205
206        let plaintext = b"remote cinch push payload";
207        let blob = encrypt(&key_a, plaintext).expect("encrypt under key A");
208
209        let result = decrypt(&key_b, &blob);
210        assert!(
211            result.is_err(),
212            "decrypting key-A ciphertext under key B must fail"
213        );
214    }
215}