@@ -197,8 +197,11 @@ pub fn deny_admin_diagnostic_fallback(req: &Request<EdgeBody>) -> Option<Respons
197197/// Successful admin EC lookup payload.
198198#[ derive( Debug , Serialize ) ]
199199struct AdminEcLookupResponse {
200- /// The EC ID that was looked up .
200+ /// The EC ID as requested, from the path or the `ts-ec` cookie .
201201 ec_id : String ,
202+ /// The identity-graph key the entry was read from, being the canonical form
203+ /// of `ec_id` that [`AcceptedProviders::canonical_kv_key`] returns.
204+ kv_key : String ,
202205 /// Platform KV store name the entry was read from.
203206 store : String ,
204207 /// Store generation marker for the entry.
@@ -283,22 +286,33 @@ pub fn handle_admin_ec_lookup(
283286 return Ok ( admin_ec_lookup_not_supported ( ) ) ;
284287 } ;
285288
286- let ec_id = match requested_ec_id ( req, & AcceptedProviders :: active ( provider) ) {
287- Ok ( ec_id ) => ec_id ,
289+ let requested = match requested_ec_id ( req, & AcceptedProviders :: active ( provider) ) {
290+ Ok ( requested ) => requested ,
288291 Err ( response) => return Ok ( * response) ,
289292 } ;
290293
291- let Some ( lookup) = kv. lookup_raw ( & ec_id) ? else {
292- log:: info!( "Admin EC lookup: no entry for '{}'" , log_id( & ec_id) ) ;
294+ // Read the row under the owning provider's canonical form of the
295+ // identifier, the key the row is stored under, rather than under the
296+ // identifier as requested. The two differ whenever the canonical form is
297+ // not the requested string itself, for example for a built-in HMAC
298+ // identifier requested with its hash in uppercase.
299+ let Some ( lookup) = kv. lookup_raw ( & requested. kv_key ) ? else {
300+ log:: info!(
301+ "Admin EC lookup: no entry for '{}'" ,
302+ log_id( & requested. ec_id)
303+ ) ;
293304 return Ok ( json_error (
294305 StatusCode :: NOT_FOUND ,
295306 "EC entry not found (KV reads are eventually consistent; a very \
296307 recent entry may not be visible yet)",
297308 ) ) ;
298309 } ;
299310
300- log:: info!( "Admin EC lookup: returning entry for '{}'" , log_id( & ec_id) ) ;
301- let payload = build_lookup_response ( registry, kv. store_name ( ) , ec_id, & lookup) ;
311+ log:: info!(
312+ "Admin EC lookup: returning entry for '{}'" ,
313+ log_id( & requested. ec_id)
314+ ) ;
315+ let payload = build_lookup_response ( registry, kv. store_name ( ) , requested, & lookup) ;
302316 let body =
303317 serde_json:: to_string ( & payload) . change_context ( TrustedServerError :: Configuration {
304318 message : "failed to serialize admin EC lookup response" . to_owned ( ) ,
@@ -341,19 +355,34 @@ fn cookie_ec_id(req: &Request<EdgeBody>) -> Result<String, Box<Response<EdgeBody
341355 } )
342356}
343357
344- /// Resolves the EC ID to look up from the path or the `ts-ec` cookie.
358+ /// An EC ID resolved for lookup, with the identity-graph key its row is stored
359+ /// under.
360+ #[ derive( Debug ) ]
361+ struct RequestedEcId {
362+ /// The EC ID as requested, from the path or the `ts-ec` cookie.
363+ ec_id : String ,
364+ /// The canonical form of `ec_id` that
365+ /// [`AcceptedProviders::canonical_kv_key`] returns, which the row is stored
366+ /// under.
367+ kv_key : String ,
368+ }
369+
370+ /// Resolves the EC ID to look up from the path or the `ts-ec` cookie, with the
371+ /// identity-graph key its row is stored under.
345372///
346373/// The identifier is validated in two parts: the global cookie bounds, then
347374/// the provider that owns its `{code}~` prefix, so an operator can look up an
348375/// identifier created by whichever provider this deployment reads rather than
349- /// only a built-in HMAC one.
376+ /// only a built-in HMAC one. The same check supplies the key, the canonical
377+ /// form of the identifier that [`AcceptedProviders::canonical_kv_key`]
378+ /// returns.
350379///
351380/// Returns the (boxed) error response to send directly when no valid ID is
352381/// available.
353382fn requested_ec_id (
354383 req : & Request < EdgeBody > ,
355384 accepted_providers : & AcceptedProviders < ' _ > ,
356- ) -> Result < String , Box < Response < EdgeBody > > > {
385+ ) -> Result < RequestedEcId , Box < Response < EdgeBody > > > {
357386 let remainder = req
358387 . uri ( )
359388 . path ( )
@@ -367,16 +396,16 @@ fn requested_ec_id(
367396 remainder. to_owned ( )
368397 } ;
369398
370- if ! accepted_providers. accepts ( & ec_id) {
399+ let Some ( kv_key ) = accepted_providers. canonical_kv_key ( & ec_id) else {
371400 return Err ( Box :: new ( json_error (
372401 StatusCode :: BAD_REQUEST ,
373402 "invalid EC ID: not an identifier any provider this deployment reads \
374403 issued (the built-in HMAC provider issues hmac~{64hex}.{6alnum} and \
375404 still reads the bare legacy form)",
376405 ) ) ) ;
377- }
406+ } ;
378407
379- Ok ( ec_id)
408+ Ok ( RequestedEcId { ec_id, kv_key } )
380409}
381410
382411/// Builds the success payload from a raw KV lookup.
@@ -386,11 +415,12 @@ fn requested_ec_id(
386415fn build_lookup_response (
387416 registry : & PartnerRegistry ,
388417 store_name : & str ,
389- ec_id : String ,
418+ requested : RequestedEcId ,
390419 lookup : & EcKvLookup ,
391420) -> AdminEcLookupResponse {
392421 let mut payload = AdminEcLookupResponse {
393- ec_id,
422+ ec_id : requested. ec_id ,
423+ kv_key : requested. kv_key ,
394424 store : store_name. to_owned ( ) ,
395425 generation : lookup. generation ,
396426 tombstone : None ,
@@ -694,6 +724,7 @@ mod tests {
694724 use crate :: ec:: kv_backend:: test_support:: InMemoryEcKv ;
695725 use crate :: ec:: kv_backend:: { EcKvStore as _, EcKvWrite , EcKvWriteMode } ;
696726 use crate :: ec:: kv_types:: KvPartnerId ;
727+ use crate :: ec:: tests:: { CANONICAL_COOKIE_VALUE , CANONICAL_KV_KEY , CanonicalizingProvider } ;
697728 use crate :: redacted:: Redacted ;
698729 use crate :: settings:: EcPartner ;
699730
@@ -1576,10 +1607,17 @@ mod tests {
15761607 let coded = format ! ( "hmac~{}" , test_ec_id( ) ) ;
15771608 let request = request_with_method ( http:: Method :: GET , & format ! ( "/_ts/admin/ec/{coded}" ) ) ;
15781609
1579- let ec_id = requested_ec_id ( & request, & AcceptedProviders :: active ( None ) )
1610+ let requested = requested_ec_id ( & request, & AcceptedProviders :: active ( None ) )
15801611 . unwrap_or_else ( |_| panic ! ( "should accept a coded HMAC identifier in the path" ) ) ;
15811612
1582- assert_eq ! ( ec_id, coded, "should look up the identifier as given" ) ;
1613+ assert_eq ! (
1614+ requested. ec_id, coded,
1615+ "should report the identifier as given"
1616+ ) ;
1617+ assert_eq ! (
1618+ requested. kv_key, coded,
1619+ "a lowercase HMAC identifier should be its own identity-graph key"
1620+ ) ;
15831621 }
15841622
15851623 #[ test]
@@ -1592,9 +1630,12 @@ mod tests {
15921630
15931631 let opaque = "t0op~Opaque_Value_MixedCase" ;
15941632 let request = request_with_method ( http:: Method :: GET , & format ! ( "/_ts/admin/ec/{opaque}" ) ) ;
1595- let ec_id = requested_ec_id ( & request, & accepted)
1633+ let requested = requested_ec_id ( & request, & accepted)
15961634 . unwrap_or_else ( |_| panic ! ( "should accept the active provider's identifier" ) ) ;
1597- assert_eq ! ( ec_id, opaque, "should look up the identifier as given" ) ;
1635+ assert_eq ! (
1636+ requested. ec_id, opaque,
1637+ "should report the identifier as given"
1638+ ) ;
15981639
15991640 // A code no configured provider reads stays a 400, even in the built-in
16001641 // HMAC shape, so one deployment cannot inspect another's identifiers.
@@ -1608,4 +1649,76 @@ mod tests {
16081649 "an unread provider code should be a 400"
16091650 ) ;
16101651 }
1652+
1653+ #[ test]
1654+ fn ec_lookup_reads_the_row_under_the_canonical_key ( ) {
1655+ // The identity graph stores a row under the owning provider's
1656+ // canonical form of the identifier. Read under the identifier as
1657+ // requested, the lookup answered 404 for a row that exists whenever a
1658+ // provider's canonical form differs from the cookie value.
1659+ let kv = kv_with_entry ( CANONICAL_KV_KEY , & sample_entry ( ) ) ;
1660+ let req =
1661+ get_request_with_cookie ( "/_ts/admin/ec" , & format ! ( "ts-ec={CANONICAL_COOKIE_VALUE}" ) ) ;
1662+
1663+ let response = handle_admin_ec_lookup (
1664+ Some ( & kv) ,
1665+ & test_registry ( ) ,
1666+ Some ( & CanonicalizingProvider ) ,
1667+ & req,
1668+ )
1669+ . expect ( "should handle lookup" ) ;
1670+
1671+ assert_eq ! (
1672+ response. status( ) ,
1673+ StatusCode :: OK ,
1674+ "the cookie value should find the row stored under the canonical key"
1675+ ) ;
1676+ let json = response_json ( response) ;
1677+ assert_eq ! (
1678+ json[ "ec_id" ] , CANONICAL_COOKIE_VALUE ,
1679+ "should report the identifier as requested"
1680+ ) ;
1681+ assert_eq ! (
1682+ json[ "kv_key" ] , CANONICAL_KV_KEY ,
1683+ "should report the key the entry was read from"
1684+ ) ;
1685+ assert_eq ! (
1686+ json[ "entry" ] [ "ids" ] [ "bidstream.example" ] [ "uid" ] , "uid-live" ,
1687+ "should return the entry stored under the canonical key"
1688+ ) ;
1689+ }
1690+
1691+ #[ test]
1692+ fn ec_lookup_given_an_uppercase_hmac_hash_reads_the_lowercase_row ( ) {
1693+ // The built-in HMAC provider issues lowercase hex, and its canonical
1694+ // form lowercases the hash, so its row key is the identifier it issued.
1695+ // An operator who pastes that identifier with the hash in uppercase is
1696+ // still asking for the same row.
1697+ let ec_id = format ! ( "hmac~{}" , test_ec_id( ) ) ;
1698+ let kv = kv_with_entry ( & ec_id, & sample_entry ( ) ) ;
1699+ let uppercase = format ! ( "hmac~{}.abc123" , "A" . repeat( 64 ) ) ;
1700+ let provider = crate :: ec:: tests:: hmac_provider ( ) ;
1701+ let req = get_request ( & format ! ( "/_ts/admin/ec/{uppercase}" ) ) ;
1702+
1703+ let response =
1704+ handle_admin_ec_lookup ( Some ( & kv) , & test_registry ( ) , Some ( provider. as_ref ( ) ) , & req)
1705+ . expect ( "should handle lookup" ) ;
1706+
1707+ assert_eq ! (
1708+ response. status( ) ,
1709+ StatusCode :: OK ,
1710+ "an uppercase hash should find the row stored under the lowercase key"
1711+ ) ;
1712+ let json = response_json ( response) ;
1713+ assert_eq ! (
1714+ json[ "ec_id" ] ,
1715+ uppercase. as_str( ) ,
1716+ "should report the identifier as requested"
1717+ ) ;
1718+ assert_eq ! (
1719+ json[ "kv_key" ] ,
1720+ ec_id. as_str( ) ,
1721+ "should report the lowercase key the entry was read from"
1722+ ) ;
1723+ }
16111724}
0 commit comments