Skip to content
Draft
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
27 changes: 27 additions & 0 deletions detekt_custom_safe_calls_third_party.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1223,3 +1223,30 @@ datadog:
# region SQLDelight
- "com.squareup.sqldelight.android.AndroidSqliteDriver.Callback.onCorruption(androidx.sqlite.db.SupportSQLiteDatabase)"
# endregion
# region Response signing v3
- "java.io.ByteArrayInputStream.constructor(kotlin.ByteArray?)"
- "java.util.LinkedHashMap.containsKey(kotlin.String)"
- "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.flags.internal.repository.SigningFlagsRepository.VerifiedFlags?)"
- "java.util.concurrent.atomic.AtomicReference.set(com.datadog.android.flags.internal.repository.SigningFlagsRepository.VerifiedFlags?)"
- "kotlin.ByteArray.forEachIndexed(kotlin.Function2)"
- "kotlin.String.decodeBase64()"
- "kotlin.collections.List.zipWithNext()"
- "kotlin.collections.MutableList.add(com.datadog.android.flags.internal.signing.SigningDer)"
- "kotlin.collections.MutableSet.sorted()"
- "okhttp3.Call.isCanceled()"
- "okhttp3.Headers.values(kotlin.String)"
- "okhttp3.OkHttpClient.Builder.followRedirects(kotlin.Boolean)"
- "okhttp3.OkHttpClient.Builder.followSslRedirects(kotlin.Boolean)"
- "okhttp3.ResponseBody.source()"
- "okio.Buffer.write(kotlin.ByteArray)"
- "okio.Buffer.write(okio.ByteString)"
- "okio.Buffer.writeByte(kotlin.Int)"
- "okio.Buffer.writeInt(kotlin.Int)"
- "okio.Buffer.writeLong(kotlin.Long)"
- "okio.Buffer.writeShort(kotlin.Int)"
- "okio.Buffer.writeUtf8(kotlin.String)"
- "okio.ByteString.base64()"
- "okio.ByteString.hex()"
- "okio.ByteString.sha256()"
- "okio.ByteString.toByteArray()"
# endregion
14 changes: 14 additions & 0 deletions detekt_custom_unsafe_calls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,17 @@ datadog:
- "com.squareup.sqldelight.transaction(kotlin.Boolean, kotlin.Function1):java.lang.IllegalStateException"
- "com.squareup.sqldelight.transactionWithResult(kotlin.Boolean, kotlin.Function1):java.lang.IllegalStateException"
# endregion
# region Response signing v3
- "java.security.SecureRandom.nextBytes(kotlin.ByteArray?):java.security.ProviderException"
- "java.security.Signature.getInstance(kotlin.String?):java.security.NoSuchAlgorithmException"
- "java.security.Signature.initVerify(java.security.PublicKey?):java.security.InvalidKeyException"
- "java.security.Signature.update(kotlin.ByteArray?):java.security.SignatureException"
- "java.security.Signature.verify(kotlin.ByteArray?):java.security.SignatureException"
- "java.security.cert.CertificateFactory.generateCertificate(java.io.InputStream?):java.security.cert.CertificateException"
- "java.security.cert.CertificateFactory.getInstance(kotlin.String?):java.security.cert.CertificateException"
- "java.security.cert.X509Certificate.verify(java.security.PublicKey?):java.security.GeneralSecurityException"
- "kotlin.ByteArray.toByteString(kotlin.Int, kotlin.Int):java.lang.IndexOutOfBoundsException,java.lang.IllegalArgumentException"
- "kotlin.String.decodeHex():java.lang.IllegalArgumentException"
- "okhttp3.ResponseBody.bytes():java.io.IOException"
- "okio.BufferedSource.request(kotlin.Long):java.io.IOException,java.lang.IllegalArgumentException"
# endregion
50 changes: 50 additions & 0 deletions features/dd-sdk-android-flags/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,56 @@ Flags.enable(flagsConfig)

The `FlagsConfiguration.Builder` supports the following options:

#### Response signing (experimental draft)

Signing-enabled requests set `X-stream: false` because whole-body proofs require buffered delivery.
Both observe and required modes apply this override. Signing-off requests keep their ordinary transport selection.

