Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ let api_key = manager.generate(Environment::production())?;
// Show to user once (they must save it)
println!("API Key: {}", api_key.key().expose_secret());

// Store only the hash
database.save(api_key.hash());
// Store both hash AND salt in database (required for hash regeneration)
let hash_data = api_key.expose_hash();
database.save(hash_data.hash(), hash_data.salt());

// Later: verify incoming key (checksum checked first)
let status = manager.verify(provided_key, stored_hash)?;
Expand Down
18 changes: 13 additions & 5 deletions crates/api-keys-simplified/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 3. Show key to user ONCE (they must save it)
println!("API Key: {}", api_key.key().expose_secret());

// 4. Store only the hash in your database
database::save_user_key_hash(user_id, api_key.hash())?;
// 4. Store both hash AND salt in database (required for hash verification)
let hash_data = api_key.expose_hash();
database::save_user_key(user_id, hash_data.hash(), hash_data.salt())?;

// 5. Later: verify an incoming key (checksum validated first!)
let provided_key_str = request.headers().get("Authorization")?.replace("Bearer ", "");
Expand Down Expand Up @@ -165,7 +166,10 @@ println!("{:?}", key); // Prints: ApiKey { key: "[REDACTED]", ... }

// ✅ Show keys only once
display_to_user_once(key.key().expose_secret());
db.save(key.hash()); // Store hash only

// Store both hash and salt (salt is needed for hash regeneration)
let hash_data = key.expose_hash();
db.save(hash_data.hash(), hash_data.salt());

// ✅ Always use HTTPS
let response = client.get("https://api.example.com")
Expand All @@ -176,14 +180,18 @@ let response = client.get("https://api.example.com")
fn rotate_key(manager: &ApiKeyManagerV0, user_id: u64) -> Result<ApiKey<Hash>, Box<dyn std::error::Error>> {
let new_key = manager.generate(Environment::production())?;
db.revoke_old_keys(user_id)?;
db.save_new_hash(user_id, new_key.hash())?;

let hash_data = new_key.expose_hash();
db.save_new_key(user_id, hash_data.hash(), hash_data.salt())?;
Ok(new_key)
}

// ✅ Use expiration for temporary access (trials, partners)
let trial_expiry = Utc::now() + Duration::days(7);
let trial_key = manager.generate_with_expiry(Environment::production(), trial_expiry)?;
db.save(user_id, trial_key.hash())?;

let hash_data = trial_key.expose_hash();
db.save(user_id, hash_data.hash(), hash_data.salt())?;

// ✅ Implement key revocation for compromised keys
fn revoke_key(user_id: u64, key_hash: &str) -> Result<(), Box<dyn std::error::Error>> {
Expand Down
140 changes: 123 additions & 17 deletions crates/api-keys-simplified/src/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,25 @@ pub struct ApiKeyManagerV0 {
}

// FIXME: Need better naming
/// Hash can be safely stored as String
/// in memory without having to worry about
/// zeroizing. Hashes are not secrets and are meant to be stored.
#[derive(Debug)]
pub struct Hash(String);
/// Contains the Argon2 hash and the salt used to generate it.
///
/// The hash can be safely stored in your database without special security measures
/// since it's already cryptographically hashed. However, avoid unnecessary cloning
/// or logging to minimize exposure.
///
/// # Fields
///
/// - `hash`: The Argon2id PHC-formatted hash string (e.g., "$argon2id$v=19$m=...")
/// - `salt`: The base64-encoded salt used during hashing (32 bytes when encoded)
///
/// Both fields can be accessed using the auto-generated getter methods `hash()` and `salt()`
/// provided by the `Getters` derive macro.
#[derive(Debug, Getters, PartialEq)]
pub struct Hash {
hash: String,
salt: String,
}

#[derive(Debug)]
pub struct NoHash;

Expand Down Expand Up @@ -64,7 +78,7 @@ impl ApiKeyManagerV0 {

// Generate dummy key and its hash for timing attack protection
let dummy_key = generator.dummy_key().clone();
let dummy_hash = hasher.hash(&dummy_key)?;
let (dummy_hash, _salt) = hasher.hash(&dummy_key)?;

let validator = KeyValidator::new(include_checksum, dummy_key, dummy_hash)?;

Expand Down Expand Up @@ -190,7 +204,7 @@ impl ApiKeyManagerV0 {
/// # use std::time::Duration;
/// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
/// # let key = manager.generate(Environment::production()).unwrap();
/// match manager.verify(key.key(), key.hash())? {
/// match manager.verify(key.key(), key.expose_hash().hash())? {
/// KeyStatus::Valid => { /* grant access */ },
/// KeyStatus::Invalid => { /* reject - wrong key or expired */ },
/// }
Expand Down Expand Up @@ -235,31 +249,112 @@ impl<T> ApiKey<T> {
}

impl ApiKey<NoHash> {
/// Creates a new API key without a hash.
///
/// This is typically used internally before converting to a hashed key.
pub fn new(key: SecureString) -> ApiKey<NoHash> {
ApiKey { key, hash: NoHash }
}

/// Converts this unhashed key into a hashed key by generating a new random salt
/// and computing the Argon2 hash.
///
/// This method is automatically called by `ApiKeyManagerV0::generate()` and
/// `ApiKeyManagerV0::generate_with_expiry()`.
pub fn into_hashed(self, hasher: &KeyHasher) -> Result<ApiKey<Hash>> {
let hash = hasher.hash(&self.key)?;
let (hash, salt) = hasher.hash(&self.key)?;

Ok(ApiKey {
key: self.key,
hash: Hash { hash, salt },
})
}

/// Converts this unhashed key into a hashed key using a specific salt.
///
/// This is useful when you need to regenerate the same hash from the same key,
/// for example in testing or when verifying hash consistency.
///
/// # Parameters
///
/// * `hasher` - The key hasher to use
/// * `salt` - Base64-encoded salt string (32 bytes when decoded)
///
/// # Example
///
/// ```rust
/// # use api_keys_simplified::{ApiKeyManagerV0, Environment, ExposeSecret};
/// # use api_keys_simplified::{SecureString, ApiKey};
/// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
/// let key1 = manager.generate(Environment::production()).unwrap();
///
/// // Regenerate hash with the same salt
/// let key2 = ApiKey::new(SecureString::from(key1.key().expose_secret()))
/// .into_hashed_with_salt(manager.hasher(), key1.expose_hash().salt())
/// .unwrap();
///
/// // Both hashes should be identical
/// assert_eq!(key1.expose_hash(), key2.expose_hash());
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn into_hashed_with_salt(self, hasher: &KeyHasher, salt: &str) -> Result<ApiKey<Hash>> {
let hash = hasher.hash_with_salt(&self.key, salt)?;

Ok(ApiKey {
key: self.key,
hash: Hash(hash),
hash: Hash {
hash,
salt: salt.to_string(),
},
})
}

/// Consumes the API key and returns the underlying secure string.
pub fn into_key(self) -> SecureString {
self.key
}
}

impl ApiKey<Hash> {
/// Returns hash.
/// SECURITY:
/// Although it's safe to store hash,
/// do NOT make unnecessary clones
/// and avoid logging the hash.
pub fn hash(&self) -> &str {
&self.hash.0
/// Returns a reference to the hash and salt.
///
/// The returned `Hash` struct contains both the Argon2 hash string and the
/// base64-encoded salt used to generate it. The hash should be stored in your
/// database for later verification.
///
/// # Accessing Fields
///
/// Use the auto-generated getter methods:
/// - `.hash()` - Returns the Argon2 hash string as `&str`
/// - `.salt()` - Returns the base64-encoded salt as `&str`
///
/// # Security Note
///
/// Although it's safe to store the hash, avoid making unnecessary clones
/// or logging the hash to minimize exposure.
///
/// # Example
///
/// ```rust
/// # use api_keys_simplified::{ApiKeyManagerV0, Environment};
/// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
/// # let api_key = manager.generate(Environment::production()).unwrap();
/// // Get the hash for storage
/// let hash_struct = api_key.expose_hash();
///
/// // Access the hash string for database storage
/// let hash_str: &str = hash_struct.hash();
/// println!("Store this hash: {}", hash_str);
///
/// // Access the salt (if needed for hash regeneration)
/// let salt: &str = hash_struct.salt();
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn expose_hash(&self) -> &Hash {
&self.hash
}

/// Consumes the API key and returns the underlying secure string.
pub fn into_key(self) -> SecureString {
self.key
}
Expand All @@ -276,7 +371,7 @@ mod tests {
let api_key = generator.generate(Environment::production()).unwrap();

let key_str = api_key.key();
let hash_str = api_key.hash();
let hash_str = api_key.expose_hash().hash();

assert!(key_str.expose_secret().starts_with("sk-live-"));
assert!(hash_str.starts_with("$argon2id$"));
Expand Down Expand Up @@ -318,4 +413,15 @@ mod tests {
let key = generator.generate(Environment::production()).unwrap();
assert!(generator.verify_checksum(key.key()).unwrap());
}

#[test]
fn compare_hash() {
let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
let key = manager.generate(Environment::production()).unwrap();
let new_secret = ApiKey::new(SecureString::from(key.key().expose_secret()))
.into_hashed_with_salt(manager.hasher(), key.expose_hash().salt())
.unwrap();

assert_eq!(new_secret.expose_hash(), key.expose_hash());
}
}
91 changes: 85 additions & 6 deletions crates/api-keys-simplified/src/hasher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,31 @@ impl KeyHasher {
pub fn new(config: HashConfig) -> Self {
Self { config }
}
pub fn hash(&self, key: &SecureString) -> Result<String> {

/// Hashes an API key using Argon2id with a randomly generated salt.
///
/// Returns a tuple containing:
/// - The Argon2id PHC-formatted hash string
/// - The base64-encoded salt (32 bytes encoded)
///
/// Each call generates a new random salt, so hashing the same key multiple
/// times will produce different hashes. To reproduce the same hash, use
/// `hash_with_salt()` with the original salt.
///
/// # Example
///
/// ```rust
/// # use api_keys_simplified::{ApiKeyManagerV0, Environment, ExposeSecret};
/// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
/// # let key = manager.generate(Environment::production()).unwrap();
/// // Hashing is done automatically when generating keys
/// // The hash and salt are stored together in the returned ApiKey
/// let hash = key.expose_hash();
/// println!("Hash: {}", hash.hash());
/// println!("Salt: {}", hash.salt());
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn hash(&self, key: &SecureString) -> Result<(String, String)> {
// Generate salt using OS cryptographic random source
let mut salt_bytes = [0u8; 32];
getrandom::fill(&mut salt_bytes)
Expand All @@ -27,6 +51,43 @@ impl KeyHasher {
let salt = SaltString::encode_b64(&salt_bytes)
.map_err(|e| OperationError::Hashing(e.to_string()))?;

let hash = self.hash_with_salt_string(key, &salt)?;

Ok((hash, salt.as_str().to_string()))
}

/// Hashes an API key using Argon2id with a specific salt.
///
/// This is useful when you need to regenerate the same hash from the same key,
/// ensuring deterministic hashing for verification or testing purposes.
///
/// # Parameters
///
/// * `key` - The API key to hash
/// * `salt_str` - Base64-encoded salt string (must be 32 bytes when decoded)
///
/// # Example
///
/// ```rust
/// # use api_keys_simplified::{ApiKeyManagerV0, Environment, ExposeSecret, SecureString, ApiKey};
/// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
/// # let key1 = manager.generate(Environment::production()).unwrap();
/// // Regenerate the same hash using the same salt
/// let key2 = ApiKey::new(SecureString::from(key1.key().expose_secret()))
/// .into_hashed_with_salt(manager.hasher(), key1.expose_hash().salt())
/// .unwrap();
///
/// assert_eq!(key1.expose_hash(), key2.expose_hash());
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn hash_with_salt(&self, key: &SecureString, salt_str: &str) -> Result<String> {
let salt = SaltString::from_b64(salt_str)
.map_err(|e| OperationError::Hashing(format!("Invalid salt: {}", e)))?;

self.hash_with_salt_string(key, &salt)
}

fn hash_with_salt_string(&self, key: &SecureString, salt: &SaltString) -> Result<String> {
let params = Params::new(
*self.config.memory_cost(),
*self.config.time_cost(),
Expand All @@ -38,7 +99,7 @@ impl KeyHasher {
let argon2 = Argon2::new(argon2::Algorithm::Argon2id, Version::V0x13, params);

let hash = argon2
.hash_password(key.expose_secret().as_bytes(), &salt)
.hash_password(key.expose_secret().as_bytes(), salt)
.map_err(|e| OperationError::Hashing(e.to_string()))?;

// SECURITY: Hashes are meant to be stored raw
Expand All @@ -57,10 +118,11 @@ mod tests {
let config = HashConfig::default();
let hasher = KeyHasher::new(config);

let hash1 = hasher.hash(&key).unwrap();
let hash2 = hasher.hash(&key).unwrap();
let (hash1, salt1) = hasher.hash(&key).unwrap();
let (hash2, salt2) = hasher.hash(&key).unwrap();

assert_ne!(hash1, hash2); // Different salts
assert_ne!(salt1, salt2); // Different salts
assert!(hash1.starts_with("$argon2id$"));
}

Expand All @@ -69,12 +131,29 @@ mod tests {
let key = SecureString::from("test_key".to_string());

let balanced_hasher = KeyHasher::new(HashConfig::balanced());
let balanced_hash = balanced_hasher.hash(&key).unwrap();
let (balanced_hash, _) = balanced_hasher.hash(&key).unwrap();

let secure_hasher = KeyHasher::new(HashConfig::high_security());
let secure_hash = secure_hasher.hash(&key).unwrap();
let (secure_hash, _) = secure_hasher.hash(&key).unwrap();

assert!(!balanced_hash.is_empty());
assert!(!secure_hash.is_empty());
}

#[test]
fn test_hash_with_same_salt() {
let key = SecureString::from("sk_test_abc123xyz789".to_string());
let config = HashConfig::default();
let hasher = KeyHasher::new(config);

// Get a salt from the first hash
let (_hash, salt) = hasher.hash(&key).unwrap();

// Use the same salt to generate two hashes
let hash1 = hasher.hash_with_salt(&key, &salt).unwrap();
let hash2 = hasher.hash_with_salt(&key, &salt).unwrap();

assert_eq!(hash1, hash2); // Same salt produces same hash
assert!(hash1.starts_with("$argon2id$"));
}
}
2 changes: 1 addition & 1 deletion crates/api-keys-simplified/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
//! let generator = ApiKeyManagerV0::init_default_config("sk")?;
//! let key = generator.generate(Environment::production())?;
//! println!("Key: {}", key.key().expose_secret()); // Show once to user
//! let hash = key.hash(); // Store this in database
//! let hash = key.expose_hash().hash(); // Store this in database
//!
//! // Validate a key - checksum is verified first for DoS protection
//! let status = generator.verify(key.key(), hash)?;
Expand Down
Loading