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..ac72582b08 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,9 @@ internal actual constructor( actual override fun equals(other: Any?) = commonEquals(other) + actual fun equals(other: ByteString, constantTime: Boolean) = + if (constantTime) commonEqualsConstantTime(other) else this == 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..20730e1c46 100644 --- a/okio/src/commonMain/kotlin/okio/ByteString.kt +++ b/okio/src/commonMain/kotlin/okio/ByteString.kt @@ -170,6 +170,15 @@ internal constructor(data: ByteArray) : Comparable { override fun equals(other: Any?): Boolean + /** + * 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 + 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..aa8845afb6 100644 --- a/okio/src/commonTest/kotlin/okio/ByteStringTest.kt +++ b/okio/src/commonTest/kotlin/okio/ByteStringTest.kt @@ -201,6 +201,31 @@ 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)) + } + + @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 2b9f723ee5..02a2462f83 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,9 @@ internal actual constructor( actual override fun equals(other: Any?) = commonEquals(other) + actual fun equals(other: ByteString, constantTime: Boolean) = + if (constantTime) commonEqualsConstantTime(other) else this == other + actual override fun hashCode() = commonHashCode() actual override fun compareTo(other: ByteString) = commonCompareTo(other) diff --git a/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt b/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt new file mode 100644 index 0000000000..13ee18f06f --- /dev/null +++ b/okio/src/jvmTest/kotlin/okio/ConstantTimeEqualsTimingTest.kt @@ -0,0 +1,82 @@ +/* + * 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 okio.ByteString.Companion.toByteString +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Statistical timing test for [ByteString.equals] with [constantTime]=true. + * + * Given two very large byte strings: + * - 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 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( + "CT(match)=$ctMatch ns and CT(mismatch)=$ctMismatch ns differ by ${ctRatio}x (expected <3x)", + ctRatio < 3.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)", + normalMismatch * 50L < ctMismatch, + ) + } + + 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] + } +} diff --git a/okio/src/nonAppleMain/kotlin/okio/ByteString.kt b/okio/src/nonAppleMain/kotlin/okio/ByteString.kt index 0353be7c19..fb0a04d5f8 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,9 @@ internal actual constructor( actual override fun equals(other: Any?) = commonEquals(other) + actual fun equals(other: ByteString, constantTime: Boolean) = + if (constantTime) commonEqualsConstantTime(other) else this == other + actual override fun hashCode() = commonHashCode() actual override fun compareTo(other: ByteString) = commonCompareTo(other)