The first public response-signing protocol is v1. The SDK advertises
`response-signing-v1`, sends signature version `1`, and verifies the
`datadog.feature-flags.precomputed-assignments.v1` transcript. Earlier POC v1/v2
and draft v3 labels are not supported public versions; public v1 rejects their proofs.
X.509 v3 certificate encoding is unchanged.

Response signing is disabled by default. During a browser-first pilot, Android can
observe the same public v1 response proofs with an explicitly provisioned public trust anchor:

```kotlin
val flagsConfig = FlagsConfiguration.Builder()
.responseSigning(ResponseSigningMode.OBSERVE, listOf(publicRootCertificateBase64Der))
.build()
```

No production root or test root is bundled. The list contains locally trusted public
root certificates encoded as padded base64 DER. Never put a private key in SDK configuration.
The root stays fixed while each response carries a leaf and one intermediate certificate,
so intermediate rotation does not require an SDK update or a prior successful fetch.
The configured root's validity still limits how long an unchanged SDK can verify responses.

- `OFF` preserves existing transport and persistence and performs no signing work.
- `OBSERVE` reports bounded verified/unverified telemetry and accepts otherwise-valid
responses when optional verification fails. Authentication, transport, redirect, and
JSON errors remain failures. Signed and unsigned requests are never retried as a downgrade.
- `REQUIRED` accepts only valid proofs. Empty or invalid roots yield evaluation defaults;
an empty root list fails locally before a request. Verified assignments are held only
in memory and are checked against their original expiry on every evaluation. Restart,
expiry, a changed request scope, or a clock earlier than admission causes defaults.
A live RUM application-ID change or stopping the Flags feature immediately invalidates
prior proofs and prevents in-flight responses from restoring them.

Observe mode keeps unverified persistence in a new namespace scoped to the emitted token,
endpoint and query, environment, application ID, evaluation context, and semantic headers.
It reads that namespace only after forming the request, so the first opt-in is a cold start.
Old client-name-only cache entries are not imported. A valid proof also creates a separate
verified memory fallback, which unsigned observations cannot overwrite or extend.
Persisted entries are never treated as verified.

The draft verifies complete buffered JSON assignment responses over HTTPS, before parsing
or installing them. It does not introduce a streaming protocol. The shared conformance
corpus and native host-JVM tests are development evidence; Android device/provider tests,
iOS API review, production anchor provisioning, and operational rollout approval remain
release prerequisites.

#### Disable RUM integration

