[
https://issues.apache.org/jira/browse/UNOMI-938?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Serge Huber updated UNOMI-938:
------------------------------
Description:
h2. Background
UNOMI-139 introduces the [ApiKey|https://github.com/apache/unomi/pull/760]
model as part of the
multi-tenant platform. Each Tenant holds a list of ApiKey objects stored in
Elasticsearch/OpenSearch.
The current implementation stores the plaintext key value in the persistence
layer and returns it in
REST responses whenever the Tenant document is fetched.
h2. Problem
h3. 1. Plaintext keys at rest
{{ApiKey.key}} is a plain {{String}} field with no special treatment. Because
{{Tenant extends Item}}
and is serialized to ES by {{CustomObjectMapper}}, the raw key value is stored
unencrypted in every
shard replica, any snapshot, and any backup. A compromised ES cluster directly
yields all active
API key material.
h3. 2. Plaintext keys over the REST layer
{{GET /tenants/{id}}} returns the full {{Tenant}} document including the nested
{{apiKeys}} list with
every raw key value, accessible to any caller holding the {{ADMINISTRATOR}}
role.
h3. 3. @JsonIgnore is not a safe stopgap
A naive fix of annotating {{ApiKey.key}} with {{@JsonIgnore}} was evaluated and
*rejected* because
Jackson's {{@JsonIgnore}} suppresses *both* serialization (Java → JSON) *and*
deserialization
(JSON → Java) on the *same* ObjectMapper instance. Unomi uses the *same*
{{CustomObjectMapper}}
for both ES persistence and the REST layer, so:
* Keys written by the migration script (raw JSON, bypasses Jackson) have a
{{key}} field in ES.
* When Java reads that document back, Jackson ignores the field → {{getKey()}}
returns {{null}}.
* Any programmatically-created key is not stored in ES at all.
* Authentication based on {{ApiKey.getKey()}} silently breaks.
h3. 4. Root cause of the mapper conflict
Unomi correctly uses separate ObjectMapper instances for different concerns:
||Mapper||Class||Used for||
|Persistence|{{CustomObjectMapper}} + {{OSCustomObjectMapper}}|ES/OS document
read/write|
|REST|CXF JAX-RS mapper ({{RestServer}})|HTTP response serialization|
|GraphQL|GraphQL servlet mapper|GraphQL response serialization|
The {{OSCustomObjectMapper}} already uses the MixIn pattern ({{OSItemMixIn}},
{{OSEventMixIn}})
to add persistence-specific behaviour. The REST mapper does not yet have a
corresponding MixIn for
{{ApiKey}}.
h2. Proposed Solution
h3. Phase 1 — Separate storage fields from presentation
Modify {{ApiKey}} to hold:
* {{keyHash}} — PBKDF2WithHmacSHA512 hash of the plaintext key, stored in ES,
*never* in REST responses.
* {{maskedKey}} — Display-safe identifier, e.g. {{"unomi_v1_****E5F2"}}, safe
to return in all contexts.
* {{key}} — Removed from the persistent entity entirely. Lives only in
{{ApiKeyCreationResult}} (a transient DTO).
{code:java}
// ApiKey.java (simplified diff)
- private String key; // plaintext — removed from entity
+ private String keyHash; // PBKDF2 hash — ES only
+ private String maskedKey; // "unomi_v1_****E5F2" — safe everywhere
{code}
h3. Phase 2 — Scope serialization per mapper using MixIns
Add {{ApiKeyRestMixIn}} to suppress {{keyHash}} from REST/GraphQL responses:
{code:java}
// new file: rest/src/main/java/.../ApiKeyRestMixIn.java
public abstract class ApiKeyRestMixIn {
@JsonIgnore abstract String getKeyHash();
@JsonIgnore abstract void setKeyHash(String keyHash);
}
{code}
Register in {{RestServer}} (one line):
{code:java}
mapper.addMixIn(ApiKey.class, ApiKeyRestMixIn.class);
{code}
The ES mapper ({{OSCustomObjectMapper}}) has no MixIn for {{ApiKey}}, so
{{keyHash}} is serialized
and deserialized normally — round-trips are preserved.
h3. Phase 3 — One-time creation response DTO
{code:java}
// ApiKeyCreationResult.java
public class ApiKeyCreationResult {
private final ApiKey apiKey; // metadata + maskedKey, no plaintext
private final String key; // plaintext — returned once, discarded
server-side
}
{code}
h3. Phase 4 — Hash service (PBKDF2WithHmacSHA512)
New {{ApiKeyHashService}} in {{services-common}}:
{code:java}
// Self-describing stored format: "iterations:base64(salt):base64(hash)"
// Iterations: 600 000 (OWASP 2023 recommendation for PBKDF2-HMAC-SHA512)
// Salt: 16 bytes (SecureRandom)
// Hash length: 256 bits
public String hash(String plaintext) { ... }
public boolean verify(String plaintext, String storedHash) {
// MessageDigest.isEqual() for constant-time comparison — prevents timing
attacks
}
{code}
Rationale for PBKDF2WithHmacSHA512 over alternatives:
* *Argon2id* is theoretically stronger but requires an external dependency
({{argon2-jvm}}) which complicates OSGi/Karaf bundle wiring.
* *bcrypt* caps input at 72 bytes; API keys are longer.
* *PBKDF2WithHmacSHA512* is built into the JDK (no new dependencies), NIST SP
800-132
compliant, and at 600 000 iterations with a 16-byte random salt provides
computationally infeasible reversal even with a GPU cluster.
h3. Phase 5 — Key format
{noformat}
Format : unomi_v1_{64-char-uppercase-hex}
Example :
unomi_v1_C606D77D1D219509637A82C062BCD17F13D6DF1501702DC396D4A12D63D4E5F2
Masked : unomi_v1_****E5F2 (prefix + **** + last 4 chars)
{noformat}
The {{unomi_v1_}} prefix enables fast rejection of obviously invalid inputs
before
the expensive hash computation and allows future algorithm migration via
version bump.
h3. Phase 6 — Authentication path update
{code:java}
// In KarafSecurityService or a new ApiKeyAuthenticator:
public boolean authenticate(String tenantId, String incomingKey) {
Tenant tenant = tenantService.getTenant(tenantId);
return tenant.getActiveApiKeys().stream()
.anyMatch(k -> apiKeyHashService.verify(incomingKey, k.getKeyHash()));
}
{code}
h3. Phase 7 — Migration script update
The migration script {{migrate-3.1.0-10-tenantInitialization.groovy}} currently
stores the
generated plaintext in {{key}}. It should be updated to:
# Compute {{keyHash}} using a migration-accessible hash utility
# Store {{keyHash}} and {{maskedKey}} in the ES document
# Remove {{key}} from the indexed JSON
The script already prints generated key values to the console and writes them to
{{${karaf.data}/migration/secrets/}} (24-hour TTL). No change to that mechanism
—
the plaintext is already surfaced once to the operator.
h2. Implementation Plan
|| # || Task || File(s) || Notes ||
| 1 | Remove {{key}} field; add {{keyHash}} + {{maskedKey}} |
{{api/.../ApiKey.java}} | Breaking change — see compatibility note |
| 2 | Update {{Tenant.java}} helpers to return {{ApiKey}} objects not
{{String}} keys | {{api/.../Tenant.java}} | {{getActivePrivateKey()}} /
{{getActivePublicKey()}} |
| 3 | Add {{ApiKeyCreationResult}} DTO | {{api/.../ApiKeyCreationResult.java}}
| New file |
| 4 | Add {{ApiKeyHashService}} interface |
{{api/.../services/ApiKeyHashService.java}} | New file |
| 5 | Implement {{ApiKeyHashServiceImpl}} (PBKDF2) |
{{services-common/.../ApiKeyHashServiceImpl.java}} | New file |
| 6 | Add {{ApiKeyRestMixIn}} | {{rest/.../ApiKeyRestMixIn.java}} | New file |
| 7 | Register MixIn in {{RestServer}} | {{rest/.../RestServer.java}} | +1 line
|
| 8 | Update {{TenantService.generateApiKey()}} |
{{services/.../TenantServiceImpl.java}} | Returns {{ApiKeyCreationResult}} |
| 9 | Update REST endpoint for key creation/rotation |
{{rest/.../TenantEndpoint.java}} | Response type changes |
| 10 | Update authentication to use hash comparison |
{{services-common/.../KarafSecurityService.java}} | Replace {{getKey()}} with
{{verify()}} |
| 11 | Update migration script |
{{tools/.../migrate-3.1.0-10-tenantInitialization.groovy}} | Hash on the Groovy
side |
| 12 | Add {{ApiKeyHashServiceImplTest}} | {{services-common/src/test/...}} |
Hash/verify round-trip, timing attack |
h2. Backward Compatibility
Existing ES documents written by the 3.1.0 migration script contain a {{key}}
field (plaintext).
When this change is deployed, those documents will be read by Jackson but
{{key}} is no longer
on the entity — Jackson's default {{FAIL_ON_UNKNOWN_PROPERTIES = false}} means
the field is
silently ignored. No migration is needed to *read* existing data.
However, existing keys stored as {{key}} (plaintext) will have {{keyHash =
null}} after this
change deploys. A one-time re-hashing step is needed:
* Add a Karaf shell command {{unomi:rehash-api-keys}} that reads all {{Tenant}}
documents,
computes {{keyHash}} from the stored plaintext {{key}} (if present), and
removes {{key}}.
* Or include as migration step {{migrate-3.1.0-20-hashApiKeys.groovy}}.
h2. Security Considerations
* The PBKDF2 verify path uses {{MessageDigest.isEqual()}} (constant-time) to
prevent timing attacks.
* The {{keyHash}} field is suppressed from REST and GraphQL via MixIn — not
just annotations —
so it cannot leak through any future ObjectMapper copy that forgets to
re-apply annotations.
* The {{maskedKey}} (last 4 chars) must be unique enough for identification but
not enough for
brute-force. 4 hex chars = 65 536 possibilities — acceptable for display
only, not security.
* Key rotation must invalidate the old {{keyHash}} atomically with writing the
new one.
* The plaintext is held in a Java {{String}} for the duration of the creation
request.
Consider {{char[]}} + explicit zeroing for highest-security deployments
(limited in practice
because the JVM GC can move objects).
h2. Acceptance Criteria
* {{ApiKey}} has no {{key}} field; {{keyHash}} is stored in ES and never
appears in any REST or
GraphQL response.
* {{ApiKeyCreationResult}} returns the plaintext key exactly once; subsequent
{{GET /tenants/{id}}}
show only {{maskedKey}}.
* {{ApiKeyHashService.verify()}} passes timing-attack resistance test (response
time does not
differ between valid and invalid keys by more than JVM noise).
* Authentication succeeds with a correct key and fails with an incorrect key or
a key belonging
to a different tenant.
* {{OSCustomObjectMapper}} round-trips {{keyHash}} through ES without loss.
* {{ApiKeyRestMixIn}} suppresses {{keyHash}} in REST responses.
* All existing ITs pass after the re-hashing migration step.
h2. Definition of Done
* All items in the implementation plan are complete.
* Unit tests for {{ApiKeyHashServiceImpl}} cover: hash, verify, wrong key,
wrong hash format,
null inputs, and confirm no timing leak.
* Integration test covers: key generation → storage → authentication → rotation.
* {{migrate-3.1.0-20-hashApiKeys.groovy}} (or {{unomi:rehash-api-keys}} shell
command) is
documented in the migration guide.
* Security review confirms no plaintext in ES snapshots or REST responses.
was:
h2. Background
UNOMI-139 introduces the [ApiKey|https://github.com/apache/unomi/pull/760]
model as part of the
multi-tenant platform. Each Tenant holds a list of ApiKey objects stored in
Elasticsearch/OpenSearch.
The current implementation stores the plaintext key value in the persistence
layer and returns it in
REST responses whenever the Tenant document is fetched.
h2. Problem
h3. 1. Plaintext keys at rest
{{ApiKey.key}} is a plain {{String}} field with no special treatment. Because
{{Tenant extends Item}}
and is serialized to ES by {{CustomObjectMapper}}, the raw key value is stored
unencrypted in every
shard replica, any snapshot, and any backup. A compromised ES cluster directly
yields all active
API key material.
h3. 2. Plaintext keys over the REST layer
{{GET /tenants/{id}}} returns the full {{Tenant}} document including the nested
{{apiKeys}} list with
every raw key value, accessible to any caller holding the {{ADMINISTRATOR}}
role.
h3. 3. @JsonIgnore is not a safe stopgap
A naive fix of annotating {{ApiKey.key}} with {{@JsonIgnore}} was evaluated and
*rejected* because
Jackson's {{@JsonIgnore}} suppresses *both* serialization (Java → JSON) *and*
deserialization
(JSON → Java) on the *same* ObjectMapper instance. Unomi uses the *same*
{{CustomObjectMapper}}
for both ES persistence and the REST layer, so:
* Keys written by the migration script (raw JSON, bypasses Jackson) have a
{{key}} field in ES.
* When Java reads that document back, Jackson ignores the field → {{getKey()}}
returns {{null}}.
* Any programmatically-created key is not stored in ES at all.
* Authentication based on {{ApiKey.getKey()}} silently breaks.
h3. 4. Root cause of the mapper conflict
Unomi correctly uses separate ObjectMapper instances for different concerns:
||Mapper||Class||Used for||
|Persistence|{{CustomObjectMapper}} + {{OSCustomObjectMapper}}|ES/OS document
read/write|
|REST|CXF JAX-RS mapper ({{RestServer}})|HTTP response serialization|
|GraphQL|GraphQL servlet mapper|GraphQL response serialization|
The {{OSCustomObjectMapper}} already uses the MixIn pattern ({{OSItemMixIn}},
{{OSEventMixIn}})
to add persistence-specific behaviour. The REST mapper does not yet have a
corresponding MixIn for
{{ApiKey}}.
h2. Proposed Solution
h3. Phase 1 — Separate storage fields from presentation
Modify {{ApiKey}} to hold:
* {{keyHash}} — PBKDF2WithHmacSHA512 hash of the plaintext key, stored in ES,
*never* in REST responses.
* {{maskedKey}} — Display-safe identifier, e.g. {{"unomi_v1_****E5F2"}}, safe
to return in all contexts.
* {{key}} — Removed from the persistent entity entirely. Lives only in
{{ApiKeyCreationResult}} (a transient DTO).
{code:java}
// ApiKey.java (simplified diff)
- private String key; // plaintext — removed from entity
+ private String keyHash; // PBKDF2 hash — ES only
+ private String maskedKey; // "unomi_v1_****E5F2" — safe everywhere
{code}
h3. Phase 2 — Scope serialization per mapper using MixIns
Add {{ApiKeyRestMixIn}} to suppress {{keyHash}} from REST/GraphQL responses:
{code:java}
// new file: rest/src/main/java/.../ApiKeyRestMixIn.java
public abstract class ApiKeyRestMixIn {
@JsonIgnore abstract String getKeyHash();
@JsonIgnore abstract void setKeyHash(String keyHash);
}
{code}
Register in {{RestServer}} (one line):
{code:java}
mapper.addMixIn(ApiKey.class, ApiKeyRestMixIn.class);
{code}
The ES mapper ({{OSCustomObjectMapper}}) has no MixIn for {{ApiKey}}, so
{{keyHash}} is serialized
and deserialized normally — round-trips are preserved.
h3. Phase 3 — One-time creation response DTO
{code:java}
// ApiKeyCreationResult.java
public class ApiKeyCreationResult {
private final ApiKey apiKey; // metadata + maskedKey, no plaintext
private final String key; // plaintext — returned once, discarded
server-side
}
{code}
h3. Phase 4 — Hash service (PBKDF2WithHmacSHA512)
New {{ApiKeyHashService}} in {{services-common}}:
{code:java}
// Self-describing stored format: "iterations:base64(salt):base64(hash)"
// Iterations: 600 000 (OWASP 2023 recommendation for PBKDF2-HMAC-SHA512)
// Salt: 16 bytes (SecureRandom)
// Hash length: 256 bits
public String hash(String plaintext) { ... }
public boolean verify(String plaintext, String storedHash) {
// MessageDigest.isEqual() for constant-time comparison — prevents timing
attacks
}
{code}
Rationale for PBKDF2WithHmacSHA512 over alternatives:
* *Argon2id* is theoretically stronger but requires an external dependency
({{argon2-jvm}}) which complicates OSGi/Karaf bundle wiring.
* *bcrypt* caps input at 72 bytes; API keys are longer.
* *PBKDF2WithHmacSHA512* is built into the JDK (no new dependencies), NIST SP
800-132
compliant, and at 600 000 iterations with a 16-byte random salt provides
computationally infeasible reversal even with a GPU cluster.
h3. Phase 5 — Key format
{noformat}
Format : unomi_v1_{64-char-uppercase-hex}
Example :
unomi_v1_C606D77D1D219509637A82C062BCD17F13D6DF1501702DC396D4A12D63D4E5F2
Masked : unomi_v1_****E5F2 (prefix + **** + last 4 chars)
{noformat}
The {{unomi_v1_}} prefix enables fast rejection of obviously invalid inputs
before
the expensive hash computation and allows future algorithm migration via
version bump.
h3. Phase 6 — Authentication path update
{code:java}
// In KarafSecurityService or a new ApiKeyAuthenticator:
public boolean authenticate(String tenantId, String incomingKey) {
Tenant tenant = tenantService.getTenant(tenantId);
return tenant.getActiveApiKeys().stream()
.anyMatch(k -> apiKeyHashService.verify(incomingKey, k.getKeyHash()));
}
{code}
h3. Phase 7 — Migration script update
The migration script {{migrate-3.1.0-10-tenantInitialization.groovy}} currently
stores the
generated plaintext in {{key}}. It should be updated to:
# Compute {{keyHash}} using a migration-accessible hash utility
# Store {{keyHash}} and {{maskedKey}} in the ES document
# Remove {{key}} from the indexed JSON
The script already prints generated key values to the console and writes them to
{{$\{karaf.data\}/migration/secrets/}} (24-hour TTL). No change to that
mechanism —
the plaintext is already surfaced once to the operator.
h2. Implementation Plan
|| # || Task || File(s) || Notes ||
| 1 | Remove {{key}} field; add {{keyHash}} + {{maskedKey}} |
{{api/.../ApiKey.java}} | Breaking change — see compatibility note |
| 2 | Update {{Tenant.java}} helpers to return {{ApiKey}} objects not
{{String}} keys | {{api/.../Tenant.java}} | {{getActivePrivateKey()}} /
{{getActivePublicKey()}} |
| 3 | Add {{ApiKeyCreationResult}} DTO | {{api/.../ApiKeyCreationResult.java}}
| New file |
| 4 | Add {{ApiKeyHashService}} interface |
{{api/.../services/ApiKeyHashService.java}} | New file |
| 5 | Implement {{ApiKeyHashServiceImpl}} (PBKDF2) |
{{services-common/.../ApiKeyHashServiceImpl.java}} | New file |
| 6 | Add {{ApiKeyRestMixIn}} | {{rest/.../ApiKeyRestMixIn.java}} | New file |
| 7 | Register MixIn in {{RestServer}} | {{rest/.../RestServer.java}} | +1 line
|
| 8 | Update {{TenantService.generateApiKey()}} |
{{services/.../TenantServiceImpl.java}} | Returns {{ApiKeyCreationResult}} |
| 9 | Update REST endpoint for key creation/rotation |
{{rest/.../TenantEndpoint.java}} | Response type changes |
| 10 | Update authentication to use hash comparison |
{{services-common/.../KarafSecurityService.java}} | Replace {{getKey()}} with
{{verify()}} |
| 11 | Update migration script |
{{tools/.../migrate-3.1.0-10-tenantInitialization.groovy}} | Hash on the Groovy
side |
| 12 | Add {{ApiKeyHashServiceImplTest}} | {{services-common/src/test/...}} |
Hash/verify round-trip, timing attack |
h2. Backward Compatibility
Existing ES documents written by the 3.1.0 migration script contain a {{key}}
field (plaintext).
When this change is deployed, those documents will be read by Jackson but
{{key}} is no longer
on the entity — Jackson's default {{FAIL_ON_UNKNOWN_PROPERTIES = false}} means
the field is
silently ignored. No migration is needed to *read* existing data.
However, existing keys stored as {{key}} (plaintext) will have {{keyHash =
null}} after this
change deploys. A one-time re-hashing step is needed:
* Add a Karaf shell command {{unomi:rehash-api-keys}} that reads all {{Tenant}}
documents,
computes {{keyHash}} from the stored plaintext {{key}} (if present), and
removes {{key}}.
* Or include as migration step {{migrate-3.1.0-20-hashApiKeys.groovy}}.
h2. Security Considerations
* The PBKDF2 verify path uses {{MessageDigest.isEqual()}} (constant-time) to
prevent timing attacks.
* The {{keyHash}} field is suppressed from REST and GraphQL via MixIn — not
just annotations —
so it cannot leak through any future ObjectMapper copy that forgets to
re-apply annotations.
* The {{maskedKey}} (last 4 chars) must be unique enough for identification but
not enough for
brute-force. 4 hex chars = 65 536 possibilities — acceptable for display
only, not security.
* Key rotation must invalidate the old {{keyHash}} atomically with writing the
new one.
* The plaintext is held in a Java {{String}} for the duration of the creation
request.
Consider {{char[]}} + explicit zeroing for highest-security deployments
(limited in practice
because the JVM GC can move objects).
h2. Acceptance Criteria
* {{ApiKey}} has no {{key}} field; {{keyHash}} is stored in ES and never
appears in any REST or
GraphQL response.
* {{ApiKeyCreationResult}} returns the plaintext key exactly once; subsequent
{{GET /tenants/{id}}}
show only {{maskedKey}}.
* {{ApiKeyHashService.verify()}} passes timing-attack resistance test (response
time does not
differ between valid and invalid keys by more than JVM noise).
* Authentication succeeds with a correct key and fails with an incorrect key or
a key belonging
to a different tenant.
* {{OSCustomObjectMapper}} round-trips {{keyHash}} through ES without loss.
* {{ApiKeyRestMixIn}} suppresses {{keyHash}} in REST responses.
* All existing ITs pass after the re-hashing migration step.
h2. Definition of Done
* All items in the implementation plan are complete.
* Unit tests for {{ApiKeyHashServiceImpl}} cover: hash, verify, wrong key,
wrong hash format,
null inputs, and confirm no timing leak.
* Integration test covers: key generation → storage → authentication → rotation.
* {{migrate-3.1.0-20-hashApiKeys.groovy}} (or {{unomi:rehash-api-keys}} shell
command) is
documented in the migration guide.
* Security review confirms no plaintext in ES snapshots or REST responses.
> Hash API keys at rest using PBKDF2 + MixIn-scoped serialization
> ---------------------------------------------------------------
>
> Key: UNOMI-938
> URL: https://issues.apache.org/jira/browse/UNOMI-938
> Project: Apache Unomi
> Issue Type: Sub-task
> Components: unomi(-core)
> Affects Versions: unomi-3.1.0
> Reporter: Serge Huber
> Assignee: Serge Huber
> Priority: Major
> Fix For: unomi-3.1.0
>
> Original Estimate: 24h
> Remaining Estimate: 24h
>
> h2. Background
> UNOMI-139 introduces the [ApiKey|https://github.com/apache/unomi/pull/760]
> model as part of the
> multi-tenant platform. Each Tenant holds a list of ApiKey objects stored in
> Elasticsearch/OpenSearch.
> The current implementation stores the plaintext key value in the persistence
> layer and returns it in
> REST responses whenever the Tenant document is fetched.
> h2. Problem
> h3. 1. Plaintext keys at rest
> {{ApiKey.key}} is a plain {{String}} field with no special treatment. Because
> {{Tenant extends Item}}
> and is serialized to ES by {{CustomObjectMapper}}, the raw key value is
> stored unencrypted in every
> shard replica, any snapshot, and any backup. A compromised ES cluster
> directly yields all active
> API key material.
> h3. 2. Plaintext keys over the REST layer
> {{GET /tenants/{id}}} returns the full {{Tenant}} document including the
> nested {{apiKeys}} list with
> every raw key value, accessible to any caller holding the {{ADMINISTRATOR}}
> role.
> h3. 3. @JsonIgnore is not a safe stopgap
> A naive fix of annotating {{ApiKey.key}} with {{@JsonIgnore}} was evaluated
> and *rejected* because
> Jackson's {{@JsonIgnore}} suppresses *both* serialization (Java → JSON) *and*
> deserialization
> (JSON → Java) on the *same* ObjectMapper instance. Unomi uses the *same*
> {{CustomObjectMapper}}
> for both ES persistence and the REST layer, so:
> * Keys written by the migration script (raw JSON, bypasses Jackson) have a
> {{key}} field in ES.
> * When Java reads that document back, Jackson ignores the field →
> {{getKey()}} returns {{null}}.
> * Any programmatically-created key is not stored in ES at all.
> * Authentication based on {{ApiKey.getKey()}} silently breaks.
> h3. 4. Root cause of the mapper conflict
> Unomi correctly uses separate ObjectMapper instances for different concerns:
> ||Mapper||Class||Used for||
> |Persistence|{{CustomObjectMapper}} + {{OSCustomObjectMapper}}|ES/OS document
> read/write|
> |REST|CXF JAX-RS mapper ({{RestServer}})|HTTP response serialization|
> |GraphQL|GraphQL servlet mapper|GraphQL response serialization|
> The {{OSCustomObjectMapper}} already uses the MixIn pattern ({{OSItemMixIn}},
> {{OSEventMixIn}})
> to add persistence-specific behaviour. The REST mapper does not yet have a
> corresponding MixIn for
> {{ApiKey}}.
> h2. Proposed Solution
> h3. Phase 1 — Separate storage fields from presentation
> Modify {{ApiKey}} to hold:
> * {{keyHash}} — PBKDF2WithHmacSHA512 hash of the plaintext key, stored in ES,
> *never* in REST responses.
> * {{maskedKey}} — Display-safe identifier, e.g. {{"unomi_v1_****E5F2"}}, safe
> to return in all contexts.
> * {{key}} — Removed from the persistent entity entirely. Lives only in
> {{ApiKeyCreationResult}} (a transient DTO).
> {code:java}
> // ApiKey.java (simplified diff)
> - private String key; // plaintext — removed from entity
> + private String keyHash; // PBKDF2 hash — ES only
> + private String maskedKey; // "unomi_v1_****E5F2" — safe everywhere
> {code}
> h3. Phase 2 — Scope serialization per mapper using MixIns
> Add {{ApiKeyRestMixIn}} to suppress {{keyHash}} from REST/GraphQL responses:
> {code:java}
> // new file: rest/src/main/java/.../ApiKeyRestMixIn.java
> public abstract class ApiKeyRestMixIn {
> @JsonIgnore abstract String getKeyHash();
> @JsonIgnore abstract void setKeyHash(String keyHash);
> }
> {code}
> Register in {{RestServer}} (one line):
> {code:java}
> mapper.addMixIn(ApiKey.class, ApiKeyRestMixIn.class);
> {code}
> The ES mapper ({{OSCustomObjectMapper}}) has no MixIn for {{ApiKey}}, so
> {{keyHash}} is serialized
> and deserialized normally — round-trips are preserved.
> h3. Phase 3 — One-time creation response DTO
> {code:java}
> // ApiKeyCreationResult.java
> public class ApiKeyCreationResult {
> private final ApiKey apiKey; // metadata + maskedKey, no plaintext
> private final String key; // plaintext — returned once, discarded
> server-side
> }
> {code}
> h3. Phase 4 — Hash service (PBKDF2WithHmacSHA512)
> New {{ApiKeyHashService}} in {{services-common}}:
> {code:java}
> // Self-describing stored format: "iterations:base64(salt):base64(hash)"
> // Iterations: 600 000 (OWASP 2023 recommendation for PBKDF2-HMAC-SHA512)
> // Salt: 16 bytes (SecureRandom)
> // Hash length: 256 bits
> public String hash(String plaintext) { ... }
> public boolean verify(String plaintext, String storedHash) {
> // MessageDigest.isEqual() for constant-time comparison — prevents timing
> attacks
> }
> {code}
> Rationale for PBKDF2WithHmacSHA512 over alternatives:
> * *Argon2id* is theoretically stronger but requires an external dependency
> ({{argon2-jvm}}) which complicates OSGi/Karaf bundle wiring.
> * *bcrypt* caps input at 72 bytes; API keys are longer.
> * *PBKDF2WithHmacSHA512* is built into the JDK (no new dependencies), NIST SP
> 800-132
> compliant, and at 600 000 iterations with a 16-byte random salt provides
> computationally infeasible reversal even with a GPU cluster.
> h3. Phase 5 — Key format
> {noformat}
> Format : unomi_v1_{64-char-uppercase-hex}
> Example :
> unomi_v1_C606D77D1D219509637A82C062BCD17F13D6DF1501702DC396D4A12D63D4E5F2
> Masked : unomi_v1_****E5F2 (prefix + **** + last 4 chars)
> {noformat}
> The {{unomi_v1_}} prefix enables fast rejection of obviously invalid inputs
> before
> the expensive hash computation and allows future algorithm migration via
> version bump.
> h3. Phase 6 — Authentication path update
> {code:java}
> // In KarafSecurityService or a new ApiKeyAuthenticator:
> public boolean authenticate(String tenantId, String incomingKey) {
> Tenant tenant = tenantService.getTenant(tenantId);
> return tenant.getActiveApiKeys().stream()
> .anyMatch(k -> apiKeyHashService.verify(incomingKey, k.getKeyHash()));
> }
> {code}
> h3. Phase 7 — Migration script update
> The migration script {{migrate-3.1.0-10-tenantInitialization.groovy}}
> currently stores the
> generated plaintext in {{key}}. It should be updated to:
> # Compute {{keyHash}} using a migration-accessible hash utility
> # Store {{keyHash}} and {{maskedKey}} in the ES document
> # Remove {{key}} from the indexed JSON
> The script already prints generated key values to the console and writes them
> to
> {{${karaf.data}/migration/secrets/}} (24-hour TTL). No change to that
> mechanism —
> the plaintext is already surfaced once to the operator.
> h2. Implementation Plan
> || # || Task || File(s) || Notes ||
> | 1 | Remove {{key}} field; add {{keyHash}} + {{maskedKey}} |
> {{api/.../ApiKey.java}} | Breaking change — see compatibility note |
> | 2 | Update {{Tenant.java}} helpers to return {{ApiKey}} objects not
> {{String}} keys | {{api/.../Tenant.java}} | {{getActivePrivateKey()}} /
> {{getActivePublicKey()}} |
> | 3 | Add {{ApiKeyCreationResult}} DTO |
> {{api/.../ApiKeyCreationResult.java}} | New file |
> | 4 | Add {{ApiKeyHashService}} interface |
> {{api/.../services/ApiKeyHashService.java}} | New file |
> | 5 | Implement {{ApiKeyHashServiceImpl}} (PBKDF2) |
> {{services-common/.../ApiKeyHashServiceImpl.java}} | New file |
> | 6 | Add {{ApiKeyRestMixIn}} | {{rest/.../ApiKeyRestMixIn.java}} | New file |
> | 7 | Register MixIn in {{RestServer}} | {{rest/.../RestServer.java}} | +1
> line |
> | 8 | Update {{TenantService.generateApiKey()}} |
> {{services/.../TenantServiceImpl.java}} | Returns {{ApiKeyCreationResult}} |
> | 9 | Update REST endpoint for key creation/rotation |
> {{rest/.../TenantEndpoint.java}} | Response type changes |
> | 10 | Update authentication to use hash comparison |
> {{services-common/.../KarafSecurityService.java}} | Replace {{getKey()}} with
> {{verify()}} |
> | 11 | Update migration script |
> {{tools/.../migrate-3.1.0-10-tenantInitialization.groovy}} | Hash on the
> Groovy side |
> | 12 | Add {{ApiKeyHashServiceImplTest}} | {{services-common/src/test/...}} |
> Hash/verify round-trip, timing attack |
> h2. Backward Compatibility
> Existing ES documents written by the 3.1.0 migration script contain a {{key}}
> field (plaintext).
> When this change is deployed, those documents will be read by Jackson but
> {{key}} is no longer
> on the entity — Jackson's default {{FAIL_ON_UNKNOWN_PROPERTIES = false}}
> means the field is
> silently ignored. No migration is needed to *read* existing data.
> However, existing keys stored as {{key}} (plaintext) will have {{keyHash =
> null}} after this
> change deploys. A one-time re-hashing step is needed:
> * Add a Karaf shell command {{unomi:rehash-api-keys}} that reads all
> {{Tenant}} documents,
> computes {{keyHash}} from the stored plaintext {{key}} (if present), and
> removes {{key}}.
> * Or include as migration step {{migrate-3.1.0-20-hashApiKeys.groovy}}.
> h2. Security Considerations
> * The PBKDF2 verify path uses {{MessageDigest.isEqual()}} (constant-time) to
> prevent timing attacks.
> * The {{keyHash}} field is suppressed from REST and GraphQL via MixIn — not
> just annotations —
> so it cannot leak through any future ObjectMapper copy that forgets to
> re-apply annotations.
> * The {{maskedKey}} (last 4 chars) must be unique enough for identification
> but not enough for
> brute-force. 4 hex chars = 65 536 possibilities — acceptable for display
> only, not security.
> * Key rotation must invalidate the old {{keyHash}} atomically with writing
> the new one.
> * The plaintext is held in a Java {{String}} for the duration of the creation
> request.
> Consider {{char[]}} + explicit zeroing for highest-security deployments
> (limited in practice
> because the JVM GC can move objects).
> h2. Acceptance Criteria
> * {{ApiKey}} has no {{key}} field; {{keyHash}} is stored in ES and never
> appears in any REST or
> GraphQL response.
> * {{ApiKeyCreationResult}} returns the plaintext key exactly once; subsequent
> {{GET /tenants/{id}}}
> show only {{maskedKey}}.
> * {{ApiKeyHashService.verify()}} passes timing-attack resistance test
> (response time does not
> differ between valid and invalid keys by more than JVM noise).
> * Authentication succeeds with a correct key and fails with an incorrect key
> or a key belonging
> to a different tenant.
> * {{OSCustomObjectMapper}} round-trips {{keyHash}} through ES without loss.
> * {{ApiKeyRestMixIn}} suppresses {{keyHash}} in REST responses.
> * All existing ITs pass after the re-hashing migration step.
> h2. Definition of Done
> * All items in the implementation plan are complete.
> * Unit tests for {{ApiKeyHashServiceImpl}} cover: hash, verify, wrong key,
> wrong hash format,
> null inputs, and confirm no timing leak.
> * Integration test covers: key generation → storage → authentication →
> rotation.
> * {{migrate-3.1.0-20-hashApiKeys.groovy}} (or {{unomi:rehash-api-keys}} shell
> command) is
> documented in the migration guide.
> * Security review confirms no plaintext in ES snapshots or REST responses.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)