Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .trivyignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,9 @@ CVE-2025-59375 exp:2025-12-15

# UID2-6128
CVE-2025-55163 exp:2025-10-30

# UID2-6340
CVE-2025-64720 exp:2025-12-16

# UID2-6340
CVE-2025-65018 exp:2025-12-16
8 changes: 8 additions & 0 deletions src/main/java/com/uid2/operator/model/AdvertisingToken.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,21 @@ public class AdvertisingToken extends VersionedToken {
public final OperatorIdentity operatorIdentity;
public final PublisherIdentity publisherIdentity;
public final UserIdentity userIdentity;
public Integer siteKeyId;

public AdvertisingToken(TokenVersion version, Instant createdAt, Instant expiresAt, OperatorIdentity operatorIdentity,
PublisherIdentity publisherIdentity, UserIdentity userIdentity) {
super(version, createdAt, expiresAt);
this.operatorIdentity = operatorIdentity;
this.publisherIdentity = publisherIdentity;
this.userIdentity = userIdentity;
this.siteKeyId = null;
}

public AdvertisingToken(TokenVersion version, Instant createdAt, Instant expiresAt, OperatorIdentity operatorIdentity,
PublisherIdentity publisherIdentity, UserIdentity userIdentity, Integer siteKeyId) {
this(version, createdAt, expiresAt, operatorIdentity, publisherIdentity, userIdentity);
this.siteKeyId = siteKeyId;
}
}

44 changes: 42 additions & 2 deletions src/main/java/com/uid2/operator/model/KeyManager.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package com.uid2.operator.model;

import com.uid2.operator.util.Tuple;
import com.uid2.operator.vertx.UIDOperatorVerticle;
import com.uid2.shared.Const;
import com.uid2.shared.auth.Keyset;
import com.uid2.shared.model.KeysetKey;
import com.uid2.shared.store.IKeysetKeyStore;
import com.uid2.shared.store.reader.RotatingKeysetProvider;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Metrics;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -17,6 +20,10 @@

public class KeyManager {
private static final Logger LOGGER = LoggerFactory.getLogger(KeyManager.class);
private static final String SITE_KEYSET_STATUS = "site_keyset_found";
private static final String FALLBACK_KEYSET_STATUS = "fallback_keyset_found";
private static final String KEYSET_NOT_FOUND_STATUS = "keyset_not_found";

private final IKeysetKeyStore keysetKeyStore;
private final RotatingKeysetProvider keysetProvider;

Expand All @@ -34,12 +41,45 @@ public KeyManagerSnapshot getKeyManagerSnapshot(int siteId) {
this.getDefaultKeysetBySiteId(siteId));
}

