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"
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user