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