1package com.peachpasswords.sync23import org.bouncycastle.crypto.digests.Blake2sDigest4import org.bouncycastle.crypto.generators.HKDFBytesGenerator5import org.bouncycastle.crypto.modes.ChaCha20Poly13056import org.bouncycastle.crypto.params.AEADParameters7import org.bouncycastle.crypto.params.HKDFParameters8import org.bouncycastle.crypto.params.KeyParameter9import org.bouncycastle.crypto.params.X25519PrivateKeyParameters10import org.bouncycastle.crypto.params.X25519PublicKeyParameters11import java.nio.ByteBuffer12import java.nio.ByteOrder13import java.security.SecureRandom1415private const val NOISE_NK_PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s"1617interface NoiseNKSession {18 fun encrypt(plaintext: ByteArray): ByteArray19 fun decrypt(ciphertext: ByteArray): ByteArray20 val handshakeHash: ByteArray21}2223private data class NoiseNKKeyPair(24 val publicKey: ByteArray,25 val secretKey: ByteArray,26)2728private fun initHash(): ByteArray {29 val nameBytes = NOISE_NK_PROTOCOL_NAME.encodeToByteArray()30 if (nameBytes.size <= 32) {31 return ByteArray(32).also { nameBytes.copyInto(it) }32 }33 34 35 36 val blake = Blake2sDigest(256)37 blake.update(nameBytes, 0, nameBytes.size)38 val out = ByteArray(32)39 blake.doFinal(out, 0)40 return out41}4243private fun mixHash(h: ByteArray, data: ByteArray): ByteArray {44 val blake = Blake2sDigest(256)45 blake.update(h, 0, h.size)46 blake.update(data, 0, data.size)47 val out = ByteArray(32)48 blake.doFinal(out, 0)49 return out50}5152private fun hkdf2(ck: ByteArray, ikm: ByteArray): Pair<ByteArray, ByteArray> {53 54 val generator = HKDFBytesGenerator(Blake2sDigest(256))55 generator.init(HKDFParameters(ikm, ck, ByteArray(0)))56 val out = ByteArray(64)57 generator.generateBytes(out, 0, out.size)58 return out.copyOfRange(0, 32) to out.copyOfRange(32, 64)59}60616263internal fun noiseNKInitHashForTest(): ByteArray = initHash()64internal fun noiseNKHkdf2ForTest(ck: ByteArray, ikm: ByteArray): Pair<ByteArray, ByteArray> = hkdf2(ck, ikm)6566private fun nonceBytes(n: Long): ByteArray {67 val buf = ByteArray(12)68 ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN).putLong(4, n)69 return buf70}7172private fun generateKeyPair(): NoiseNKKeyPair {73 val priv = X25519PrivateKeyParameters(SecureRandom())74 return NoiseNKKeyPair(75 publicKey = priv.generatePublicKey().encoded,76 secretKey = priv.encoded,77 )78}7980internal fun x25519SharedKey(privateKey: ByteArray, publicKey: ByteArray): ByteArray {81 require(privateKey.size == 32 && publicKey.size == 32) { "X25519 keys must be 32 bytes" }82 val priv = X25519PrivateKeyParameters(privateKey, 0)83 val pub = X25519PublicKeyParameters(publicKey, 0)84 val out = ByteArray(32)85 priv.generateSecret(pub, out, 0)86 require(out.any { it.toInt() != 0 }) { "X25519 low-order point rejected" }87 return out88}8990private fun chachaSeal(key: ByteArray, nonce: ByteArray, plaintext: ByteArray, aad: ByteArray): ByteArray {91 val cipher = ChaCha20Poly1305()92 cipher.init(true, AEADParameters(KeyParameter(key), 128, nonce, aad))93 val out = ByteArray(cipher.getOutputSize(plaintext.size))94 var len = cipher.processBytes(plaintext, 0, plaintext.size, out, 0)95 len += cipher.doFinal(out, len)96 return out.copyOf(len)97}9899private fun chachaOpen(key: ByteArray, nonce: ByteArray, ciphertext: ByteArray, aad: ByteArray): ByteArray? {100 return try {101 val cipher = ChaCha20Poly1305()102 cipher.init(false, AEADParameters(KeyParameter(key), 128, nonce, aad))103 val out = ByteArray(cipher.getOutputSize(ciphertext.size))104 var len = cipher.processBytes(ciphertext, 0, ciphertext.size, out, 0)105 len += cipher.doFinal(out, len)106 out.copyOf(len)107 } catch (_: Throwable) {108 null109 }110}111112suspend fun noiseNKInitiator(113 responderStaticPublicKey: ByteArray,114 send: suspend (ByteArray) -> Unit,115 receive: suspend () -> ByteArray,116): NoiseNKSession {117 var h = initHash()118 var ck = h.copyOf()119120 h = mixHash(h, responderStaticPublicKey)121122 val ephemeral = generateKeyPair()123 h = mixHash(h, ephemeral.publicKey)124 val es = x25519SharedKey(ephemeral.secretKey, responderStaticPublicKey)125 val (ck1, k1) = hkdf2(ck, es)126 ck = ck1127128 val cipher1 = ChaCha20Poly1305()129 cipher1.init(true, AEADParameters(KeyParameter(k1), 128, nonceBytes(0L), h))130 val encrypted1 = ByteArray(cipher1.getOutputSize(0))131 cipher1.doFinal(encrypted1, 0)132 h = mixHash(h, encrypted1)133134 val msg1 = ByteArray(32 + encrypted1.size)135 ephemeral.publicKey.copyInto(msg1, 0)136 encrypted1.copyInto(msg1, 32)137 send(msg1)138139 val msg2 = receive()140 require(msg2.size >= 48) { "Noise NK: msg2 too short" }141 val remoteEphemeralPublic = msg2.copyOfRange(0, 32)142 val encrypted2 = msg2.copyOfRange(32, msg2.size)143144 h = mixHash(h, remoteEphemeralPublic)145 val ee = x25519SharedKey(ephemeral.secretKey, remoteEphemeralPublic)146 val (ck2, k2) = hkdf2(ck, ee)147 ck = ck2148149 val decrypted2 = chachaOpen(k2, nonceBytes(0L), encrypted2, h)150 requireNotNull(decrypted2) { "Noise NK: msg2 auth failed — wrong responder key or MITM" }151 h = mixHash(h, encrypted2)152153 val (sendKey, recvKey) = hkdf2(ck, ByteArray(0))154 val sessionHash = h.copyOf()155156 var sendNonce = 0L157 var recvNonce = 0L158159 return object : NoiseNKSession {160 override fun encrypt(plaintext: ByteArray): ByteArray {161 return chachaSeal(sendKey, nonceBytes(sendNonce++), plaintext, ByteArray(0))162 }163164 override fun decrypt(ciphertext: ByteArray): ByteArray {165 return chachaOpen(recvKey, nonceBytes(recvNonce++), ciphertext, ByteArray(0))166 ?: throw IllegalStateException("Noise NK: decryption failed")167 }168169 override val handshakeHash: ByteArray = sessionHash170 }171}172173suspend fun noiseNKResponder(174 ourStaticPrivateKey: ByteArray,175 ourStaticPublicKey: ByteArray,176 send: suspend (ByteArray) -> Unit,177 receive: suspend () -> ByteArray,178): NoiseNKSession {179 var h = initHash()180 var ck = h.copyOf()181182 h = mixHash(h, ourStaticPublicKey)183184 val msg1 = receive()185 require(msg1.size >= 48) { "Noise NK: msg1 too short" }186 val remoteEphemeralPublic = msg1.copyOfRange(0, 32)187 val encrypted1 = msg1.copyOfRange(32, msg1.size)188189 h = mixHash(h, remoteEphemeralPublic)190 val es = x25519SharedKey(ourStaticPrivateKey, remoteEphemeralPublic)191 val (ck1, k1) = hkdf2(ck, es)192 ck = ck1193194 val decrypted1 = chachaOpen(k1, nonceBytes(0L), encrypted1, h)195 requireNotNull(decrypted1) { "Noise NK: msg1 auth failed — wrong static key or MITM" }196 h = mixHash(h, encrypted1)197198 val ephemeral = generateKeyPair()199 h = mixHash(h, ephemeral.publicKey)200 val ee = x25519SharedKey(ephemeral.secretKey, remoteEphemeralPublic)201 val (ck2, k2) = hkdf2(ck, ee)202 ck = ck2203204 val encrypted2 = chachaSeal(k2, nonceBytes(0L), ByteArray(0), h)205 h = mixHash(h, encrypted2)206207 val msg2 = ByteArray(32 + encrypted2.size)208 ephemeral.publicKey.copyInto(msg2, 0)209 encrypted2.copyInto(msg2, 32)210 send(msg2)211212 val (initiatorSendKey, responderSendKey) = hkdf2(ck, ByteArray(0))213 val sessionHash = h.copyOf()214215 var sendNonce = 0L216 var recvNonce = 0L217218 return object : NoiseNKSession {219 override fun encrypt(plaintext: ByteArray): ByteArray {220 return chachaSeal(responderSendKey, nonceBytes(sendNonce++), plaintext, ByteArray(0))221 }222223 override fun decrypt(ciphertext: ByteArray): ByteArray {224 return chachaOpen(initiatorSendKey, nonceBytes(recvNonce++), ciphertext, ByteArray(0))225 ?: throw IllegalStateException("Noise NK: decryption failed")226 }227228 override val handshakeHash: ByteArray = sessionHash229 }230}231232fun derivePairingSAS(handshakeHash: ByteArray): String {233 return SASDerivation.derive(handshakeHash)234}