@@ -564,45 +564,47 @@ def test_non_fmi_tokens_not_affected_by_fmi_cache(self):
564564
565565
566566class 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"\u00e9 x" }),
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