By default, flag evaluations are automatically sent to RUM (Real User Monitoring) and attached to the current view if RUM is enabled. You can disable this integration:
Expand Down
5 changes: 5 additions & 0 deletions features/dd-sdk-android-flags/api/apiSurface
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ fun FlagsClient.setEvaluationTelemetryOptOut(Boolean)
data class com.datadog.android.flags.FlagsConfiguration
fun copy(Boolean = this.trackExposures, Boolean = this.trackEvaluations, String? = this.customExposureEndpoint, String? = this.customEvaluationEndpoint, String? = this.customFlagEndpoint, Long = this.evaluationFlushIntervalMs, Boolean = this.rumIntegrationEnabled, Boolean = this.gracefulModeEnabled): FlagsConfiguration
class Builder
fun responseSigning(ResponseSigningMode, List<String>): Builder
fun trackExposures(Boolean): Builder
fun trackEvaluations(Boolean): Builder
fun useCustomExposureEndpoint(String): Builder
Expand All @@ -41,6 +42,10 @@ interface com.datadog.android.flags.FlagsStateListener
fun onStateChanged(com.datadog.android.flags.model.FlagsClientState)
interface com.datadog.android.flags.FlagsSubscription
fun unsubscribe()
enum com.datadog.android.flags.ResponseSigningMode
- OFF
- OBSERVE
- REQUIRED
interface com.datadog.android.flags.StateObservable
fun getCurrentState(): com.datadog.android.flags.model.FlagsClientState
fun addListener(FlagsStateListener)
Expand Down
10 changes: 10 additions & 0 deletions features/dd-sdk-android-flags/api/dd-sdk-android-flags.api
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public final class com/datadog/android/flags/FlagsConfiguration$Builder {
public final fun evaluationFlushInterval (J)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
public final fun gracefulModeEnabled (Z)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
public final fun initializationTimeout (J)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
public final fun responseSigning (Lcom/datadog/android/flags/ResponseSigningMode;Ljava/util/List;)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
public final fun rumIntegrationEnabled (Z)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
public final fun trackEvaluations (Z)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
public final fun trackExposures (Z)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
Expand All @@ -87,6 +88,15 @@ public abstract interface class com/datadog/android/flags/FlagsSubscription {
public abstract fun unsubscribe ()V
}

public final class com/datadog/android/flags/ResponseSigningMode : java/lang/Enum {
public static final field OBSERVE Lcom/datadog/android/flags/ResponseSigningMode;
public static final field OFF Lcom/datadog/android/flags/ResponseSigningMode;
public static final field REQUIRED Lcom/datadog/android/flags/ResponseSigningMode;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public static fun valueOf (Ljava/lang/String;)Lcom/datadog/android/flags/ResponseSigningMode;
public static fun values ()[Lcom/datadog/android/flags/ResponseSigningMode;
}

public abstract interface class com/datadog/android/flags/StateObservable {
public abstract fun addListener (Lcom/datadog/android/flags/FlagsStateListener;)V
public abstract fun getCurrentState ()Lcom/datadog/android/flags/model/FlagsClientState;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ import com.datadog.android.flags.internal.RumEvaluationLogger
import com.datadog.android.flags.internal.evaluation.EvaluationsManager
import com.datadog.android.flags.internal.net.PrecomputedAssignmentsDownloader
import com.datadog.android.flags.internal.repository.DefaultFlagsRepository
import com.datadog.android.flags.internal.repository.FlagsRepository
import com.datadog.android.flags.internal.repository.NoOpFlagsRepository
import com.datadog.android.flags.internal.repository.SigningFlagsRepository
import com.datadog.android.flags.internal.repository.net.PrecomputeMapper
import com.datadog.android.flags.internal.signing.ResponseSigningState
import com.datadog.android.flags.model.EvaluationContext
import com.datadog.android.flags.model.FlagsClientState
import com.datadog.android.flags.model.ResolutionDetails
Expand Down Expand Up @@ -348,6 +351,7 @@ interface FlagsClient {
* ensuring thread-safe access and proper lifecycle management.
*/
companion object {
private const val SIGNING_MILLIS_PER_SECOND = 1000
private const val DEFAULT_CLIENT_NAME = "default"

/**
Expand Down Expand Up @@ -436,31 +440,54 @@ interface FlagsClient {
val networkExecutorService = featureSdkCore.createSingleThreadExecutorService(
executorContext = FLAGS_NETWORK_EXECUTOR_NAME
)
val signing = flagsFeature.signingContext?.let {
ResponseSigningState(configuration.responseSigning, featureSdkCore.internalLogger, context = it) {
featureSdkCore.timeProvider.getDeviceTimestampMillis() / SIGNING_MILLIS_PER_SECOND
}
}
val deliverFirstFlags: (Runnable) -> Unit = { delivery ->
val worker = featureSdkCore.createSingleThreadExecutorService(FLAGS_FIRST_FLAGS_EXECUTOR_NAME)
try {
worker.executeSafe("Deliver first flags", featureSdkCore.internalLogger, delivery)
} finally {
worker.shutdown()
}
}
val datastore = featureSdkCore.getFeature(FLAGS_FEATURE_NAME)
?.dataStore
val flagsRepository = if (datastore != null) {
DefaultFlagsRepository(
featureSdkCore = featureSdkCore,
dataStore = datastore,
instanceName = name,
deliverFirstFlags = { delivery ->
val worker = featureSdkCore.createSingleThreadExecutorService(FLAGS_FIRST_FLAGS_EXECUTOR_NAME)
try {
worker.executeSafe("Deliver first flags", featureSdkCore.internalLogger, delivery)
} finally {
worker.shutdown()
}
}
val ordinaryRepository: (String) -> FlagsRepository = { instanceName ->
if (datastore != null) {
DefaultFlagsRepository(
featureSdkCore = featureSdkCore,
dataStore = datastore,
instanceName = instanceName,
deliverFirstFlags = deliverFirstFlags
)
} else {
NoOpFlagsRepository()
}
}
val flagsRepository = signing?.let {
SigningFlagsRepository(
{ scope -> ordinaryRepository("$name-signing-v1-observe-$scope") },
it,
deliverFirstFlags
)
} ?: ordinaryRepository(name)

val callFactory = if (signing == null) {
featureSdkCore.createOkHttpCallFactory()
} else {
NoOpFlagsRepository()
featureSdkCore.createOkHttpCallFactory {
followRedirects(false)
followSslRedirects(false)
}
}

val callFactory = featureSdkCore.createOkHttpCallFactory()
val assignmentsDownloader = PrecomputedAssignmentsDownloader(
internalLogger = featureSdkCore.internalLogger,
callFactory = callFactory,
requestFactory = flagsFeature.precomputedRequestFactory
requestFactory = flagsFeature.precomputedRequestFactory,
signing = signing
)

val precomputeMapper = PrecomputeMapper(featureSdkCore.internalLogger)
Expand All @@ -481,7 +508,8 @@ interface FlagsClient {
precomputeMapper = precomputeMapper,
flagStateManager = flagStateManager,
initializationTimeoutMs = configuration.initializationTimeoutMs,
initializationTimeoutScheduler = flagsFeature.initializationTimeoutScheduler
initializationTimeoutScheduler = flagsFeature.initializationTimeoutScheduler,
signing = signing
)

val rumEvaluationLogger = createRumEvaluationLogger(featureSdkCore)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

package com.datadog.android.flags

import com.datadog.android.flags.internal.signing.ResponseSigningConfiguration

private const val DEFAULT_INITIALIZATION_TIMEOUT_MS = 5_000L

/**
Expand All @@ -21,7 +23,8 @@ data class FlagsConfiguration internal constructor(
internal val evaluationFlushIntervalMs: Long,
internal val rumIntegrationEnabled: Boolean,
internal val gracefulModeEnabled: Boolean,
internal val initializationTimeoutMs: Long?
internal val initializationTimeoutMs: Long?,
internal val responseSigning: ResponseSigningConfiguration = ResponseSigningConfiguration()
) {
/**
* Copies this configuration and preserves the initialization timeout.
Expand All @@ -46,7 +49,8 @@ data class FlagsConfiguration internal constructor(
evaluationFlushIntervalMs = evaluationFlushIntervalMs,
rumIntegrationEnabled = rumIntegrationEnabled,
gracefulModeEnabled = gracefulModeEnabled,
initializationTimeoutMs = initializationTimeoutMs
initializationTimeoutMs = initializationTimeoutMs,
responseSigning = responseSigning
)

/**
Expand All @@ -62,6 +66,20 @@ data class FlagsConfiguration internal constructor(
private var rumIntegrationEnabled: Boolean = true
private var gracefulModeEnabled: Boolean = true
private var initializationTimeoutMs: Long? = DEFAULT_INITIALIZATION_TIMEOUT_MS
private var responseSigning = ResponseSigningConfiguration()

/**
* Opts into assignment response verification. No production trust anchor is bundled.
*
* @param mode OFF by default; OBSERVE accepts unverified data, REQUIRED uses verified memory only.
* @param trustedRootCertificates Explicit trusted public root certificates as padded base64 DER.
* Empty or invalid roots leave responses unverified; REQUIRED then returns evaluation defaults.
* @return this builder.
*/
fun responseSigning(mode: ResponseSigningMode, trustedRootCertificates: List<String>): Builder {
responseSigning = ResponseSigningConfiguration(mode, trustedRootCertificates.toList())
return this
}

/**
* Sets whether exposures should be logged to the dedicated exposures intake endpoint.
Expand Down Expand Up @@ -215,7 +233,8 @@ data class FlagsConfiguration internal constructor(
evaluationFlushIntervalMs = evaluationFlushIntervalMs,
rumIntegrationEnabled = rumIntegrationEnabled,
gracefulModeEnabled = gracefulModeEnabled,
initializationTimeoutMs = initializationTimeoutMs
initializationTimeoutMs = initializationTimeoutMs,
responseSigning = responseSigning
)

internal companion object {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

package com.datadog.android.flags

/** Controls verification of assignment responses. Disabled unless explicitly configured. */
enum class ResponseSigningMode {
/** Preserves the existing transport and cache behavior without cryptographic work. */
OFF,

/** Reports verification outcomes while accepting otherwise valid unsigned assignments. */
OBSERVE,

/** Requires valid proofs; verified assignments remain in memory until their original expiry. */
REQUIRED
}
Loading
Loading