Prepare 0.9.6-SNAPSHOT with DH input validation and distinct signer checks
This commit is contained in:
1 parent
66254f8fa6
commit
70d7a13fc3
7 files changed
+175
-7
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -20,7 +20,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "net.sergeych"
|
||||
version = "0.9.4-SNAPSHOT"
|
||||
version = "0.9.6-SNAPSHOT"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
|
||||
@@ -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<VerifyingPublicKey>): Boolean
|
||||
|
||||
@@ -96,8 +97,9 @@ sealed class Multikey {
|
||||
) : Multikey() {
|
||||
override fun check(keys: Iterable<VerifyingPublicKey>): Boolean {
|
||||
var matches = 0
|
||||
val matchedKeys = mutableSetOf<VerifyingPublicKey>()
|
||||
for (signer in keys) {
|
||||
if (signer in validKeys) {
|
||||
if (signer in validKeys && matchedKeys.add(signer)) {
|
||||
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
|
||||
* 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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user