|
| 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