Bug 233673

Summary: PCM: Unlinkable tokens for triggering event to prevent fraud
Product: WebKit Reporter: John Wilander <wilander>
Component: WebKit Misc.Assignee: John Wilander <wilander>
Status: RESOLVED FIXED    
Severity: Normal CC: achristensen, cdumez, changseok, esprehn+autocc, ews-watchlist, gyuyoung.kim, japhet, katherine_cheney, webkit-bug-importer
Priority: P2 Keywords: InRadar
Version: WebKit Nightly Build   
Hardware: Unspecified   
OS: Unspecified   
Attachments:
Description Flags
Patch
ews-feeder: commit-queue-
Patch
none
Patch
none
Patch
none
Patch none

Description John Wilander 2021-11-30 17:10:13 PST
We recently introduced click fraud prevention with unlinkable tokens on the source side in Private Click Measurement (PCM). We should design and implement a way for click destinations to sign unlinkable tokens too and have those tokens be part of the resulting ad attribution report. This helps fight at least three types of fraud:

1. A fraudulent user induces a triggering event that the merchant doesn’t trust. The new feature would allow them to simply not sign a token for that event.
2. A fraudulent publisher creates an attribution report with a signed token from itself and submits it to the merchant to claim attribution.
3. A fraudulent merchant claims that a specific attribution report was fraudulent so that they don’t have to pay for attribution.
Comment 1 John Wilander 2021-11-30 17:10:36 PST
<rdar://79426347>
Comment 2 John Wilander 2021-11-30 20:28:17 PST
Created attachment 445507 [details]
Patch
Comment 3 John Wilander 2021-11-30 20:48:23 PST
Created attachment 445509 [details]
Patch
Comment 4 John Wilander 2021-11-30 22:03:56 PST
Created attachment 445521 [details]
Patch
Comment 5 John Wilander 2021-12-01 08:09:02 PST
The iOS layout test failure is just a remaining text diff change I need to make. I’ll do that as part of addressing code review comments.
Comment 6 John Wilander 2021-12-01 11:09:29 PST
The iOS API test failure is unrelated and being addressed similar to https://trac.webkit.org/changeset/286368/webkit.
Comment 7 Alex Christensen 2021-12-01 12:05:02 PST
Comment on attachment 445521 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=445521&action=review

> Source/WebCore/loader/PrivateClickMeasurement.cpp:95
> +PrivateClickMeasurement::UnlinkableToken PrivateClickMeasurement::UnlinkableToken::isolatedCopy() const

nit: If you use trailing return type syntax, it reduces the number of "PrivateClickMeasurement::" needed.

> Source/WebCore/loader/PrivateClickMeasurement.cpp:107
> +PrivateClickMeasurement::SourceUnlinkableToken PrivateClickMeasurement::SourceUnlinkableToken::isolatedCopy() const

This is a lot of duplicate code.  Can we just have UnlinkableToken have an isolatedCopy() without having 3 copies of the exact same code?  Same with the secret tokens.

