Skip to main content

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(&current) {
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.