NoiseNK.kt

8.1 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}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}