diff --git a/libs/logging/src/main/kotlin/com/getcode/utils/LiveSecrets.kt b/libs/logging/src/main/kotlin/com/getcode/utils/LiveSecrets.kt new file mode 100644 index 0000000000..cf76ff6e5a --- /dev/null +++ b/libs/logging/src/main/kotlin/com/getcode/utils/LiveSecrets.kt @@ -0,0 +1,31 @@ +package com.getcode.utils + +import java.util.concurrent.ConcurrentHashMap + +/** + * Values that must not reach a log in full while they can still be used, such as a payment + * request's `payment_intent_id`: anyone who learns it before the share is paid can use it up + * and block the payment. Once the payment succeeds the value is harmless and is [release]d so + * logs can show it in full again. + * + * Values are base58 strings, the form the log formatters render ids in. + */ +object LiveSecrets { + private val held = ConcurrentHashMap.newKeySet() + + fun hold(value: String) { + held += value + } + + fun release(value: String) { + held -= value + } + + fun isLive(value: String): Boolean = value in held + + /** [value] masked if it is held, otherwise unchanged. */ + fun redact(value: String): String = if (isLive(value)) value.maskedSuffix() else value +} + +/** Only the last [visibleLength] characters, after an ellipsis: `…3kQx9Z`. */ +fun String.maskedSuffix(visibleLength: Int = 6): String = "…${takeLast(visibleLength)}" diff --git a/libs/logging/src/test/kotlin/com/getcode/utils/LiveSecretsTest.kt b/libs/logging/src/test/kotlin/com/getcode/utils/LiveSecretsTest.kt new file mode 100644 index 0000000000..6d842013a2 --- /dev/null +++ b/libs/logging/src/test/kotlin/com/getcode/utils/LiveSecretsTest.kt @@ -0,0 +1,34 @@ +package com.getcode.utils + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test + +class LiveSecretsTest { + + private val id = "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi" + + @After + fun tearDown() { + LiveSecrets.release(id) + } + + @Test + fun `maskedSuffix keeps only the last six characters`() { + assertEquals("…3bkLKi", id.maskedSuffix()) + } + + @Test + fun `redact masks a held value and passes others through`() { + LiveSecrets.hold(id) + assertEquals("…3bkLKi", LiveSecrets.redact(id)) + assertEquals("other", LiveSecrets.redact("other")) + } + + @Test + fun `redact shows a released value in full`() { + LiveSecrets.hold(id) + LiveSecrets.release(id) + assertEquals(id, LiveSecrets.redact(id)) + } +} diff --git a/services/opencode/build.gradle.kts b/services/opencode/build.gradle.kts index e3129cb094..538849797b 100644 --- a/services/opencode/build.gradle.kts +++ b/services/opencode/build.gradle.kts @@ -94,4 +94,6 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) testImplementation(testFixtures(project(":libs:currency-math"))) + // formatProto tests render a flipcash2 response that carries a payment_intent_id. + testImplementation(libs.flipcash2.client.protocol) } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/IntentExecutor.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/IntentExecutor.kt index 6d9f07fbb6..ca61a3569d 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/IntentExecutor.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/IntentExecutor.kt @@ -15,7 +15,9 @@ import com.getcode.opencode.solana.intents.ServerParameter import com.getcode.services.opencode.BuildConfig import com.getcode.solana.keys.base58 import com.getcode.solana.keys.redact +import com.getcode.utils.LiveSecrets import com.getcode.utils.TraceType +import com.getcode.utils.maskedSuffix import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -101,9 +103,10 @@ class IntentExecutor( ): Result = openBidirectionalStreamForResult( streamRef = streamRef, apiCall = { request -> + val id = intent.id.base58() trace( tag = "SubmitIntent", - message = "submitting intent ${intent.id.base58().redact()}" + message = "submitting intent ${if (LiveSecrets.isLive(id)) id.maskedSuffix() else id.redact()}" ) api.submitIntent(request) }, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/utils/logging/LoggingClientCallListener.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/utils/logging/LoggingClientCallListener.kt index 7edb1afadd..e1b6289283 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/utils/logging/LoggingClientCallListener.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/utils/logging/LoggingClientCallListener.kt @@ -1,7 +1,10 @@ package com.getcode.opencode.utils.logging +import androidx.annotation.VisibleForTesting +import com.getcode.utils.LiveSecrets import com.getcode.utils.TraceManager import com.getcode.utils.TraceType +import com.getcode.utils.maskedSuffix import com.getcode.utils.trace import com.getcode.vendor.Base58 import com.google.protobuf.ByteString @@ -70,13 +73,23 @@ private val BASE58_WRAPPER_NAMES = setOf( "Hash", "Blockhash", "IntentId", "SwapId", ) +/** + * Fields whose value can be used by anyone who reads it until the payment it belongs to + * succeeds, so they are only ever logged as their last six base58 characters. + */ +private val SENSITIVE_FIELD_NAMES = setOf( + "paymentIntentId_", +) + /** * Formats a gRPC message (expected to be a protobuf [MessageLite]) into a * human-readable single-line string. Known byte-wrapper types are rendered - * as base58; UUIDs are rendered in standard 8-4-4-4-12 form; all other - * messages have their fields printed recursively. + * as base58, masked while [LiveSecrets] holds them; UUIDs are rendered in + * standard 8-4-4-4-12 form; all other messages have their fields printed + * recursively, with [SENSITIVE_FIELD_NAMES] masked. */ -private fun formatProto(obj: Any?): String { +@VisibleForTesting +internal fun formatProto(obj: Any?): String { if (obj == null) return "null" if (obj !is MessageLite) return obj.toString() @@ -86,7 +99,7 @@ private fun formatProto(obj: Any?): String { // Byte-wrapper → base58 if (name in BASE58_WRAPPER_NAMES) { val bytes = obj.getByteField("value_") ?: return name - return Base58.encode(bytes) + return LiveSecrets.redact(Base58.encode(bytes)) } // UUID wrapper → standard format @@ -115,7 +128,11 @@ private fun formatProto(obj: Any?): String { field.isAccessible = true val value = field.get(obj) ?: continue - val formatted = formatFieldValue(fieldName, value) + val formatted = if (fieldName in SENSITIVE_FIELD_NAMES) { + formatSensitive(value) + } else { + formatFieldValue(fieldName, value) + } if (formatted != null) { // Strip the trailing underscore that protobuf-lite adds. val displayName = fieldName.trimEnd('_') @@ -160,6 +177,17 @@ private fun formatFieldValue(fieldName: String, value: Any): String? = when (val else -> value.toString().masked() } +/** A sensitive field's value as its masked base58 suffix, or `null` when unset. */ +private fun formatSensitive(value: Any): String? { + val bytes = when (value) { + is ByteString -> value.toByteArray() + is MessageLite -> value.getByteField("value_") ?: value.toByteArray() + else -> return "…" + } + if (bytes.isEmpty()) return null + return Base58.encode(bytes).maskedSuffix() +} + /** Read a [ByteString] instance field by name via reflection. */ private fun MessageLite.getByteField(name: String): ByteArray? { return try { diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/utils/logging/FormatProtoTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/utils/logging/FormatProtoTest.kt new file mode 100644 index 0000000000..e0665a409f --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/utils/logging/FormatProtoTest.kt @@ -0,0 +1,56 @@ +package com.getcode.opencode.utils.logging + +import com.codeinc.flipcash.gen.common.v1.Common +import com.codeinc.flipcash.gen.paymentrequest.v1.PaymentrequestService.GetPaymentRequestResponse +import com.codeinc.opencode.gen.common.v1.Model +import com.getcode.utils.LiveSecrets +import com.getcode.vendor.Base58 +import com.google.protobuf.ByteString +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class FormatProtoTest { + + private val idBytes = ByteArray(32) { (it + 1).toByte() } + private val idBase58 = Base58.encode(idBytes) + + @AfterTest + fun tearDown() { + LiveSecrets.release(idBase58) + } + + @Test + fun `payment_intent_id in a GetPaymentRequest response is logged as its last six characters`() { + val response = GetPaymentRequestResponse.newBuilder() + .setResult(GetPaymentRequestResponse.Result.DENIED) + .setPaymentIntentId(Common.IntentId.newBuilder().setValue(ByteString.copyFrom(idBytes))) + .build() + + val line = formatProto(response) + + assertContains(line, "paymentIntentId=…${idBase58.takeLast(6)}") + assertFalse(idBase58 in line, "full id leaked: $line") + // The rest of the body is still logged (OK is the zero value, which the formatter skips). + assertContains(line, "result=") + } + + @Test + fun `an intent id held in LiveSecrets is logged as its last six characters`() { + val intentId = Model.IntentId.newBuilder().setValue(ByteString.copyFrom(idBytes)).build() + LiveSecrets.hold(idBase58) + + assertEquals("…${idBase58.takeLast(6)}", formatProto(intentId)) + } + + @Test + fun `an intent id is logged in full once released`() { + val intentId = Model.IntentId.newBuilder().setValue(ByteString.copyFrom(idBytes)).build() + LiveSecrets.hold(idBase58) + LiveSecrets.release(idBase58) + + assertEquals(idBase58, formatProto(intentId)) + } +}