Skip to content

Commit 453dc3d

Browse files
fix: expose salt used for hash (#22)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 6e78332 commit 453dc3d

File tree

14 files changed

+357
-102
lines changed

14 files changed

+357
-102
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ let api_key = manager.generate(Environment::production())?;
2929
// Show to user once (they must save it)
3030
println!("API Key: {}", api_key.key().expose_secret());
3131

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

3536
// Later: verify incoming key (checksum checked first)
3637
let status = manager.verify(provided_key, stored_hash)?;

crates/api-keys-simplified/README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
3434
// 3. Show key to user ONCE (they must save it)
3535
println!("API Key: {}", api_key.key().expose_secret());
3636

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

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

166167
// ✅ Show keys only once
167168
display_to_user_once(key.key().expose_secret());
168-
db.save(key.hash()); // Store hash only
169+
170+
// Store both hash and salt (salt is needed for hash regeneration)
171+
let hash_data = key.expose_hash();
172+
db.save(hash_data.hash(), hash_data.salt());
169173

170174
// ✅ Always use HTTPS
171175
let response = client.get("https://api.example.com")
@@ -176,14 +180,18 @@ let response = client.get("https://api.example.com")
176180
fn rotate_key(manager: &ApiKeyManagerV0, user_id: u64) -> Result<ApiKey<Hash>, Box<dyn std::error::Error>> {
177181
let new_key = manager.generate(Environment::production())?;
178182
db.revoke_old_keys(user_id)?;
179-
db.save_new_hash(user_id, new_key.hash())?;
183+
184+
let hash_data = new_key.expose_hash();
185+
db.save_new_key(user_id, hash_data.hash(), hash_data.salt())?;
180186
Ok(new_key)
181187
}
182188

183189
// ✅ Use expiration for temporary access (trials, partners)
184190
let trial_expiry = Utc::now() + Duration::days(7);
185191
let trial_key = manager.generate_with_expiry(Environment::production(), trial_expiry)?;
186-
db.save(user_id, trial_key.hash())?;
192+
193+
let hash_data = trial_key.expose_hash();
194+
db.save(user_id, hash_data.hash(), hash_data.salt())?;
187195

188196
// ✅ Implement key revocation for compromised keys
189197
fn revoke_key(user_id: u64, key_hash: &str) -> Result<(), Box<dyn std::error::Error>> {

crates/api-keys-simplified/src/domain.rs

Lines changed: 123 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,25 @@ pub struct ApiKeyManagerV0 {
3232
}
3333

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

@@ -64,7 +78,7 @@ impl ApiKeyManagerV0 {
6478

6579
// Generate dummy key and its hash for timing attack protection
6680
let dummy_key = generator.dummy_key().clone();
67-
let dummy_hash = hasher.hash(&dummy_key)?;
81+
let (dummy_hash, _salt) = hasher.hash(&dummy_key)?;
6882

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

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

237251
impl ApiKey<NoHash> {
252+
/// Creates a new API key without a hash.
253+
///
254+
/// This is typically used internally before converting to a hashed key.
238255
pub fn new(key: SecureString) -> ApiKey<NoHash> {
239256
ApiKey { key, hash: NoHash }
240257
}
258+
259+
/// Converts this unhashed key into a hashed key by generating a new random salt
260+
/// and computing the Argon2 hash.
261+
///
262+
/// This method is automatically called by `ApiKeyManagerV0::generate()` and
263+
/// `ApiKeyManagerV0::generate_with_expiry()`.
241264
pub fn into_hashed(self, hasher: &KeyHasher) -> Result<ApiKey<Hash>> {
242-
let hash = hasher.hash(&self.key)?;
265+
let (hash, salt) = hasher.hash(&self.key)?;
266+
267+
Ok(ApiKey {
268+
key: self.key,
269+
hash: Hash { hash, salt },
270+
})
271+
}
272+
273+
/// Converts this unhashed key into a hashed key using a specific salt.
274+
///
275+
/// This is useful when you need to regenerate the same hash from the same key,
276+
/// for example in testing or when verifying hash consistency.
277+
///
278+
/// # Parameters
279+
///
280+
/// * `hasher` - The key hasher to use
281+
/// * `salt` - Base64-encoded salt string (32 bytes when decoded)
282+
///
283+
/// # Example
284+
///
285+
/// ```rust
286+
/// # use api_keys_simplified::{ApiKeyManagerV0, Environment, ExposeSecret};
287+
/// # use api_keys_simplified::{SecureString, ApiKey};
288+
/// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
289+
/// let key1 = manager.generate(Environment::production()).unwrap();
290+
///
291+
/// // Regenerate hash with the same salt
292+
/// let key2 = ApiKey::new(SecureString::from(key1.key().expose_secret()))
293+
/// .into_hashed_with_salt(manager.hasher(), key1.expose_hash().salt())
294+
/// .unwrap();
295+
///
296+
/// // Both hashes should be identical
297+
/// assert_eq!(key1.expose_hash(), key2.expose_hash());
298+
/// # Ok::<(), Box<dyn std::error::Error>>(())
299+
/// ```
300+
pub fn into_hashed_with_salt(self, hasher: &KeyHasher, salt: &str) -> Result<ApiKey<Hash>> {
301+
let hash = hasher.hash_with_salt(&self.key, salt)?;
243302

244303
Ok(ApiKey {
245304
key: self.key,
246-
hash: Hash(hash),
305+
hash: Hash {
306+
hash,
307+
salt: salt.to_string(),
308+
},
247309
})
248310
}
311+
312+
/// Consumes the API key and returns the underlying secure string.
249313
pub fn into_key(self) -> SecureString {
250314
self.key
251315
}
252316
}
253317

254318
impl ApiKey<Hash> {
255-
/// Returns hash.
256-
/// SECURITY:
257-
/// Although it's safe to store hash,
258-
/// do NOT make unnecessary clones
259-
/// and avoid logging the hash.
260-
pub fn hash(&self) -> &str {
261-
&self.hash.0
319+
/// Returns a reference to the hash and salt.
320+
///
321+
/// The returned `Hash` struct contains both the Argon2 hash string and the
322+
/// base64-encoded salt used to generate it. The hash should be stored in your
323+
/// database for later verification.
324+
///
325+
/// # Accessing Fields
326+
///
327+
/// Use the auto-generated getter methods:
328+
/// - `.hash()` - Returns the Argon2 hash string as `&str`
329+
/// - `.salt()` - Returns the base64-encoded salt as `&str`
330+
///
331+
/// # Security Note
332+
///
333+
/// Although it's safe to store the hash, avoid making unnecessary clones
334+
/// or logging the hash to minimize exposure.
335+
///
336+
/// # Example
337+
///
338+
/// ```rust
339+
/// # use api_keys_simplified::{ApiKeyManagerV0, Environment};
340+
/// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
341+
/// # let api_key = manager.generate(Environment::production()).unwrap();
342+
/// // Get the hash for storage
343+
/// let hash_struct = api_key.expose_hash();
344+
///
345+
/// // Access the hash string for database storage
346+
/// let hash_str: &str = hash_struct.hash();
347+
/// println!("Store this hash: {}", hash_str);
348+
///
349+
/// // Access the salt (if needed for hash regeneration)
350+
/// let salt: &str = hash_struct.salt();
351+
/// # Ok::<(), Box<dyn std::error::Error>>(())
352+
/// ```
353+
pub fn expose_hash(&self) -> &Hash {
354+
&self.hash
262355
}
356+
357+
/// Consumes the API key and returns the underlying secure string.
263358
pub fn into_key(self) -> SecureString {
264359
self.key
265360
}
@@ -276,7 +371,7 @@ mod tests {
276371
let api_key = generator.generate(Environment::production()).unwrap();
277372

278373
let key_str = api_key.key();
279-
let hash_str = api_key.hash();
374+
let hash_str = api_key.expose_hash().hash();
280375

281376
assert!(key_str.expose_secret().starts_with("sk-live-"));
282377
assert!(hash_str.starts_with("$argon2id$"));
@@ -318,4 +413,15 @@ mod tests {
318413
let key = generator.generate(Environment::production()).unwrap();
319414
assert!(generator.verify_checksum(key.key()).unwrap());
320415
}
416+
417+
#[test]
418+
fn compare_hash() {
419+
let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
420+
let key = manager.generate(Environment::production()).unwrap();
421+
let new_secret = ApiKey::new(SecureString::from(key.key().expose_secret()))
422+
.into_hashed_with_salt(manager.hasher(), key.expose_hash().salt())
423+
.unwrap();
424+
425+
assert_eq!(new_secret.expose_hash(), key.expose_hash());
426+
}
321427
}

crates/api-keys-simplified/src/hasher.rs

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,31 @@ impl KeyHasher {
1818
pub fn new(config: HashConfig) -> Self {
1919
Self { config }
2020
}
21-
pub fn hash(&self, key: &SecureString) -> Result<String> {
21+
22+
/// Hashes an API key using Argon2id with a randomly generated salt.
23+
///
24+
/// Returns a tuple containing:
25+
/// - The Argon2id PHC-formatted hash string
26+
/// - The base64-encoded salt (32 bytes encoded)
27+
///
28+
/// Each call generates a new random salt, so hashing the same key multiple
29+
/// times will produce different hashes. To reproduce the same hash, use
30+
/// `hash_with_salt()` with the original salt.
31+
///
32+
/// # Example
33+
///
34+
/// ```rust
35+
/// # use api_keys_simplified::{ApiKeyManagerV0, Environment, ExposeSecret};
36+
/// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
37+
/// # let key = manager.generate(Environment::production()).unwrap();
38+
/// // Hashing is done automatically when generating keys
39+
/// // The hash and salt are stored together in the returned ApiKey
40+
/// let hash = key.expose_hash();
41+
/// println!("Hash: {}", hash.hash());
42+
/// println!("Salt: {}", hash.salt());
43+
/// # Ok::<(), Box<dyn std::error::Error>>(())
44+
/// ```
45+
pub fn hash(&self, key: &SecureString) -> Result<(String, String)> {
2246
// Generate salt using OS cryptographic random source
2347
let mut salt_bytes = [0u8; 32];
2448
getrandom::fill(&mut salt_bytes)
@@ -27,6 +51,43 @@ impl KeyHasher {
2751
let salt = SaltString::encode_b64(&salt_bytes)
2852
.map_err(|e| OperationError::Hashing(e.to_string()))?;
2953

54+
let hash = self.hash_with_salt_string(key, &salt)?;
55+
56+
Ok((hash, salt.as_str().to_string()))
57+
}
58+
59+
/// Hashes an API key using Argon2id with a specific salt.
60+
///
61+
/// This is useful when you need to regenerate the same hash from the same key,
62+
/// ensuring deterministic hashing for verification or testing purposes.
63+
///
64+
/// # Parameters
65+
///
66+
/// * `key` - The API key to hash
67+
/// * `salt_str` - Base64-encoded salt string (must be 32 bytes when decoded)
68+
///
69+
/// # Example
70+
///
71+
/// ```rust
72+
/// # use api_keys_simplified::{ApiKeyManagerV0, Environment, ExposeSecret, SecureString, ApiKey};
73+
/// # let manager = ApiKeyManagerV0::init_default_config("sk").unwrap();
74+
/// # let key1 = manager.generate(Environment::production()).unwrap();
75+
/// // Regenerate the same hash using the same salt
76+
/// let key2 = ApiKey::new(SecureString::from(key1.key().expose_secret()))
77+
/// .into_hashed_with_salt(manager.hasher(), key1.expose_hash().salt())
78+
/// .unwrap();
79+
///
80+
/// assert_eq!(key1.expose_hash(), key2.expose_hash());
81+
/// # Ok::<(), Box<dyn std::error::Error>>(())
82+
/// ```
83+
pub fn hash_with_salt(&self, key: &SecureString, salt_str: &str) -> Result<String> {
84+
let salt = SaltString::from_b64(salt_str)
85+
.map_err(|e| OperationError::Hashing(format!("Invalid salt: {}", e)))?;
86+
87+
self.hash_with_salt_string(key, &salt)
88+
}
89+
90+
fn hash_with_salt_string(&self, key: &SecureString, salt: &SaltString) -> Result<String> {
3091
let params = Params::new(
3192
*self.config.memory_cost(),
3293
*self.config.time_cost(),
@@ -38,7 +99,7 @@ impl KeyHasher {
3899
let argon2 = Argon2::new(argon2::Algorithm::Argon2id, Version::V0x13, params);
39100

40101
let hash = argon2
41-
.hash_password(key.expose_secret().as_bytes(), &salt)
102+
.hash_password(key.expose_secret().as_bytes(), salt)
42103
.map_err(|e| OperationError::Hashing(e.to_string()))?;
43104

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

60-
let hash1 = hasher.hash(&key).unwrap();
61-
let hash2 = hasher.hash(&key).unwrap();
121+
let (hash1, salt1) = hasher.hash(&key).unwrap();
122+
let (hash2, salt2) = hasher.hash(&key).unwrap();
62123

63124
assert_ne!(hash1, hash2); // Different salts
125+
assert_ne!(salt1, salt2); // Different salts
64126
assert!(hash1.starts_with("$argon2id$"));
65127
}
66128

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

71133
let balanced_hasher = KeyHasher::new(HashConfig::balanced());
72-
let balanced_hash = balanced_hasher.hash(&key).unwrap();
134+
let (balanced_hash, _) = balanced_hasher.hash(&key).unwrap();
73135

74136
let secure_hasher = KeyHasher::new(HashConfig::high_security());
75-
let secure_hash = secure_hasher.hash(&key).unwrap();
137+
let (secure_hash, _) = secure_hasher.hash(&key).unwrap();
76138

77139
assert!(!balanced_hash.is_empty());
78140
assert!(!secure_hash.is_empty());
79141
}
142+
143+
#[test]
144+
fn test_hash_with_same_salt() {
145+
let key = SecureString::from("sk_test_abc123xyz789".to_string());
146+
let config = HashConfig::default();
147+
let hasher = KeyHasher::new(config);
148+
149+
// Get a salt from the first hash
150+
let (_hash, salt) = hasher.hash(&key).unwrap();
151+
152+
// Use the same salt to generate two hashes
153+
let hash1 = hasher.hash_with_salt(&key, &salt).unwrap();
154+
let hash2 = hasher.hash_with_salt(&key, &salt).unwrap();
155+
156+
assert_eq!(hash1, hash2); // Same salt produces same hash
157+
assert!(hash1.starts_with("$argon2id$"));
158+
}
80159
}

crates/api-keys-simplified/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
//! let generator = ApiKeyManagerV0::init_default_config("sk")?;
1414
//! let key = generator.generate(Environment::production())?;
1515
//! println!("Key: {}", key.key().expose_secret()); // Show once to user
16-
//! let hash = key.hash(); // Store this in database
16+
//! let hash = key.expose_hash().hash(); // Store this in database
1717
//!
1818
//! // Validate a key - checksum is verified first for DoS protection
1919
//! let status = generator.verify(key.key(), hash)?;

0 commit comments

Comments
 (0)