hsh_kms/lib.rs
1#![forbid(unsafe_code)]
2#![cfg_attr(
3 test,
4 allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)
5)]
6// Copyright © 2023-2026 Hash (HSH) library contributors. All rights reserved.
7// SPDX-License-Identifier: Apache-2.0 OR MIT
8
9//! # `hsh-kms` — pepper / KMS integration for `hsh`
10//!
11//! This crate provides the [`Pepper`] trait and a small set of
12//! pluggable backends that let an application "pepper" its passwords
13//! with a secret key held outside the password database — typically in
14//! AWS KMS, Google Cloud KMS, Azure Key Vault, or HashiCorp Vault.
15//!
16//! ## The pepper pattern
17//!
18//! A *pepper* is a server-side secret applied to every password before
19//! it is hashed. Unlike a per-password salt (which lives next to the
20//! hash), the pepper is **the same for every password** and lives in a
21//! separate trust boundary — usually a KMS / HSM that the password
22//! database cannot read.
23//!
24//! Concretely, [`Pepper::apply`] computes
25//! `HMAC-SHA-256(key_at(version), password)` and returns the 32-byte
26//! tag, which the `hsh` crate then feeds into Argon2id / bcrypt /
27//! scrypt as if it were the user's password.
28//!
29//! ### Why
30//!
31//! - **Defence in depth** — an attacker who steals only the password
32//! DB cannot brute-force credentials offline because they're missing
33//! the pepper.
34//! - **Rotatable** — bump [`KeyVersion`] periodically; on each
35//! successful login under the old version, `hsh::api::verify_and_upgrade`
36//! re-hashes under the new version transparently.
37//! - **Compliance** — PCI DSS 4.0 §3.5.1.1 effectively requires this
38//! for PAN hashing; many SOC 2 / ISO 27001 auditors expect it for
39//! password storage too.
40//!
41//! ## Backends
42//!
43//! - [`LocalPepper`] — keys held in process memory. Safe for tests,
44//! short-lived workloads, or apps without a KMS.
45//! - `aws::fetch_pepper` (feature `aws-kms`) — fetch a key from AWS
46//! KMS via the `aws-sdk-kms` crate, returning a [`LocalPepper`]
47//! snapshot.
48//! - `gcp::fetch_pepper` (feature `gcp-kms`) — likewise for GCP Cloud
49//! KMS.
50//! - `azure::fetch_pepper` (feature `azure-key-vault`).
51//! - `vault::fetch_pepper` (feature `hashicorp-vault`).
52//!
53//! Provider implementations are currently **stubs** that document the
54//! intended interface; the real network calls land incrementally as
55//! they get integration-tested against the cloud providers.
56//!
57//! ## Example
58//!
59//! ```
60//! use hsh_kms::{KeyVersion, LocalPepper, Pepper};
61//!
62//! let pepper = LocalPepper::builder()
63//! .add(KeyVersion::new(1), b"the-server-pepper-v1-DO-NOT-COMMIT".to_vec())
64//! .current(KeyVersion::new(1))
65//! .build()
66//! .unwrap();
67//!
68//! let tag = pepper.apply(KeyVersion::new(1), b"correct horse").unwrap();
69//! assert_eq!(tag.len(), 32);
70//! ```
71
72pub mod error;
73
74#[cfg(feature = "aws-kms")]
75pub mod aws;
76#[cfg(feature = "azure-key-vault")]
77pub mod azure;
78#[cfg(feature = "gcp-kms")]
79pub mod gcp;
80#[cfg(feature = "hashicorp-vault")]
81pub mod vault;
82
83use std::collections::BTreeMap;
84use std::fmt;
85
86use hmac::{Hmac, Mac};
87use sha2::Sha256;
88use zeroize::Zeroize;
89
90pub use error::PepperError;
91
92/// A monotonically increasing key version used to identify which pepper
93/// was applied to a given password hash. Stored alongside the hash so
94/// rotation is non-destructive.
95#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
96pub struct KeyVersion(u32);
97
98impl KeyVersion {
99 /// Constructs a `KeyVersion`.
100 #[must_use]
101 pub const fn new(v: u32) -> Self {
102 Self(v)
103 }
104
105 /// Returns the underlying `u32`.
106 #[must_use]
107 pub const fn get(self) -> u32 {
108 self.0
109 }
110}
111
112impl Default for KeyVersion {
113 fn default() -> Self {
114 Self(1)
115 }
116}
117
118impl fmt::Display for KeyVersion {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 write!(f, "{}", self.0)
121 }
122}
123
124/// A pepper provider — produces an HMAC-SHA-256 tag over the password
125/// keyed by the secret material for a given [`KeyVersion`].
126///
127/// Implementations must be `Send + Sync` so a `Policy` carrying a
128/// pepper can be shared across worker threads.
129pub trait Pepper: fmt::Debug + Send + Sync {
130 /// Computes `HMAC-SHA-256(key_at(version), password)` and returns
131 /// the 32-byte tag. Errors if the requested `version` is not
132 /// available in this provider.
133 ///
134 /// # Errors
135 ///
136 /// Returns [`PepperError::UnknownVersion`] if the version isn't
137 /// stored, or [`PepperError::Backend`] if the backend (KMS) fails.
138 fn apply(
139 &self,
140 version: KeyVersion,
141 password: &[u8],
142 ) -> Result<[u8; 32], PepperError>;
143
144 /// Returns the key version to use for *new* hashes. Older versions
145 /// remain usable via [`Pepper::apply`] for verifying existing
146 /// hashes; rotation is handled by `hsh::api::verify_and_upgrade`.
147 fn current(&self) -> KeyVersion;
148}
149
150/// In-memory pepper provider. **Keys live in process memory** — use a
151/// real KMS for production secrets.
152pub struct LocalPepper {
153 keys: BTreeMap<KeyVersion, Vec<u8>>,
154 current: KeyVersion,
155}
156
157impl LocalPepper {
158 /// Starts building a `LocalPepper`.
159 #[must_use]
160 pub fn builder() -> LocalPepperBuilder {
161 LocalPepperBuilder::default()
162 }
163
164 /// Returns the set of key versions held in memory, sorted ascending.
165 #[must_use]
166 pub fn versions(&self) -> Vec<KeyVersion> {
167 self.keys.keys().copied().collect()
168 }
169}
170
171impl Pepper for LocalPepper {
172 fn apply(
173 &self,
174 version: KeyVersion,
175 password: &[u8],
176 ) -> Result<[u8; 32], PepperError> {
177 let key = self
178 .keys
179 .get(&version)
180 .ok_or(PepperError::UnknownVersion(version))?;
181
182 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key)
183 .map_err(|e| PepperError::Backend(e.to_string()))?;
184 mac.update(password);
185 let tag = mac.finalize().into_bytes();
186 let mut out = [0u8; 32];
187 out.copy_from_slice(&tag);
188 Ok(out)
189 }
190
191 fn current(&self) -> KeyVersion {
192 self.current
193 }
194}
195
196impl fmt::Debug for LocalPepper {
197 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198 // Never expose the raw key bytes — only metadata.
199 f.debug_struct("LocalPepper")
200 .field("versions", &self.keys.keys().collect::<Vec<_>>())
201 .field("current", &self.current)
202 .finish()
203 }
204}
205
206impl Drop for LocalPepper {
207 fn drop(&mut self) {
208 for k in self.keys.values_mut() {
209 k.zeroize();
210 }
211 }
212}
213
214/// Builder for [`LocalPepper`].
215#[derive(Debug, Default)]
216pub struct LocalPepperBuilder {
217 keys: BTreeMap<KeyVersion, Vec<u8>>,
218 current: Option<KeyVersion>,
219}
220
221impl LocalPepperBuilder {
222 /// Registers a key at `version`. Keys should be at least 32 bytes
223 /// of cryptographic-quality entropy (typically the OS CSPRNG).
224 #[must_use]
225 pub fn add(mut self, version: KeyVersion, key: Vec<u8>) -> Self {
226 let _ = self.keys.insert(version, key);
227 self
228 }
229
230 /// Sets the current key version used for new hashes. Must match
231 /// one of the versions registered via [`add`](Self::add).
232 #[must_use]
233 pub fn current(mut self, version: KeyVersion) -> Self {
234 self.current = Some(version);
235 self
236 }
237
238 /// Finalises the builder.
239 ///
240 /// # Errors
241 ///
242 /// - [`PepperError::EmptyKeyset`] if no keys were added.
243 /// - [`PepperError::UnknownVersion`] if the `current` version
244 /// isn't in the keyset.
245 /// - [`PepperError::KeyTooShort`] if any registered key is shorter
246 /// than 16 bytes (a sanity floor — production keys should be 32+).
247 pub fn build(self) -> Result<LocalPepper, PepperError> {
248 if self.keys.is_empty() {
249 return Err(PepperError::EmptyKeyset);
250 }
251 for (v, k) in &self.keys {
252 if k.len() < 16 {
253 return Err(PepperError::KeyTooShort {
254 version: *v,
255 actual: k.len(),
256 minimum: 16,
257 });
258 }
259 }
260 let current = self
261 .current
262 .or_else(|| self.keys.keys().last().copied())
263 .ok_or(PepperError::EmptyKeyset)?;
264 if !self.keys.contains_key(¤t) {
265 return Err(PepperError::UnknownVersion(current));
266 }
267 Ok(LocalPepper {
268 keys: self.keys,
269 current,
270 })
271 }
272}
273
274// Note: the historical `#[cfg(test)] mod tests { ... }` block lived
275// here and exercised LocalPepper / KeyVersion / PepperError through
276// the public surface. CodeQL's `rust/hard-coded-cryptographic-value`
277// heuristic flagged the test fixtures (deterministic byte literals
278// passed to `Pepper::apply`) because inline tests in `src/` aren't
279// caught by the path-exclusion config that covers `tests/`. The
280// tests moved to `crates/hsh-kms/tests/coverage.rs` for that reason;
281// no test was deleted, only relocated.