NoiseNK.kt

7.9 KB · raw

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    // PCH-01-002: the named pattern hash is BLAKE2s; the protocol-name init hash34    // MUST use it too (Noise spec §5.2), not SHA-256. Coordinated wire change with35    // peach-browser noise-js.ts.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    // PCH-01-002: HKDF must run over the named hash (BLAKE2s), not SHA-256.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}6061// PCH-01-002: test-only seams for the cross-platform BLAKE2s vectors pinned in62// CrossPlatformKATTest.kt (must stay byte-identical to peach-browser noise-js.ts).63internal 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}7980private fun sharedKey(privateKey: ByteArray, publicKey: ByteArray): ByteArray {81    val priv = X25519PrivateKeyParameters(privateKey, 0)82    val pub = X25519PublicKeyParameters(publicKey, 0)83    val out = ByteArray(32)84    priv.generateSecret(pub, out, 0)85    return out86}8788private fun chachaSeal(key: ByteArray, nonce: ByteArray, plaintext: ByteArray, aad: ByteArray): ByteArray {89    val cipher = ChaCha20Poly1305()90    cipher.init(true, AEADParameters(KeyParameter(key), 128, nonce, aad))91    val out = ByteArray(cipher.getOutputSize(plaintext.size))92    var len = cipher.processBytes(plaintext, 0, plaintext.size, out, 0)93    len += cipher.doFinal(out, len)94    return out.copyOf(len)95}9697private fun chachaOpen(key: ByteArray, nonce: ByteArray, ciphertext: ByteArray, aad: ByteArray): ByteArray? {98    return try {99        val cipher = ChaCha20Poly1305()100        cipher.init(false, AEADParameters(KeyParameter(key), 128, nonce, aad))101        val out = ByteArray(cipher.getOutputSize(ciphertext.size))102        var len = cipher.processBytes(ciphertext, 0, ciphertext.size, out, 0)103        len += cipher.doFinal(out, len)104        out.copyOf(len)105    } catch (_: Throwable) {106        null107    }108}109110suspend fun noiseNKInitiator(111    responderStaticPublicKey: ByteArray,112    send: suspend (ByteArray) -> Unit,113    receive: suspend () -> ByteArray,114): NoiseNKSession {115    var h = initHash()116    var ck = h.copyOf()117118    h = mixHash(h, responderStaticPublicKey)119120    val ephemeral = generateKeyPair()121    h = mixHash(h, ephemeral.publicKey)122    val es = sharedKey(ephemeral.secretKey, responderStaticPublicKey)123    val (ck1, k1) = hkdf2(ck, es)124    ck = ck1125126    val cipher1 = ChaCha20Poly1305()127    cipher1.init(true, AEADParameters(KeyParameter(k1), 128, nonceBytes(0L), h))128    val encrypted1 = ByteArray(cipher1.getOutputSize(0))129    cipher1.doFinal(encrypted1, 0)130    h = mixHash(h, encrypted1)131132    val msg1 = ByteArray(32 + encrypted1.size)133    ephemeral.publicKey.copyInto(msg1, 0)134    encrypted1.copyInto(msg1, 32)135    send(msg1)136137    val msg2 = receive()138    require(msg2.size >= 48) { "Noise NK: msg2 too short" }139    val remoteEphemeralPublic = msg2.copyOfRange(0, 32)140    val encrypted2 = msg2.copyOfRange(32, msg2.size)141142    h = mixHash(h, remoteEphemeralPublic)143    val ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic)144    val (ck2, k2) = hkdf2(ck, ee)145    ck = ck2146147    val decrypted2 = chachaOpen(k2, nonceBytes(0L), encrypted2, h)148    requireNotNull(decrypted2) { "Noise NK: msg2 auth failed — wrong responder key or MITM" }149    h = mixHash(h, encrypted2)150151    val (sendKey, recvKey) = hkdf2(ck, ByteArray(0))152    val sessionHash = h.copyOf()153154    var sendNonce = 0L155    var recvNonce = 0L156157    return object : NoiseNKSession {158        override fun encrypt(plaintext: ByteArray): ByteArray {159            return chachaSeal(sendKey, nonceBytes(sendNonce++), plaintext, ByteArray(0))160        }161162        override fun decrypt(ciphertext: ByteArray): ByteArray {163            return chachaOpen(recvKey, nonceBytes(recvNonce++), ciphertext, ByteArray(0))164                ?: throw IllegalStateException("Noise NK: decryption failed")165        }166167        override val handshakeHash: ByteArray = sessionHash168    }169}170171suspend fun noiseNKResponder(172    ourStaticPrivateKey: ByteArray,173    ourStaticPublicKey: ByteArray,174    send: suspend (ByteArray) -> Unit,175    receive: suspend () -> ByteArray,176): NoiseNKSession {177    var h = initHash()178    var ck = h.copyOf()179180    h = mixHash(h, ourStaticPublicKey)181182    val msg1 = receive()183    require(msg1.size >= 48) { "Noise NK: msg1 too short" }184    val remoteEphemeralPublic = msg1.copyOfRange(0, 32)185    val encrypted1 = msg1.copyOfRange(32, msg1.size)186187    h = mixHash(h, remoteEphemeralPublic)188    val es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic)189    val (ck1, k1) = hkdf2(ck, es)190    ck = ck1191192    val decrypted1 = chachaOpen(k1, nonceBytes(0L), encrypted1, h)193    requireNotNull(decrypted1) { "Noise NK: msg1 auth failed — wrong static key or MITM" }194    h = mixHash(h, encrypted1)195196    val ephemeral = generateKeyPair()197    h = mixHash(h, ephemeral.publicKey)198    val ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic)199    val (ck2, k2) = hkdf2(ck, ee)200    ck = ck2201202    val encrypted2 = chachaSeal(k2, nonceBytes(0L), ByteArray(0), h)203    h = mixHash(h, encrypted2)204205    val msg2 = ByteArray(32 + encrypted2.size)206    ephemeral.publicKey.copyInto(msg2, 0)207    encrypted2.copyInto(msg2, 32)208    send(msg2)209210    val (initiatorSendKey, responderSendKey) = hkdf2(ck, ByteArray(0))211    val sessionHash = h.copyOf()212213    var sendNonce = 0L214    var recvNonce = 0L215216    return object : NoiseNKSession {217        override fun encrypt(plaintext: ByteArray): ByteArray {218            return chachaSeal(responderSendKey, nonceBytes(sendNonce++), plaintext, ByteArray(0))219        }220221        override fun decrypt(ciphertext: ByteArray): ByteArray {222            return chachaOpen(initiatorSendKey, nonceBytes(recvNonce++), ciphertext, ByteArray(0))223                ?: throw IllegalStateException("Noise NK: decryption failed")224        }225226        override val handshakeHash: ByteArray = sessionHash227    }228}229230fun derivePairingSAS(handshakeHash: ByteArray): String {231    return SASDerivation.derive(handshakeHash)232}