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
31 changes: 31 additions & 0 deletions libs/logging/src/main/kotlin/com/getcode/utils/LiveSecrets.kt
Original file line number Diff line number Diff line change
@@ -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<String>()

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)}"
34 changes: 34 additions & 0 deletions libs/logging/src/test/kotlin/com/getcode/utils/LiveSecretsTest.kt
Original file line number Diff line number Diff line change
@@ -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))
}
}
2 changes: 2 additions & 0 deletions services/opencode/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -101,9 +103,10 @@ class IntentExecutor(
): Result<IntentType> = 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)
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand Down Expand Up @@ -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('_')
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading