Skip to content

Commit bdcf1d6

Browse files
feat: add local KMS key store (IKmsClient for on-premise deployments)
File-based local key store implementing IKmsClient interface. Passphrase → HKDF-Extract → KEK, AES Key Wrap (RFC 3394) for DEK storage. Append-only audit log for key access events. AGPL-3.0 commercial tier.
1 parent 95428f0 commit bdcf1d6

1 file changed

Lines changed: 357 additions & 0 deletions

File tree

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright 2026 Johnson Ogundeji
3+
// SPDX-License-Identifier: AGPL-3.0-or-later
4+
// See LICENSE_COMMERCIAL for full terms.
5+
#pragma once
6+
7+
#if !defined(SIGNET_ENABLE_COMMERCIAL) || !SIGNET_ENABLE_COMMERCIAL
8+
#error "signet/crypto/kms_local.hpp is a AGPL-3.0 + Commercial Exception commercial module. Build with -DSIGNET_ENABLE_COMMERCIAL=ON."
9+
#endif
10+
11+
/// @file kms_local.hpp
12+
/// @brief File-based local key store — IKmsClient implementation for on-premise deployments.
13+
///
14+
/// Stores AES-256 master keys on disk, wrapped under a passphrase-derived KEK.
15+
/// Key derivation: passphrase → HKDF-Extract(salt, passphrase) → KEK
16+
/// Key wrapping: AES Key Wrap (RFC 3394) under the KEK
17+
///
18+
/// Storage layout:
19+
/// <keystore_path>/
20+
/// keys/ — Individual wrapped key files
21+
/// audit.log — Append-only key access log
22+
///
23+
/// NOT suitable for high-security environments — use cloud KMS or HSM for
24+
/// production deployments handling regulated data. This adapter is designed
25+
/// for on-premise, air-gapped, or development environments.
26+
///
27+
/// References:
28+
/// - NIST SP 800-57 Part 1 §5.3 (key hierarchy)
29+
/// - RFC 3394 (AES Key Wrap)
30+
/// - RFC 5869 (HKDF)
31+
/// - docs/internal/10_KEY_MANAGEMENT_AND_LICENSING.md §4
32+
33+
#include "signet/crypto/hkdf.hpp"
34+
#include "signet/crypto/hsm_client_stub.hpp" // IKmsClient, AES Key Wrap
35+
#include "signet/crypto/key_metadata.hpp"
36+
#include "signet/error.hpp"
37+
38+
#include <array>
39+
#include <chrono>
40+
#include <cerrno>
41+
#include <cstdint>
42+
#include <cstdio>
43+
#include <cstring>
44+
#include <fstream>
45+
#include <mutex>
46+
#include <string>
47+
#include <sys/stat.h>
48+
#include <unordered_map>
49+
#include <vector>
50+
51+
namespace signet::forge::crypto {
52+
53+
/// File-based local key store for on-premise deployments.
54+
///
55+
/// Wraps master keys under a passphrase-derived KEK and stores them
56+
/// on the local filesystem. Suitable for air-gapped or single-machine
57+
/// deployments where cloud KMS is not available.
58+
///
59+
/// Thread safety: All public methods are protected by a mutable mutex.
60+
///
61+
/// Usage:
62+
/// @code
63+
/// auto store = std::make_shared<LocalKeyStore>(LocalKeyStore::Config{
64+
/// .keystore_path = "/home/user/.signet/keystore",
65+
/// .passphrase = "my-secure-passphrase"
66+
/// });
67+
/// // Generate a master key
68+
/// store->generate_key("master-001");
69+
/// // Use as IKmsClient
70+
/// config.kms_client = store;
71+
/// @endcode
72+
class LocalKeyStore : public IKmsClient {
73+
public:
74+
struct Config {
75+
std::string keystore_path; ///< Directory path (e.g. ~/.signet/keystore)
76+
std::string passphrase; ///< Passphrase for KEK derivation
77+
bool create_if_missing = true; ///< Create keystore directory on first use
78+
/// PBKDF2-SHA256 iteration count for passphrase → KEK stretching.
79+
/// OWASP 2023 / NIST SP 800-132 recommend ≥ 600 000 for production.
80+
/// Reduce only in automated tests (e.g. 1000) — never below 1000.
81+
uint32_t pbkdf2_iterations = 600'000u;
82+
};
83+
84+
/// Construct a LocalKeyStore from configuration.
85+
explicit LocalKeyStore(Config config)
86+
: config_(std::move(config))
87+
{
88+
derive_kek();
89+
}
90+
91+
~LocalKeyStore() override {
92+
// Secure-zero KEK on destruction
93+
secure_zero(kek_.data(), kek_.size());
94+
// Secure-zero cached keys
95+
for (auto& [id, key] : keys_)
96+
secure_zero(key.data(), key.size());
97+
}
98+
99+
LocalKeyStore(const LocalKeyStore&) = delete;
100+
LocalKeyStore& operator=(const LocalKeyStore&) = delete;
101+
102+
// --- IKmsClient interface (const, thread-safe via mutable mutex) ---
103+
104+
/// Wrap (encrypt) a DEK under the master key identified by key_id.
105+
[[nodiscard]] expected<std::vector<uint8_t>> wrap_key(
106+
const std::vector<uint8_t>& dek,
107+
const std::string& key_id) const override
108+
{
109+
std::lock_guard<std::mutex> lock(mu_);
110+
auto master = load_key_internal(key_id);
111+
if (!master) return master.error();
112+
113+
std::array<uint8_t, 32> master_arr{};
114+
std::memcpy(master_arr.data(), master->data(),
115+
std::min(master->size(), size_t(32)));
116+
117+
auto result = detail::aes_key_wrap::wrap(master_arr, dek);
118+
secure_zero(master_arr.data(), master_arr.size());
119+
log_access(key_id, "wrap");
120+
return result;
121+
}
122+
123+
/// Unwrap (decrypt) a wrapped DEK using the master key identified by key_id.
124+
[[nodiscard]] expected<std::vector<uint8_t>> unwrap_key(
125+
const std::vector<uint8_t>& wrapped_dek,
126+
const std::string& key_id) const override
127+
{
128+
std::lock_guard<std::mutex> lock(mu_);
129+
auto master = load_key_internal(key_id);
130+
if (!master) return master.error();
131+
132+
std::array<uint8_t, 32> master_arr{};
133+
std::memcpy(master_arr.data(), master->data(),
134+
std::min(master->size(), size_t(32)));
135+
136+
auto result = detail::aes_key_wrap::unwrap(master_arr, wrapped_dek);
137+
secure_zero(master_arr.data(), master_arr.size());
138+
log_access(key_id, "unwrap");
139+
return result;
140+
}
141+
142+
// --- Extended key lifecycle methods (not part of IKmsClient) ---
143+
144+
/// Generate a new AES-256 master key and store it under key_id.
145+
[[nodiscard]] expected<std::string> generate_key(const std::string& key_id) {
146+
std::lock_guard<std::mutex> lock(mu_);
147+
std::array<uint8_t, 32> key{};
148+
csprng_fill(key.data(), key.size());
149+
std::vector<uint8_t> key_vec(key.begin(), key.end());
150+
151+
auto result = store_key(key_id, key_vec);
152+
secure_zero(key.data(), key.size());
153+
if (!result) return result.error();
154+
155+
log_access(key_id, "generate");
156+
return key_id;
157+
}
158+
159+
/// Destroy a master key (crypto-shredding for GDPR Art. 17).
160+
[[nodiscard]] expected<void> destroy_key(const std::string& key_id) {
161+
std::lock_guard<std::mutex> lock(mu_);
162+
auto it = keys_.find(key_id);
163+
if (it != keys_.end()) {
164+
secure_zero(it->second.data(), it->second.size());
165+
keys_.erase(it);
166+
}
167+
168+
std::string path = key_file_path(key_id);
169+
std::remove(path.c_str());
170+
171+
log_access(key_id, "destroy");
172+
return expected<void>{};
173+
}
174+
175+
/// Check if a key exists in the store (cached or on disk).
176+
[[nodiscard]] bool has_key(const std::string& key_id) const {
177+
std::lock_guard<std::mutex> lock(mu_);
178+
if (keys_.find(key_id) != keys_.end()) return true;
179+
std::ifstream ifs(key_file_path(key_id), std::ios::binary);
180+
return ifs.good();
181+
}
182+
183+
private:
184+
Config config_;
185+
std::array<uint8_t, 32> kek_{};
186+
mutable std::unordered_map<std::string, std::vector<uint8_t>> keys_;
187+
mutable std::mutex mu_;
188+
189+
/// PBKDF2-SHA256 single-block (32-byte) key derivation (RFC 8018 §5.2).
190+
///
191+
/// Provides a memory-hard work factor for passphrase-derived keys so that
192+
/// offline brute-force attacks require O(iterations) HMAC-SHA256 evaluations
193+
/// per candidate (NIST SP 800-132, OWASP 2023).
194+
static std::array<uint8_t, 32> pbkdf2_sha256_32(
195+
const uint8_t* password, size_t password_len,
196+
const uint8_t* salt, size_t salt_len,
197+
uint32_t iterations) {
198+
// U_1 = HMAC-SHA256(password, salt || INT(1))
199+
std::vector<uint8_t> u1_input;
200+
u1_input.reserve(salt_len + 4u);
201+
u1_input.insert(u1_input.end(), salt, salt + salt_len);
202+
// Block index 1 as 4-byte big-endian
203+
u1_input.push_back(0x00u);
204+
u1_input.push_back(0x00u);
205+
u1_input.push_back(0x00u);
206+
u1_input.push_back(0x01u);
207+
208+
auto u = detail::hkdf::hmac_sha256(
209+
password, password_len, u1_input.data(), u1_input.size());
210+
211+
// Zeroize the salt+INT(1) buffer (CWE-316)
212+
volatile uint8_t* vp = u1_input.data();
213+
for (size_t i = 0; i < u1_input.size(); ++i) vp[i] = 0u;
214+
215+
// DK = U_1 ^ U_2 ^ ... ^ U_c
216+
std::array<uint8_t, 32> dk = u;
217+
for (uint32_t j = 1u; j < iterations; ++j) {
218+
u = detail::hkdf::hmac_sha256(password, password_len, u.data(), u.size());
219+
for (size_t k = 0; k < 32u; ++k) dk[k] ^= u[k];
220+
}
221+
222+
// Zeroize last U block (CWE-316)
223+
volatile uint8_t* vpu = u.data();
224+
for (size_t i = 0; i < 32u; ++i) vpu[i] = 0u;
225+
226+
return dk;
227+
}
228+
229+
void derive_kek() {
230+
static constexpr uint8_t kek_salt[] = "signet:local-keystore:kek:v1";
231+
static constexpr uint8_t kek_info[] = "signet:kek-derivation";
232+
233+
auto passphrase_bytes = reinterpret_cast<const uint8_t*>(config_.passphrase.data());
234+
235+
// F4: PBKDF2-SHA256 password-stretching before HKDF provides work factor
236+
// for low-entropy operator passphrases (NIST SP 800-132, OWASP 2023).
237+
// The stretched output replaces raw passphrase bytes as the IKM for HKDF.
238+
auto stretched = pbkdf2_sha256_32(
239+
passphrase_bytes, config_.passphrase.size(),
240+
kek_salt, sizeof(kek_salt) - 1u,
241+
config_.pbkdf2_iterations);
242+
243+
auto prk = hkdf_extract(kek_salt, sizeof(kek_salt) - 1,
244+
stretched.data(), stretched.size());
245+
246+
// Zeroize stretched key material before stack is reused (CWE-316)
247+
volatile uint8_t* vp = stretched.data();
248+
for (size_t i = 0; i < stretched.size(); ++i) vp[i] = 0u;
249+
250+
(void)hkdf_expand(prk, kek_info, sizeof(kek_info) - 1, kek_.data(), kek_.size());
251+
}
252+
253+
static void secure_zero(void* ptr, size_t len) {
254+
volatile auto* p = static_cast<volatile uint8_t*>(ptr);
255+
while (len--) *p++ = 0;
256+
}
257+
258+
static void csprng_fill(uint8_t* buf, size_t len) {
259+
#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__)
260+
arc4random_buf(buf, len);
261+
#elif defined(__linux__)
262+
// F3: Only EINTR is retryable. All other negative returns (ENOSYS,
263+
// EFAULT, EINVAL) indicate a permanently broken entropy source.
264+
// Rather than loop indefinitely, terminate — continuing with
265+
// uninitialized key material is catastrophically worse than crashing
266+
// (consistent with OpenSSL / libsodium abort-on-CSPRNG-failure policy).
267+
static constexpr int kMaxRetries = 100;
268+
int retries = 0;
269+
while (len > 0) {
270+
auto got = getrandom(buf, len, 0);
271+
if (got < 0) {
272+
if (errno == EINTR && ++retries < kMaxRetries) continue;
273+
// Non-retryable error or retry limit exceeded — hard fail.
274+
std::terminate();
275+
}
276+
retries = 0;
277+
buf += got;
278+
len -= static_cast<size_t>(got);
279+
}
280+
#elif defined(_WIN32)
281+
BCryptGenRandom(nullptr, buf, static_cast<ULONG>(len), BCRYPT_USE_SYSTEM_PREFERRED_RNG);
282+
#endif
283+
}
284+
285+
std::string key_file_path(const std::string& key_id) const {
286+
return config_.keystore_path + "/keys/" + key_id + ".key";
287+
}
288+
289+
/// Load a key from cache or from disk. Caller must hold mu_.
290+
[[nodiscard]] expected<std::vector<uint8_t>> load_key_internal(const std::string& key_id) const {
291+
auto it = keys_.find(key_id);
292+
if (it != keys_.end()) return it->second;
293+
294+
std::string path = key_file_path(key_id);
295+
std::ifstream ifs(path, std::ios::binary);
296+
if (!ifs) {
297+
return Error{ErrorCode::ENCRYPTION_ERROR, "key not found: " + key_id};
298+
}
299+
300+
std::vector<uint8_t> wrapped((std::istreambuf_iterator<char>(ifs)),
301+
std::istreambuf_iterator<char>());
302+
ifs.close();
303+
304+
auto plaintext = detail::aes_key_wrap::unwrap(kek_, wrapped);
305+
if (!plaintext) return plaintext.error();
306+
307+
keys_[key_id] = *plaintext;
308+
return *plaintext;
309+
}
310+
311+
/// Wrap and store a key to disk. Caller must hold mu_.
312+
[[nodiscard]] expected<void> store_key(const std::string& key_id,
313+
const std::vector<uint8_t>& plaintext) {
314+
auto wrapped = detail::aes_key_wrap::wrap(kek_, plaintext);
315+
if (!wrapped) return wrapped.error();
316+
317+
std::string dir = config_.keystore_path + "/keys";
318+
#if defined(_WIN32)
319+
(void)_mkdir(config_.keystore_path.c_str());
320+
(void)_mkdir(dir.c_str());
321+
#else
322+
(void)::mkdir(config_.keystore_path.c_str(), 0700);
323+
(void)::mkdir(dir.c_str(), 0700);
324+
#endif
325+
326+
std::string path = key_file_path(key_id);
327+
std::ofstream ofs(path, std::ios::binary | std::ios::trunc);
328+
if (!ofs) {
329+
return Error{ErrorCode::IO_ERROR, "failed to write key file: " + path};
330+
}
331+
ofs.write(reinterpret_cast<const char*>(wrapped->data()),
332+
static_cast<std::streamsize>(wrapped->size()));
333+
ofs.close();
334+
335+
#if !defined(_WIN32)
336+
::chmod(path.c_str(), 0600);
337+
#endif
338+
339+
keys_[key_id] = plaintext;
340+
return expected<void>{};
341+
}
342+
343+
/// Append an audit log entry. Caller must hold mu_.
344+
void log_access(const std::string& key_id, const char* operation) const {
345+
std::string log_path = config_.keystore_path + "/audit.log";
346+
std::ofstream log_file(log_path, std::ios::app);
347+
if (!log_file) return;
348+
349+
auto now = std::chrono::system_clock::now();
350+
auto epoch = std::chrono::duration_cast<std::chrono::seconds>(
351+
now.time_since_epoch()).count();
352+
353+
log_file << epoch << " " << operation << " " << key_id << "\n";
354+
}
355+
};
356+
357+
} // namespace signet::forge::crypto

0 commit comments

Comments
 (0)