diff --git a/RELEASE-0.9.6-SNAPSHOT.md b/RELEASE-0.9.6-SNAPSHOT.md new file mode 100644 index 0000000..89002f8 --- /dev/null +++ b/RELEASE-0.9.6-SNAPSHOT.md @@ -0,0 +1,15 @@ +# crypto2 0.9.6-SNAPSHOT + +SafeKeyExchange now rejects peer public keys whose encoded length is not exactly +32 bytes before calling native bindings. Low-order DH rejection remains delegated +to libsodium crypto_kx; its failure already propagates through the Kotlin bindings. +Regression tests cover low-order inputs (including nonzero inputs and high-bit +aliases), malformed lengths, and valid bidirectional exchange. + +This snapshot retains the distinct-signer threshold fix described in +[SECURITY-0.9.5.md](SECURITY-0.9.5.md). Wire formats are unchanged. + +Validation: all 67 JVM tests and all 67 Linux x64 Native tests passed. The three +focused JavaScript/Node tests fail during initialization with the previously +documented `_sodium_init is not a function` loader error, before exercising DH. +Other platform runtimes were not qualified. This snapshot does not fix the JS loader. diff --git a/SECURITY-0.9.5.md b/SECURITY-0.9.5.md new file mode 100644 index 0000000..94d7162 --- /dev/null +++ b/SECURITY-0.9.5.md @@ -0,0 +1,23 @@ +# crypto2 0.9.5 security fix + +`Multikey.Keys.check` now counts distinct authorized signer keys. Earlier versions +could count repeated copies of one allowed key toward an N-of-M threshold. +A serialized `SealedBox` containing repeated copies of a valid seal could therefore +pass threshold authorization without the required distinct signers. Signature +verification itself was not forged. Policies built with `someOf` or `allOf`, +including these thresholds inside nested expressions, were affected. + +Upgrade consumers to 0.9.5. Wire formats and public APIs are unchanged. Repeated +seals remain readable but cannot supply extra votes. Boolean expression semantics +(including intentionally overlapping branches) are unchanged. + +Regression coverage includes exhaustive subsets for one through four required +keys, duplicate and unauthorized signers, nested policies, and serialized seal +repetition with valid distinct-signature controls. + +Release qualification: all 64 JVM tests and all 64 Linux x64 native tests pass. +The JavaScript/Node suite cannot complete: 61 of 64 tests fail at the existing +libsodium loader (`_sodium_init is not a function`). An unchanged-source signing +test reproduces that same failure; this release does not fix the JS loader. +All 12 configured publications were uploaded to Maven Local and the private Maven +repository; SHA-256 comparison of 53 downloaded release files matched Maven Local. diff --git a/build.gradle.kts b/build.gradle.kts index 37edab0..fd3637b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,7 +20,7 @@ plugins { } group = "net.sergeych" -version = "0.9.4-SNAPSHOT" +version = "0.9.6-SNAPSHOT" repositories { mavenCentral() diff --git a/src/commonMain/kotlin/net/sergeych/crypto2/Multikey.kt b/src/commonMain/kotlin/net/sergeych/crypto2/Multikey.kt index 8e55f1c..4d9f157 100644 --- a/src/commonMain/kotlin/net/sergeych/crypto2/Multikey.kt +++ b/src/commonMain/kotlin/net/sergeych/crypto2/Multikey.kt @@ -56,7 +56,8 @@ import net.sergeych.crypto2.Multikey.Companion.someOfMultikeys sealed class Multikey { /** - * Check that the [keys] satisfy the condition of this instance + * Check that the [keys] satisfy the condition of this instance. Repeated signer keys + * count only once toward each key threshold. */ abstract fun check(keys: Iterable): Boolean @@ -96,8 +97,9 @@ sealed class Multikey { ) : Multikey() { override fun check(keys: Iterable): Boolean { var matches = 0 + val matchedKeys = mutableSetOf() for (signer in keys) { - if (signer in validKeys) { + if (signer in validKeys && matchedKeys.add(signer)) { if (++matches >= requiredMinimum) return true } } diff --git a/src/commonMain/kotlin/net/sergeych/crypto2/SafeKeyExchange.kt b/src/commonMain/kotlin/net/sergeych/crypto2/SafeKeyExchange.kt index c84b528..54fe6e0 100644 --- a/src/commonMain/kotlin/net/sergeych/crypto2/SafeKeyExchange.kt +++ b/src/commonMain/kotlin/net/sergeych/crypto2/SafeKeyExchange.kt @@ -195,17 +195,28 @@ class SafeKeyExchange private constructor( * Create asymmetric [SessionKey] to encrypt the session. It can't decrypt what is encrypted by it * but can decrypt what was encrypted with the [serverSessionKey] on the another side. */ - fun clientSessionKey(serverPublicKey: PublicKey): SessionKey = - KeyExchange.clientSessionKeys(pair.publicKey, pair.secretKey, serverPublicKey.keyBytes) + fun clientSessionKey(serverPublicKey: PublicKey): SessionKey { + requirePeerKeySize(serverPublicKey) + // crypto_kx rejects low-order inputs before deriving keys; the binding propagates failure. + return KeyExchange.clientSessionKeys(pair.publicKey, pair.secretKey, serverPublicKey.keyBytes) .let { SessionKey(SymmetricKey(it.sendKey), SymmetricKey(it.receiveKey), isClient = true) } + } /** * Create an asymmetric [SessionKey] instance to work with [clientSessionKey] on the other side. * Note that asymmetric means it can't decrypt what it encrypted. */ - fun serverSessionKey(clientPublicKey: PublicKey): SessionKey = - KeyExchange.serverSessionKeys(pair.publicKey, pair.secretKey, clientPublicKey.keyBytes) + fun serverSessionKey(clientPublicKey: PublicKey): SessionKey { + requirePeerKeySize(clientPublicKey) + return KeyExchange.serverSessionKeys(pair.publicKey, pair.secretKey, clientPublicKey.keyBytes) .let { SessionKey(SymmetricKey(it.sendKey), SymmetricKey(it.receiveKey), isClient = false) } + } + + private fun requirePeerKeySize(peer: PublicKey) { + require(peer.keyBytes.size == crypto_kx_PUBLICKEYBYTES) { + "Peer DH public key must be $crypto_kx_PUBLICKEYBYTES bytes" + } + } } diff --git a/src/commonTest/kotlin/DuplicateSignerTest.kt b/src/commonTest/kotlin/DuplicateSignerTest.kt new file mode 100644 index 0000000..57f14e1 --- /dev/null +++ b/src/commonTest/kotlin/DuplicateSignerTest.kt @@ -0,0 +1,57 @@ +import kotlinx.coroutines.test.runTest +import net.sergeych.bipack.BipackDecoder +import net.sergeych.crypto2.* +import kotlin.test.* + +class DuplicateSignerTest { + @Test fun thresholdsCountDistinctAuthorizedKeys() = runTest { + initCrypto() + val keys = List(4) { SigningSecretKey.new().verifyingKey } + val outsider = SigningSecretKey.new().verifyingKey + // Exhaust all subsets of authorized keys, thresholds, and repeated inputs. + for (threshold in 1..keys.size) { + val policy = Multikey.someOf(threshold, keys) + for (mask in 0 until (1 shl keys.size)) { + val selected = keys.filterIndexed { index, _ -> mask and (1 shl index) != 0 } + for (copies in 1..5) { + val signers = selected.flatMap { key -> List(copies) { key } } + List(5) { outsider } + assertEquals(selected.size >= threshold, policy.check(signers)) + assertEquals(selected.size >= threshold, policy.check(signers.reversed())) + } + } + } + assertFalse(Multikey.allOf(keys).check(List(10) { keys.first() })) + assertTrue(Multikey.allOf(keys).check(keys + keys)) + } + + @Test fun nestedPoliciesKeepBooleanSemantics() = runTest { + initCrypto() + val a = SigningSecretKey.new().verifyingKey + val b = SigningSecretKey.new().verifyingKey + val c = SigningSecretKey.new().verifyingKey + val d = SigningSecretKey.new().verifyingKey + val threshold = Multikey.someOf(2, a, b) + val policy = (threshold and c) or d + assertFalse(policy.check(a, a, c)) + assertTrue(policy.check(a, a, b, c)) + assertTrue(policy.check(d)) + // Overlapping branches remain logical expressions, not globally consumed signatures. + assertTrue(((a or b) and (a or c)).check(a)) + } + + @Test fun repeatedValidSealCannotSatisfyThresholdAfterDecoding() = runTest { + initCrypto() + val a = SigningSecretKey.new() + val b = SigningSecretKey.new() + val policy = Multikey.someOf(2, a.verifyingKey, b.verifyingKey) + val original = SealedBox.create("threshold regression".encodeToByteArray().toUByteArray(), a) + val repeated = SealedBox(original.message, List(3) { original.seals.single() }) + val decoded = BipackDecoder.decode(repeated.encoded.asByteArray()) + assertEquals(3, decoded.seals.size) + assertEquals(1, decoded.signedByKeys.toSet().size) + assertFalse(decoded.isSealedBy(policy)) + assertTrue(decoded.isSealedBy(Multikey(a))) + val authorized = BipackDecoder.decode((original + b).encoded.asByteArray()) + assertTrue(authorized.isSealedBy(policy)) + } +} diff --git a/src/commonTest/kotlin/KeyExchangeValidationTest.kt b/src/commonTest/kotlin/KeyExchangeValidationTest.kt new file mode 100644 index 0000000..64165ce --- /dev/null +++ b/src/commonTest/kotlin/KeyExchangeValidationTest.kt @@ -0,0 +1,60 @@ +import kotlinx.coroutines.test.runTest +import net.sergeych.crypto2.SafeKeyExchange +import net.sergeych.crypto2.initCrypto +import kotlin.test.* + +class KeyExchangeValidationTest { + @Test + fun rejectsLowOrderPeerKeysOnBothSides() = runTest { + initCrypto() + // u = 0, 1, p-1, p, p+1, including aliases with the ignored high bit set. + val points = listOf( + UByteArray(32), + UByteArray(32).also { it[0] = 1u }, + fieldEncoding(0xecu), + fieldEncoding(0xedu), + fieldEncoding(0xeeu), + ) + for ((index, point) in points.withIndex()) { + for (highBit in listOf(false, true)) { + val bytes = point.copyOf() + if (highBit) bytes[31] = (bytes[31].toInt() or 0x80).toUByte() + val peer = SafeKeyExchange.PublicKey(bytes) + assertFails("client accepted low-order point $index, highBit=$highBit") { + SafeKeyExchange().clientSessionKey(peer) + } + assertFails("server accepted low-order point $index, highBit=$highBit") { + SafeKeyExchange().serverSessionKey(peer) + } + } + } + } + + @Test + fun rejectsWrongLengthBeforeCallingNativeCode() = runTest { + initCrypto() + val valid = SafeKeyExchange().publicKey.keyBytes + for (size in listOf(0, 1, 31, 33, 64)) { + val peer = SafeKeyExchange.PublicKey(valid.copyOf(size)) + assertFailsWith { SafeKeyExchange().clientSessionKey(peer) } + assertFailsWith { SafeKeyExchange().serverSessionKey(peer) } + } + } + + @Test + fun validPeersStillAgreeInBothDirections() = runTest { + initCrypto() + val client = SafeKeyExchange() + val server = SafeKeyExchange() + val c = client.clientSessionKey(server.publicKey) + val s = server.serverSessionKey(client.publicKey) + assertContentEquals(c.sessionTag, s.sessionTag) + assertEquals("client", s.decryptString(c.encrypt("client"))) + assertEquals("server", c.decryptString(s.encrypt("server"))) + } + + private fun fieldEncoding(first: UByte) = UByteArray(32) { 0xffu }.also { + it[0] = first + it[31] = 0x7fu + } +}