From dd9f61421ac6705449b91993781ecd4d3104e8f1 Mon Sep 17 00:00:00 2001 From: SAY-5 Date: Sun, 24 May 2026 15:33:16 -0700 Subject: [PATCH 1/6] feat: add ByteString.equals(other, constantTime) overload --- okio/api/okio.api | 1 + okio/src/appleMain/kotlin/okio/ByteString.kt | 3 +++ okio/src/commonMain/kotlin/okio/ByteString.kt | 8 ++++++++ .../commonMain/kotlin/okio/internal/ByteString.kt | 10 ++++++++++ okio/src/commonTest/kotlin/okio/ByteStringTest.kt | 15 +++++++++++++++ okio/src/jvmMain/kotlin/okio/ByteString.kt | 3 +++ okio/src/nonAppleMain/kotlin/okio/ByteString.kt | 3 +++ 7 files changed, 43 insertions(+) diff --git a/okio/api/okio.api b/okio/api/okio.api index e4ad369c62..0cd19670f8 100644 --- a/okio/api/okio.api +++ b/okio/api/okio.api @@ -314,6 +314,7 @@ public class okio/ByteString : java/io/Serializable, java/lang/Comparable { public final fun endsWith (Lokio/ByteString;)Z public final fun endsWith ([B)Z public fun equals (Ljava/lang/Object;)Z + public final fun equals (Lokio/ByteString;Z)Z public final fun getByte (I)B public fun hashCode ()I public fun hex ()Ljava/lang/String; diff --git a/okio/src/appleMain/kotlin/okio/ByteString.kt b/okio/src/appleMain/kotlin/okio/ByteString.kt index 988138ccfc..ff733d96bd 100644 --- a/okio/src/appleMain/kotlin/okio/ByteString.kt +++ b/okio/src/appleMain/kotlin/okio/ByteString.kt @@ -35,6 +35,7 @@ import okio.internal.commonDecodeHex import okio.internal.commonEncodeUtf8 import okio.internal.commonEndsWith import okio.internal.commonEquals +import okio.internal.commonEqualsConstantTime import okio.internal.commonGetByte import okio.internal.commonGetSize import okio.internal.commonHashCode @@ -168,6 +169,8 @@ internal actual constructor( actual override fun equals(other: Any?) = commonEquals(other) + actual fun equals(other: ByteString, constantTime: Boolean) = commonEqualsConstantTime(other) + actual override fun hashCode() = commonHashCode() actual override fun compareTo(other: ByteString) = commonCompareTo(other) diff --git a/okio/src/commonMain/kotlin/okio/ByteString.kt b/okio/src/commonMain/kotlin/okio/ByteString.kt index d4c1e5b40b..262a19aef7 100644 --- a/okio/src/commonMain/kotlin/okio/ByteString.kt +++ b/okio/src/commonMain/kotlin/okio/ByteString.kt @@ -170,6 +170,14 @@ internal constructor(data: ByteArray) : Comparable { override fun equals(other: Any?): Boolean + /** + * Returns true if the bytes of this equal the bytes of `other`. Unlike [equals], this always + * inspects every byte and does not short-circuit on the first mismatch, so its running time does + * not depend on where the byte strings differ. Use this for timing-safe comparison of secrets + * like hashes or message authentication codes. + */ + fun equals(other: ByteString, constantTime: Boolean): Boolean + override fun hashCode(): Int override fun compareTo(other: ByteString): Int diff --git a/okio/src/commonMain/kotlin/okio/internal/ByteString.kt b/okio/src/commonMain/kotlin/okio/internal/ByteString.kt index 99ac7ab199..8966bcd754 100644 --- a/okio/src/commonMain/kotlin/okio/internal/ByteString.kt +++ b/okio/src/commonMain/kotlin/okio/internal/ByteString.kt @@ -234,6 +234,16 @@ internal inline fun ByteString.commonEquals(other: Any?): Boolean { } } +internal fun ByteString.commonEqualsConstantTime(other: ByteString): Boolean { + if (other === this) return true + if (other.size != size) return false + var result = 0 + for (i in 0 until size) { + result = result or (this[i].toInt() xor other[i].toInt()) + } + return result == 0 +} + @Suppress("NOTHING_TO_INLINE") internal inline fun ByteString.commonHashCode(): Int { val result = hashCode diff --git a/okio/src/commonTest/kotlin/okio/ByteStringTest.kt b/okio/src/commonTest/kotlin/okio/ByteStringTest.kt index e97af18f68..a1609c8f69 100644 --- a/okio/src/commonTest/kotlin/okio/ByteStringTest.kt +++ b/okio/src/commonTest/kotlin/okio/ByteStringTest.kt @@ -201,6 +201,21 @@ class ByteStringTest( assertEquals(ByteString.of(), factory.decodeHex("")) } + @Test fun equalsConstantTime() { + val byteString = factory.decodeHex("000102") + assertTrue(byteString.equals(byteString, constantTime = true)) + assertTrue(byteString.equals("000102".decodeHex(), constantTime = true)) + assertFalse(byteString.equals("800102".decodeHex(), constantTime = true)) + assertFalse(byteString.equals("000180".decodeHex(), constantTime = true)) + assertFalse(byteString.equals("0001".decodeHex(), constantTime = true)) + assertFalse(byteString.equals("00010203".decodeHex(), constantTime = true)) + } + + @Test fun equalsConstantTimeEmptyTest() { + assertTrue(factory.decodeHex("").equals(ByteString.EMPTY, constantTime = true)) + assertFalse(factory.decodeHex("").equals("00".decodeHex(), constantTime = true)) + } + private val bronzeHorseman = "На берегу пустынных волн" @Test fun utf8() { diff --git a/okio/src/jvmMain/kotlin/okio/ByteString.kt b/okio/src/jvmMain/kotlin/okio/ByteString.kt index 2b9f723ee5..9811f386be 100644 --- a/okio/src/jvmMain/kotlin/okio/ByteString.kt +++ b/okio/src/jvmMain/kotlin/okio/ByteString.kt @@ -37,6 +37,7 @@ import okio.internal.commonDecodeHex import okio.internal.commonEncodeUtf8 import okio.internal.commonEndsWith import okio.internal.commonEquals +import okio.internal.commonEqualsConstantTime import okio.internal.commonGetByte import okio.internal.commonGetSize import okio.internal.commonHashCode @@ -189,6 +190,8 @@ internal actual constructor( actual override fun equals(other: Any?) = commonEquals(other) + actual fun equals(other: ByteString, constantTime: Boolean) = commonEqualsConstantTime(other) + actual override fun hashCode() = commonHashCode() actual override fun compareTo(other: ByteString) = commonCompareTo(other) diff --git a/okio/src/nonAppleMain/kotlin/okio/ByteString.kt b/okio/src/nonAppleMain/kotlin/okio/ByteString.kt index 0353be7c19..1ab348b4cd 100644 --- a/okio/src/nonAppleMain/kotlin/okio/ByteString.kt +++ b/okio/src/nonAppleMain/kotlin/okio/ByteString.kt @@ -31,6 +31,7 @@ import okio.internal.commonDecodeHex import okio.internal.commonEncodeUtf8 import okio.internal.commonEndsWith import okio.internal.commonEquals +import okio.internal.commonEqualsConstantTime import okio.internal.commonGetByte import okio.internal.commonGetSize import okio.internal.commonHashCode @@ -162,6 +163,8 @@ internal actual constructor( actual override fun equals(other: Any?) = commonEquals(other) + actual fun equals(other: ByteString, constantTime: Boolean) = commonEqualsConstantTime(other) + actual override fun hashCode() = commonHashCode() actual override fun compareTo(other: ByteString) = commonCompareTo(other) From d9deb8cb6682c6ca6cbe86c1274779912d28f32d Mon Sep 17 00:00:00 2001 From: say Date: Mon, 25 May 2026 14:38:06 -0700 Subject: [PATCH 2/6] test(jvm): add statistical timing test for constantTime equals Signed-off-by: say --- .../okio/ConstantTimeEqualsTimingTest.kt | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt diff --git a/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt b/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt new file mode 100644 index 0000000000..ebbe6100be --- /dev/null +++ b/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2026 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okio + +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Statistical timing test for [ByteString.equals] with [constantTime]=true. + * + * Given two very large byte strings: + * - When equal, constant-time equals takes the same time as normal equals (both scan all bytes). + * - When not equal, constant-time equals takes the same time as when equal (no short-circuit). + * - When not equal, normal equals takes statistically significantly less time than when equal + * (short-circuits at the first differing byte). + */ +class ConstantTimeEqualsTimingTest { + + @Test + fun constantTimeEqualsDoesNotShortCircuit() { + val n = 1_000_000 + val aBytes = ByteArray(n) { 0 } + val bBytes = ByteArray(n) { 0 } // identical to a + val cBytes = ByteArray(n) { 0 }.also { it[0] = 1 } // differs at byte 0 + + val a = aBytes.toByteString() + val b = bBytes.toByteString() + val c = cBytes.toByteString() + + // Warm up the JIT. + repeat(200) { + a.equals(b, constantTime = true) + a.equals(c, constantTime = true) + a == b + a == c + } + + val iterations = 500 + + val ctMatch = median(iterations) { a.equals(b, constantTime = true) } + val ctMismatch = median(iterations) { a.equals(c, constantTime = true) } + val normalMatch = median(iterations) { a == b } + val normalMismatch = median(iterations) { a == c } + + // CT(match) and CT(mismatch) should be within 3x of each other: neither short-circuits. + val ctRatio = if (ctMatch > ctMismatch) ctMatch.toDouble() / ctMismatch else ctMismatch.toDouble() / ctMatch + assertTrue(ctRatio < 3.0, + "CT(match)=$ctMatch ns and CT(mismatch)=$ctMismatch ns differ by ${ctRatio}x (expected <3x)") + + // CT(match) and normal(match) should be in the same ballpark: both scan all n bytes. + val matchRatio = if (ctMatch > normalMatch) ctMatch.toDouble() / normalMatch else normalMatch.toDouble() / ctMatch + assertTrue(matchRatio < 5.0, + "CT(match)=$ctMatch ns and normal(match)=$normalMatch ns differ by ${matchRatio}x (expected <5x)") + + // normal(mismatch) must be significantly faster than CT(mismatch): normal short-circuits at byte 0. + assertTrue(normalMismatch * 50L < ctMismatch, + "normal(mismatch)=$normalMismatch ns should be <2% of CT(mismatch)=$ctMismatch ns (short-circuit at byte 0)") + } + + private inline fun median(n: Int, block: () -> Unit): Long { + val times = LongArray(n) + repeat(n) { i -> + val t0 = System.nanoTime() + block() + times[i] = System.nanoTime() - t0 + } + times.sort() + return times[n / 2] + } +} From 94451415899d8345409df29f1a167773d98ea4cc Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Mon, 25 May 2026 20:45:37 -0700 Subject: [PATCH 3/6] test(jvm): use org.junit imports and ktlint-friendly assertTrue form Signed-off-by: Sai Asish Y --- .../okio/ConstantTimeEqualsTimingTest.kt | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt b/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt index ebbe6100be..ed8feca878 100644 --- a/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt +++ b/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt @@ -15,8 +15,8 @@ */ package okio -import kotlin.test.Test -import kotlin.test.assertTrue +import org.junit.Assert.assertTrue +import org.junit.Test /** * Statistical timing test for [ByteString.equals] with [constantTime]=true. @@ -33,8 +33,8 @@ class ConstantTimeEqualsTimingTest { fun constantTimeEqualsDoesNotShortCircuit() { val n = 1_000_000 val aBytes = ByteArray(n) { 0 } - val bBytes = ByteArray(n) { 0 } // identical to a - val cBytes = ByteArray(n) { 0 }.also { it[0] = 1 } // differs at byte 0 + val bBytes = ByteArray(n) { 0 } // identical to a + val cBytes = ByteArray(n) { 0 }.also { it[0] = 1 } // differs at byte 0 val a = aBytes.toByteString() val b = bBytes.toByteString() @@ -56,18 +56,26 @@ class ConstantTimeEqualsTimingTest { val normalMismatch = median(iterations) { a == c } // CT(match) and CT(mismatch) should be within 3x of each other: neither short-circuits. - val ctRatio = if (ctMatch > ctMismatch) ctMatch.toDouble() / ctMismatch else ctMismatch.toDouble() / ctMatch - assertTrue(ctRatio < 3.0, - "CT(match)=$ctMatch ns and CT(mismatch)=$ctMismatch ns differ by ${ctRatio}x (expected <3x)") + val ctRatio = + if (ctMatch > ctMismatch) ctMatch.toDouble() / ctMismatch else ctMismatch.toDouble() / ctMatch + assertTrue( + "CT(match)=$ctMatch ns and CT(mismatch)=$ctMismatch ns differ by ${ctRatio}x (expected <3x)", + ctRatio < 3.0, + ) // CT(match) and normal(match) should be in the same ballpark: both scan all n bytes. - val matchRatio = if (ctMatch > normalMatch) ctMatch.toDouble() / normalMatch else normalMatch.toDouble() / ctMatch - assertTrue(matchRatio < 5.0, - "CT(match)=$ctMatch ns and normal(match)=$normalMatch ns differ by ${matchRatio}x (expected <5x)") + val matchRatio = + if (ctMatch > normalMatch) ctMatch.toDouble() / normalMatch else normalMatch.toDouble() / ctMatch + assertTrue( + "CT(match)=$ctMatch ns and normal(match)=$normalMatch ns differ by ${matchRatio}x (expected <5x)", + matchRatio < 5.0, + ) // normal(mismatch) must be significantly faster than CT(mismatch): normal short-circuits at byte 0. - assertTrue(normalMismatch * 50L < ctMismatch, - "normal(mismatch)=$normalMismatch ns should be <2% of CT(mismatch)=$ctMismatch ns (short-circuit at byte 0)") + assertTrue( + "normal(mismatch)=$normalMismatch ns should be <2% of CT(mismatch)=$ctMismatch ns (short-circuit at byte 0)", + normalMismatch * 50L < ctMismatch, + ) } private inline fun median(n: Int, block: () -> Unit): Long { From 4bfb638efd859b442e7e80efca0d121de1ce9357 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Mon, 25 May 2026 22:32:09 -0700 Subject: [PATCH 4/6] fix(test): add missing toByteString import in ConstantTimeEqualsTimingTest Signed-off-by: Sai Asish Y --- okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt b/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt index ed8feca878..d21e35e797 100644 --- a/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt +++ b/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt @@ -15,6 +15,7 @@ */ package okio +import okio.ByteString.Companion.toByteString import org.junit.Assert.assertTrue import org.junit.Test From a59f221bd56b9388bd4607934ebceae9193d7899 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Tue, 26 May 2026 11:57:59 -0700 Subject: [PATCH 5/6] fix(timing-test): drop flaky CT-vs-normal-match ratio assertion Signed-off-by: Sai Asish Y --- .../kotlin/okio/ConstantTimeEqualsTimingTest.kt | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt b/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt index d21e35e797..13ee18f06f 100644 --- a/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt +++ b/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt @@ -23,7 +23,6 @@ import org.junit.Test * Statistical timing test for [ByteString.equals] with [constantTime]=true. * * Given two very large byte strings: - * - When equal, constant-time equals takes the same time as normal equals (both scan all bytes). * - When not equal, constant-time equals takes the same time as when equal (no short-circuit). * - When not equal, normal equals takes statistically significantly less time than when equal * (short-circuits at the first differing byte). @@ -53,7 +52,6 @@ class ConstantTimeEqualsTimingTest { val ctMatch = median(iterations) { a.equals(b, constantTime = true) } val ctMismatch = median(iterations) { a.equals(c, constantTime = true) } - val normalMatch = median(iterations) { a == b } val normalMismatch = median(iterations) { a == c } // CT(match) and CT(mismatch) should be within 3x of each other: neither short-circuits. @@ -64,14 +62,6 @@ class ConstantTimeEqualsTimingTest { ctRatio < 3.0, ) - // CT(match) and normal(match) should be in the same ballpark: both scan all n bytes. - val matchRatio = - if (ctMatch > normalMatch) ctMatch.toDouble() / normalMatch else normalMatch.toDouble() / ctMatch - assertTrue( - "CT(match)=$ctMatch ns and normal(match)=$normalMatch ns differ by ${matchRatio}x (expected <5x)", - matchRatio < 5.0, - ) - // normal(mismatch) must be significantly faster than CT(mismatch): normal short-circuits at byte 0. assertTrue( "normal(mismatch)=$normalMismatch ns should be <2% of CT(mismatch)=$ctMismatch ns (short-circuit at byte 0)", From a4e8c402c0efb70f92d11664e4d8b391ae137e68 Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Tue, 9 Jun 2026 14:28:31 -0700 Subject: [PATCH 6/6] fix: dispatch to standard equals when constantTime is false Signed-off-by: Sai Asish Y --- okio/src/appleMain/kotlin/okio/ByteString.kt | 3 ++- okio/src/commonMain/kotlin/okio/ByteString.kt | 9 +++++---- okio/src/commonTest/kotlin/okio/ByteStringTest.kt | 10 ++++++++++ okio/src/jvmMain/kotlin/okio/ByteString.kt | 3 ++- okio/src/nonAppleMain/kotlin/okio/ByteString.kt | 3 ++- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/okio/src/appleMain/kotlin/okio/ByteString.kt b/okio/src/appleMain/kotlin/okio/ByteString.kt index ff733d96bd..ac72582b08 100644 --- a/okio/src/appleMain/kotlin/okio/ByteString.kt +++ b/okio/src/appleMain/kotlin/okio/ByteString.kt @@ -169,7 +169,8 @@ internal actual constructor( actual override fun equals(other: Any?) = commonEquals(other) - actual fun equals(other: ByteString, constantTime: Boolean) = commonEqualsConstantTime(other) + actual fun equals(other: ByteString, constantTime: Boolean) = + if (constantTime) commonEqualsConstantTime(other) else this == other actual override fun hashCode() = commonHashCode() diff --git a/okio/src/commonMain/kotlin/okio/ByteString.kt b/okio/src/commonMain/kotlin/okio/ByteString.kt index 262a19aef7..20730e1c46 100644 --- a/okio/src/commonMain/kotlin/okio/ByteString.kt +++ b/okio/src/commonMain/kotlin/okio/ByteString.kt @@ -171,10 +171,11 @@ internal constructor(data: ByteArray) : Comparable { override fun equals(other: Any?): Boolean /** - * Returns true if the bytes of this equal the bytes of `other`. Unlike [equals], this always - * inspects every byte and does not short-circuit on the first mismatch, so its running time does - * not depend on where the byte strings differ. Use this for timing-safe comparison of secrets - * like hashes or message authentication codes. + * Returns true if the bytes of this equal the bytes of `other`. If [constantTime] is true this + * always inspects every byte and does not short-circuit on the first mismatch, so its running + * time does not depend on where the byte strings differ. Use that for timing-safe comparison of + * secrets like hashes or message authentication codes. If [constantTime] is false this behaves + * like [equals] and may return as soon as a mismatch is found. */ fun equals(other: ByteString, constantTime: Boolean): Boolean diff --git a/okio/src/commonTest/kotlin/okio/ByteStringTest.kt b/okio/src/commonTest/kotlin/okio/ByteStringTest.kt index a1609c8f69..aa8845afb6 100644 --- a/okio/src/commonTest/kotlin/okio/ByteStringTest.kt +++ b/okio/src/commonTest/kotlin/okio/ByteStringTest.kt @@ -216,6 +216,16 @@ class ByteStringTest( assertFalse(factory.decodeHex("").equals("00".decodeHex(), constantTime = true)) } + @Test fun equalsNotConstantTime() { + val byteString = factory.decodeHex("000102") + assertTrue(byteString.equals(byteString, constantTime = false)) + assertTrue(byteString.equals("000102".decodeHex(), constantTime = false)) + assertFalse(byteString.equals("800102".decodeHex(), constantTime = false)) + assertFalse(byteString.equals("000180".decodeHex(), constantTime = false)) + assertFalse(byteString.equals("0001".decodeHex(), constantTime = false)) + assertFalse(byteString.equals("00010203".decodeHex(), constantTime = false)) + } + private val bronzeHorseman = "На берегу пустынных волн" @Test fun utf8() { diff --git a/okio/src/jvmMain/kotlin/okio/ByteString.kt b/okio/src/jvmMain/kotlin/okio/ByteString.kt index 9811f386be..02a2462f83 100644 --- a/okio/src/jvmMain/kotlin/okio/ByteString.kt +++ b/okio/src/jvmMain/kotlin/okio/ByteString.kt @@ -190,7 +190,8 @@ internal actual constructor( actual override fun equals(other: Any?) = commonEquals(other) - actual fun equals(other: ByteString, constantTime: Boolean) = commonEqualsConstantTime(other) + actual fun equals(other: ByteString, constantTime: Boolean) = + if (constantTime) commonEqualsConstantTime(other) else this == other actual override fun hashCode() = commonHashCode() diff --git a/okio/src/nonAppleMain/kotlin/okio/ByteString.kt b/okio/src/nonAppleMain/kotlin/okio/ByteString.kt index 1ab348b4cd..fb0a04d5f8 100644 --- a/okio/src/nonAppleMain/kotlin/okio/ByteString.kt +++ b/okio/src/nonAppleMain/kotlin/okio/ByteString.kt @@ -163,7 +163,8 @@ internal actual constructor( actual override fun equals(other: Any?) = commonEquals(other) - actual fun equals(other: ByteString, constantTime: Boolean) = commonEqualsConstantTime(other) + actual fun equals(other: ByteString, constantTime: Boolean) = + if (constantTime) commonEqualsConstantTime(other) else this == other actual override fun hashCode() = commonHashCode()