chore: sync konduit-platform
This commit is contained in:
383
konduit-platform/src/crypto.rs
Normal file
383
konduit-platform/src/crypto.rs
Normal file
@@ -0,0 +1,383 @@
|
||||
use anyhow::Result;
|
||||
use chacha20poly1305::{
|
||||
aead::{Aead, KeyInit},
|
||||
ChaCha20Poly1305, Key, Nonce,
|
||||
};
|
||||
use rand::rngs::OsRng;
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
pub struct Cipher {
|
||||
inner: ChaCha20Poly1305,
|
||||
}
|
||||
|
||||
impl Cipher {
|
||||
pub fn new(key_bytes: &[u8; 32]) -> Self {
|
||||
let key = Key::from_slice(key_bytes);
|
||||
Self {
|
||||
inner: ChaCha20Poly1305::new(key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encrypt(&self, nonce: &[u8; 12], plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
let nonce = Nonce::from_slice(nonce);
|
||||
self.inner
|
||||
.encrypt(nonce, plaintext)
|
||||
.map_err(|_e| anyhow::anyhow!("Encryption error"))
|
||||
}
|
||||
|
||||
pub fn decrypt(&self, nonce: &[u8; 12], ciphertext: &[u8]) -> Result<Vec<u8>> {
|
||||
let nonce = Nonce::from_slice(nonce);
|
||||
self.inner
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_e| anyhow::anyhow!("Decryption error"))
|
||||
}
|
||||
}
|
||||
|
||||
// X25519 Key Exchange for Session Encryption
|
||||
pub struct KeyExchange {
|
||||
secret: StaticSecret,
|
||||
}
|
||||
|
||||
impl KeyExchange {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
secret: StaticSecret::random_from_rng(OsRng),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_secret_bytes(bytes: [u8; 32]) -> Self {
|
||||
Self {
|
||||
secret: StaticSecret::from(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_key_bytes(&self) -> [u8; 32] {
|
||||
let pub_key = PublicKey::from(&self.secret);
|
||||
*pub_key.as_bytes()
|
||||
}
|
||||
|
||||
pub fn exchange(&self, peer_public: &[u8; 32]) -> [u8; 32] {
|
||||
let peer_pub = PublicKey::from(*peer_public);
|
||||
let shared = self.secret.diffie_hellman(&peer_pub);
|
||||
*shared.as_bytes()
|
||||
}
|
||||
|
||||
pub fn as_bytes(&self) -> [u8; 32] {
|
||||
self.secret.to_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
// Ed25519 Identity Keys for Micro-CA Signatures
|
||||
use ed25519_dalek::{Signer, Verifier};
|
||||
|
||||
pub struct IdentityKey {
|
||||
keypair: ed25519_dalek::SigningKey,
|
||||
}
|
||||
|
||||
impl IdentityKey {
|
||||
pub fn generate() -> Self {
|
||||
use rand::RngCore;
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
Self {
|
||||
keypair: ed25519_dalek::SigningKey::from_bytes(&bytes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8; 32]) -> Self {
|
||||
Self {
|
||||
keypair: ed25519_dalek::SigningKey::from_bytes(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_key_bytes(&self) -> [u8; 32] {
|
||||
self.keypair.verifying_key().to_bytes()
|
||||
}
|
||||
|
||||
pub fn sign(&self, message: &[u8]) -> [u8; 64] {
|
||||
self.keypair.sign(message).to_bytes()
|
||||
}
|
||||
|
||||
pub fn as_bytes(&self) -> [u8; 32] {
|
||||
self.keypair.to_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IdentityVerifier;
|
||||
|
||||
impl IdentityVerifier {
|
||||
pub fn verify(pub_key: &[u8; 32], message: &[u8], signature: &[u8; 64]) -> Result<()> {
|
||||
let key = ed25519_dalek::VerifyingKey::from_bytes(pub_key)
|
||||
.map_err(|_| anyhow::anyhow!("Invalid Ed25519 public key"))?;
|
||||
let sig = ed25519_dalek::Signature::from_bytes(signature);
|
||||
key.verify(message, &sig)
|
||||
.map_err(|_| anyhow::anyhow!("Signature verification failed"))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Key Derivation (Brain Wallet) ────────────────────────────────────────────
|
||||
//
|
||||
// Algorithm: Argon2id (memory-hard, side-channel resistant)
|
||||
// All improvements over the original PBKDF2 path:
|
||||
// 1. Memory-hard KDF (GPU/ASIC resistance)
|
||||
// 2. Salt is 32 cryptographically random bytes (not a string)
|
||||
// 3. Passphrase is NFKC-normalised + trimmed before hashing
|
||||
// 4. KDF parameters are stored alongside derived data (recoverability)
|
||||
// 5. Format version field enables future algorithm agility
|
||||
// 6. Returns Result<> — no panics in crypto code
|
||||
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
/// Versioned KDF parameters that must be stored alongside any data protected
|
||||
/// by a key derived from a mantra. They are everything needed to reproduce the
|
||||
/// exact same key from the same mantra on any platform, now or in the future.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct KdfParams {
|
||||
/// Format version (currently 2). Increment when changing algorithm or
|
||||
/// parameter semantics so old configs can still be read.
|
||||
pub version: u8,
|
||||
/// KDF algorithm name — always "argon2id" for version 2.
|
||||
pub algorithm: String,
|
||||
/// Memory cost in KiB (262144 = 256 MiB).
|
||||
pub m_cost: u32,
|
||||
/// Number of iterations / time cost.
|
||||
pub t_cost: u32,
|
||||
/// Degree of parallelism.
|
||||
pub p_cost: u32,
|
||||
/// 32 cryptographically-random bytes, hex-encoded. Never derived from user
|
||||
/// input. Must be stored; losing it makes the key irrecoverable.
|
||||
pub salt_hex: String,
|
||||
}
|
||||
|
||||
impl KdfParams {
|
||||
/// Default secure parameters: RFC 9106 "interactive" profile.
|
||||
/// ~256 MiB RAM, 3 passes, 4 threads — takes ≈1–3 s on typical hardware.
|
||||
pub const DEFAULT_M_COST: u32 = 262_144; // 256 MiB
|
||||
pub const DEFAULT_T_COST: u32 = 3;
|
||||
pub const DEFAULT_P_COST: u32 = 4;
|
||||
|
||||
/// Create new v2 params with the given pre-generated random salt.
|
||||
pub fn new_v2(salt: [u8; 32]) -> Self {
|
||||
Self {
|
||||
version: 2,
|
||||
algorithm: "argon2id".to_string(),
|
||||
m_cost: Self::DEFAULT_M_COST,
|
||||
t_cost: Self::DEFAULT_T_COST,
|
||||
p_cost: Self::DEFAULT_P_COST,
|
||||
salt_hex: hex::encode(salt),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct KeyGenerator;
|
||||
|
||||
impl KeyGenerator {
|
||||
/// Generate a fresh [`KdfParams`] with a cryptographically-random 32-byte
|
||||
/// salt. Call this once per bootstrap; then store the params in `server.toml`.
|
||||
pub fn generate_kdf_params() -> KdfParams {
|
||||
let mut salt = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
KdfParams::new_v2(salt)
|
||||
}
|
||||
|
||||
/// Derive a 32-byte master key from a human passphrase (mantra).
|
||||
///
|
||||
/// ## What this does
|
||||
/// 1. **NFKC-normalise** the passphrase and trim surrounding whitespace so
|
||||
/// that different Unicode representations of the same text always hash
|
||||
/// identically (critical for cross-platform recovery).
|
||||
/// 2. Decode the random salt from `params.salt_hex`.
|
||||
/// 3. Run **Argon2id** with the stored memory/time/parallelism parameters.
|
||||
///
|
||||
/// Returns `Err` on invalid params or hex — never panics.
|
||||
pub fn derive_from_passphrase(passphrase: &str, params: &KdfParams) -> Result<[u8; 32]> {
|
||||
// Step 1: NFKC normalise + trim (cross-platform / cross-keyboard safety)
|
||||
let normalised: String = passphrase.nfkc().collect::<String>().trim().to_string();
|
||||
if normalised.is_empty() {
|
||||
anyhow::bail!("Passphrase must not be empty after normalisation");
|
||||
}
|
||||
|
||||
// Step 2: Decode the stored random salt
|
||||
let salt_bytes = hex::decode(¶ms.salt_hex)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid salt hex: {}", e))?;
|
||||
|
||||
// Step 3: Build Argon2id with stored parameters
|
||||
let argon2_params = Params::new(params.m_cost, params.t_cost, params.p_cost, Some(32))
|
||||
.map_err(|e| anyhow::anyhow!("Invalid Argon2 parameters: {}", e))?;
|
||||
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
argon2
|
||||
.hash_password_into(normalised.as_bytes(), &salt_bytes, &mut key)
|
||||
.map_err(|e| anyhow::anyhow!("Argon2id derivation failed: {}", e))?;
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// **Legacy v1 path — signature preserved for migration tooling.**
|
||||
///
|
||||
/// The pbkdf2/hmac/sha2 crates have been removed. To re-enable this for a
|
||||
/// migration binary, add those deps back and replace this body.
|
||||
#[allow(dead_code)]
|
||||
pub fn derive_from_passphrase_v1_legacy(_passphrase: &str, _salt: &str) -> ! {
|
||||
panic!(
|
||||
"Legacy PBKDF2 path is not compiled in. \
|
||||
Add pbkdf2/hmac/sha2 deps and restore the body to use this."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionKeys {
|
||||
pub server_to_client: [u8; 32],
|
||||
pub client_to_server: [u8; 32],
|
||||
}
|
||||
|
||||
impl SessionKeys {
|
||||
pub fn derive(shared_secret: &[u8; 32], client_pub: &[u8; 32], server_pub: &[u8; 32]) -> Self {
|
||||
// Simple HKDF-like derivation using BLAKE3
|
||||
let mut context = Vec::new();
|
||||
context.extend_from_slice(client_pub);
|
||||
context.extend_from_slice(server_pub);
|
||||
|
||||
let mut hasher = blake3::Hasher::new_derive_key("konduit-s2c");
|
||||
hasher.update(shared_secret);
|
||||
hasher.update(&context);
|
||||
let mut s2c = [0u8; 32];
|
||||
s2c.copy_from_slice(hasher.finalize().as_bytes());
|
||||
|
||||
let mut hasher = blake3::Hasher::new_derive_key("konduit-c2s");
|
||||
hasher.update(shared_secret);
|
||||
hasher.update(&context);
|
||||
let mut c2s = [0u8; 32];
|
||||
c2s.copy_from_slice(hasher.finalize().as_bytes());
|
||||
|
||||
SessionKeys {
|
||||
server_to_client: s2c,
|
||||
client_to_server: c2s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NonceCounter {
|
||||
counter: u64,
|
||||
}
|
||||
|
||||
impl NonceCounter {
|
||||
pub fn new() -> Self {
|
||||
Self { counter: 0 }
|
||||
}
|
||||
|
||||
pub fn next(&mut self) -> [u8; 12] {
|
||||
let mut nonce = [0u8; 12];
|
||||
// 4 bytes random prefix (salt) to prevent collision on restart?
|
||||
// For now just 12 bytes counter le
|
||||
nonce[0..8].copy_from_slice(&self.counter.to_le_bytes());
|
||||
self.counter += 1;
|
||||
nonce
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute HMAC-like MAC using BLAKE3 for authentication
|
||||
pub fn compute_mac(key: &[u8], message: &[u8]) -> Result<[u8; 32]> {
|
||||
use blake3::Hasher;
|
||||
|
||||
let key_32: [u8; 32] = key
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("Mac key must be exactly 32 bytes (64 hex characters)"))?;
|
||||
|
||||
let mut hasher = Hasher::new_keyed(&key_32);
|
||||
hasher.update(message);
|
||||
Ok(*hasher.finalize().as_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hmac_deterministic() {
|
||||
let key = b"test-key-32-bytes-long-padding!!";
|
||||
let message = b"hello world";
|
||||
|
||||
let hmac1 = compute_mac(key, message).unwrap();
|
||||
let hmac2 = compute_mac(key, message).unwrap();
|
||||
|
||||
assert_eq!(hmac1, hmac2, "HMAC should be deterministic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_different_keys() {
|
||||
let key1 = b"key1-32-bytes-long-padding!!!!!!";
|
||||
let key2 = b"key2-32-bytes-long-padding!!!!!!";
|
||||
let message = b"hello world";
|
||||
|
||||
let hmac1 = compute_mac(key1, message).unwrap();
|
||||
let hmac2 = compute_mac(key2, message).unwrap();
|
||||
|
||||
assert_ne!(
|
||||
hmac1, hmac2,
|
||||
"Different keys should produce different HMACs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kdf_deterministic() {
|
||||
let mantra = "my secret server mantra";
|
||||
let params = KdfParams::new_v2([42u8; 32]);
|
||||
|
||||
let key1 = KeyGenerator::derive_from_passphrase(mantra, ¶ms).unwrap();
|
||||
let key2 = KeyGenerator::derive_from_passphrase(mantra, ¶ms).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
key1, key2,
|
||||
"Derivation should be exactly deterministic for the same mantra and params"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kdf_different_salts() {
|
||||
let mantra = "my secret server mantra";
|
||||
let params1 = KdfParams::new_v2([1u8; 32]);
|
||||
let params2 = KdfParams::new_v2([2u8; 32]);
|
||||
|
||||
let key1 = KeyGenerator::derive_from_passphrase(mantra, ¶ms1).unwrap();
|
||||
let key2 = KeyGenerator::derive_from_passphrase(mantra, ¶ms2).unwrap();
|
||||
|
||||
assert_ne!(key1, key2, "Different salts must produce different keys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kdf_different_mantras() {
|
||||
let params = KdfParams::new_v2([42u8; 32]);
|
||||
|
||||
let key1 = KeyGenerator::derive_from_passphrase("mantra A", ¶ms).unwrap();
|
||||
let key2 = KeyGenerator::derive_from_passphrase("mantra B", ¶ms).unwrap();
|
||||
|
||||
assert_ne!(key1, key2, "Different mantras must produce different keys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kdf_nfkc_normalization() {
|
||||
// "é" can be represented as a single code point (U+00E9) or two (U+0065, U+0301)
|
||||
let composed = "stréssed";
|
||||
let decomposed = "stre\u{0301}ssed";
|
||||
|
||||
let params = KdfParams::new_v2([42u8; 32]);
|
||||
|
||||
let key1 = KeyGenerator::derive_from_passphrase(composed, ¶ms).unwrap();
|
||||
let key2 = KeyGenerator::derive_from_passphrase(decomposed, ¶ms).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
key1, key2,
|
||||
"NFKC normalization must ensure visually identical mantras yield the same key"
|
||||
);
|
||||
|
||||
// Also test leading/trailing whitespace trimming
|
||||
let trimmed = KeyGenerator::derive_from_passphrase(" stréssed \n ", ¶ms).unwrap();
|
||||
assert_eq!(key1, trimmed, "Whitespace should be trimmed securely");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user