> Source/WebCore/loader/PrivateClickMeasurement.cpp:164
> +    for (auto& parameter : parameters) {
> +        if (parameter.key == "attributionSource") {

This seems to allow two attributionDestinationNonces or two attributionSources.  I think the intent of the two parameters is that there is one attributionSource and one attributionDestinationNonce.

> Source/WebCore/loader/PrivateClickMeasurement.cpp:183
> +    AttributionTriggerData attributionTriggerData;

I prefer the pattern of calling the constructor with all the members when returning it over calling the default constructor then filling in the members.  It makes it easier to remember all the members.

> Source/WebCore/loader/PrivateClickMeasurement.cpp:343
> +bool PrivateClickMeasurement::EphemeralDestinationNonce::isValid() const

Can we make EphemeralSourceNonce and EphemeralDestinationNonce share a parent class to have less duplicate code like we do with the tokens?

> Source/WebCore/loader/PrivateClickMeasurement.cpp:365
> +const std::optional<const URL> PrivateClickMeasurement::tokenPublicKeyURL(const RegistrableDomain& registrableDomain)

A URL already has a null state.  std::optional<URL> is almost always overkill.  Just check if the URL is non-null and valid.

> Source/WebCore/loader/PrivateClickMeasurement.cpp:413
> +        return reportDetails;

Do we want an empty object here instead of nullptr?

> Source/WebCore/loader/cocoa/PrivateClickMeasurementCocoa.mm:91
> +Expected<PrivateClickMeasurement::DestinationSecretToken, String> PrivateClickMeasurement::calculateAndUpdateDestinationSecretToken(const String& serverResponseBase64URL, DestinationUnlinkableToken& unlinkableToken)

Why is this signature so different from calculateAndUpdateSourceSecretToken?

> Source/WebKit/NetworkProcess/PrivateClickMeasurement/PrivateClickMeasurementDatabase.cpp:719
>  bool Database::needsUpdatedSchema()

I'm not sure if this needs updating.  Schema migration needs a test like MigrateFromPCM1

> Source/WebKit/NetworkProcess/PrivateClickMeasurement/PrivateClickMeasurementDatabase.h:104
> +    std::optional<WebCore::PrivateClickMeasurement::DestinationSecretToken> m_destinationSecretToken;

Why do we need to keep a destination secret token here but not a source secret token here?

> Source/WebKit/NetworkProcess/PrivateClickMeasurement/PrivateClickMeasurementManager.cpp:283
> +void PrivateClickMeasurementManager::getSignedUnlinkableTokenForDestination(SourceSite&& sourceSite, AttributionDestinationSite&& destinationSite, AttributionTriggerData&& attributionTriggerData, const ApplicationBundleIdentifier& applicationBundleIdentifier)

This shares a lot of code with PrivateClickMeasurementManager::getSignedUnlinkableToken.  Can they be better abstracted?

> Tools/TestWebKitAPI/Tests/WebKitCocoa/EventAttribution.mm:349
> +TEST(PrivateClickMeasurement, DestinationClickFraudPrevention)

This has a lot of duplicate code with TEST(PrivateClickMeasurement, FraudPrevention).  Can they share anything?
Comment 8 Kate Cheney 2021-12-01 12:29:47 PST
Comment on attachment 445521 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=445521&action=review

> Source/WebKit/NetworkProcess/DatabaseUtilities.cpp:163
> +    // These indices are zero-based for some reason. There should be a comment here explaining why.

This is explained in the SQLite docs: https://www.sqlite.org/c3ref/column_blob.html "The leftmost column of the result set has the index 0".

> Source/WebKit/NetworkProcess/PrivateClickMeasurement/PrivateClickMeasurementDatabase.cpp:-714
> -    checkColumns(unattributedTableName);

Removing this will cause an API test to fail, MigrateFromPCM1. We should replace it with a test for the new columns.
Comment 9 John Wilander 2021-12-01 18:07:28 PST
(In reply to Alex Christensen from comment #7)
> Comment on attachment 445521 [details]
> Patch
> 
> View in context:
> https://bugs.webkit.org/attachment.cgi?id=445521&action=review
> 
> > Source/WebCore/loader/PrivateClickMeasurement.cpp:95
> > +PrivateClickMeasurement::UnlinkableToken PrivateClickMeasurement::UnlinkableToken::isolatedCopy() const
> 
> nit: If you use trailing return type syntax, it reduces the number of
> "PrivateClickMeasurement::" needed.
> 
> > Source/WebCore/loader/PrivateClickMeasurement.cpp:107
> > +PrivateClickMeasurement::SourceUnlinkableToken PrivateClickMeasurement::SourceUnlinkableToken::isolatedCopy() const
> 
> This is a lot of duplicate code.  Can we just have UnlinkableToken have an
> isolatedCopy() without having 3 copies of the exact same code?  Same with
> the secret tokens.

That's what I did first but the compiler did not accept my cast to the specific type. Any idea how it should be done? I specifically want to have distinct types to minimize the risk of tokens getting mixed up in the code.

> > Source/WebCore/loader/PrivateClickMeasurement.cpp:164
> > +    for (auto& parameter : parameters) {
> > +        if (parameter.key == "attributionSource") {
> 
> This seems to allow two attributionDestinationNonces or two
> attributionSources.  I think the intent of the two parameters is that there
> is one attributionSource and one attributionDestinationNonce.

True.

> > Source/WebCore/loader/PrivateClickMeasurement.cpp:183
> > +    AttributionTriggerData attributionTriggerData;
> 
> I prefer the pattern of calling the constructor with all the members when
> returning it over calling the default constructor then filling in the
> members.  It makes it easier to remember all the members.

Some of them are optional which is why it was done this way. Are you saying you want them to have default values in the constructor?

> > Source/WebCore/loader/PrivateClickMeasurement.cpp:343
> > +bool PrivateClickMeasurement::EphemeralDestinationNonce::isValid() const
> 
> Can we make EphemeralSourceNonce and EphemeralDestinationNonce share a
> parent class to have less duplicate code like we do with the tokens?

I actually don't think they need to be distinct since they are not stored or included in attribution reports. I'll try to change that.

> > Source/WebCore/loader/PrivateClickMeasurement.cpp:365
> > +const std::optional<const URL> PrivateClickMeasurement::tokenPublicKeyURL(const RegistrableDomain& registrableDomain)
> 
> A URL already has a null state.  std::optional<URL> is almost always
> overkill.  Just check if the URL is non-null and valid.

The problem here is that checking if the URL is non-empty has to be done by the caller and it's easy to forget. We already had that problem in how we create the token URLs which is why I made them optional. When they are optional, the caller *has to check*. Mandatory check is what I want. Another way to achieve that is by having an optional error/exception. Is that preferable?

> > Source/WebCore/loader/PrivateClickMeasurement.cpp:413
> > +        return reportDetails;
> 
> Do we want an empty object here instead of nullptr?

The function returns a Ref<JSON::Object>. Can that be null?

> > Source/WebCore/loader/cocoa/PrivateClickMeasurementCocoa.mm:91
> > +Expected<PrivateClickMeasurement::DestinationSecretToken, String> PrivateClickMeasurement::calculateAndUpdateDestinationSecretToken(const String& serverResponseBase64URL, DestinationUnlinkableToken& unlinkableToken)
> 
> Why is this signature so different from calculateAndUpdateSourceSecretToken?

It's static since the DestinationUnlinkableToken is not a member. I at one point made them both static but removed that to make fewer changes.

> > Source/WebKit/NetworkProcess/PrivateClickMeasurement/PrivateClickMeasurementDatabase.cpp:719
> >  bool Database::needsUpdatedSchema()
> 
> I'm not sure if this needs updating.  Schema migration needs a test like
> MigrateFromPCM1

Is this what Kate referred to in her subsequent comment?

> > Source/WebKit/NetworkProcess/PrivateClickMeasurement/PrivateClickMeasurementDatabase.h:104
> > +    std::optional<WebCore::PrivateClickMeasurement::DestinationSecretToken> m_destinationSecretToken;
> 
> Why do we need to keep a destination secret token here but not a source
> secret token here?

Sorry, this is a leftover from before I had implemented the DB part and wanted to build a full roundtrip test case. I kept the destination token in memory to build the test case and then continued to implement the DB part. Will remove.

> > Source/WebKit/NetworkProcess/PrivateClickMeasurement/PrivateClickMeasurementManager.cpp:283
> > +void PrivateClickMeasurementManager::getSignedUnlinkableTokenForDestination(SourceSite&& sourceSite, AttributionDestinationSite&& destinationSite, AttributionTriggerData&& attributionTriggerData, const ApplicationBundleIdentifier& applicationBundleIdentifier)
> 
> This shares a lot of code with
> PrivateClickMeasurementManager::getSignedUnlinkableToken.  Can they be
> better abstracted?

Agreed. I started out trying to combine them but the flow is quite different. The first one gets the signature before a PCM object is persisted. The second gets the signature before trying to match with an already stored PCM object. I'll see if I can extract some of the code into a helper function.

> > Tools/TestWebKitAPI/Tests/WebKitCocoa/EventAttribution.mm:349
> > +TEST(PrivateClickMeasurement, DestinationClickFraudPrevention)
> 
> This has a lot of duplicate code with TEST(PrivateClickMeasurement,
> FraudPrevention).  Can they share anything?

I'll try.

Thanks!
Comment 10 John Wilander 2021-12-01 18:11:07 PST
(In reply to Kate Cheney from comment #8)
> Comment on attachment 445521 [details]
> Patch
> 
> View in context:
> https://bugs.webkit.org/attachment.cgi?id=445521&action=review
> 
> > Source/WebKit/NetworkProcess/DatabaseUtilities.cpp:163
> > +    // These indices are zero-based for some reason. There should be a comment here explaining why.
> 
> This is explained in the SQLite docs:
> https://www.sqlite.org/c3ref/column_blob.html "The leftmost column of the
> result set has the index 0".

Thanks! I'll add that link in a comment so that others won't get as confused as I was.

> > Source/WebKit/NetworkProcess/PrivateClickMeasurement/PrivateClickMeasurementDatabase.cpp:-714
> > -    checkColumns(unattributedTableName);
> 
> Removing this will cause an API test to fail, MigrateFromPCM1. We should
> replace it with a test for the new columns.

I got no API test failures though, neither locally nor on the bots. Any clues as to why?

Thanks!
Comment 11 John Wilander 2021-12-02 16:54:55 PST
Created attachment 445793 [details]
Patch
Comment 12 John Wilander 2021-12-02 17:05:36 PST
Changes from last patch:
- Extracted a bunch of shared code to reduce duplication, both in implementation and test code.
- Made sure duplicate query parameters are not allowed.
- Collapsed source and destination nonces into once class.
- Removed stray member variable.
- Added comment and link on zero-based indices.

Remaining things to discuss:

# Constructor with default values vs filling in members

PrivateClickMeasurement::parseAttributionRequestQuery() returns a PrivateClickMeasurement::AttributionTriggerData object for further population which is why it's done that way. The alternative I considered was to create the PrivateClickMeasurement::AttributionTriggerData object and submit it in the call to PrivateClickMeasurement::parseAttributionRequestQuery() but that still means filling in members after construction.

# Use of std::optional<URL>

I want to force the caller to have to check instead of relying on the caller to check if the returned URL is empty or not. This is because the signature URL stuff doesn't react on empty URLs and I don't want to risk us producing tokens for invalid URLs.

# MigrateFromPCM1

You both commented on this but the test passes with my changes. There's a comment in the code that we should remove the migration code late 2021. I looked at how the test is set up and wasn't able to figure out if it's supposed to fail with my changes or not. Kate suggested I repurpose this test for the new columns. Guidance would be appreciated. :)
Comment 13 Alex Christensen 2021-12-03 12:37:31 PST
Comment on attachment 445793 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=445793&action=review

My comment about MigrateFromPCM1 was that we need a MigrateFromPCM2 to test what happens when we open a database written before this schema change.

> Source/WebCore/loader/PrivateClickMeasurement.cpp:75
> +    copy.tokenBase64URL = tokenBase64URL.isolatedCopy();

This is all duplicate code.  It should just call SecretToken::isolatedCopy.

> Source/WebCore/loader/PrivateClickMeasurement.cpp:84
> +    copy.tokenBase64URL = tokenBase64URL.isolatedCopy();

ditto.  Actually, I think if you just completely remove these 4 functions it will do the right thing.

> Source/WebCore/loader/PrivateClickMeasurement.cpp:111
> +    copy.blinder = blinder;

ditto with UnlinkableToken::isolatedCopy

> Source/WebCore/loader/PrivateClickMeasurement.cpp:123
> +    copy.blinder = blinder;

ditto
Comment 14 John Wilander 2021-12-03 14:03:36 PST
(In reply to Alex Christensen from comment #13)
> Comment on attachment 445793 [details]
> Patch
> 
> View in context:
> https://bugs.webkit.org/attachment.cgi?id=445793&action=review
> 
> My comment about MigrateFromPCM1 was that we need a MigrateFromPCM2 to test
> what happens when we open a database written before this schema change.
> 
> > Source/WebCore/loader/PrivateClickMeasurement.cpp:75
> > +    copy.tokenBase64URL = tokenBase64URL.isolatedCopy();
> 
> This is all duplicate code.  It should just call SecretToken::isolatedCopy.
> 
> > Source/WebCore/loader/PrivateClickMeasurement.cpp:84
> > +    copy.tokenBase64URL = tokenBase64URL.isolatedCopy();
> 
> ditto.  Actually, I think if you just completely remove these 4 functions it
> will do the right thing.
> 
> > Source/WebCore/loader/PrivateClickMeasurement.cpp:111
> > +    copy.blinder = blinder;
> 
> ditto with UnlinkableToken::isolatedCopy
> 
> > Source/WebCore/loader/PrivateClickMeasurement.cpp:123
> > +    copy.blinder = blinder;
> 
> ditto

I did that originally and ran into problems casting to the specific type that I want the respective isolatedCopy() to return. DestinationSecretToken::isolatedCopy() should return a DestinationSecretToken and vice versa. I may have done it wrong.

return static_cast<DestinationSecretToken>(SecretToken::isolatedCopy());

… results in …

error: no matching conversion for static_cast from 'PrivateClickMeasurement::SecretToken' to 'WebCore::PrivateClickMeasurement::SourceSecretToken'
Comment 15 Alex Christensen 2021-12-03 14:04:34 PST
Don't static_cast, call the constructor.
Comment 16 John Wilander 2021-12-03 14:39:02 PST
Created attachment 445902 [details]
Patch
Comment 17 John Wilander 2021-12-03 14:41:06 PST
(In reply to Alex Christensen from comment #15)
> Don't static_cast, call the constructor.

That worked! Thanks
Comment 18 John Wilander 2021-12-03 15:47:44 PST
Comment on attachment 445902 [details]
Patch

Thank you, Alex!
Comment 19 EWS 2021-12-03 16:02:26 PST
Committed r286519 (244854@main): <https://commits.webkit.org/244854@main>

All reviewed patches have been landed. Closing bug and clearing flags on attachment 445902 [details].