1 /* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.adservices.service.measurement.reporting; 18 19 import android.annotation.IntDef; 20 import android.annotation.NonNull; 21 import android.annotation.Nullable; 22 import android.util.Pair; 23 24 import com.android.adservices.data.measurement.DatastoreException; 25 import com.android.adservices.data.measurement.IMeasurementDao; 26 import com.android.adservices.service.Flags; 27 import com.android.adservices.service.FlagsFactory; 28 import com.android.adservices.service.common.AllowLists; 29 import com.android.adservices.service.measurement.EventSurfaceType; 30 import com.android.adservices.service.measurement.Source; 31 import com.android.adservices.service.measurement.Trigger; 32 import com.android.adservices.service.measurement.util.AdIdEncryption; 33 import com.android.adservices.service.measurement.util.UnsignedLong; 34 import com.android.adservices.service.stats.AdServicesLogger; 35 import com.android.adservices.service.stats.AdServicesLoggerImpl; 36 import com.android.adservices.service.stats.MsmtAdIdMatchForDebugKeysStats; 37 import com.android.adservices.service.stats.MsmtDebugKeysMatchStats; 38 import com.android.internal.annotations.VisibleForTesting; 39 40 import java.lang.annotation.Retention; 41 import java.lang.annotation.RetentionPolicy; 42 import java.util.HashSet; 43 import java.util.Objects; 44 import java.util.Set; 45 import java.util.regex.Pattern; 46 47 /** Util class for DebugKeys */ 48 public class DebugKeyAccessor { 49 /** AdID is alphanumeric, sectioned by hyphens. The sections have 8,4,4,4 & 12 characters. */ 50 private static final Pattern AD_ID_REGEX_PATTERN = 51 Pattern.compile("^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$"); 52 53 @NonNull private final Flags mFlags; 54 @NonNull private final AdServicesLogger mAdServicesLogger; 55 @NonNull private final IMeasurementDao mMeasurementDao; 56 DebugKeyAccessor(IMeasurementDao measurementDao)57 public DebugKeyAccessor(IMeasurementDao measurementDao) { 58 this(FlagsFactory.getFlags(), AdServicesLoggerImpl.getInstance(), measurementDao); 59 } 60 61 @VisibleForTesting DebugKeyAccessor( @onNull Flags flags, @NonNull AdServicesLogger adServicesLogger, @NonNull IMeasurementDao measurementDao)62 DebugKeyAccessor( 63 @NonNull Flags flags, 64 @NonNull AdServicesLogger adServicesLogger, 65 @NonNull IMeasurementDao measurementDao) { 66 mFlags = flags; 67 mAdServicesLogger = adServicesLogger; 68 mMeasurementDao = measurementDao; 69 } 70 71 /** 72 * This is kept in sync with the match type codes in {@link 73 * com.android.adservices.service.stats.AdServicesStatsLog}. 74 */ 75 @IntDef( 76 value = { 77 AttributionType.UNKNOWN, 78 AttributionType.SOURCE_APP_TRIGGER_APP, 79 AttributionType.SOURCE_APP_TRIGGER_WEB, 80 AttributionType.SOURCE_WEB_TRIGGER_APP, 81 AttributionType.SOURCE_WEB_TRIGGER_WEB 82 }) 83 @Retention(RetentionPolicy.SOURCE) 84 public @interface AttributionType { 85 int UNKNOWN = 0; 86 int SOURCE_APP_TRIGGER_APP = 1; 87 int SOURCE_APP_TRIGGER_WEB = 2; 88 int SOURCE_WEB_TRIGGER_APP = 3; 89 int SOURCE_WEB_TRIGGER_WEB = 4; 90 } 91 92 /** Returns DebugKey according to the permissions set */ getDebugKeys(Source source, Trigger trigger)93 public Pair<UnsignedLong, UnsignedLong> getDebugKeys(Source source, Trigger trigger) 94 throws DatastoreException { 95 Set<String> allowedEnrollmentsString = 96 new HashSet<>( 97 AllowLists.splitAllowList( 98 mFlags.getMeasurementDebugJoinKeyEnrollmentAllowlist())); 99 String blockedEnrollmentsAdIdMatchingString = 100 mFlags.getMeasurementPlatformDebugAdIdMatchingEnrollmentBlocklist(); 101 Set<String> blockedEnrollmentsAdIdMatching = 102 new HashSet<>(AllowLists.splitAllowList(blockedEnrollmentsAdIdMatchingString)); 103 UnsignedLong sourceDebugKey = null; 104 UnsignedLong triggerDebugKey = null; 105 Long joinKeyHash = null; 106 @AttributionType int attributionType = getAttributionType(source, trigger); 107 boolean doDebugJoinKeysMatch = false; 108 Boolean doesPlatformAndDebugAdIdMatch = null; 109 switch (attributionType) { 110 case AttributionType.SOURCE_APP_TRIGGER_APP: 111 if (source.hasAdIdPermission()) { 112 sourceDebugKey = source.getDebugKey(); 113 } 114 if (trigger.hasAdIdPermission()) { 115 triggerDebugKey = trigger.getDebugKey(); 116 } 117 break; 118 case AttributionType.SOURCE_WEB_TRIGGER_WEB: 119 // TODO(b/280323940): Web<>Web Debug Keys AdID option 120 if (trigger.getRegistrant().equals(source.getRegistrant())) { 121 if (source.hasArDebugPermission()) { 122 sourceDebugKey = source.getDebugKey(); 123 } 124 if (trigger.hasArDebugPermission()) { 125 triggerDebugKey = trigger.getDebugKey(); 126 } 127 } else if (canMatchJoinKeys(source, trigger, allowedEnrollmentsString)) { 128 // Attempted to match, so assigning a non-null value to emit metric 129 joinKeyHash = 0L; 130 if (source.getDebugJoinKey().equals(trigger.getDebugJoinKey())) { 131 sourceDebugKey = source.getDebugKey(); 132 triggerDebugKey = trigger.getDebugKey(); 133 joinKeyHash = (long) source.getDebugJoinKey().hashCode(); 134 doDebugJoinKeysMatch = true; 135 } 136 } 137 break; 138 case AttributionType.SOURCE_APP_TRIGGER_WEB: 139 if (canMatchAdIdAppSourceToWebTrigger(trigger) 140 && canMatchAdIdEnrollments( 141 source, 142 trigger, 143 blockedEnrollmentsAdIdMatchingString, 144 blockedEnrollmentsAdIdMatching)) { 145 if (arePlatformAndDebugAdIdEqual( 146 trigger.getDebugAdId(), 147 source.getPlatformAdId(), 148 trigger.getEnrollmentId()) 149 && isEnrollmentIdWithinUniqueAdIdLimit(trigger.getEnrollmentId())) { 150 sourceDebugKey = source.getDebugKey(); 151 triggerDebugKey = trigger.getDebugKey(); 152 doesPlatformAndDebugAdIdMatch = true; 153 } else { 154 doesPlatformAndDebugAdIdMatch = false; 155 } 156 // TODO(b/280322027): Record result for metrics emission. 157 break; 158 } 159 // fall-through for join key matching 160 case AttributionType.SOURCE_WEB_TRIGGER_APP: 161 if (canMatchAdIdWebSourceToAppTrigger(source) 162 && canMatchAdIdEnrollments( 163 source, 164 trigger, 165 blockedEnrollmentsAdIdMatchingString, 166 blockedEnrollmentsAdIdMatching)) { 167 if (arePlatformAndDebugAdIdEqual( 168 source.getDebugAdId(), 169 trigger.getPlatformAdId(), 170 trigger.getEnrollmentId()) 171 && isEnrollmentIdWithinUniqueAdIdLimit(source.getEnrollmentId())) { 172 sourceDebugKey = source.getDebugKey(); 173 triggerDebugKey = trigger.getDebugKey(); 174 doesPlatformAndDebugAdIdMatch = true; 175 } else { 176 doesPlatformAndDebugAdIdMatch = false; 177 } 178 // TODO(b/280322027): Record result for metrics emission. 179 break; 180 } 181 if (canMatchJoinKeys(source, trigger, allowedEnrollmentsString)) { 182 // Attempted to match, so assigning a non-null value to emit metric 183 joinKeyHash = 0L; 184 if (source.getDebugJoinKey().equals(trigger.getDebugJoinKey())) { 185 sourceDebugKey = source.getDebugKey(); 186 triggerDebugKey = trigger.getDebugKey(); 187 joinKeyHash = (long) source.getDebugJoinKey().hashCode(); 188 doDebugJoinKeysMatch = true; 189 } 190 } 191 break; 192 case AttributionType.UNKNOWN: 193 // fall-through 194 default: 195 break; 196 } 197 logPlatformAdIdAndDebugAdIdMatch( 198 trigger.getEnrollmentId(), 199 attributionType, 200 doesPlatformAndDebugAdIdMatch, 201 mAdServicesLogger, 202 source.getRegistrant().toString()); 203 logDebugKeysMatch( 204 joinKeyHash, 205 source, 206 trigger, 207 attributionType, 208 doDebugJoinKeysMatch, 209 mAdServicesLogger); 210 return new Pair<>(sourceDebugKey, triggerDebugKey); 211 } 212 213 /** Returns DebugKey according to the permissions set */ getDebugKeysForVerboseTriggerDebugReport( @ullable Source source, @NonNull Trigger trigger)214 public Pair<UnsignedLong, UnsignedLong> getDebugKeysForVerboseTriggerDebugReport( 215 @Nullable Source source, @NonNull Trigger trigger) throws DatastoreException { 216 if (source == null) { 217 if (trigger.getDestinationType() == EventSurfaceType.WEB 218 && trigger.hasArDebugPermission()) { 219 return new Pair<>(null, trigger.getDebugKey()); 220 } else if (trigger.getDestinationType() == EventSurfaceType.APP 221 && trigger.hasAdIdPermission()) { 222 return new Pair<>(null, trigger.getDebugKey()); 223 } else { 224 return new Pair<>(null, null); 225 } 226 } 227 Set<String> allowedEnrollmentsString = 228 new HashSet<>( 229 AllowLists.splitAllowList( 230 mFlags.getMeasurementDebugJoinKeyEnrollmentAllowlist())); 231 String blockedEnrollmentsAdIdMatchingString = 232 mFlags.getMeasurementPlatformDebugAdIdMatchingEnrollmentBlocklist(); 233 Set<String> blockedEnrollmentsAdIdMatching = 234 new HashSet<>(AllowLists.splitAllowList(blockedEnrollmentsAdIdMatchingString)); 235 UnsignedLong sourceDebugKey = null; 236 UnsignedLong triggerDebugKey = null; 237 Long joinKeyHash = null; 238 @AttributionType int attributionType = getAttributionType(source, trigger); 239 boolean doDebugJoinKeysMatch = false; 240 Boolean doesPlatformAndDebugAdIdMatch = null; 241 switch (attributionType) { 242 case AttributionType.SOURCE_APP_TRIGGER_APP: 243 // Gated on Trigger Adid permission. 244 if (!trigger.hasAdIdPermission()) { 245 break; 246 } 247 triggerDebugKey = trigger.getDebugKey(); 248 if (source.hasAdIdPermission()) { 249 sourceDebugKey = source.getDebugKey(); 250 } 251 break; 252 case AttributionType.SOURCE_WEB_TRIGGER_WEB: 253 // Gated on Trigger ar_debug permission. 254 if (!trigger.hasArDebugPermission()) { 255 break; 256 } 257 triggerDebugKey = trigger.getDebugKey(); 258 if (trigger.getRegistrant().equals(source.getRegistrant())) { 259 if (source.hasArDebugPermission()) { 260 sourceDebugKey = source.getDebugKey(); 261 } 262 } else { 263 // Send source_debug_key when condition meets. 264 if (canMatchJoinKeys(source, trigger, allowedEnrollmentsString)) { 265 // Attempted to match, so assigning a non-null value to emit metric 266 joinKeyHash = 0L; 267 if (source.getDebugJoinKey().equals(trigger.getDebugJoinKey())) { 268 sourceDebugKey = source.getDebugKey(); 269 joinKeyHash = (long) source.getDebugJoinKey().hashCode(); 270 doDebugJoinKeysMatch = true; 271 } 272 } 273 } 274 break; 275 case AttributionType.SOURCE_APP_TRIGGER_WEB: 276 // Gated on Trigger ar_debug permission. 277 if (!trigger.hasArDebugPermission()) { 278 break; 279 } 280 triggerDebugKey = trigger.getDebugKey(); 281 // Send source_debug_key when condition meets. 282 if (canMatchAdIdAppSourceToWebTrigger(trigger) 283 && canMatchAdIdEnrollments( 284 source, 285 trigger, 286 blockedEnrollmentsAdIdMatchingString, 287 blockedEnrollmentsAdIdMatching)) { 288 if (arePlatformAndDebugAdIdEqual( 289 trigger.getDebugAdId(), 290 source.getPlatformAdId(), 291 trigger.getEnrollmentId()) 292 && isEnrollmentIdWithinUniqueAdIdLimit(trigger.getEnrollmentId())) { 293 sourceDebugKey = source.getDebugKey(); 294 doesPlatformAndDebugAdIdMatch = true; 295 } else { 296 doesPlatformAndDebugAdIdMatch = false; 297 } 298 // TODO(b/280322027): Record result for metrics emission. 299 } else if (canMatchJoinKeys(source, trigger, allowedEnrollmentsString)) { 300 // Attempted to match, so assigning a non-null value to emit metric 301 joinKeyHash = 0L; 302 if (source.getDebugJoinKey().equals(trigger.getDebugJoinKey())) { 303 sourceDebugKey = source.getDebugKey(); 304 joinKeyHash = (long) source.getDebugJoinKey().hashCode(); 305 doDebugJoinKeysMatch = true; 306 } 307 } 308 break; 309 case AttributionType.SOURCE_WEB_TRIGGER_APP: 310 // Gated on Trigger Adid permission. 311 if (!trigger.hasAdIdPermission()) { 312 break; 313 } 314 triggerDebugKey = trigger.getDebugKey(); 315 // Send source_debug_key when condition meets. 316 if (canMatchAdIdWebSourceToAppTrigger(source) 317 && canMatchAdIdEnrollments( 318 source, 319 trigger, 320 blockedEnrollmentsAdIdMatchingString, 321 blockedEnrollmentsAdIdMatching)) { 322 if (arePlatformAndDebugAdIdEqual( 323 source.getDebugAdId(), 324 trigger.getPlatformAdId(), 325 trigger.getEnrollmentId()) 326 && isEnrollmentIdWithinUniqueAdIdLimit(source.getEnrollmentId())) { 327 sourceDebugKey = source.getDebugKey(); 328 doesPlatformAndDebugAdIdMatch = true; 329 } else { 330 doesPlatformAndDebugAdIdMatch = false; 331 } 332 // TODO(b/280322027): Record result for metrics emission. 333 } else if (canMatchJoinKeys(source, trigger, allowedEnrollmentsString)) { 334 // Attempted to match, so assigning a non-null value to emit metric 335 joinKeyHash = 0L; 336 if (source.getDebugJoinKey().equals(trigger.getDebugJoinKey())) { 337 sourceDebugKey = source.getDebugKey(); 338 joinKeyHash = (long) source.getDebugJoinKey().hashCode(); 339 doDebugJoinKeysMatch = true; 340 } 341 } 342 break; 343 case AttributionType.UNKNOWN: 344 // fall-through 345 default: 346 break; 347 } 348 logPlatformAdIdAndDebugAdIdMatch( 349 trigger.getEnrollmentId(), 350 attributionType, 351 doesPlatformAndDebugAdIdMatch, 352 mAdServicesLogger, 353 source.getRegistrant().toString()); 354 logDebugKeysMatch( 355 joinKeyHash, 356 source, 357 trigger, 358 attributionType, 359 doDebugJoinKeysMatch, 360 mAdServicesLogger); 361 return new Pair<>(sourceDebugKey, triggerDebugKey); 362 } 363 arePlatformAndDebugAdIdEqual( @onNull String debugAdId, @Nullable String platformAdId, @NonNull String enrollmentId)364 private static boolean arePlatformAndDebugAdIdEqual( 365 @NonNull String debugAdId, 366 // enrollment ID should be of the trigger's to handle XNA because source enrollment ID 367 // is of its parent's if it's a derived source 368 @Nullable String platformAdId, 369 @NonNull String enrollmentId) { 370 if (platformAdId != null && isAdIdActual(platformAdId)) { 371 String shaEncryptedAdId = 372 AdIdEncryption.encryptAdIdAndEnrollmentSha256(platformAdId, enrollmentId); 373 return Objects.equals(shaEncryptedAdId, debugAdId); 374 } else { 375 // TODO (b/290948164): cleanup this check once no existing sources store adId in 376 // encrypted format 377 // The adId is encrypted. This is to support migration - we stored encrypted adId until 378 // this change. 379 return Objects.equals(platformAdId, debugAdId); 380 } 381 } 382 isAdIdActual(@onNull String platformAdId)383 private static boolean isAdIdActual(@NonNull String platformAdId) { 384 return AD_ID_REGEX_PATTERN.matcher(platformAdId).matches(); 385 } 386 logDebugKeysMatch( Long joinKeyHash, Source source, Trigger trigger, int attributionType, boolean doDebugJoinKeysMatch, AdServicesLogger mAdServicesLogger)387 private void logDebugKeysMatch( 388 Long joinKeyHash, 389 Source source, 390 Trigger trigger, 391 int attributionType, 392 boolean doDebugJoinKeysMatch, 393 AdServicesLogger mAdServicesLogger) { 394 long debugKeyHashLimit = mFlags.getMeasurementDebugJoinKeyHashLimit(); 395 // The provided hash limit is valid and the join key was attempted to be matched. 396 if (debugKeyHashLimit > 0 && joinKeyHash != null) { 397 long hashedValue = joinKeyHash % debugKeyHashLimit; 398 MsmtDebugKeysMatchStats stats = 399 MsmtDebugKeysMatchStats.builder() 400 .setAdTechEnrollmentId(trigger.getEnrollmentId()) 401 .setAttributionType(attributionType) 402 .setMatched(doDebugJoinKeysMatch) 403 .setDebugJoinKeyHashedValue(hashedValue) 404 .setDebugJoinKeyHashLimit(debugKeyHashLimit) 405 .setSourceRegistrant(source.getRegistrant().toString()) 406 .build(); 407 mAdServicesLogger.logMeasurementDebugKeysMatch(stats); 408 } 409 } 410 logPlatformAdIdAndDebugAdIdMatch( String enrollmentId, int attributionType, Boolean doesPlatformAdIdMatchDebugAdId, AdServicesLogger adServicesLogger, String sourceRegistrant)411 private void logPlatformAdIdAndDebugAdIdMatch( 412 String enrollmentId, 413 int attributionType, 414 Boolean doesPlatformAdIdMatchDebugAdId, 415 AdServicesLogger adServicesLogger, 416 String sourceRegistrant) 417 throws DatastoreException { 418 // The debug AdID was attempted to match to the platform AdID. 419 if (doesPlatformAdIdMatchDebugAdId != null) { 420 long platformDebugAdIdMatchingLimit = 421 mFlags.getMeasurementPlatformDebugAdIdMatchingLimit(); 422 MsmtAdIdMatchForDebugKeysStats stats = 423 MsmtAdIdMatchForDebugKeysStats.builder() 424 .setAdTechEnrollmentId(enrollmentId) 425 .setAttributionType(attributionType) 426 .setMatched(doesPlatformAdIdMatchDebugAdId) 427 .setNumUniqueAdIds(getNumUniqueAdIdsUsed(enrollmentId)) 428 .setNumUniqueAdIdsLimit(platformDebugAdIdMatchingLimit) 429 .setSourceRegistrant(sourceRegistrant) 430 .build(); 431 adServicesLogger.logMeasurementAdIdMatchForDebugKeysStats(stats); 432 } 433 } 434 canMatchJoinKeys( Source source, Trigger trigger, Set<String> allowedEnrollmentsString)435 private static boolean canMatchJoinKeys( 436 Source source, Trigger trigger, Set<String> allowedEnrollmentsString) { 437 return source.getParentId() == null 438 && allowedEnrollmentsString.contains(trigger.getEnrollmentId()) 439 && allowedEnrollmentsString.contains(source.getEnrollmentId()) 440 && Objects.nonNull(source.getDebugJoinKey()) 441 && Objects.nonNull(trigger.getDebugJoinKey()); 442 } 443 canMatchAdIdEnrollments( Source source, Trigger trigger, String blockedEnrollmentsString, Set<String> blockedEnrollments)444 private boolean canMatchAdIdEnrollments( 445 Source source, 446 Trigger trigger, 447 String blockedEnrollmentsString, 448 Set<String> blockedEnrollments) { 449 return !AllowLists.doesAllowListAllowAll(blockedEnrollmentsString) 450 && !blockedEnrollments.contains(source.getEnrollmentId()) 451 && !blockedEnrollments.contains(trigger.getEnrollmentId()); 452 } 453 canMatchAdIdAppSourceToWebTrigger(Trigger trigger)454 private static boolean canMatchAdIdAppSourceToWebTrigger(Trigger trigger) { 455 return trigger.hasArDebugPermission() 456 && Objects.nonNull(trigger.getDebugAdId()); 457 } 458 canMatchAdIdWebSourceToAppTrigger(Source source)459 private static boolean canMatchAdIdWebSourceToAppTrigger(Source source) { 460 return source.getParentId() == null 461 && source.hasArDebugPermission() 462 && Objects.nonNull(source.getDebugAdId()); 463 } 464 isEnrollmentIdWithinUniqueAdIdLimit(String enrollmentId)465 private boolean isEnrollmentIdWithinUniqueAdIdLimit(String enrollmentId) 466 throws DatastoreException { 467 long numUnique = mMeasurementDao.countDistinctDebugAdIdsUsedByEnrollment(enrollmentId); 468 return numUnique < mFlags.getMeasurementPlatformDebugAdIdMatchingLimit(); 469 } 470 getNumUniqueAdIdsUsed(String enrollmentId)471 private long getNumUniqueAdIdsUsed(String enrollmentId) throws DatastoreException { 472 return mMeasurementDao.countDistinctDebugAdIdsUsedByEnrollment(enrollmentId); 473 } 474 475 @AttributionType getAttributionType(Source source, Trigger trigger)476 private static int getAttributionType(Source source, Trigger trigger) { 477 boolean isSourceApp = source.getPublisherType() == EventSurfaceType.APP; 478 if (trigger.getDestinationType() == EventSurfaceType.WEB) { 479 // Web Conversion 480 return isSourceApp 481 ? AttributionType.SOURCE_APP_TRIGGER_WEB 482 : AttributionType.SOURCE_WEB_TRIGGER_WEB; 483 } else { 484 // App Conversion 485 return isSourceApp 486 ? AttributionType.SOURCE_APP_TRIGGER_APP 487 : AttributionType.SOURCE_WEB_TRIGGER_APP; 488 } 489 } 490 } 491