Prepare 0.9.6-SNAPSHOT with DH input validation and distinct signer checks

This commit is contained in:
sergeych committed 2026-10-21 23:17:13 +04:00
1 parent 66254f8fa6
commit 70d7a13fc3
7 files changed
+175 -7

No files matched your search

+15
View File
@@ -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.
+23
View File
@@ -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.
+1 -1
View File
@@ -20,7 +20,7 @@ plugins {
} }
group = "net.sergeych" group = "net.sergeych"
version = "0.9.4-SNAPSHOT" version = "0.9.6-SNAPSHOT"
repositories { repositories {
mavenCentral() mavenCentral()
@@ -56,7 +56,8 @@ import net.sergeych.crypto2.Multikey.Companion.someOfMultikeys
sealed class Multikey { 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<VerifyingPublicKey>): Boolean abstract fun check(keys: Iterable<VerifyingPublicKey>): Boolean
@@ -96,8 +97,9 @@ sealed class Multikey {
) : Multikey() { ) : Multikey() {
override fun check(keys: Iterable<VerifyingPublicKey>): Boolean { override fun check(keys: Iterable<VerifyingPublicKey>): Boolean {
var matches = 0 var matches = 0
val matchedKeys = mutableSetOf<VerifyingPublicKey>()
for (signer in keys) { for (signer in keys) {
if (signer in validKeys) { if (signer in validKeys && matchedKeys.add(signer)) {
if (++matches >= requiredMinimum) return true if (++matches >= requiredMinimum) return true
} }
} }
@@ -195,17 +195,28 @@ class SafeKeyExchange private constructor(
* Create asymmetric [SessionKey] to encrypt the session. It can't decrypt what is encrypted by it * 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. * but can decrypt what was encrypted with the [serverSessionKey] on the another side.
*/ */
fun clientSessionKey(serverPublicKey: PublicKey): SessionKey = fun clientSessionKey(serverPublicKey: PublicKey): SessionKey {
KeyExchange.clientSessionKeys(pair.publicKey, pair.secretKey, serverPublicKey.keyBytes) 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) } .let { SessionKey(SymmetricKey(it.sendKey), SymmetricKey(it.receiveKey), isClient = true) }
}
/** /**
* Create an asymmetric [SessionKey] instance to work with [clientSessionKey] on the other side. * Create an asymmetric [SessionKey] instance to work with [clientSessionKey] on the other side.
* Note that asymmetric means it can't decrypt what it encrypted. * Note that asymmetric means it can't decrypt what it encrypted.
*/ */
fun serverSessionKey(clientPublicKey: PublicKey): SessionKey = fun serverSessionKey(clientPublicKey: PublicKey): SessionKey {
KeyExchange.serverSessionKeys(pair.publicKey, pair.secretKey, clientPublicKey.keyBytes) requirePeerKeySize(clientPublicKey)
return KeyExchange.serverSessionKeys(pair.publicKey, pair.secretKey, clientPublicKey.keyBytes)
.let { SessionKey(SymmetricKey(it.sendKey), SymmetricKey(it.receiveKey), isClient = false) } .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"
}
}
} }
@@ -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<SealedBox>(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<SealedBox>((original + b).encoded.asByteArray())
assertTrue(authorized.isSealedBy(policy))
}
}
@@ -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<IllegalArgumentException> { SafeKeyExchange().clientSessionKey(peer) }
assertFailsWith<IllegalArgumentException> { 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
}
}