Initial commit
This commit is contained in:
14
crates/cgcx-crypto/Cargo.toml
Normal file
14
crates/cgcx-crypto/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "cgcx-crypto"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
cgcx-core = { path = "../cgcx-core" }
|
||||
rand = "0.8"
|
||||
blake3 = "1.5"
|
||||
sodiumoxide = "0.2"
|
||||
aes-kw = "0.2"
|
||||
aes = "0.8"
|
||||
hex = "0.4"
|
||||
tracing = "0.1"
|
||||
107
crates/cgcx-crypto/src/lib.rs
Normal file
107
crates/cgcx-crypto/src/lib.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use blake3::Hasher;
|
||||
use sodiumoxide::crypto::secretstream::xchacha20poly1305;
|
||||
use std::path::Path;
|
||||
|
||||
pub mod master_key;
|
||||
pub use master_key::MasterKey;
|
||||
|
||||
const KEY_WRAP_VERSION: u8 = 0x01;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContentKey {
|
||||
pub key: xchacha20poly1305::Key,
|
||||
}
|
||||
|
||||
impl ContentKey {
|
||||
pub fn generate() -> Self {
|
||||
let key = xchacha20poly1305::gen_key();
|
||||
Self { key }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wrap_content_key(content_key: &xchacha20poly1305::Key, master_key: &MasterKey) -> Vec<u8> {
|
||||
let kek: aes_kw::KekAes256 = (*master_key.as_bytes()).into();
|
||||
let key_bytes = content_key.as_ref();
|
||||
let mut wrapped = vec![0u8; key_bytes.len() + 8];
|
||||
kek.wrap(key_bytes, &mut wrapped).expect("AES-KW wrap failed");
|
||||
let mut out = vec![KEY_WRAP_VERSION];
|
||||
out.extend_from_slice(&wrapped);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn unwrap_content_key(wrapped: &[u8], master_key: &MasterKey) -> cgcx_core::Result<xchacha20poly1305::Key> {
|
||||
if wrapped.is_empty() || wrapped[0] != KEY_WRAP_VERSION {
|
||||
return Err(cgcx_core::CgcxError::Crypto("unsupported key wrap version".into()));
|
||||
}
|
||||
let kek: aes_kw::KekAes256 = (*master_key.as_bytes()).into();
|
||||
let wrapped_key = &wrapped[1..];
|
||||
let mut unwrapped = vec![0u8; wrapped_key.len().saturating_sub(8)];
|
||||
kek.unwrap(wrapped_key, &mut unwrapped)
|
||||
.map_err(|e| cgcx_core::CgcxError::Crypto(format!("AES-KW unwrap failed: {:?}", e)))?;
|
||||
xchacha20poly1305::Key::from_slice(&unwrapped)
|
||||
.ok_or_else(|| cgcx_core::CgcxError::Crypto("invalid unwrapped key length".into()))
|
||||
}
|
||||
|
||||
pub struct EncryptStream {
|
||||
stream: xchacha20poly1305::Stream<xchacha20poly1305::Push>,
|
||||
hasher: Hasher,
|
||||
header: xchacha20poly1305::Header,
|
||||
}
|
||||
|
||||
impl EncryptStream {
|
||||
pub fn new(key: &xchacha20poly1305::Key) -> Self {
|
||||
let (stream, header) = xchacha20poly1305::Stream::init_push(key)
|
||||
.expect("secretstream init_push failed");
|
||||
let mut hasher = Hasher::new();
|
||||
hasher.update(header.as_ref());
|
||||
Self { stream, hasher, header }
|
||||
}
|
||||
|
||||
pub fn header(&self) -> &xchacha20poly1305::Header {
|
||||
&self.header
|
||||
}
|
||||
|
||||
pub fn push(&mut self, plaintext: &[u8], tag: xchacha20poly1305::Tag) -> Vec<u8> {
|
||||
let ciphertext = self.stream.push(plaintext, None, tag)
|
||||
.expect("secretstream push failed");
|
||||
self.hasher.update(&ciphertext);
|
||||
ciphertext
|
||||
}
|
||||
|
||||
pub fn finalize(self) -> [u8; 32] {
|
||||
self.hasher.finalize().into()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DecryptStream {
|
||||
stream: xchacha20poly1305::Stream<xchacha20poly1305::Pull>,
|
||||
hasher: Hasher,
|
||||
}
|
||||
|
||||
impl DecryptStream {
|
||||
pub fn new(key: &xchacha20poly1305::Key, header: &xchacha20poly1305::Header) -> cgcx_core::Result<Self> {
|
||||
let stream = xchacha20poly1305::Stream::init_pull(header, key)
|
||||
.map_err(|_| cgcx_core::CgcxError::Crypto("secretstream init_pull failed".into()))?;
|
||||
let mut hasher = Hasher::new();
|
||||
hasher.update(header.as_ref());
|
||||
Ok(Self { stream, hasher })
|
||||
}
|
||||
|
||||
pub fn pull(&mut self, ciphertext: &[u8]) -> cgcx_core::Result<(Vec<u8>, xchacha20poly1305::Tag)> {
|
||||
let result = self.stream.pull(ciphertext, None)
|
||||
.map_err(|_| cgcx_core::CgcxError::Crypto("secretstream pull failed (tampered data?)".into()))?;
|
||||
self.hasher.update(ciphertext);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn finalize(self) -> [u8; 32] {
|
||||
self.hasher.finalize().into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hash_file_at_path(path: &Path) -> cgcx_core::Result<[u8; 32]> {
|
||||
let mut hasher = Hasher::new();
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
std::io::copy(&mut file, &mut hasher)?;
|
||||
Ok(hasher.finalize().into())
|
||||
}
|
||||
93
crates/cgcx-crypto/src/master_key.rs
Normal file
93
crates/cgcx-crypto/src/master_key.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use blake3::Hasher;
|
||||
use rand::RngCore;
|
||||
use std::path::Path;
|
||||
use tracing::{info, trace, warn};
|
||||
|
||||
pub struct MasterKey([u8; 32]);
|
||||
|
||||
impl MasterKey {
|
||||
pub fn generate() -> Self {
|
||||
let mut key = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut key);
|
||||
Self(key)
|
||||
}
|
||||
|
||||
pub fn from_hex(hex_str: &str) -> cgcx_core::Result<Self> {
|
||||
let bytes = hex::decode(hex_str.trim())
|
||||
.map_err(|e| cgcx_core::CgcxError::Crypto(format!("invalid master key hex: {}", e)))?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(cgcx_core::CgcxError::Crypto(format!(
|
||||
"master key must be 32 bytes (64 hex chars), got {} bytes",
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&bytes);
|
||||
Ok(Self(key))
|
||||
}
|
||||
|
||||
pub fn to_hex(&self) -> String {
|
||||
hex::encode(self.0)
|
||||
}
|
||||
|
||||
pub fn as_bytes(&self) -> &[u8; 32] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn fingerprint(&self) -> String {
|
||||
let hash = Hasher::new().update(&self.0).finalize();
|
||||
hex::encode(&hash.as_bytes()[..8])
|
||||
}
|
||||
|
||||
pub fn load_from_env(var: &str) -> cgcx_core::Result<Self> {
|
||||
sodiumoxide::init().map_err(|_| cgcx_core::CgcxError::Crypto("sodiumoxide init failed".into()))?;
|
||||
match std::env::var(var) {
|
||||
Ok(val) => {
|
||||
let key = Self::from_hex(&val)?;
|
||||
info!("Master key loaded from env var {}", var);
|
||||
Ok(key)
|
||||
}
|
||||
Err(_) => {
|
||||
let key = Self::generate();
|
||||
warn!(
|
||||
"Env var {} not set. A new master key has been generated.\n\
|
||||
SAVE THIS KEY IMMEDIATELY (64 hex chars):\n{}\n\
|
||||
Set it as {}=<key> to persist across restarts.",
|
||||
var, key.to_hex(), var
|
||||
);
|
||||
Ok(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_from_file(path: &Path) -> cgcx_core::Result<Self> {
|
||||
sodiumoxide::init().map_err(|_| cgcx_core::CgcxError::Crypto("sodiumoxide init failed".into()))?;
|
||||
if path.exists() {
|
||||
let val = std::fs::read_to_string(path)
|
||||
.map_err(|e| cgcx_core::CgcxError::Crypto(format!("read key file: {}", e)))?;
|
||||
let key = Self::from_hex(&val)?;
|
||||
info!("Master key loaded from file {:?}", path);
|
||||
Ok(key)
|
||||
} else {
|
||||
let key = Self::generate();
|
||||
std::fs::write(path, key.to_hex())
|
||||
.map_err(|e| cgcx_core::CgcxError::Crypto(format!("write key file: {}", e)))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(path).unwrap().permissions();
|
||||
perms.set_mode(0o600);
|
||||
std::fs::set_permissions(path, perms).ok();
|
||||
}
|
||||
warn!("Generated new master key and wrote to {:?}", path);
|
||||
Ok(key)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log_startup(&self, debug_log_keys: bool) {
|
||||
info!("Storage master key loaded. fingerprint={}", self.fingerprint());
|
||||
if debug_log_keys {
|
||||
trace!("Master key full hex: {}", self.to_hex());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user