diff --git a/benchmark/src/main/scala/ApiBenchmark.scala b/benchmark/src/main/scala/ApiBenchmark.scala index 6fd2d02..1402a58 100644 --- a/benchmark/src/main/scala/ApiBenchmark.scala +++ b/benchmark/src/main/scala/ApiBenchmark.scala @@ -2,6 +2,7 @@ package bench import java.util.concurrent.TimeUnit import org.openjdk.jmh.annotations._ +import cats.effect.unsafe.implicits.global import com.ironcorelabs.recrypt diff --git a/benchmark/src/main/scala/Ed25519.scala b/benchmark/src/main/scala/Ed25519.scala index d224dab..383eabb 100644 --- a/benchmark/src/main/scala/Ed25519.scala +++ b/benchmark/src/main/scala/Ed25519.scala @@ -3,10 +3,9 @@ */ package bench -import org.abstractj.kalium.NaCl.Sodium.{ CRYPTO_SIGN_ED25519_PUBLICKEYBYTES, CRYPTO_SIGN_ED25519_BYTES, CRYPTO_SIGN_ED25519_SECRETKEYBYTES } //scalastyle:ignore -import org.abstractj.kalium.NaCl.sodium +import org.bouncycastle.crypto.params.{ Ed25519PrivateKeyParameters, Ed25519PublicKeyParameters } +import org.bouncycastle.crypto.signers.Ed25519Signer import scodec.bits.ByteVector -import jnr.ffi.byref.LongLongByReference /** * Functions and types for working with the ed25519 signature. @@ -19,23 +18,20 @@ object Ed25519 { //scalastyle:ignore override def toString: String = s"Ed25519.PublicKey as Base64: '${bytes.toBase64}'" } final object PublicKey { - final val Length = CRYPTO_SIGN_ED25519_PUBLICKEYBYTES + final val Length = 32 final val LengthLong = Length.toLong private[Ed25519] def create(bytes: ByteVector): PublicKey = new PublicKey(bytes) {} def unsafeFromBytes(b: ByteVector): PublicKey = fromBytes(b).getOrElse(throw new Exception(s"'$b' did not have $Length length.")) - /** - * Verifies the length of the byte vector satisfies the invariant detailed in NaCl - */ def fromBytes(bytes: ByteVector): Option[PublicKey] = Some(bytes) .filter(_.length == Length) .map(create) /** - * Create a value safely by just taking and padding bytes. This isn't ideal, but because of the - * interaction with recrypt and lack of sizing guarentees (a la refined) it's needed. + * Create a value by truncating-or-left-padding to the required length. This isn't ideal, but + * because of the interaction with recrypt and lack of sizing guarantees (a la refined) it's needed. */ def fromPaddedBytes(bytes: ByteVector): PublicKey = create(bytes @@ -44,31 +40,46 @@ object Ed25519 { //scalastyle:ignore } /** - * An Ed25519 Private Key, which always has the correct length, can be used in `verify` + * An Ed25519 Private Key, which always has the correct length, can be used in `verify`. + * + * WARNING: bytes are interpreted in the NaCl layout `seed (32) || publicKey (32)`. The + * BouncyCastle backend only consumes the first 32 bytes (the seed); the trailing 32 bytes + * are carried for byte-compat with the prior kalium implementation and the recrypt + * `PrivateSigningKey` round-trip. If you construct a `PrivateKey` from bytes that do NOT + * follow this layout, `sign` will produce signatures from whatever 32-byte prefix is present + * and `verify` against the corresponding `PublicKey` will silently fail. Prefer + * `generateKeyPair` to obtain a well-formed key. */ sealed abstract case class PrivateKey(bytes: ByteVector) { //A protection against logging Private keys. override def toString: String = s"Ed25519.PrivateKey()" + + /** The 32-byte Ed25519 seed (first half of the NaCl layout). */ + def seed: ByteVector = bytes.take(PrivateKey.SeedLength.toLong) + + /** The 32-byte public key embedded in this private key (second half of the NaCl layout). */ + def embeddedPublicKey: ByteVector = bytes.drop(PrivateKey.SeedLength.toLong) } final object PrivateKey { - final val Length = CRYPTO_SIGN_ED25519_SECRETKEYBYTES + /** Length of the Ed25519 seed in bytes. */ + final val SeedLength = 32 + /** Total length of a NaCl-style Ed25519 private key: seed (32) || public key (32). */ + final val Length = SeedLength + PublicKey.Length final val LengthLong = Length.toLong private[Ed25519] def create(bytes: ByteVector): PrivateKey = new PrivateKey(bytes) {} - /** - * Verifies the length of the byte vector satisfies the invariant detailed in NaCl. - */ def fromBytes(bytes: ByteVector): Option[PrivateKey] = Some(bytes) .filter(_.length == Length) .map(create) /** - * Create a value safely by just taking and padding bytes. This isn't ideal, but because of the - * interaction with recrypt and lack of sizing guarentees (a la refined) it's needed. + * Create a value by truncating-or-left-padding to the required length. This isn't ideal, but + * because of the interaction with recrypt and lack of sizing guarantees (a la refined) it's needed. */ def fromPaddedBytes(bytes: ByteVector): PrivateKey = create(bytes.take(LengthLong).padLeft(LengthLong)) } + /** * An Ed25519 signature, which always has the correct length, can be checked via `verify` */ @@ -76,68 +87,54 @@ object Ed25519 { //scalastyle:ignore override def toString: String = s"Signature in Base64: '${bytes.toBase64}'" } final object Signature { - val Length = CRYPTO_SIGN_ED25519_BYTES + val Length = 64 val LengthLong = Length.toLong private[Ed25519] def create(bytes: ByteVector): Signature = new Signature(bytes) {} def unsafeFromBytes(b: ByteVector): Signature = fromBytes(b).getOrElse(throw new Exception(s"'$b' did not have $Length length.")) - /** - * Verifies the length of the byte vector satisfies the invariant detailed in NaCl - */ def fromBytes(bytes: ByteVector): Option[Signature] = Some(bytes) .filter(_.length == Length) .map(create) /** - * Create a value safely by just taking and padding bytes. This isn't ideal, but because of the - * interaction with recrypt and lack of sizing guarentees (a la refined) it's needed. + * Create a value by truncating-or-left-padding to the required length. This isn't ideal, but + * because of the interaction with recrypt and lack of sizing guarantees (a la refined) it's needed. */ def fromPaddedBytes(bytes: ByteVector): Signature = create(bytes .take(LengthLong) .padLeft(LengthLong)) - } def verify(key: PublicKey, message: ByteVector, signature: Signature): Boolean = { - val mergedBytes = signature.bytes ++ message - val mergedByteArray = mergedBytes.toArray - val sodiumResult = sodium.crypto_sign_ed25519_open( - arrayOfZeros(mergedByteArray.length), //Out buffer which we don't need the result of - new LongLongByReference(0), // Out buffer length which we don't need the result of - mergedByteArray, //signature + message bytes - mergedByteArray.length, // signature + message bytes length - key.bytes.toArray //public key bytes - ) - isValid(sodiumResult) + val verifier = new Ed25519Signer() + val pubKeyParams = new Ed25519PublicKeyParameters(key.bytes.toArray, 0) + verifier.init(false, pubKeyParams) + val msgBytes = message.toArray + verifier.update(msgBytes, 0, msgBytes.length) + verifier.verifySignature(signature.bytes.toArray) } def sign(key: PrivateKey, message: ByteVector): Signature = { - val messageLength = message.length.toInt - val privateKeyBytes = key.bytes.toArray - - val outputByteArray = arrayOfZeros(messageLength + Signature.Length) - sodium.crypto_sign_ed25519( - outputByteArray, - new LongLongByReference(0), //Don't care. - message.toArray, - messageLength, - privateKeyBytes - ) - Signature.create(ByteVector.view(outputByteArray).take(Signature.LengthLong)) + // BouncyCastle's Ed25519 takes only the 32-byte seed. We pull it from the NaCl-layout + // private key via `key.seed`; see the WARNING on `PrivateKey` for what happens if the + // caller built one from non-NaCl-format bytes. + val signer = new Ed25519Signer() + val privKeyParams = new Ed25519PrivateKeyParameters(key.seed.toArray, 0) + signer.init(true, privKeyParams) + val msgBytes = message.toArray + signer.update(msgBytes, 0, msgBytes.length) + Signature.create(ByteVector.view(signer.generateSignature())) } def generateKeyPair(seed: ByteVector): (PublicKey, PrivateKey) = { - //Allocate the arrays, which will get filled out by crypto_sign_ed25519_seed_keypair - val privateKeyArray = arrayOfZeros(PrivateKey.Length) - val publicKeyArray = arrayOfZeros(PublicKey.Length) - sodium.crypto_sign_ed25519_seed_keypair(publicKeyArray, privateKeyArray, seed.toArray) - PublicKey.create(ByteVector.view(publicKeyArray)) -> PrivateKey.create(ByteVector.view(privateKeyArray)) + val privKeyParams = new Ed25519PrivateKeyParameters(seed.toArray, 0) + val pubKeyParams = privKeyParams.generatePublicKey() + val publicKeyBytes = ByteVector.view(pubKeyParams.getEncoded) + // Reconstruct the NaCl-style 64-byte private key: seed || public key. + val privateKeyBytes = seed ++ publicKeyBytes + PublicKey.create(publicKeyBytes) -> PrivateKey.create(privateKeyBytes) } - - final private def isValid(code: Int): Boolean = code == 0 - - final private def arrayOfZeros(n: Int): Array[Byte] = new Array[Byte](n) } diff --git a/benchmark/src/main/scala/GenerateFp12Benchmark.scala b/benchmark/src/main/scala/GenerateFp12Benchmark.scala index 4dea2ad..824599d 100644 --- a/benchmark/src/main/scala/GenerateFp12Benchmark.scala +++ b/benchmark/src/main/scala/GenerateFp12Benchmark.scala @@ -6,6 +6,7 @@ import org.openjdk.jmh.annotations._ import com.ironcorelabs.recrypt.internal._ import cats.instances.list._ import cats.syntax.traverse._ +import cats.effect.unsafe.implicits.global @State(Scope.Benchmark) @OutputTimeUnit(TimeUnit.SECONDS) diff --git a/benchmark/src/main/scala/HomogeneousPointBenchmark.scala b/benchmark/src/main/scala/HomogeneousPointBenchmark.scala index bf7ead5..735cce5 100644 --- a/benchmark/src/main/scala/HomogeneousPointBenchmark.scala +++ b/benchmark/src/main/scala/HomogeneousPointBenchmark.scala @@ -2,6 +2,7 @@ package bench import java.util.concurrent.TimeUnit import org.openjdk.jmh.annotations._ +import cats.effect.unsafe.implicits.global import com.ironcorelabs.recrypt.internal._ import point.HomogeneousPoint diff --git a/benchmark/src/main/scala/InternalEncryptBenchmark.scala b/benchmark/src/main/scala/InternalEncryptBenchmark.scala index e7f9b26..6ecc26c 100644 --- a/benchmark/src/main/scala/InternalEncryptBenchmark.scala +++ b/benchmark/src/main/scala/InternalEncryptBenchmark.scala @@ -5,6 +5,7 @@ import org.openjdk.jmh.annotations._ import com.ironcorelabs.recrypt.internal._ import scodec.bits.ByteVector +import cats.effect.unsafe.implicits.global @State(Scope.Benchmark) @OutputTimeUnit(TimeUnit.SECONDS) diff --git a/benchmark/src/test/scala/Ed25519Test.scala b/benchmark/src/test/scala/Ed25519Test.scala new file mode 100644 index 0000000..e6909ec --- /dev/null +++ b/benchmark/src/test/scala/Ed25519Test.scala @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2017-present IronCore Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package bench + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import scodec.bits.ByteVector + +class Ed25519Test extends AnyWordSpec with Matchers { + // A fixed 32-byte seed so failures are reproducible. + private val seed = ByteVector.fromValidHex( + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + ) + private val message = ByteVector.encodeUtf8("recrypt benchmark sanity check").toOption.get + + "Ed25519.generateKeyPair" should { + "produce a 32-byte public key and a 64-byte NaCl-layout private key" in { + val (pub, priv) = Ed25519.generateKeyPair(seed) + pub.bytes.length shouldBe Ed25519.PublicKey.Length + priv.bytes.length shouldBe Ed25519.PrivateKey.Length + } + + "embed the public key as the second half of the private key" in { + val (pub, priv) = Ed25519.generateKeyPair(seed) + priv.seed shouldBe seed + priv.embeddedPublicKey shouldBe pub.bytes + } + + "be deterministic for a given seed" in { + val a = Ed25519.generateKeyPair(seed) + val b = Ed25519.generateKeyPair(seed) + a._1.bytes shouldBe b._1.bytes + a._2.bytes shouldBe b._2.bytes + } + } + + "Ed25519.sign / verify" should { + "round-trip a signature produced from a freshly generated key" in { + val (pub, priv) = Ed25519.generateKeyPair(seed) + val sig = Ed25519.sign(priv, message) + sig.bytes.length shouldBe Ed25519.Signature.Length + Ed25519.verify(pub, message, sig) shouldBe true + } + + "reject a signature whose bytes have been tampered with" in { + val (pub, priv) = Ed25519.generateKeyPair(seed) + val sig = Ed25519.sign(priv, message) + val tampered = Ed25519.Signature.unsafeFromBytes(sig.bytes.update(0, (sig.bytes(0) ^ 0x01).toByte)) + Ed25519.verify(pub, message, tampered) shouldBe false + } + + "reject a valid signature against a different message" in { + val (pub, priv) = Ed25519.generateKeyPair(seed) + val sig = Ed25519.sign(priv, message) + val otherMessage = message ++ ByteVector(0x00.toByte) + Ed25519.verify(pub, otherMessage, sig) shouldBe false + } + + "reject a valid signature against an unrelated public key" in { + val (_, priv) = Ed25519.generateKeyPair(seed) + val otherSeed = ByteVector.fill(Ed25519.PrivateKey.SeedLength.toLong)(0x42.toByte) + val (otherPub, _) = Ed25519.generateKeyPair(otherSeed) + val sig = Ed25519.sign(priv, message) + Ed25519.verify(otherPub, message, sig) shouldBe false + } + } +} diff --git a/build.sbt b/build.sbt index 5e4c894..932168a 100644 --- a/build.sbt +++ b/build.sbt @@ -111,7 +111,7 @@ lazy val benchmark = project.in(file("benchmark")) .settings(recryptSettings: _*) .settings(coverageEnabled := false) .settings(noPublish: _*) - .settings( libraryDependencies ++= Seq( - "org.abstractj.kalium" % "kalium" % "0.8.0" - )) + .settings(libraryDependencies ++= Seq( + "org.bouncycastle" % "bcprov-jdk18on" % "1.84" + )) .enablePlugins(JmhPlugin) diff --git a/flake.lock b/flake.lock index c6acf6c..89544d6 100644 --- a/flake.lock +++ b/flake.lock @@ -2,15 +2,17 @@ "nodes": { "devshell": { "inputs": { - "nixpkgs": "nixpkgs", - "systems": "systems" + "nixpkgs": [ + "typelevel-nix", + "nixpkgs" + ] }, "locked": { - "lastModified": 1688380630, - "narHash": "sha256-8ilApWVb1mAi4439zS3iFeIT0ODlbrifm/fegWwgHjA=", + "lastModified": 1764011051, + "narHash": "sha256-M7SZyPZiqZUR/EiiBJnmyUbOi5oE/03tCeFrTiUZchI=", "owner": "numtide", "repo": "devshell", - "rev": "f9238ec3d75cefbb2b42a44948c4e8fb1ae9a205", + "rev": "17ed8d9744ebe70424659b0ef74ad6d41fc87071", "type": "github" }, "original": { @@ -21,14 +23,14 @@ }, "flake-utils": { "inputs": { - "systems": "systems_2" + "systems": "systems" }, "locked": { - "lastModified": 1687709756, - "narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=", + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", - "rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { @@ -39,27 +41,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1677383253, - "narHash": "sha256-UfpzWfSxkfXHnb4boXZNaKsAcUrZT9Hw+tao1oZxd08=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "9952d6bc395f5841262b006fbace8dd7e143b634", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_2": { - "locked": { - "lastModified": 1688221086, - "narHash": "sha256-cdW6qUL71cNWhHCpMPOJjlw0wzSRP0pVlRn2vqX/VVg=", + "lastModified": 1764947035, + "narHash": "sha256-EYHSjVM4Ox4lvCXUMiKKs2vETUSL5mx+J2FfutM7T9w=", "owner": "nixos", "repo": "nixpkgs", - "rev": "cd99c2b3c9f160cd004318e0697f90bbd5960825", + "rev": "a672be65651c80d3f592a89b3945466584a22069", "type": "github" }, "original": { @@ -97,33 +83,18 @@ "type": "github" } }, - "systems_2": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, "typelevel-nix": { "inputs": { "devshell": "devshell", "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1688414327, - "narHash": "sha256-NmY28Qd1m3VBnPw+kq5qq+obOEeEH2F9XngunDEvgiA=", + "lastModified": 1765301495, + "narHash": "sha256-lTgPc0ym7WsbnY7JxNq00AwfbbYeZHfl+fgXrBReoZI=", "owner": "typelevel", "repo": "typelevel-nix", - "rev": "a09e2c0f410955262f4e5a6cec75fc38233fc5ad", + "rev": "70bd996e1a803249644b6e474c9bd195d4508a00", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 810ab0f..845f112 100644 --- a/flake.nix +++ b/flake.nix @@ -10,7 +10,7 @@ let pkgs = import nixpkgs { inherit system; - overlays = [ typelevel-nix.overlay ]; + overlays = [ typelevel-nix.overlays.default ]; }; mkShell = jdk: pkgs.devshell.mkShell { @@ -22,11 +22,10 @@ }; in rec { - devShell = devShells."temurin@11"; + devShell = devShells."temurin@25"; devShells = { - "temurin@11" = mkShell pkgs.temurin-bin-11; - "temurin@17" = mkShell pkgs.temurin-bin-17; + "temurin@25" = mkShell pkgs.temurin-bin-25; }; } );