3939import java .time .Instant ;
4040import java .time .format .DateTimeFormatter ;
4141import java .util .*;
42- import java .util .stream .Collectors ;
4342
4443import static com .uid2 .admin .vertx .Endpoints .*;
4544
@@ -111,11 +110,17 @@ public void setupRoutes(Router router) {
111110 }
112111 }, new AuditParams (List .of ("fraction" , "target_date" ), Collections .emptyList ()), Role .SUPER_USER , Role .SECRET_ROTATION ));
113112
113+ router .post ("/api/salt/simulate/token" ).blockingHandler (auth .handle ((ctx ) -> {
114+ synchronized (writeLock ) {
115+ this .handleSaltSimulateToken (ctx );
116+ }
117+ }, new AuditParams (List .of ("target_date" ), Collections .emptyList ()), Role .MAINTAINER , Role .SECRET_ROTATION ));
118+
114119 router .post ("/api/salt/simulate" ).blockingHandler (auth .handle ((ctx ) -> {
115120 synchronized (writeLock ) {
116121 this .handleSaltSimulate (ctx );
117122 }
118- }, new AuditParams (List .of ("fraction" , "target_date" ), Collections .emptyList ()), Role .SUPER_USER , Role .SECRET_ROTATION ));
123+ }, new AuditParams (List .of ("fraction" , "target_date" ), Collections .emptyList ()), Role .MAINTAINER , Role .SECRET_ROTATION ));
119124 }
120125
121126 private void handleSaltSnapshots (RoutingContext rc ) {
@@ -169,7 +174,7 @@ private void handleSaltRotate(RoutingContext rc) {
169174 final Optional <Double > fraction = RequestUtil .getDouble (rc , "fraction" );
170175 if (fraction .isEmpty ()) return ;
171176
172- LOGGER .info ("Salt rotation age thresholds in seconds: {}" , Arrays .stream (SALT_ROTATION_AGE_THRESHOLDS ).map (Duration ::toSeconds ).collect ( Collectors . toList () ));
177+ LOGGER .info ("Salt rotation age thresholds in seconds: {}" , Arrays .stream (SALT_ROTATION_AGE_THRESHOLDS ).map (Duration ::toSeconds ).toList ());
173178
174179 final TargetDate targetDate =
175180 RequestUtil .getDate (rc , "target_date" , DateTimeFormatter .ISO_LOCAL_DATE )
@@ -216,6 +221,77 @@ private JsonObject toJson(RotatingSaltProvider.SaltSnapshot snapshot) {
216221 return jo ;
217222 }
218223
224+ private void handleSaltSimulateToken (RoutingContext rc ) {
225+ try {
226+ final double fraction = RequestUtil .getDouble (rc , "fraction" ).orElse (0.002740 );
227+ final int iterations = RequestUtil .getDouble (rc , "iterations" ).orElse (0.0 ).intValue ();
228+
229+ TargetDate targetDate =
230+ RequestUtil .getDate (rc , "target_date" , DateTimeFormatter .ISO_LOCAL_DATE )
231+ .map (TargetDate ::new )
232+ .orElse (TargetDate .now ().plusDays (1 ));
233+
234+ // Step 1. Run /v2/token/generate for 10,000 emails
235+ List <String > emails = new ArrayList <>();
236+ for (int j = 0 ; j < IDENTITY_COUNT ; j ++) {
237+ String email = randomEmail ();
238+ emails .add (email );
239+ }
240+
241+ Map <String , String > emailToRefreshTokenMap = new HashMap <>();
242+ Map <String , String > emailToRefreshResponseKeyMap = new HashMap <>();
243+ for (String email : emails ) {
244+ JsonNode tokens = v2TokenGenerate (email );
245+ String refreshToken = tokens .at ("/body/refresh_token" ).asText ();
246+ String refreshResponseKey = tokens .at ("/body/refresh_response_key" ).asText ();
247+
248+ emailToRefreshTokenMap .put (email , refreshToken );
249+ emailToRefreshResponseKeyMap .put (email , refreshResponseKey );
250+ }
251+
252+ // Step 2. Rotate salts
253+ saltRotation .setEnableV4RawUid (true );
254+ RotatingSaltProvider .SaltSnapshot snapshot = null ;
255+ for (int i = 0 ; i < iterations ; i ++) {
256+ snapshot = rotateSalts (rc , fraction , targetDate , i );
257+ }
258+
259+ // Step 3. Count how many emails are v2 vs v4 salts
260+ Map <String , Boolean > emailToV4TokenMap = new HashMap <>();
261+ for (String email : emails ) {
262+ SaltEntry salt = getSalt (email , snapshot );
263+ boolean isV4 = salt .currentKeySalt () != null && salt .currentKeySalt ().key () != null && salt .currentKeySalt ().salt () != null ;
264+ emailToV4TokenMap .put (email , isV4 );
265+ }
266+
267+ // Step 4. Run /v2/token/refresh for all emails
268+ Map <String , Boolean > emailToRefreshSuccessMap = new HashMap <>();
269+ for (String email : emails ) {
270+ try {
271+ JsonNode response = v2TokenRefresh (emailToRefreshTokenMap .get (email ), emailToRefreshResponseKeyMap .get (email ));
272+ emailToRefreshSuccessMap .put (email , response != null );
273+ } catch (Exception e ) {
274+ LOGGER .error (e .getMessage (), e );
275+ emailToRefreshSuccessMap .put (email , false );
276+ }
277+ }
278+
279+ LOGGER .info (
280+ "UID token simulation: success_count={}, failure_count={}, v4_count={}, v2_count={}" ,
281+ emailToRefreshSuccessMap .values ().stream ().filter (x -> x ).count (),
282+ emailToRefreshSuccessMap .values ().stream ().filter (x -> !x ).count (),
283+ emailToV4TokenMap .values ().stream ().filter (x -> x ).count (),
284+ emailToV4TokenMap .values ().stream ().filter (x -> !x ).count ());
285+
286+ rc .response ()
287+ .putHeader (HttpHeaders .CONTENT_TYPE , "application/json" )
288+ .end ();
289+ } catch (Exception e ) {
290+ LOGGER .error (e .getMessage (), e );
291+ rc .fail (500 , e );
292+ }
293+ }
294+
219295 private void handleSaltSimulate (RoutingContext rc ) {
220296 try {
221297 final double fraction = RequestUtil .getDouble (rc , "fraction" ).orElse (0.002740 );
@@ -230,7 +306,6 @@ private void handleSaltSimulate(RoutingContext rc) {
230306 .orElse (TargetDate .now ().plusDays (1 ));
231307
232308 RotatingSaltProvider .SaltSnapshot firstSnapshot = saltProvider .getSnapshots ().getLast ();
233- SaltRotation .Result result = null ;
234309 List <String > emails = new ArrayList <>();
235310 Map <String , Map <Long , Map <String , String >>> emailToUidMapping = new HashMap <>();
236311 for (int j = 0 ; j < IDENTITY_COUNT ; j ++) {
@@ -264,7 +339,7 @@ private void handleSaltSimulate(RoutingContext rc) {
264339
265340 rc .response ()
266341 .putHeader (HttpHeaders .CONTENT_TYPE , "application/json" )
267- .end (toJson ( result . getSnapshot ()). encode () );
342+ .end ();
268343 } catch (Exception e ) {
269344 LOGGER .error (e .getMessage (), e );
270345 rc .fail (500 , e );
@@ -416,7 +491,7 @@ private boolean assertSaltState(
416491 LOGGER .error ("Invalid salt state - V4 UID enabled but missing key on rotated salt" );
417492 return false ;
418493 } else if (rotated && !enabledV4Uid && missingSalt ) {
419- LOGGER .error ("Invalid salt state - V4 UID enabled but missing salt on rotated salt" );
494+ LOGGER .error ("Invalid salt state - V4 UID disabled but missing salt on rotated salt" );
420495 return false ;
421496 }
422497
@@ -578,10 +653,18 @@ private byte[] generateIV(String salt, byte[] firstLevelHashLast16Bytes, byte me
578653 ivBase .write (salt .getBytes ());
579654 ivBase .write (firstLevelHashLast16Bytes );
580655 ivBase .write (metadata );
581- ivBase .write (keyId );
656+ ivBase .write (getKeyIdBytes ( keyId ) );
582657 return Arrays .copyOfRange (getSha256Bytes (ivBase .toByteArray (), null ), 0 , IV_LENGTH );
583658 }
584659
660+ private byte [] getKeyIdBytes (int keyId ) {
661+ return new byte [] {
662+ (byte ) ((keyId >> 16 ) & 0xFF ), // MSB
663+ (byte ) ((keyId >> 8 ) & 0xFF ), // Middle
664+ (byte ) (keyId & 0xFF ), // LSB
665+ };
666+ }
667+
585668 private byte [] encryptHash (String encryptionKey , byte [] hash , byte [] iv ) throws Exception {
586669 // Set up AES256-CTR cipher
587670 Cipher aesCtr = Cipher .getInstance ("AES/CTR/NoPadding" );
@@ -655,7 +738,20 @@ private JsonNode v3IdentityMap(List<String> emails) throws Exception {
655738 return v2DecryptEncryptedResponse (response .body (), envelope .nonce (), CLIENT_API_SECRET );
656739 }
657740
658- private static String randomEmail () {
741+ private JsonNode v2TokenGenerate (String email ) throws Exception {
742+ String reqBody = String .format ("{ \" email\" : \" %s\" , \" optout_check\" : 1}" , email );
743+
744+ V2Envelope envelope = v2CreateEnvelope (reqBody , CLIENT_API_SECRET );
745+ HttpResponse <String > response = HTTP_CLIENT .post (String .format ("%s/v2/token/generate" , OPERATOR_URL ), envelope .envelope (), OPERATOR_HEADERS );
746+ return v2DecryptEncryptedResponse (response .body (), envelope .nonce (), CLIENT_API_SECRET );
747+ }
748+
749+ private JsonNode v2TokenRefresh (String refreshToken , String refreshResponseKey ) throws Exception {
750+ HttpResponse <String > response = HTTP_CLIENT .post (String .format ("%s/v2/token/refresh" , OPERATOR_URL ), refreshToken , OPERATOR_HEADERS );
751+ return v2DecryptRefreshResponse (response .body (), refreshResponseKey );
752+ }
753+
754+ private String randomEmail () {
659755 return "email_" + Math .abs (SECURE_RANDOM .nextLong ()) + "@example.com" ;
660756 }
661757
@@ -693,18 +789,36 @@ private JsonNode v2DecryptEncryptedResponse(String encryptedResponse, byte[] non
693789 return OBJECT_MAPPER .readTree (decryptedResponse );
694790 }
695791
792+ private JsonNode v2DecryptRefreshResponse (String response , String refreshResponseKey ) throws Exception {
793+ if (response .contains ("client_error" )) {
794+ return null ;
795+ }
796+
797+ byte [] encryptedResponseBytes = base64ToByteArray (response );
798+ byte [] refreshResponseKeyBytes = base64ToByteArray (refreshResponseKey );
799+ byte [] payload = decryptGCM (encryptedResponseBytes , refreshResponseKeyBytes );
800+ return OBJECT_MAPPER .readTree (new String (payload , StandardCharsets .UTF_8 ));
801+ }
802+
696803 private byte [] encryptGDM (byte [] b , byte [] secretBytes ) throws Exception {
697804 Class <?> clazz = Class .forName ("com.uid2.client.Uid2Encryption" );
698805 Method encryptGDMMethod = clazz .getDeclaredMethod ("encryptGCM" , byte [].class , byte [].class , byte [].class );
699806 encryptGDMMethod .setAccessible (true );
700807 return (byte []) encryptGDMMethod .invoke (clazz , b , null , secretBytes );
701808 }
702809
703- private static byte [] base64ToByteArray (String str ) {
810+ private byte [] decryptGCM (byte [] b , byte [] secretBytes ) throws Exception {
811+ Class <?> clazz = Class .forName ("com.uid2.client.Uid2Encryption" );
812+ Method decryptGCMMethod = clazz .getDeclaredMethod ("decryptGCM" , byte [].class , int .class , byte [].class );
813+ decryptGCMMethod .setAccessible (true );
814+ return (byte []) decryptGCMMethod .invoke (clazz , b , 0 , secretBytes );
815+ }
816+
817+ private byte [] base64ToByteArray (String str ) {
704818 return Base64 .getDecoder ().decode (str );
705819 }
706820
707- private static String byteArrayToBase64 (byte [] b ) {
821+ private String byteArrayToBase64 (byte [] b ) {
708822 return Base64 .getEncoder ().encodeToString (b );
709823 }
710824
0 commit comments