Skip to content

Commit 5250ff9

Browse files
4gustCopilot
andcommitted
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. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6efa99f2-b923-4ea8-b5b8-40696d1470a5
1 parent b299e3b commit 5250ff9

2 files changed

Lines changed: 166 additions & 46 deletions

File tree

msal/token_cache.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,20 @@ 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, so semantically
99+
different component sets can never collide onto the same cache key. (A plain
100+
``key + value`` concatenation is ambiguous: ``{fmi_path: "value"}`` and
101+
``{fmi_pat: "hvalue"}`` would both serialize to ``fmi_pathvalue``.) The byte
102+
length (``len(s.encode("utf-8"))``), not the Unicode code-point count, is
103+
used so the hash stays byte-identical across the MSAL SDK family
104+
(Go/.NET/Java/JS) as they converge on this scheme.
100105
"""
101106
if not data:
102107
return ""
@@ -106,11 +111,13 @@ def _compute_ext_cache_key(data):
106111
}
107112
if not cache_components:
108113
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.)
114+
# Sort keys, then length-prefix each key and value so the serialization is
115+
# injective (see docstring). Byte-identical to MSAL Go's netstring encoding.
112116
key_str = "".join(
113-
k + cache_components[k] for k in sorted(cache_components.keys())
117+
"{klen}:{k}{vlen}:{v}".format(
118+
klen=len(k.encode("utf-8")), k=k,
119+
vlen=len(cache_components[k].encode("utf-8")), v=cache_components[k])
120+
for k in sorted(cache_components.keys())
114121
)
115122
hash_bytes = hashlib.sha256(key_str.encode("utf-8")).digest()
116123
return base64.urlsafe_b64encode(hash_bytes).rstrip(b"=").decode("ascii").lower()

tests/test_token_cache.py

Lines changed: 146 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -564,45 +564,47 @@ 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+
The length prefixes make the serialization injective, so semantically
579+
different component sets can never collide onto the same cache key.
578580
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.
581+
NOTE: This is the encoding the whole MSAL SDK family is converging on
582+
(Go already merged it; .NET/Java/JS are landing the same change), so these
583+
hashes are byte-identical across SDKs. The cache *key format* (the 'atext'
584+
segment layout, asserted below) matches Go and .NET too. Caches are not
585+
shared across languages, so cross-language parity is a convenience, not a
586+
correctness requirement.
585587
"""
586588

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

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

597-
def test_five_params_hash_matches_dotnet(self):
598-
""".NET expected (full hash): rn_gkpxxkkqjxcqnvnmr2duvxg66xanvkz6qfqpwp2e"""
599+
def test_five_params_hash_matches_shared_encoding(self):
600+
"""Shared length-prefix expected (full hash): prrdp31y37ufw3lo7hly0oimjjvg_34m9ji30ocu4tw"""
599601
result = _compute_ext_cache_key({
600602
"key3": "value3", "key4": "value4",
601603
"key5": "value5", "key6": "value6", "key7": "value7",
602604
})
603-
self.assertEqual("rn_gkpxxkkqjxcqnvnmr2duvxg66xanvkz6qfqpwp2e", result)
605+
self.assertEqual("prrdp31y37ufw3lo7hly0oimjjvg_34m9ji30ocu4tw", result)
604606

605-
def test_order_independence_matches_dotnet(self):
607+
def test_order_independence_matches_shared_encoding(self):
606608
"""Same keys in different insertion order must produce the same hash
607609
(mirrors TestCacheKeyComponentHashConsistency in Go)."""
608610
h1 = _compute_ext_cache_key({"key3": "value3", "key4": "value4",
@@ -623,9 +625,9 @@ def test_at_cache_key_uses_atext_credential_type(self):
623625
key = key_maker(
624626
home_account_id="hid", environment="env", client_id="cid",
625627
realm="realm", target="scope",
626-
ext_cache_key="bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi")
628+
ext_cache_key="latlwkpewb_a0rcsmjvkecqt0_huumkw4sflzociike")
627629
self.assertEqual(
628-
"hid-env-atext-cid-realm-scope-bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi",
630+
"hid-env-atext-cid-realm-scope-latlwkpewb_a0rcsmjvkecqt0_huumkw4sflzociike",
629631
key)
630632

631633
def test_at_cache_key_without_ext_uses_accesstoken(self):
@@ -637,9 +639,10 @@ def test_at_cache_key_without_ext_uses_accesstoken(self):
637639
realm="realm", target="scope")
638640
self.assertEqual("hid-env-accesstoken-cid-realm-scope", key)
639641

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'
642+
def test_full_at_cache_key(self):
643+
"""Reproduce the full 'atext' cache key layout (mirrors MSAL .NET's
644+
CacheKeyExtensionTests expectedCacheKey1 shape) with the shared
645+
length-prefix hash for {key1:value1, key2:value2}.
643646
"""
644647
cache = TokenCache()
645648
key_maker = cache.key_makers[TokenCache.CredentialType.ACCESS_TOKEN]
@@ -651,11 +654,12 @@ def test_dotnet_style_full_at_cache_key(self):
651654
realm="common",
652655
target="r1/scope1 r1/scope2",
653656
ext_cache_key=ext_hash)
654-
expected = "-login.windows.net-atext-d3adb33f-c0de-ed0c-c0de-deadb33fc0d3-common-r1/scope1 r1/scope2-bns2ytmx5hxkh4fnfixridmezpbbayhnmuh6t4bbghi"
657+
expected = "-login.windows.net-atext-d3adb33f-c0de-ed0c-c0de-deadb33fc0d3-common-r1/scope1 r1/scope2-latlwkpewb_a0rcsmjvkecqt0_huumkw4sflzociike"
655658
self.assertEqual(expected, key)
656659

657-
def test_dotnet_style_second_cache_key(self):
658-
"""Reproduce CacheKeyExtensionTests expectedCacheKey2."""
660+
def test_second_full_at_cache_key(self):
661+
"""Reproduce the 'atext' cache key layout (mirrors expectedCacheKey2 shape)
662+
with the shared length-prefix hash for {key3:value3, key4:value4}."""
659663
cache = TokenCache()
660664
key_maker = cache.key_makers[TokenCache.CredentialType.ACCESS_TOKEN]
661665
ext_hash = _compute_ext_cache_key({"key3": "value3", "key4": "value4"})
@@ -666,13 +670,13 @@ def test_dotnet_style_second_cache_key(self):
666670
realm="common",
667671
target="r1/scope1 r1/scope2",
668672
ext_cache_key=ext_hash)
669-
expected = "-login.windows.net-atext-d3adb33f-c0de-ed0c-c0de-deadb33fc0d3-common-r1/scope1 r1/scope2-3-rg6_wyjx5bcy0c3cqq7gajtzgsqy3oxqpwj4y8k4u"
673+
expected = "-login.windows.net-atext-d3adb33f-c0de-ed0c-c0de-deadb33fc0d3-common-r1/scope1 r1/scope2-jjoe9jgfmdtnj0rzuetsqy7kzs2m1xfnjjxwsfxsrxq"
670674
self.assertEqual(expected, key)
671675

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

0 commit comments

Comments
 (0)