Skip to content

Fix cache key hash collision: length-prefix extended cache key components#1048

Open
4gust wants to merge 2 commits into
devfrom
4gust-fix-cache-key-hash-collision
Open

Fix cache key hash collision: length-prefix extended cache key components#1048
4gust wants to merge 2 commits into
devfrom
4gust-fix-cache-key-hash-collision

Conversation

@4gust

@4gust 4gust commented Jul 21, 2026

Copy link
Copy Markdown

Problem

StringHelper.computeExtCacheKeyHash(SortedMap<String,String>) builds the extended cache-key hash by concatenating the sorted key + value pairs with no delimiters before hashing. That serialization is not injective, so semantically different component sets can produce the same string and hash to the same cache key.

For example, both of these serialize to "fmi_pathvalue":

  • { fmi_path: "value" }
  • { fmi_pat: "hvalue" }

The FMI / agent-identity and client_claims features feed such component maps into the cache key, so this is a real cache-slot collision: one entry overwrites/evicts another, causing redundant token re-fetches.

Fix

Replace the delimiter-less concatenation with a length-prefix (netstring) encoding. For each entry (iterated in the map's sorted-key order) we append:

<utf8ByteLen(key)>:<key><utf8ByteLen(value)>:<value>

then SHA-256 → Base64URL (no padding), exactly as before.

  • The length prefixes make the serialization injective, so distinct component sets can never collide onto the same slot.
  • UTF-8 byte length (str.getBytes(StandardCharsets.UTF_8).length) is used, not String.length() (UTF-16 code units). This keeps it byte-identical to the matching fixes in MSAL Go/.NET/Python/JS, so the whole SDK family converges on the same cache keys.
  • The null/empty early-return is preserved and the docstring is updated to describe the new scheme.

This mirrors the already-merged MSAL Go implementation:

for _, key := range keys { // sorted
    val := components[key]
    fmt.Fprintf(&sb, "%d:%s%d:%s", len(key), key, len(val), val)
}

Tests

Added to FmiTest (direct StringHelper.computeExtCacheKeyHash calls):

  • Injectivity fuzz over an adversarial alphabet (digits, :, |, \, empty string, 2-byte é, an e + combining accent sequence, and a 4-byte emoji) — asserts no two distinct component maps collide.
  • Boundary ambiguity{fmi_path:"value"} vs {fmi_pat:"hvalue"} and {a:"b",cd:"e"} vs {ab:"c",d:"e"} hash differently.
  • Input-order independence — same components, different insertion order → same hash.
  • UTF-8 byte-length correctness — precomposed é (U+00E9) vs e+U+0301 must not collide, proving byte length (not String.length()) is used.
  • Empty / single-entry / empty-value edges.
  • Base64URL-no-pad shape check.
  • Cross-SDK golden vectors (compared case-insensitively), which double as a consistency guard against the other SDKs.

The two previously hard-coded SomeFmiPath/... expected hashes in FmiTest were recomputed for the new scheme.

Validated with mvn -pl msal4j-sdk -am test -Dtest=FmiTest,ClientClaimsTest,UserFederatedIdentityCredentialTest — all pass (25 / 15 / 25, 0 failures).

Compatibility note

This changes the cache-key hash for existing FMI / client_claims cache entries. On upgrade, previously cached entries hash to a different key and are missed once, triggering a single token re-fetch that repopulates the cache under the new key. This is a one-time, self-healing cache miss and matches the behavior of the parallel fixes in the other MSAL SDKs. No public API changes.

…ents

computeExtCacheKeyHash concatenated sorted key+value with no delimiters, so
distinct component sets could serialize to the same string and hash to the
same cache key (e.g. {fmi_path:"value"} and {fmi_pat:"hvalue"} both ->
"fmi_pathvalue"), causing cache-slot collisions and redundant token re-fetches.

Serialize each sorted entry as <utf8ByteLen(key)>:<key><utf8ByteLen(value)>:<value>
(length-prefix/netstring), matching the merged MSAL Go fix so the SDK family is
byte-identical. Uses UTF-8 byte length, not String.length() (UTF-16 units).

Adds injectivity/fuzz, order-independence, UTF-8 byte-length, edge-case, and
cross-SDK golden-vector tests; updates the two hard-coded FmiTest expected
hashes to the new scheme.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f8d44485-b8f1-489d-98cb-c5148ab413f2
@4gust
4gust requested a review from a team as a code owner July 21, 2026 10:21
The integration test FmiIT hard-coded the two fmi_path cache-key hashes
computed with the old delimiter-less algorithm, so it failed after the
length-prefix fix. Update them to the new byte-identical-to-Go values:
SomeFmiPath/FmiCredentialPath and SomeFmiPath/Path.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f8d44485-b8f1-489d-98cb-c5148ab413f2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants