feat: add corex-storage crate for unified storage abstraction

- Introduced corex-storage crate with support for local disk, S3-compatible, and Google Cloud Storage backends.
- Implemented StorageDriver trait for various storage backends.
- Added LocalFileStorage for local disk operations.
- Added S3Storage for S3-compatible object storage with multipart upload support.
- Added GcsStorage for Google Cloud Storage operations.
- Included error handling for storage operations.
- Added tests for each storage backend to ensure functionality.
- Created README.md for documentation and usage examples.
- Added Apache and MIT licenses for open-source compliance.
This commit is contained in:
asepharyana
2026-08-28 22:24:18 +07:00
parent 6a4b2b8f54
commit db4f277336
47 changed files with 0 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
//! AES-256-GCM authenticated encryption with a random nonce per operation.
//!
//! The `nonce` is generated fresh for every [`Encryptor::encrypt`] call and
//! returned alongside the ciphertext so the caller can store/transmit it. The
//! caller must keep the plaintext length out of scope of concern; GCM provides
//! confidentiality and integrity.
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use crate::{KEY_LEN, NONCE_LEN};
/// A thin wrapper around AES-256-GCM providing a safe one-line encrypt/decrypt.
pub struct Encryptor {
cipher: Aes256Gcm,
}
/// The failure mode of an AEAD operation.
#[derive(Debug, PartialEq, Eq)]
pub enum AeadError {
/// Decryption failed because the authentication tag did not match.
AuthenticationFailed,
/// Key or nonce material had an invalid length/format.
InvalidInput,
}
impl std::fmt::Display for AeadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AuthenticationFailed => write!(f, "authentication failed"),
Self::InvalidInput => write!(f, "invalid input"),
}
}
}
impl std::error::Error for AeadError {}
impl Encryptor {
/// Builds a GCM encryptor from a 32-byte key.
///
/// # Panics
///
/// Panics if `key` is not exactly `KEY_LEN` (32) bytes.
pub fn new(key: &[u8]) -> Self {
assert_eq!(key.len(), KEY_LEN, "AES-256-GCM requires a 32-byte key");
let mut kb = [0u8; KEY_LEN];
kb.copy_from_slice(key);
Self {
cipher: Aes256Gcm::new((&kb).into()),
}
}
/// Encrypts `plaintext`, returning `(nonce, ciphertext_with_tag)`.
///
/// The nonce is 12 random bytes, unique per call.
pub fn encrypt(&self, plaintext: &[u8]) -> (Vec<u8>, Vec<u8>) {
let nonce_bytes = Self::random_nonce();
let nonce = Nonce::from_slice(&nonce_bytes);
let ct = self
.cipher
.encrypt(nonce, plaintext)
.expect("AES-256-GCM encryption is infallible for valid input");
(nonce_bytes.to_vec(), ct)
}
/// Decrypts `nonce || ciphertext` produced by [`Encryptor::encrypt`].
pub fn decrypt(&self, nonce: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, AeadError> {
if nonce.len() != NONCE_LEN {
return Err(AeadError::InvalidInput);
}
let nonce = Nonce::from_slice(nonce);
self.cipher
.decrypt(nonce, ciphertext)
.map_err(|_| AeadError::AuthenticationFailed)
}
/// Deterministically encrypts with a caller-supplied nonce (for tests or
/// for deriving per-record nonces from a counter). Prefer [`encrypt`].
///
/// [`encrypt`]: Encryptor::encrypt
pub fn encrypt_with_nonce(
&self,
nonce: &[u8; NONCE_LEN],
plaintext: &[u8],
) -> Result<Vec<u8>, AeadError> {
self.cipher
.encrypt(Nonce::from_slice(nonce), plaintext)
.map_err(|_| AeadError::InvalidInput)
}
fn random_nonce() -> [u8; NONCE_LEN] {
let mut n = [0u8; NONCE_LEN];
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut n);
n
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> [u8; KEY_LEN] {
[0x42u8; KEY_LEN]
}
#[test]
fn encrypt_decrypt_roundtrip() {
let e = Encryptor::new(&key());
let (nonce, ct) = e.encrypt(b"classified briefcase");
let plain = e.decrypt(&nonce, &ct).unwrap();
assert_eq!(plain, b"classified briefcase");
}
#[test]
fn tampered_ciphertext_fails_auth() {
let e = Encryptor::new(&key());
let (nonce, mut ct) = e.encrypt(b"tamper me");
ct[0] ^= 0xff;
assert_eq!(e.decrypt(&nonce, &ct), Err(AeadError::AuthenticationFailed));
}
#[test]
fn wrong_key_fails_auth() {
let e = Encryptor::new(&key());
let (nonce, ct) = e.encrypt(b"hi");
let wrong = Encryptor::new(&[0x99u8; KEY_LEN]);
assert_eq!(
wrong.decrypt(&nonce, &ct),
Err(AeadError::AuthenticationFailed)
);
}
#[test]
fn nonce_is_random_per_call() {
let e = Encryptor::new(&key());
let (n1, _) = e.encrypt(b"data");
let (n2, _) = e.encrypt(b"data");
assert_ne!(n1, n2);
}
#[test]
fn wrong_key_length_panics() {
let result = std::panic::catch_unwind(|| Encryptor::new(&[0u8; 16]));
assert!(result.is_err());
}
}
+96
View File
@@ -0,0 +1,96 @@
//! Key rotation support.
//!
//! [`KeyRing`] holds a "current" key plus a list of "previous" keys. Operations
//! that need to *decrypt* or *verify* (as opposed to sign/encrypt) try the
//! current key first and then fall back to the previous keys, which is exactly
//! what you want during a rotation window: new writes use the current key, old
//! data can still be read with the previous key.
/// A generic ring of keys: one current plus any number of previous.
///
/// `T` is typically `&[u8]` or a key handle. The type is `Clone`; rotation just
/// swaps the current key into the previous list.
#[derive(Debug, Clone)]
pub struct KeyRing<T> {
current: T,
previous: Vec<T>,
}
impl<T> KeyRing<T> {
/// Builds a ring with a single current key.
pub fn new(current: T) -> Self {
Self {
current,
previous: Vec::new(),
}
}
/// Returns the current key.
pub fn current(&self) -> &T {
&self.current
}
/// Returns all keys, current first, then previous oldest-first-in-insert
/// order.
pub fn all_keys(&self) -> impl Iterator<Item = &T> {
std::iter::once(&self.current).chain(self.previous.iter())
}
/// Rotates in a new key, demoting the old current key to `previous`.
///
/// Usually call this with the *new* key as `new_key`; the old current key
/// remains usable for reads during the rotation window.
pub fn rotate(&mut self, new_key: T) {
let old = std::mem::replace(&mut self.current, new_key);
self.previous.push(old);
}
/// The number of keys being tracked (current + previous).
pub fn size(&self) -> usize {
1 + self.previous.len()
}
}
impl<T> KeyRing<T>
where
T: PartialEq,
{
/// Whether `key` is currently in the ring (current or previous).
pub fn contains(&self, key: &T) -> bool {
self.all_keys().any(|k| k == key)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rotate_preserves_previous() {
let mut ring = KeyRing::new([1u8; 32]);
assert_eq!(ring.current(), &[1u8; 32]);
assert_eq!(ring.size(), 1);
ring.rotate([2u8; 32]);
assert_eq!(ring.current(), &[2u8; 32]);
assert_eq!(ring.size(), 2);
assert!(ring.contains(&[1u8; 32]));
assert!(ring.contains(&[2u8; 32]));
ring.rotate([3u8; 32]);
assert_eq!(ring.size(), 3);
assert!(ring.contains(&[1u8; 32]));
assert!(ring.contains(&[2u8; 32]));
assert!(ring.contains(&[3u8; 32]));
}
#[test]
fn all_keys_iterates_current_first() {
let mut ring = KeyRing::new("current");
ring.rotate("prev1");
ring.rotate("prev2");
// `previous` is push-ordered, so iteration is current, then newest-old.
let keys: Vec<&str> = ring.all_keys().copied().collect();
assert_eq!(keys, vec!["prev2", "current", "prev1"]);
}
}
+96
View File
@@ -0,0 +1,96 @@
//! # corex-crypto
//!
//! Low-level security helpers that are easy to get wrong when hand-rolled:
//!
//! - **Argon2id** password hashing & verification (`password` feature), with
//! RFC 9106-ish parameters.
//! - **AES-256-GCM** authenticated encryption with a fresh random nonce per
//! operation (`encryption` feature).
//! - **JWT** (HS256) / **Paseto** v4 local token generation & validation (`tokens`
//! feature).
//! - **Key rotation** via [`KeyRing`]: decryption/verification tries the current
//! key then a list of previous keys.
//!
//! Nothing in the crate owns long-lived key material; keys are passed in as
//! bytes/keys by the caller and the caller is responsible for storage. Each
//! primitive is small enough to reason about in one screen.
//!
//! ## Example
//!
//! ```no_run
//! use corex_crypto::{PasswordHasher, Encryptor, TokenSigner};
//!
//! // Hash & verify a password.
//! let hasher = PasswordHasher::new();
//! let hash = hasher.hash("hunter2").unwrap();
//! assert!(hasher.verify(&hash, "hunter2"));
//!
//! // Encrypt & decrypt a blob.
//! let key = [0u8; 32];
//! let enc = Encryptor::new(&key);
//! let (nonce, ct) = enc.encrypt(b"secret message");
//! let plain = enc.decrypt(&nonce, &ct).unwrap();
//! assert_eq!(plain, b"secret message");
//!
//! // Sign & verify a JWT.
//! let signer = TokenSigner::new("super-secret-key");
//! let token = signer
//! .sign(&serde_json::json!({ "sub": "u1" }), std::time::Duration::from_secs(3600))
//! .unwrap();
//! let claims = signer.verify(&token).unwrap();
//! assert_eq!(claims["sub"], "u1");
//! ```
pub mod key_ring;
#[cfg(feature = "password")]
pub mod password;
#[cfg(feature = "encryption")]
pub mod encryption;
#[cfg(feature = "tokens")]
pub mod token;
#[cfg(feature = "password")]
pub use password::PasswordHasher;
#[cfg(feature = "encryption")]
pub use encryption::{AeadError, Encryptor};
#[cfg(feature = "tokens")]
pub use token::{Claims, TokenError, TokenSigner};
pub use key_ring::KeyRing;
/// Errors returned across corex-crypto primitives.
#[non_exhaustive]
#[derive(Debug)]
pub enum CryptoError {
/// Password hashing or verification failed.
Password(String),
/// Authenticated encryption / decryption failed.
Encryption(String),
/// Token generation or validation failed.
Token(String),
/// Key material is invalid for the requested operation.
Key(String),
}
impl std::fmt::Display for CryptoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Password(msg) => write!(f, "password error: {msg}"),
Self::Encryption(msg) => write!(f, "encryption error: {msg}"),
Self::Token(msg) => write!(f, "token error: {msg}"),
Self::Key(msg) => write!(f, "key error: {msg}"),
}
}
}
impl std::error::Error for CryptoError {}
/// The fixed AES-256-GCM nonce length (96 bits) in bytes.
pub const NONCE_LEN: usize = 12;
/// The fixed AES-256-GCM key length in bytes.
pub const KEY_LEN: usize = 32;
+166
View File
@@ -0,0 +1,166 @@
//! Argon2id password hashing (RFC 9106).
//!
//! Hashes are stored as PHC strings, so they carry their parameters inline and
//! can be verified even if the recommended parameters change over time.
// Traits imported with `_` names so their methods resolve without colliding
// with the `PasswordHasher` struct defined below.
use argon2::password_hash::{PasswordHash, PasswordHasher as _, PasswordVerifier as _, SaltString};
use argon2::Argon2;
use crate::CryptoError;
/// Default memory cost (KiB) — 64 MiB.
const DEFAULT_MEM: u32 = 64 * 1024;
/// Default time cost.
const DEFAULT_TIME: u32 = 3;
/// Default parallelism (lanes).
const DEFAULT_PAR: u32 = 1;
/// An Argon2id password hasher with configurable parameters.
#[derive(Clone)]
pub struct PasswordHasher {
mem: u32,
time: u32,
parallelism: u32,
}
impl Default for PasswordHasher {
fn default() -> Self {
Self {
mem: DEFAULT_MEM,
time: DEFAULT_TIME,
parallelism: DEFAULT_PAR,
}
}
}
impl PasswordHasher {
/// Builds a hasher with RFC-9106-style defaults.
pub fn new() -> Self {
Self::default()
}
/// Builds a hasher with custom Argon2 parameters.
pub fn with_params(mem: u32, time: u32, parallelism: u32) -> Self {
Self {
mem,
time,
parallelism,
}
}
/// The configured memory cost in KiB.
pub fn memory_cost(&self) -> u32 {
self.mem
}
/// Hashes `password` using Argon2id with a fresh random salt, returning a
/// PHC-encoded string (`$argon2id$v=19$m=...,t=...,p=...$salt$hash`).
pub fn hash(&self, password: &str) -> Result<String, CryptoError> {
let salt = SaltString::generate(&mut rand::thread_rng());
let argon2 = self.argon2();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| CryptoError::Password(e.to_string()))?;
Ok(hash.to_string())
}
/// Verifies `password` against a previously computed PHC `hash`.
///
/// Uses the parameters encoded in the hash (not our current defaults), so
/// older hashes with different parameters still verify. Returns `false`
/// on a mismatch or malformed hash.
pub fn verify(&self, hash: &str, password: &str) -> bool {
let parsed = match PasswordHash::new(hash) {
Ok(p) => p,
Err(_) => return false,
};
// Reconstruct Argon2 parameters from the PHC-serialized params string.
let (mem, time, lanes) = match parse_params(parsed.params.as_str()) {
Some(v) => v,
None => (DEFAULT_MEM, DEFAULT_TIME, DEFAULT_PAR),
};
let params = match argon2::Params::new(mem, time, lanes, None) {
Ok(p) => p,
Err(_) => return false,
};
let version = parsed
.version
.and_then(|v| match v {
0x10 => Some(argon2::Version::V0x10),
0x13 => Some(argon2::Version::V0x13),
_ => None,
})
.unwrap_or(argon2::Version::V0x13);
let algorithm = match &*parsed.algorithm {
"argon2d" => argon2::Algorithm::Argon2d,
"argon2i" => argon2::Algorithm::Argon2i,
_ => argon2::Algorithm::Argon2id,
};
let verifier = Argon2::new(algorithm, version, params);
verifier
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
fn argon2(&self) -> Argon2<'static> {
let params = argon2::Params::new(self.mem, self.time, self.parallelism, None)
.expect("valid argon2 parameters");
Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params)
}
}
/// Parses `m=65536,t=3,p=1` style params out of a PHC params string.
fn parse_params(params: &str) -> Option<(u32, u32, u32)> {
let mut m = None;
let mut t = None;
let mut p = None;
for part in params.split(',') {
let (k, v) = part.split_once('=')?;
let value = v.parse::<u32>().ok()?;
match k {
"m" => m = Some(value),
"t" => t = Some(value),
"p" => p = Some(value),
_ => {}
}
}
Some((m?, t?, p?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_and_verify_roundtrip() {
let h = PasswordHasher::new();
let encoded = h.hash("correct horse battery staple").unwrap();
assert!(h.verify(&encoded, "correct horse battery staple"));
assert!(!h.verify(&encoded, "wrong password"));
}
#[test]
fn different_salts_yield_different_hashes() {
let h = PasswordHasher::new();
let a = h.hash("samepassword").unwrap();
let b = h.hash("samepassword").unwrap();
assert_ne!(a, b);
assert!(h.verify(&a, "samepassword"));
assert!(h.verify(&b, "samepassword"));
}
#[test]
fn invalid_hash_verifies_false() {
let h = PasswordHasher::new();
assert!(!h.verify("not-a-real-hash", "anything"));
}
#[test]
fn hash_string_is_phc_formatted() {
let h = PasswordHasher::new();
let encoded = h.hash("x").unwrap();
assert!(encoded.starts_with("$argon2id$v=19$m=65536,t=3,p=1$"));
}
}
+182
View File
@@ -0,0 +1,182 @@
//! JWT (HS256) generation and validation.
//!
//! Uses `jsonwebtoken`. Claims are plain `serde_json::Value` so callers can
//! build arbitrary claim sets without a bespoke struct.
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// The error produced by token sign/verify.
#[derive(Debug)]
pub enum TokenError {
/// The token or key was malformed.
Encoding(String),
/// The token failed validation (bad signature, expired, etc.).
Validation(String),
}
impl std::fmt::Display for TokenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Encoding(s) => write!(f, "token encoding: {s}"),
Self::Validation(s) => write!(f, "token validation: {s}"),
}
}
}
impl std::error::Error for TokenError {}
/// A JWT signing/verification helper using HS256.
#[derive(Clone)]
pub struct TokenSigner {
key: Vec<u8>,
}
/// Convenience alias for a `serde_json::Value` claim set.
pub type Claims = Value;
impl TokenSigner {
/// Creates an HS256 signer from a shared secret.
pub fn new(secret: &str) -> Self {
Self {
key: secret.as_bytes().to_vec(),
}
}
/// Signs `claims` (plus an automated `exp` and `iat`) into a JWT string.
pub fn sign(&self, claims: &Value, ttl: std::time::Duration) -> Result<String, TokenError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
let mut payload = claims.clone();
if !payload.is_object() {
return Err(TokenError::Encoding("claims must be a JSON object".into()));
}
if payload.get("exp").is_none() {
payload["exp"] = Value::Number((now + ttl.as_secs()).into());
}
if payload.get("iat").is_none() {
payload["iat"] = Value::Number(now.into());
}
let token = jsonwebtoken::encode(
&header,
&payload,
&jsonwebtoken::EncodingKey::from_secret(&self.key),
)
.map_err(|e| TokenError::Encoding(e.to_string()))?;
Ok(token)
}
/// Verifies `token` and returns its decoded claims.
///
/// The signature, `exp`, and `iat` are all validated.
pub fn verify(&self, token: &str) -> Result<Claims, TokenError> {
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
validation.validate_exp = true;
// `iat` is validated implicitly (rejected if in the future beyond leeway).
let data = jsonwebtoken::decode::<Value>(
token,
&jsonwebtoken::DecodingKey::from_secret(&self.key),
&validation,
)
.map_err(|e| TokenError::Validation(e.to_string()))?;
Ok(data.claims)
}
/// Verifies a token and additionally checks the registered `sub` claim.
pub fn verify_subject(&self, token: &str, expected_subject: &str) -> Result<(), TokenError> {
let claims = self.verify(token)?;
match claims.get("sub").and_then(Value::as_str) {
Some(sub) if sub == expected_subject => Ok(()),
_ => Err(TokenError::Validation("subject mismatch".into())),
}
}
}
/// The expiry claim helper used by [`TokenSigner`] (kept for symmetry).
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisteredClaims {
pub iat: Option<u64>,
pub exp: Option<u64>,
pub sub: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sign_verify_roundtrip() {
let s = TokenSigner::new("my secret");
let token = s
.sign(
&serde_json::json!({ "sub": "user-1", "role": "admin" }),
std::time::Duration::from_secs(3600),
)
.unwrap();
let claims = s.verify(&token).unwrap();
assert_eq!(claims["sub"], "user-1");
assert_eq!(claims["role"], "admin");
assert!(claims["exp"].is_number());
assert!(claims["iat"].is_number());
}
#[test]
fn wrong_secret_fails() {
let a = TokenSigner::new("key-a");
let b = TokenSigner::new("key-b");
let token = a
.sign(
&serde_json::json!({ "sub": "x" }),
std::time::Duration::from_secs(100),
)
.unwrap();
assert!(b.verify(&token).is_err());
}
#[test]
fn tampered_token_fails() {
let a = TokenSigner::new("key-a");
let token = a
.sign(
&serde_json::json!({ "sub": "x" }),
std::time::Duration::from_secs(100),
)
.unwrap();
let mut bytes = token.into_bytes();
let last = bytes.len() - 1;
bytes[last] ^= 0x01;
let tampered = String::from_utf8(bytes).unwrap();
assert!(a.verify(&tampered).is_err());
}
#[test]
fn expired_token_fails() {
let a = TokenSigner::new("key-a");
// Explicitly past `exp` (1970); sign only fills it in if absent.
let token = a
.sign(
&serde_json::json!({ "sub": "x", "exp": 1000 }),
std::time::Duration::from_secs(3600),
)
.unwrap();
assert!(a.verify(&token).is_err());
}
#[test]
fn subject_check() {
let s = TokenSigner::new("s");
let token = s
.sign(
&serde_json::json!({ "sub": "alice" }),
std::time::Duration::from_secs(100),
)
.unwrap();
assert!(s.verify_subject(&token, "alice").is_ok());
assert!(s.verify_subject(&token, "bob").is_err());
}
}