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
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "corex-crypto"
version = "1.2.0"
edition = "2021"
rust-version = "1.75"
license = "MIT OR Apache-2.0"
repository = "https://github.com/asepharyana/mytheclipse"
homepage = "https://github.com/asepharyana/mytheclipse"
documentation = "https://docs.rs/corex-crypto"
authors = ["asepharyana <superaseph@gmail.com>"]
description = "Safe hashing, encryption, and token helpers (Argon2id, AES-256-GCM, JWT/Paseto) with key rotation support."
readme = "README.md"
keywords = ["crypto", "argon2", "aes-gcm", "jwt", "security"]
categories = ["cryptography", "authentication"]
[features]
default = ["password", "encryption", "tokens"]
# Low-level primitives are always available (zero-cost). The feature flags
# pull in the backing SDK crates.
password = ["dep:password-hash", "dep:argon2"]
encryption = ["dep:aead", "dep:aes-gcm", "dep:rand_core", "dep:rand"]
tokens = ["encryption", "dep:serde", "dep:serde_json", "dep:base64", "dep:jsonwebtoken"]
[dependencies]
tracing = "0.1"
# Optional backends
argon2 = { version = "0.5", default-features = false, features = ["std"], optional = true }
password-hash = { version = "0.5", default-features = false, features = ["std"], optional = true }
aes-gcm = { version = "0.10", default-features = false, features = ["aes", "alloc"], optional = true }
aead = { version = "0.5", default-features = false, features = ["alloc"], optional = true }
jsonwebtoken = { version = "9", default-features = false, optional = true }
base64 = { version = "0.22", default-features = false, optional = true }
serde = { version = "1", optional = true, features = ["derive"] }
serde_json = { version = "1", optional = true }
rand = { version = "0.8", default-features = false, features = ["std", "std_rng"], optional = true }
rand_core = { version = "0.6", optional = true }
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 The corex Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The corex Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+36
View File
@@ -0,0 +1,36 @@
# corex-crypto
Safe, one-line security helpers that are easy to get wrong when hand-rolled:
- **Argon2id** password hashing & verification (PHC-encoded, RFC 9106-style).
- **AES-256-GCM** authenticated encryption with a fresh random nonce per op.
- **JWT** (HS256) token generation & validation.
- **Key rotation** via `KeyRing` — reads keep working with the previous key
during a rotation window.
## Features
- `password` (default) — Argon2id hashing.
- `encryption` (default) — AES-256-GCM.
- `tokens` (default) — JSON Web Tokens.
Zero features enabled by default? No — all three are on, but each is cheap and
independent.
## Usage
```rust
use corex_crypto::{PasswordHasher, Encryptor, TokenSigner};
let hasher = PasswordHasher::new();
let hash = hasher.hash("letmein").unwrap();
assert!(hasher.verify(&hash, "letmein"));
let enc = Encryptor::new(&[0u8; 32]);
let (nonce, ct) = enc.encrypt(b"secret");
assert_eq!(enc.decrypt(&nonce, &ct).unwrap(), b"secret");
let signer = TokenSigner::new("my-secret");
let token = signer.sign(&serde_json::json!({"sub":"u1"}), std::time::Duration::from_secs(3600)).unwrap();
assert_eq!(signer.verify(&token).unwrap()["sub"], "u1");
```
+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());
}
}