public KeysetKey getActiveKeyBySiteIdWithFallback(int siteId, int fallbackSiteId, Instant asOf) {
private void recordSiteKeysetStatusMetrics(int siteId, Boolean keysetFound, Boolean isFallback, Map<Tuple.Tuple2<String, String>, Counter> siteKeysetStatusMetrics) {
String status;
if (!keysetFound) {
status = KEYSET_NOT_FOUND_STATUS;
} else if (isFallback) {
status = FALLBACK_KEYSET_STATUS;
} else {
status = SITE_KEYSET_STATUS;
}

siteKeysetStatusMetrics.computeIfAbsent(
new Tuple.Tuple2<>(String.valueOf(siteId), status),
tuple -> Counter
.builder("uid2_site_keyset_status")
.description("counts site keyset status by site ID")
.tags(
"site_id", tuple.getItem1(),
"status", tuple.getItem2()
)
.register(Metrics.globalRegistry)
).increment();
}

public KeysetKey getActiveKeyBySiteIdWithFallback(int siteId, int fallbackSiteId, Instant asOf, Map<Tuple.Tuple2<String, String>, Counter> siteKeysetStatusMetrics) {
boolean isFallback = false;

KeysetKey key = getActiveKeyBySiteId(siteId, asOf);
if (key == null) key = getActiveKeyBySiteId(fallbackSiteId, asOf);

if (key == null) {
isFallback = true;
key = getActiveKeyBySiteId(fallbackSiteId, asOf);
}

if (key == null) {
recordSiteKeysetStatusMetrics(siteId, false, null, siteKeysetStatusMetrics);
throw new NoActiveKeyException(String.format("Cannot get active key in default keyset with SITE ID %d or %d.", siteId, fallbackSiteId));
}

recordSiteKeysetStatusMetrics(siteId, true, isFallback, siteKeysetStatusMetrics);
return key;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.uid2.operator.model;

public enum TokenValidateResult {
MATCH,
MISMATCH,
UNAUTHORIZED,
INVALID_TOKEN,
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.uid2.operator.service;

import com.uid2.operator.model.*;
import com.uid2.operator.util.Tuple;
import com.uid2.operator.vertx.ClientInputValidationException;
import com.uid2.shared.Const.Data;
import com.uid2.shared.encryption.AesCbc;
Expand All @@ -14,17 +15,20 @@

import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

public class EncryptedTokenEncoder implements ITokenEncoder {
private final KeyManager keyManager;
private final Map<Tuple.Tuple2<String, String>, Counter> siteKeysetStatusMetrics = new HashMap<>();

public EncryptedTokenEncoder(KeyManager keyManager) {
this.keyManager = keyManager;
}

public byte[] encode(AdvertisingToken t, Instant asOf) {
final KeysetKey masterKey = this.keyManager.getMasterKey(asOf);
final KeysetKey siteEncryptionKey = this.keyManager.getActiveKeyBySiteIdWithFallback(t.publisherIdentity.siteId, Data.AdvertisingTokenSiteId, asOf);
final KeysetKey siteEncryptionKey = this.keyManager.getActiveKeyBySiteIdWithFallback(t.publisherIdentity.siteId, Data.AdvertisingTokenSiteId, asOf, siteKeysetStatusMetrics);

return t.version == TokenVersion.V2
? encodeV2(t, masterKey, siteEncryptionKey)
Expand Down Expand Up @@ -225,7 +229,8 @@ public AdvertisingToken decodeAdvertisingTokenV2(Buffer b) {
Instant.ofEpochMilli(expiresMillis),
new OperatorIdentity(0, OperatorType.Service, 0, masterKeyId),
new PublisherIdentity(siteId, siteKeyId, 0),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we directly use the siteKeyId stored in PublisherIdentity for validation?

new UserIdentity(IdentityScope.UID2, IdentityType.Email, advertisingId, privacyBits, Instant.ofEpochMilli(establishedMillis), null)
new UserIdentity(IdentityScope.UID2, IdentityType.Email, advertisingId, privacyBits, Instant.ofEpochMilli(establishedMillis), null),
siteKeyId
);

} catch (Exception e) {
Expand Down Expand Up @@ -263,7 +268,8 @@ public AdvertisingToken decodeAdvertisingTokenV3orV4(Buffer b, byte[] bytes, Tok

return new AdvertisingToken(
tokenVersion, createdAt, expiresAt, operatorIdentity, publisherIdentity,
new UserIdentity(identityScope, identityType, id, privacyBits, establishedAt, refreshedAt)
new UserIdentity(identityScope, identityType, id, privacyBits, establishedAt, refreshedAt),
siteKeyId
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@ void invalidateTokensAsync(UserIdentity userIdentity, Instant asOf, String uidTr
String email, String phone, String clientIp,
Handler<AsyncResult<Instant>> handler);

boolean advertisingTokenMatches(String advertisingToken, UserIdentity userIdentity, Instant asOf, IdentityEnvironment env);
TokenValidateResult validateAdvertisingToken(int participantSiteId, String advertisingToken, UserIdentity userIdentity, Instant asOf, IdentityEnvironment env);
}
25 changes: 21 additions & 4 deletions src/main/java/com/uid2/operator/service/UIDOperatorService.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,18 @@ public class UIDOperatorService implements IUIDOperatorService {

private final Handler<Boolean> saltRetrievalResponseHandler;
private final UidInstanceIdProvider uidInstanceIdProvider;
private final KeyManager keyManager;

public UIDOperatorService(IOptOutStore optOutStore, ISaltProvider saltProvider, ITokenEncoder encoder, Clock clock,
IdentityScope identityScope, Handler<Boolean> saltRetrievalResponseHandler, boolean identityV3Enabled, UidInstanceIdProvider uidInstanceIdProvider) {
IdentityScope identityScope, Handler<Boolean> saltRetrievalResponseHandler, boolean identityV3Enabled, UidInstanceIdProvider uidInstanceIdProvider, KeyManager keyManager) {
this.saltProvider = saltProvider;
this.encoder = encoder;
this.optOutStore = optOutStore;
this.clock = clock;
this.identityScope = identityScope;
this.saltRetrievalResponseHandler = saltRetrievalResponseHandler;
this.uidInstanceIdProvider = uidInstanceIdProvider;
this.keyManager = keyManager;

this.testOptOutIdentityForEmail = getFirstLevelHashIdentity(identityScope, IdentityType.Email,
InputUtil.normalizeEmail(OptOutIdentityForEmail).getIdentityInput(), Instant.now());
Expand Down Expand Up @@ -197,12 +199,27 @@ public void invalidateTokensAsync(UserIdentity userIdentity, Instant asOf, Strin
}

@Override
public boolean advertisingTokenMatches(String advertisingToken, UserIdentity userIdentity, Instant asOf, IdentityEnvironment env) {
public TokenValidateResult validateAdvertisingToken(int participantSiteId, String advertisingToken, UserIdentity userIdentity, Instant asOf, IdentityEnvironment env) {
final UserIdentity firstLevelHashIdentity = getFirstLevelHashIdentity(userIdentity, asOf);
final MappedIdentity mappedIdentity = getMappedIdentity(firstLevelHashIdentity, asOf, env);

final AdvertisingToken token = this.encoder.decodeAdvertisingToken(advertisingToken);
return Arrays.equals(mappedIdentity.advertisingId, token.userIdentity.id);
final AdvertisingToken token;
try {
token = this.encoder.decodeAdvertisingToken(advertisingToken);
} catch (Exception e) {
return TokenValidateResult.INVALID_TOKEN;
}

int tokenSiteId = this.keyManager.getSiteIdFromKeyId(token.siteKeyId);
if (tokenSiteId != participantSiteId) {
return TokenValidateResult.UNAUTHORIZED;
}

if (!Arrays.equals(mappedIdentity.advertisingId, token.userIdentity.id)) {
return TokenValidateResult.MISMATCH;
}

return TokenValidateResult.MATCH;
}

private void validateTokenDurations(Duration refreshIdentityAfter, Duration refreshExpiresAfter, Duration identityExpiresAfter) {
Expand Down
78 changes: 53 additions & 25 deletions src/main/java/com/uid2/operator/vertx/UIDOperatorVerticle.java
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ public class UIDOperatorVerticle extends AbstractVerticle {
private final Map<String, Tuple.Tuple2<Counter, Counter>> _identityMapUnmappedIdentifiers = new HashMap<>();
private final Map<String, Counter> _identityMapRequestWithUnmapped = new HashMap<>();
private final Map<Tuple.Tuple2<String, String>, Counter> _clientVersions = new HashMap<>();
private final Map<Tuple.Tuple2<String, String>, Counter> _tokenValidateCounters = new HashMap<>();

private final Map<String, DistributionSummary> optOutStatusCounters = new HashMap<>();
private final IdentityScope identityScope;
Expand Down Expand Up @@ -210,7 +211,8 @@ public void start(Promise<Void> startPromise) throws Exception {
this.identityScope,
this.saltRetrievalResponseHandler,
this.identityV3Enabled,
this.uidInstanceIdProvider
this.uidInstanceIdProvider,
this.keyManager
);

final Router router = createRoutesSetup();
Expand All @@ -233,13 +235,8 @@ public void start(Promise<Void> startPromise) throws Exception {

}

private Router createRoutesSetup() throws IOException {
final Router router = Router.router(vertx);

router.allowForward(AllowForwardHeaders.X_FORWARD);
router.route().handler(new RequestCapturingHandler(siteProvider));
router.route().handler(new ClientVersionCapturingHandler("static/js", "*.js", clientKeyProvider));
router.route().handler(CorsHandler.create()
private CorsHandler createCorsHandler() {
return CorsHandler.create()
.addRelativeOrigin(".*.")
.allowedMethod(io.vertx.core.http.HttpMethod.GET)
.allowedMethod(io.vertx.core.http.HttpMethod.POST)
Expand All @@ -249,7 +246,17 @@ private Router createRoutesSetup() throws IOException {
.allowedHeader("Access-Control-Allow-Credentials")
.allowedHeader("Access-Control-Allow-Origin")
.allowedHeader("Access-Control-Allow-Headers")
.allowedHeader("Content-Type"));
.allowedHeader("Content-Type");
}

private Router createRoutesSetup() throws IOException {
final Router router = Router.router(vertx);

router.allowForward(AllowForwardHeaders.X_FORWARD);
router.route().handler(new RequestCapturingHandler(siteProvider));
router.route().handler(new ClientVersionCapturingHandler("static/js", "*.js", clientKeyProvider));
router.route(V2_TOKEN_VALIDATE.toString()).handler(createCorsHandler().allowedHeader("Authorization"));
router.route().handler(createCorsHandler());
router.route().handler(new StatsCollectorHandler(_statsCollectorQueue, vertx));
router.route("/static/*").handler(StaticHandler.create("static"));
router.route().handler(ctx -> {
Expand Down Expand Up @@ -807,6 +814,22 @@ private void recordOperatorServedSdkUsage(RoutingContext rc, Integer siteId, Str
}
}

private void recordTokenValidateStats(Integer siteId, String result) {
final String siteIdStr = siteId != null ? String.valueOf(siteId) : "unknown";
_tokenValidateCounters.computeIfAbsent(
new Tuple.Tuple2<>(siteIdStr, result),
tuple -> Counter
.builder("uid2_token_validate_total")
.description("counter for token validate endpoint results")
.tags(
"site_id", tuple.getItem1(),
"site_name", getSiteName(siteProvider, Integer.valueOf(tuple.getItem1())),
"result", tuple.getItem2()
)
.register(Metrics.globalRegistry)
).increment();
}

private void handleTokenRefreshV2(RoutingContext rc) {
Integer siteId = null;
TokenResponseStatsCollector.PlatformType platformType = TokenResponseStatsCollector.PlatformType.Other;
Expand Down Expand Up @@ -848,33 +871,38 @@ private void handleTokenRefreshV2(RoutingContext rc) {
private void handleTokenValidateV2(RoutingContext rc) {
RuntimeConfig config = this.getConfigFromRc(rc);
IdentityEnvironment env = config.getIdentityEnvironment();
final Integer participantSiteId = AuthMiddleware.getAuthClient(rc).getSiteId();

try {
final JsonObject req = (JsonObject) rc.data().get("request");

final InputUtil.InputVal input = getTokenInputV2(req);
if (!isTokenInputValid(input, rc)) {
recordTokenValidateStats(participantSiteId, "invalid_input");
return;
}
if ((input.getIdentityType() == IdentityType.Email && Arrays.equals(ValidateIdentityForEmailHash, input.getIdentityInput()))
|| (input.getIdentityType() == IdentityType.Phone && Arrays.equals(ValidateIdentityForPhoneHash, input.getIdentityInput()))) {
try {
final Instant now = Instant.now();
final String token = req.getString("token");

if (this.idService.advertisingTokenMatches(token, input.toUserIdentity(this.identityScope, 0, now), now, env)) {
ResponseUtil.SuccessV2(rc, Boolean.TRUE);
} else {
ResponseUtil.SuccessV2(rc, Boolean.FALSE);
}
} catch (Exception e) {
ResponseUtil.SuccessV2(rc, Boolean.FALSE);
}
} else {

final Instant now = Instant.now();
final String token = req.getString("token");

final TokenValidateResult result = this.idService.validateAdvertisingToken(participantSiteId, token, input.toUserIdentity(this.identityScope, 0, now), now, env);

if (result == TokenValidateResult.MATCH) {
recordTokenValidateStats(participantSiteId, "match");
ResponseUtil.SuccessV2(rc, Boolean.TRUE);
} else if (result == TokenValidateResult.MISMATCH) {
recordTokenValidateStats(participantSiteId, "mismatch");
ResponseUtil.SuccessV2(rc, Boolean.FALSE);
} else if (result == TokenValidateResult.UNAUTHORIZED) {
recordTokenValidateStats(participantSiteId, "unauthorized");
ResponseUtil.LogInfoAndSend400Response(rc, "Unauthorised to validate token");
} else if (result == TokenValidateResult.INVALID_TOKEN) {
recordTokenValidateStats(participantSiteId, "invalid_token");
ResponseUtil.LogInfoAndSend400Response(rc, "Invalid token");
}
} catch (Exception e) {
LOGGER.error("Unknown error while validating token v2", e);
recordTokenValidateStats(participantSiteId, "error");
LOGGER.error("Unknown error while validating token", e);
rc.fail(500);
}
}
Expand Down
Loading
Loading