diff --git a/src/kotlin/kage/crypto/x25519/X25519.kt b/src/kotlin/kage/crypto/x25519/X25519.kt new file mode 100644 index 0000000..ea13694 --- /dev/null +++ b/src/kotlin/kage/crypto/x25519/X25519.kt @@ -0,0 +1,54 @@ +/** + * Copyright 2021 The kage Authors. All rights reserved. Use of this source code is governed by + * either an Apache 2.0 or MIT license at your discretion, that can be found in the LICENSE-APACHE + * or LICENSE-MIT files respectively. + */ +package kage.crypto.x25519 + +import org.bouncycastle.math.ec.rfc7748.X25519 + +public object X25519 { + public val BASEPOINT: ByteArray = + byteArrayOf( + 9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ) + + public fun scalarMult(input: ByteArray, r: ByteArray): ByteArray { + val out = ByteArray(input.size) + + X25519.scalarMult(input, 0, r, 0, out, 0) + + return out + } +} diff --git a/src/kotlin/kage/crypto/x25519/X25519Recipient.kt b/src/kotlin/kage/crypto/x25519/X25519Recipient.kt new file mode 100644 index 0000000..436fc88 --- /dev/null +++ b/src/kotlin/kage/crypto/x25519/X25519Recipient.kt @@ -0,0 +1,47 @@ +/** + * Copyright 2021 The kage Authors. All rights reserved. Use of this source code is governed by + * either an Apache 2.0 or MIT license at your discretion, that can be found in the LICENSE-APACHE + * or LICENSE-MIT files respectively. + */ +package kage.crypto.x25519 + +import at.favre.lib.crypto.HKDF +import java.security.SecureRandom +import kage.Recipient +import kage.crypto.chacha20.ChaCha20Poly1305 +import kage.crypto.chacha20.ChaCha20Poly1305.CHACHA_20_POLY_1305_NONCE_LENGTH +import kage.format.AgeStanza +import kage.utils.encodeBase64 + +public class X25519Recipient(private val publicKey: ByteArray) : Recipient { + + override fun wrap(fileKey: ByteArray): List { + val ephemeralSecret = ByteArray(EPHEMERAL_SECRET_LEN) + SecureRandom().nextBytes(ephemeralSecret) + + val ephemeralShare = X25519.scalarMult(ephemeralSecret, X25519.BASEPOINT) + + val salt = ephemeralShare.plus(publicKey) + + val sharedSecret = X25519.scalarMult(ephemeralSecret, publicKey) + + val hkdf = HKDF.fromHmacSha256() + + val wrapingKey = + hkdf.extractAndExpand(salt, sharedSecret, X25519_INFO.toByteArray(), MAC_KEY_LENGTH) + + val nonce = ByteArray(CHACHA_20_POLY_1305_NONCE_LENGTH) + val wrappedKey = ChaCha20Poly1305.encrypt(wrapingKey, nonce, fileKey) + + val stanza = AgeStanza(X25519_STANZA_TYPE, listOf(ephemeralShare.encodeBase64()), wrappedKey) + + return listOf(stanza) + } + + internal companion object { + const val X25519_STANZA_TYPE = "X25519" + const val X25519_INFO = "age-encryption.org/v1/X25519" + const val MAC_KEY_LENGTH = 32 // bytes + const val EPHEMERAL_SECRET_LEN = 32 // bytes + } +} diff --git a/src/test/kotlin/AgeTest.kt b/src/test/kotlin/AgeTest.kt index c24303c..85a4c2f 100644 --- a/src/test/kotlin/AgeTest.kt +++ b/src/test/kotlin/AgeTest.kt @@ -7,8 +7,11 @@ package kage import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.util.* import kage.crypto.chacha20.ChaCha20Poly1305OutputStream import kage.crypto.scrypt.ScryptRecipient +import kage.crypto.x25519.X25519Recipient +import org.bouncycastle.util.encoders.Hex import org.junit.Test // TODO: Write some integration tests using another implementation of `age` @@ -27,7 +30,7 @@ class AgeTest { } @Test - fun testEncryptExactBlockSizeDoesNotThrow() { + fun testScryptEncryptExactBlockSizeDoesNotThrow() { // Encrypt exactly 2 chunks val i = ByteArray(ChaCha20Poly1305OutputStream.CHUNK_SIZE * 2) i.fill("0".toByte()) @@ -40,4 +43,19 @@ class AgeTest { // println(Base64.getEncoder().encodeToString(baos.toByteArray())) // TODO: Test this better when `decrypt` is implemented } + + @Test + fun testX25519EncryptDoesNotThrow() { + val publicKey = Hex.decode("1292e55a1e907ddb45726667ab19b48efdf323732cbd31ade84ef2ec0eb0eb0b") + + val recipients = listOf(X25519Recipient(publicKey)) + + val bais = ByteArrayInputStream("this is my file".toByteArray()) + val baos = ByteArrayOutputStream() + + Age.encrypt(recipients, bais, baos, generateArmor = false) + + println(Base64.getEncoder().encodeToString(baos.toByteArray())) + // TODO: Test this better when `decrypt` is implemented + } } diff --git a/src/test/kotlin/kage/crypto/x25519/X25519RecipientTest.kt b/src/test/kotlin/kage/crypto/x25519/X25519RecipientTest.kt new file mode 100644 index 0000000..07e6441 --- /dev/null +++ b/src/test/kotlin/kage/crypto/x25519/X25519RecipientTest.kt @@ -0,0 +1,33 @@ +/** + * Copyright 2021 The kage Authors. All rights reserved. Use of this source code is governed by + * either an Apache 2.0 or MIT license at your discretion, that can be found in the LICENSE-APACHE + * or LICENSE-MIT files respectively. + */ +package kage.kage.crypto.x25519 + +import java.security.SecureRandom +import kage.crypto.x25519.X25519Recipient +import kage.utils.decodeBase64 +import kotlin.test.assertEquals +import org.junit.Test + +class X25519RecipientTest { + @Test + fun testWrap() { + val publicKey = ByteArray(32) + SecureRandom().nextBytes(publicKey) + + val recipient = X25519Recipient(publicKey) + + val fileKey = ByteArray(32) + + val stanza = recipient.wrap(fileKey).first() + + val sharedSecret = stanza.args.first().decodeBase64() + + assertEquals(X25519Recipient.EPHEMERAL_SECRET_LEN, sharedSecret.size) + assertEquals(X25519Recipient.X25519_STANZA_TYPE, stanza.type) + + // TODO: Test this with `unwrap` when implemented + } +}