Skip to content

Commit 43ed3a8

Browse files
authored
Fix cache key hash collision: length-prefix extended cache key components (#946)
* Fix cache key hash collision: length-prefix extended cache key components The extended cache key serialization concatenated sorted key+value pairs with no delimiters, which is not injective: distinct component sets such as {fmi_path: 'value'} and {fmi_pat: 'hvalue'} serialized to the same string and hashed to the same cache key, causing cache-slot collisions and redundant token re-fetches. Adopt MSAL Go's length-prefixed (netstring) encoding (<byteLen(key)>:<key><byteLen(value)>:<value> per sorted key, UTF-8 byte lengths), which is injective and byte-identical across the MSAL SDK family. * Address review: harden injectivity fuzz test Skip same-key pair combinations explicitly (a dict comprehension would silently overwrite and collapse intended 2-entry cases), and assert on len(seen) -- the count of distinct component sets exercised -- instead of a raw iteration counter. * Address review: clarify injectivity wording in docstrings Reword the token_cache and test docstrings to state that the length-prefix scheme makes the *serialization* injective (distinct inputs cannot produce the same pre-hash string), rather than implying absolute impossibility of a collision at the SHA-256 hash layer. Also quote the dict-literal examples as valid Python.
1 parent 9a207a9 commit 43ed3a8

2 files changed

Lines changed: 179 additions & 47 deletions

File tree

msal/token_cache.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,22 @@ def _compute_ext_cache_key(data):
8888
8989
Returns an empty string when *data* has no hashable fields.
9090
91-
The algorithm matches MSAL .NET's ``ComputeAccessTokenExtCacheKey``: sorted
92-
key+value pairs are concatenated (no separators) and SHA256 hashed, then
93-
base64url encoded. This keeps the hash byte-identical to MSAL .NET.
94-
95-
MSAL Go's ``CacheExtKeyGenerator`` has since switched to a length-prefixed
96-
encoding (AzureAD/microsoft-authentication-library-for-go#629) to make it
97-
injective; Python deliberately tracks .NET instead, so these hashes are not
98-
byte-identical to current Go. Caches are not shared across languages, so the
99-
difference does not affect runtime correctness.
91+
The algorithm uses a length-prefixed ("netstring") serialization matching
92+
MSAL Go's ``CacheExtKeyGenerator``
93+
(AzureAD/microsoft-authentication-library-for-go#629): for each key sorted
94+
ascending, ``<byteLen(key)>:<key><byteLen(value)>:<value>`` is appended and
95+
the parts concatenated, then SHA256 hashed and base64url (no padding)
96+
encoded and lowercased.
97+
98+
The length prefixes make the *serialization* injective: distinct component
99+
sets can never produce the same pre-hash string, so they cannot collide at
100+
the serialization layer. (The final cache key is still a SHA-256 digest, so
101+
only a cryptographically negligible hash collision remains possible.) A plain
102+
``key + value`` concatenation, by contrast, is ambiguous: ``{"fmi_path":
103+
"value"}`` and ``{"fmi_pat": "hvalue"}`` would both serialize to
104+
``fmi_pathvalue``. The byte length (``len(s.encode("utf-8"))``), not the
105+
Unicode code-point count, is used so the hash stays byte-identical across the
106+
MSAL SDK family (Go/.NET/Java/JS) as they converge on this scheme.
100107
"""
101108
if not data:
102109
return ""
@@ -106,11 +113,13 @@ def _compute_ext_cache_key(data):
106113
}
107114
if not cache_components:
108115
return ""
109-
# Sort keys, then concatenate key+value pairs with no separators. This
110-
# matches MSAL .NET's ComputeAccessTokenExtCacheKey byte-for-byte. (See the
111-
# docstring re: the Go #629 length-prefixed divergence.)
116+
# Sort keys, then length-prefix each key and value so the serialization is
117+
# injective (see docstring). Byte-identical to MSAL Go's netstring encoding.
112118
key_str = "".join(
113-
k + cache_components[k] for k in sorted(cache_components.keys())
119+
"{klen}:{k}{vlen}:{v}".format(
120+
klen=len(k.encode("utf-8")), k=k,
121+
vlen=len(cache_components[k].encode("utf-8")), v=cache_components[k])
122+
for k in sorted(cache_components.keys())
114123
)
115124
hash_bytes = hashlib.sha256(key_str.encode("utf-8")).digest()
116125
return base64.urlsafe_b64encode(hash_bytes).rstrip(b"=").decode("ascii").lower()

tests/test_token_cache.py

Lines changed: 157 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -564,45 +564,49 @@ def test_non_fmi_tokens_not_affected_by_fmi_cache(self):
564564

565565

566566
class TestCrossMsalCacheKeyCompatibility(unittest.TestCase):
567-
"""Verify that _compute_ext_cache_key produces hashes identical to MSAL .NET
568-
(CoreHelpers.ComputeAccessTokenExtCacheKey).
567+
"""Verify that _compute_ext_cache_key produces hashes identical to the shared
568+
MSAL length-prefix ("netstring") encoding used by MSAL Go's
569+
CacheExtKeyGenerator (AzureAD/microsoft-authentication-library-for-go#629).
569570
570571
The algorithm:
571572
1. Sort key-value pairs alphabetically by key (ordinal / case-sensitive)
572-
2. Concatenate them with no separators: "key1value1key2value2…"
573+
2. Length-prefix each part with its UTF-8 byte length and concatenate:
574+
"<len(key)>:<key><len(value)>:<value>..."
573575
3. SHA-256 hash
574576
4. Base64url encode (no padding), lowercased
575577
576-
The expected hashes below are copied from MSAL .NET's CacheKeyExtensionTests.cs
577-
(RunHappyPathTest, CacheExtEnsurePopKeysFunctionAsync).
578-
579-
NOTE: MSAL Go's CacheExtKeyGenerator has since switched to a *length-prefixed*
580-
encoding (AzureAD/microsoft-authentication-library-for-go#629), so these hashes
581-
are intentionally NOT byte-identical to current Go; Python deliberately tracks
582-
.NET here. The cache *key format* (the 'atext' segment layout, asserted below)
583-
still matches both Go and .NET. Caches are not shared across languages, so this
584-
cross-language hash difference does not affect runtime correctness.
578+
The length prefixes make the *serialization* injective: distinct component
579+
sets can never produce the same pre-hash string. (The final cache key is a
580+
SHA-256 digest, so only a cryptographically negligible hash collision
581+
remains possible.)
582+
583+
NOTE: This is the encoding the whole MSAL SDK family is converging on
584+
(Go already merged it; .NET/Java/JS are landing the same change), so these
585+
hashes are byte-identical across SDKs. The cache *key format* (the 'atext'
586+
segment layout, asserted below) matches Go and .NET too. Caches are not
587+
shared across languages, so cross-language parity is a convenience, not a
588+
correctness requirement.
585589
"""
586590

587-
def test_two_params_hash_matches_dotnet(self):
588-
""".NET expected: bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi"""
591+
def test_two_params_hash_matches_shared_encoding(self):
592+
"""Shared length-prefix expected: latlwkpewb_a0rcsmjvkecqt0_huumkw4sflzociike"""
589593
result = _compute_ext_cache_key({"key1": "value1", "key2": "value2"})
590-
self.assertEqual("bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi", result)
594+
self.assertEqual("latlwkpewb_a0rcsmjvkecqt0_huumkw4sflzociike", result)
591595

592-
def test_two_different_params_hash_matches_dotnet(self):
593-
""".NET expected: 3-rg6_wyjx5bcy0c3cqq7gajtzgsqy3oxqpwj4y8k4u"""
596+
def test_two_different_params_hash_matches_shared_encoding(self):
597+
"""Shared length-prefix expected: jjoe9jgfmdtnj0rzuetsqy7kzs2m1xfnjjxwsfxsrxq"""
594598
result = _compute_ext_cache_key({"key3": "value3", "key4": "value4"})
595-
self.assertEqual("3-rg6_wyjx5bcy0c3cqq7gajtzgsqy3oxqpwj4y8k4u", result)
599+
self.assertEqual("jjoe9jgfmdtnj0rzuetsqy7kzs2m1xfnjjxwsfxsrxq", result)
596600

597-
def test_five_params_hash_matches_dotnet(self):
598-
""".NET expected (full hash): rn_gkpxxkkqjxcqnvnmr2duvxg66xanvkz6qfqpwp2e"""
601+
def test_five_params_hash_matches_shared_encoding(self):
602+
"""Shared length-prefix expected (full hash): prrdp31y37ufw3lo7hly0oimjjvg_34m9ji30ocu4tw"""
599603
result = _compute_ext_cache_key({
600604
"key3": "value3", "key4": "value4",
601605
"key5": "value5", "key6": "value6", "key7": "value7",
602606
})
603-
self.assertEqual("rn_gkpxxkkqjxcqnvnmr2duvxg66xanvkz6qfqpwp2e", result)
607+
self.assertEqual("prrdp31y37ufw3lo7hly0oimjjvg_34m9ji30ocu4tw", result)
604608

605-
def test_order_independence_matches_dotnet(self):
609+
def test_order_independence_matches_shared_encoding(self):
606610
"""Same keys in different insertion order must produce the same hash
607611
(mirrors TestCacheKeyComponentHashConsistency in Go)."""
608612
h1 = _compute_ext_cache_key({"key3": "value3", "key4": "value4",
@@ -623,9 +627,9 @@ def test_at_cache_key_uses_atext_credential_type(self):
623627
key = key_maker(
624628
home_account_id="hid", environment="env", client_id="cid",
625629
realm="realm", target="scope",
626-
ext_cache_key="bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi")
630+
ext_cache_key="latlwkpewb_a0rcsmjvkecqt0_huumkw4sflzociike")
627631
self.assertEqual(
628-
"hid-env-atext-cid-realm-scope-bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi",
632+
"hid-env-atext-cid-realm-scope-latlwkpewb_a0rcsmjvkecqt0_huumkw4sflzociike",
629633
key)
630634

631635
def test_at_cache_key_without_ext_uses_accesstoken(self):
@@ -637,9 +641,10 @@ def test_at_cache_key_without_ext_uses_accesstoken(self):
637641
realm="realm", target="scope")
638642
self.assertEqual("hid-env-accesstoken-cid-realm-scope", key)
639643

640-
def test_dotnet_style_full_at_cache_key(self):
641-
"""Reproduce the exact cache key from MSAL .NET CacheKeyExtensionTests:
642-
expectedCacheKey1 = '-login.windows.net-atext-d3adb33f-c0de-ed0c-c0de-deadb33fc0d3-common-r1/scope1 r1/scope2-bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi'
644+
def test_full_at_cache_key(self):
645+
"""Reproduce the full 'atext' cache key layout (mirrors MSAL .NET's
646+
CacheKeyExtensionTests expectedCacheKey1 shape) with the shared
647+
length-prefix hash for {key1:value1, key2:value2}.
643648
"""
644649
cache = TokenCache()
645650
key_maker = cache.key_makers[TokenCache.CredentialType.ACCESS_TOKEN]
@@ -651,11 +656,12 @@ def test_dotnet_style_full_at_cache_key(self):
651656
realm="common",
652657
target="r1/scope1 r1/scope2",
653658
ext_cache_key=ext_hash)
654-
expected = "-login.windows.net-atext-d3adb33f-c0de-ed0c-c0de-deadb33fc0d3-common-r1/scope1 r1/scope2-bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi"
659+
expected = "-login.windows.net-atext-d3adb33f-c0de-ed0c-c0de-deadb33fc0d3-common-r1/scope1 r1/scope2-latlwkpewb_a0rcsmjvkecqt0_huumkw4sflzociike"
655660
self.assertEqual(expected, key)
656661

657-
def test_dotnet_style_second_cache_key(self):
658-
"""Reproduce CacheKeyExtensionTests expectedCacheKey2."""
662+
def test_second_full_at_cache_key(self):
663+
"""Reproduce the 'atext' cache key layout (mirrors expectedCacheKey2 shape)
664+
with the shared length-prefix hash for {key3:value3, key4:value4}."""
659665
cache = TokenCache()
660666
key_maker = cache.key_makers[TokenCache.CredentialType.ACCESS_TOKEN]
661667
ext_hash = _compute_ext_cache_key({"key3": "value3", "key4": "value4"})
@@ -666,13 +672,13 @@ def test_dotnet_style_second_cache_key(self):
666672
realm="common",
667673
target="r1/scope1 r1/scope2",
668674
ext_cache_key=ext_hash)
669-
expected = "-login.windows.net-atext-d3adb33f-c0de-ed0c-c0de-deadb33fc0d3-common-r1/scope1 r1/scope2-3-rg6_wyjx5bcy0c3cqq7gajtzgsqy3oxqpwj4y8k4u"
675+
expected = "-login.windows.net-atext-d3adb33f-c0de-ed0c-c0de-deadb33fc0d3-common-r1/scope1 r1/scope2-jjoe9jgfmdtnj0rzuetsqy7kzs2m1xfnjjxwsfxsrxq"
670676
self.assertEqual(expected, key)
671677

672678
def test_go_style_at_cache_key(self):
673679
"""Reproduce the Go AccessToken.Key() *format* (segment layout):
674-
'testhid-env-atext-clientid-realm-user.read-{hash}'. The hash follows our
675-
.NET-matching encoding (see class note on the Go #629 divergence).
680+
'testhid-env-atext-clientid-realm-user.read-{hash}'. The hash follows the
681+
shared length-prefix encoding (byte-identical to MSAL Go).
676682
"""
677683
cache = TokenCache()
678684
key_maker = cache.key_makers[TokenCache.CredentialType.ACCESS_TOKEN]
@@ -684,5 +690,122 @@ def test_go_style_at_cache_key(self):
684690
realm="realm",
685691
target="user.read",
686692
ext_cache_key=ext_hash)
687-
expected = "testhid-env-atext-clientid-realm-user.read-bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi"
693+
expected = "testhid-env-atext-clientid-realm-user.read-latlwkpewb_a0rcsmjvkecqt0_huumkw4sflzociike"
688694
self.assertEqual(expected, key)
695+
696+
697+
class TestExtCacheKeyCollisionResistance(unittest.TestCase):
698+
"""The length-prefix ("netstring") serialization must be injective: no two
699+
semantically different component sets may serialize to the same pre-hash
700+
string. (The cache key itself is a SHA-256 digest, so the residual
701+
collision risk is only the cryptographically negligible hash-layer one;
702+
these tests pin the serialization layer that we control.)
703+
704+
A plain ``key + value`` concatenation (the old scheme) is ambiguous and
705+
caused cache-slot collisions -- one FMI/agent-identity token entry could
706+
evict another, forcing redundant token re-fetches. These tests pin the
707+
boundary cases that the old scheme got wrong.
708+
"""
709+
710+
def test_key_value_boundary_ambiguity(self):
711+
# Old scheme: both -> "fmi_pathvalue".
712+
self.assertNotEqual(
713+
_compute_ext_cache_key({"fmi_path": "value"}),
714+
_compute_ext_cache_key({"fmi_pat": "hvalue"}))
715+
716+
def test_multi_entry_boundary_ambiguity(self):
717+
# Old scheme: both -> "abcde".
718+
self.assertNotEqual(
719+
_compute_ext_cache_key({"a": "b", "cd": "e"}),
720+
_compute_ext_cache_key({"ab": "c", "d": "e"}))
721+
722+
def test_value_containing_encoding_delimiters(self):
723+
# Values that themselves contain the "<len>:<data>" delimiters must not
724+
# be able to forge a different component layout.
725+
self.assertNotEqual(
726+
_compute_ext_cache_key({"a": "5:hello"}),
727+
_compute_ext_cache_key({"a5": "hello"}))
728+
self.assertNotEqual(
729+
_compute_ext_cache_key({"x": "1:y1:z"}),
730+
_compute_ext_cache_key({"x": "1:y", "1": "z"}))
731+
732+
def test_injectivity_over_adversarial_alphabet(self):
733+
# Build many distinct component dicts from an adversarial alphabet that
734+
# stresses the length-prefix delimiters and UTF-8 boundaries, then assert
735+
# no two *distinct* dicts share a hash (and identical dicts agree).
736+
import itertools
737+
alphabet = ["", "0", "1", "9", ":", "|", "\\", "a", "ab",
738+
u"\u00e9", u"\U0001F600", u"e\u0301"]
739+
seen = {}
740+
for k1, v1 in itertools.product(alphabet, repeat=2):
741+
for k2, v2 in itertools.product(alphabet, repeat=2):
742+
# Keys must be truthy to survive field-selection, and distinct so
743+
# the two pairs don't silently overwrite each other in a dict
744+
# (which would collapse intended 2-entry cases). Values must be
745+
# truthy too; empty values are dropped by field-selection.
746+
pairs = [(k, v) for k, v in ((k1, v1), (k2, v2)) if k and v]
747+
if len(pairs) == 2 and pairs[0][0] == pairs[1][0]:
748+
continue
749+
comps = dict(pairs)
750+
if not comps:
751+
continue
752+
# Canonicalize so logically-equal dicts compare equal.
753+
canonical = tuple(sorted(comps.items()))
754+
h = _compute_ext_cache_key(comps)
755+
if h in seen:
756+
self.assertEqual(
757+
seen[h], canonical,
758+
"Hash collision between {!r} and {!r} -> {}".format(
759+
dict(seen[h]), comps, h))
760+
else:
761+
seen[h] = canonical
762+
# seen holds one entry per *distinct* component set, so its size proves
763+
# we exercised a meaningful number of unique inputs (not repeats).
764+
self.assertGreater(len(seen), 100)
765+
766+
def test_utf8_byte_length_not_codepoint_length(self):
767+
# 'é' as one 2-byte codepoint (U+00E9) vs 'e' + combining acute accent
768+
# (U+0065 U+0301, 3 bytes) render alike but are distinct. A code-point
769+
# length prefix (len(str)) would risk a collision at some boundary; the
770+
# UTF-8 byte-length prefix keeps them apart and matches MSAL Go.
771+
precomposed = u"\u00e9" # 1 code point, 2 UTF-8 bytes
772+
decomposed = u"e\u0301" # 2 code points, 3 UTF-8 bytes
773+
self.assertNotEqual(
774+
_compute_ext_cache_key({"k": precomposed}),
775+
_compute_ext_cache_key({"k": decomposed}))
776+
# A concrete boundary pair the two length schemes disagree on:
777+
# code-point length would make these ambiguous, byte length does not.
778+
self.assertNotEqual(
779+
_compute_ext_cache_key({"k": u"\u00e9x"}),
780+
_compute_ext_cache_key({"k": u"e\u0301"}))
781+
782+
def test_key_order_independence(self):
783+
self.assertEqual(
784+
_compute_ext_cache_key({"b": "2", "a": "1", "c": "3"}),
785+
_compute_ext_cache_key({"c": "3", "a": "1", "b": "2"}))
786+
787+
def test_empty_and_single_entry_edges(self):
788+
self.assertEqual("", _compute_ext_cache_key(None))
789+
self.assertEqual("", _compute_ext_cache_key({}))
790+
self.assertEqual("", _compute_ext_cache_key({"fmi_path": ""}))
791+
single = _compute_ext_cache_key({"fmi_path": "p"})
792+
self.assertTrue(single)
793+
self.assertEqual(single, _compute_ext_cache_key({"fmi_path": "p"}))
794+
795+
def test_golden_vectors(self):
796+
# Shared length-prefix golden vectors: byte-identical across the MSAL SDK
797+
# family (Go/.NET/Java/JS). Output is already lowercased.
798+
golden = {
799+
u"a0ry_zl4gccsdp7gnw927x8s0mrmnodv6tyilt0u07m":
800+
{"fmi_path": "agent-app-id"},
801+
u"cybgactkrvlzlen1aiwzwl3ay5krkyixommrobc-ri4":
802+
{"a": "b", "cd": "e"},
803+
u"n_lucewkadzv_nybtg-2wtorgf2nrns6ihlfa7vbuzg":
804+
{"fmi_path": "value"},
805+
u"tjtm16m-suk2_bkniblr25lyuki40qyceco7knuyu0k":
806+
{"fmi_pat": "hvalue"},
807+
u"xskzaoz4ibr3mznftyxctvg1ptuh-0fuzpty7ndbfls":
808+
{u"\u00e9": u"\u00e9"},
809+
}
810+
for expected, components in golden.items():
811+
self.assertEqual(expected, _compute_ext_cache_key(components))

0 commit comments

Comments
 (0)