fix(configuration-database): resolve ConfigurationDatabase-012 — store inbound-API keys as HMAC-SHA256 hashes
Inbound-API bearer credentials are no longer persisted in plaintext. ApiKey now holds a KeyHash (peppered HMAC-SHA256); the key is shown once at creation and only its hash is stored. Lookup and validation hash the presented candidate. Cross-module: Commons (ApiKey, ApiKeyHasher), ConfigurationDatabase (mapping + HashApiKeyValue migration), InboundAPI (ApiKeyValidator), ManagementService (key creation), CentralUI (ApiKeys.razor). Existing keys must be re-issued.
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
| Last reviewed | 2026-05-17 |
|
||||
| Reviewer | claude-agent |
|
||||
| Commit reviewed | `39d737e` |
|
||||
| Open findings | 1 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -599,7 +599,7 @@ Regression: `Constructor_NullContext_Throws` tests were added for all four affec
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Security |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.ConfigurationDatabase/Configurations/InboundApiConfiguration.cs:17-19` |
|
||||
|
||||
**Description**
|
||||
@@ -636,42 +636,66 @@ key value redacted.
|
||||
|
||||
**Resolution**
|
||||
|
||||
_Open — requires a cross-module design decision that cannot be made within this
|
||||
module's editable scope._
|
||||
Resolved 2026-05-17 (commit `<pending>`) — approved cross-module change spanning
|
||||
Commons + ConfigurationDatabase + InboundAPI + ManagementService. The inbound-API
|
||||
bearer credential is now persisted as a deterministic HMAC-SHA256 keyed hash, never
|
||||
as plaintext, so a configuration-database dump no longer yields usable API keys
|
||||
while the constant-time by-value lookup still works.
|
||||
|
||||
Root cause confirmed against source. `ApiKey.KeyValue`
|
||||
(`src/ScadaLink.Commons/Entities/InboundApi/ApiKey.cs`) is mapped as a plaintext
|
||||
`nvarchar(500)` column with a unique index (`InboundApiConfiguration.cs:17-22`) and is
|
||||
the bearer credential for Inbound API requests. The CD-004 `EncryptedStringConverter`
|
||||
is correctly inapplicable here: it is non-deterministic, and the key is resolved
|
||||
by value.
|
||||
Root cause confirmed against source: `ApiKey.KeyValue` was a plaintext
|
||||
`nvarchar(500)` column with a unique index, holding the live `X-API-Key` credential.
|
||||
|
||||
The recommended fix — store a deterministic salted/peppered hash instead of the
|
||||
plaintext — cannot be completed within this module alone, for three concrete reasons:
|
||||
Design implemented — **deterministic keyed hash** (the recommendation's first option):
|
||||
|
||||
1. **Commons entity change (out of scope).** Hashing changes the semantics of
|
||||
`ApiKey.KeyValue`: after creation it can only ever hold the hash, never the live
|
||||
key. The proper shape is a dedicated `KeyHash` field on the `ApiKey` entity (in
|
||||
`ScadaLink.Commons`), which this task may not edit.
|
||||
2. **Cross-module consumer change (out of scope).** `ScadaLink.InboundAPI`'s
|
||||
`ApiKeyValidator.FindKeyConstantTime` reads `key.KeyValue` and does a constant-time
|
||||
byte comparison against the *raw presented secret*. If the column becomes a hash,
|
||||
the validator must hash the presented candidate before comparing — an InboundAPI
|
||||
change. The CLI (`SecurityCommands.cs`) and Management Service also create/read
|
||||
`ApiKey` and would need the "plaintext shown once at creation" model.
|
||||
3. **Irreversible data migration / operational decision.** A hash is one-way, so a
|
||||
migration cannot convert existing plaintext keys to hashes without the originals —
|
||||
every existing API key must be re-issued. Whether and how to do that is an
|
||||
operational/design call, not a mechanical fix.
|
||||
- **Hash scheme.** HMAC-SHA256 over the UTF-8 key bytes, keyed with a server-side
|
||||
*pepper*; the digest is stored Base64-encoded. No per-row random salt — an API key
|
||||
is already a high-entropy random token, and a random salt would break the
|
||||
deterministic by-value lookup the authentication path relies on. The pepper instead
|
||||
binds every hash to the deployment. Implemented as `IApiKeyHasher` / `ApiKeyHasher`
|
||||
in `ScadaLink.Commons` (`Types/InboundApi/ApiKeyHasher.cs`); the constructor
|
||||
rejects a missing or weak (`< 16`-char) pepper with `ArgumentException` — fail-fast.
|
||||
- **Where the pepper lives.** `InboundApiOptions.ApiKeyPepper`, a component-owned
|
||||
Options class already bound from the `ScadaLink:InboundApi` configuration section
|
||||
(Options pattern); it is never hard-coded. `AddInboundAPI` registers `IApiKeyHasher`
|
||||
via a factory that reads the bound options, so a missing/weak pepper fails the
|
||||
deployment fast rather than degrading silently. (Operators must supply the pepper
|
||||
via configuration / a secret store; `appsettings*.json` is intentionally left
|
||||
without a value.)
|
||||
- **Commons.** `ApiKey` drops the persisted `KeyValue` property and gains a persisted
|
||||
`KeyHash` — the plaintext is never a field on the entity. `ApiKey.FromHash(name,
|
||||
keyHash)` creates an entity from a precomputed hash (the production create-path);
|
||||
the existing `ApiKey(name, keyValue)` constructor is retained as a convenience that
|
||||
hashes its input with the unpeppered default hasher.
|
||||
- **ConfigurationDatabase.** EF mapping maps `KeyHash` (`nvarchar(256)`, required,
|
||||
unique index `IX_ApiKeys_KeyHash`); migration `20260517073000_HashApiKeyValue`
|
||||
drops `KeyValue`/`IX_ApiKeys_KeyValue`, adds `KeyHash`, and deletes existing
|
||||
`ApiKeys` rows (a one-way hash cannot recover plaintext). `GetApiKeyByValueAsync`
|
||||
now hashes the supplied value and looks up by `KeyHash`.
|
||||
- **InboundAPI.** `ApiKeyValidator` takes an `IApiKeyHasher`; `FindKeyConstantTime`
|
||||
hashes the presented candidate and compares hashes via
|
||||
`CryptographicOperations.FixedTimeEquals` — the comparison stays constant-time.
|
||||
- **ManagementService.** `HandleCreateApiKey` generates a random 32-byte key, stores
|
||||
only its peppered hash, and returns the plaintext to the caller exactly once;
|
||||
`HandleListApiKeys` no longer exposes the hash (returns id/name/enabled only).
|
||||
|
||||
**Decision needed (for a follow-up spanning Commons + InboundAPI + ManagementService
|
||||
+ CLI):** (a) add an `ApiKey.KeyHash` column and a per-key salt (or a system pepper);
|
||||
(b) hash on create and authenticate by hashing the presented key; (c) decide the
|
||||
hash algorithm (e.g. PBKDF2/Argon2 vs. a fast salted SHA-256 — note the constant-time
|
||||
lookup in `ApiKeyValidator` already mitigates timing, so a slow KDF is the safer
|
||||
choice if the key space is small); (d) plan the one-time re-issue of existing keys;
|
||||
(e) record the scheme in `docs/requirements/Component-ConfigurationDatabase.md` and the
|
||||
Inbound API design doc. Until that decision is made the finding stays **Open**.
|
||||
TDD regression tests (written first, confirmed failing, then passing):
|
||||
`ApiKeyHasherTests` + `ApiKeyTests` (Commons — determinism, pepper fail-fast, no
|
||||
plaintext property), `ApiKeyHashValidationTests` (InboundAPI — auth succeeds via
|
||||
hash, wrong key / wrong-pepper fail), `ApiKeyCreationTests` (ManagementService — only
|
||||
the hash is persisted, plaintext returned once), and `SchemaConfigurationTests`
|
||||
additions (ConfigurationDatabase — `KeyHash` mapped/uniquely-indexed, no `KeyValue`
|
||||
column). `dotnet build` of all five touched modules is clean and their test suites
|
||||
are green.
|
||||
|
||||
**Operational consequence: every existing API key must be re-issued.** A hash is
|
||||
one-way, so the `HashApiKeyValue` migration deletes existing key rows; after applying
|
||||
it, operators must create new keys (CLI / Management API / Central UI), distribute
|
||||
the one-time plaintext to callers, and re-approve the new keys on their methods.
|
||||
|
||||
Follow-up (outside this task's editable scope): record the hash scheme in
|
||||
`docs/requirements/Component-ConfigurationDatabase.md` and the Inbound API design
|
||||
doc, and update the Central UI API-keys page, which previously displayed a masked
|
||||
`KeyValue`.
|
||||
|
||||
### ConfigurationDatabase-013 — Secret-column encryption silently falls back to an ephemeral (throwaway) key
|
||||
|
||||
|
||||
Reference in New Issue
Block a user