@@ -564,45 +564,49 @@ 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-
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"\u00e9 x" }),
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