NoiseXX.kt

13.0 KB · raw

1package com.peachpasswords.sync23import org.bouncycastle.crypto.InvalidCipherTextException4import org.bouncycastle.crypto.digests.Blake2sDigest5import org.bouncycastle.crypto.generators.HKDFBytesGenerator6import org.bouncycastle.crypto.modes.ChaCha20Poly13057import org.bouncycastle.crypto.params.AEADParameters8import org.bouncycastle.crypto.params.HKDFParameters9import org.bouncycastle.crypto.params.KeyParameter10import org.bouncycastle.crypto.params.X25519PrivateKeyParameters11import org.bouncycastle.crypto.params.X25519PublicKeyParameters12import java.nio.ByteBuffer13import java.nio.ByteOrder14import java.security.SecureRandom1516private const val PROTOCOL_NAME = "Noise_XX_25519_ChaChaPoly_BLAKE2s"1718private fun initHash(): ByteArray {19    val nameBytes = PROTOCOL_NAME.encodeToByteArray()20    if (nameBytes.size <= 32) {21        return ByteArray(32).also { nameBytes.copyInto(it) }22    }23    // PCH-01-002: the named pattern hash is BLAKE2s; the protocol-name init hash24    // MUST use it too (Noise spec §5.2), not SHA-256. Coordinated wire change with25    // peach-browser noise-xx.ts.26    val blake = Blake2sDigest(256)27    blake.update(nameBytes, 0, nameBytes.size)28    val out = ByteArray(32)29    blake.doFinal(out, 0)30    return out31}3233private fun mixHash(h: ByteArray, data: ByteArray): ByteArray {34    val blake = Blake2sDigest(256)35    blake.update(h, 0, h.size)36    blake.update(data, 0, data.size)37    val out = ByteArray(32)38    blake.doFinal(out, 0)39    return out40}4142private fun hkdf2(ck: ByteArray, ikm: ByteArray): Pair<ByteArray, ByteArray> {43    // PCH-01-002: HKDF must run over the named hash (BLAKE2s), not SHA-256.44    val generator = HKDFBytesGenerator(Blake2sDigest(256))45    generator.init(HKDFParameters(ikm, ck, ByteArray(0)))46    val out = ByteArray(64)47    generator.generateBytes(out, 0, out.size)48    return out.copyOfRange(0, 32) to out.copyOfRange(32, 64)49}5051// PCH-01-002: test-only seams for the cross-platform BLAKE2s vectors pinned in52// CrossPlatformKATTest.kt (must stay byte-identical to peach-browser noise-xx.ts).53internal fun noiseXXInitHashForTest(): ByteArray = initHash()54internal fun noiseXXHkdf2ForTest(ck: ByteArray, ikm: ByteArray): Pair<ByteArray, ByteArray> = hkdf2(ck, ikm)5556private fun nonceBytes(n: Long): ByteArray {57    val buf = ByteArray(12)58    ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN).putLong(4, n)59    return buf60}6162interface NoiseXXSession {63    fun encrypt(plaintext: ByteArray): ByteArray64    fun decrypt(ciphertext: ByteArray): ByteArray65    val remoteStaticPublicKey: ByteArray66    val handshakeHash: ByteArray67}6869private data class X25519KeyPair(70    val publicKey: ByteArray,71    val secretKey: ByteArray,72)7374private fun generateKeyPair(): X25519KeyPair {75    val priv = X25519PrivateKeyParameters(SecureRandom())76    return X25519KeyPair(77        publicKey = priv.generatePublicKey().encoded,78        secretKey = priv.encoded,79    )80}8182private fun keyPairFromSeed(seed: ByteArray): X25519KeyPair {83    val priv = X25519PrivateKeyParameters(seed, 0)84    return X25519KeyPair(85        publicKey = priv.generatePublicKey().encoded,86        secretKey = priv.encoded,87    )88}8990private fun sharedKey(privateKey: ByteArray, publicKey: ByteArray): ByteArray {91    val priv = X25519PrivateKeyParameters(privateKey, 0)92    val pub = X25519PublicKeyParameters(publicKey, 0)93    val out = ByteArray(32)94    priv.generateSecret(pub, out, 0)95    return out96}9798private fun chachaSeal(key: ByteArray, nonce: ByteArray, plaintext: ByteArray, aad: ByteArray): ByteArray {99    val cipher = ChaCha20Poly1305()100    cipher.init(true, AEADParameters(KeyParameter(key), 128, nonce, aad))101    val out = ByteArray(cipher.getOutputSize(plaintext.size))102    var len = cipher.processBytes(plaintext, 0, plaintext.size, out, 0)103    len += cipher.doFinal(out, len)104    return out.copyOf(len)105}106107private fun chachaOpen(key: ByteArray, nonce: ByteArray, ciphertext: ByteArray, aad: ByteArray): ByteArray? {108    return try {109        val cipher = ChaCha20Poly1305()110        cipher.init(false, AEADParameters(KeyParameter(key), 128, nonce, aad))111        val out = ByteArray(cipher.getOutputSize(ciphertext.size))112        var len = cipher.processBytes(ciphertext, 0, ciphertext.size, out, 0)113        len += cipher.doFinal(out, len)114        out.copyOf(len)115    } catch (_: InvalidCipherTextException) {116        null117    }118}119120internal fun noiseXXDeterministicHandshakeHashForTest(): ByteArray {121    val initiatorStatic = keyPairFromSeed(ByteArray(32) { 0xa1.toByte() })122    val responderStatic = keyPairFromSeed(ByteArray(32) { 0xb2.toByte() })123    val initiatorEphemeral = keyPairFromSeed(ByteArray(32) { 0x01 })124    val responderEphemeral = keyPairFromSeed(ByteArray(32) { 0x02 })125126    var hI = initHash()127    var ckI = hI.copyOf()128    var hR = initHash()129    var ckR = hR.copyOf()130131    // Message 1: -> e132    val msg1 = initiatorEphemeral.publicKey133    hI = mixHash(hI, msg1)134    hI = mixHash(hI, ByteArray(0))135    hR = mixHash(hR, msg1)136    hR = mixHash(hR, ByteArray(0))137138    // Message 2: <- e, ee, s, es139    hR = mixHash(hR, responderEphemeral.publicKey)140    val eeR = sharedKey(responderEphemeral.secretKey, msg1)141    val (ckR1, kR1) = hkdf2(ckR, eeR)142    ckR = ckR1143    val encryptedResponderStatic = chachaSeal(kR1, nonceBytes(0L), responderStatic.publicKey, hR)144    hR = mixHash(hR, encryptedResponderStatic)145146    val esR = sharedKey(responderStatic.secretKey, msg1)147    val (ckR2, kR2) = hkdf2(ckR, esR)148    ckR = ckR2149    val encryptedPayload = chachaSeal(kR2, nonceBytes(0L), ByteArray(0), hR)150    hR = mixHash(hR, encryptedPayload)151152    val remoteEphemeral = responderEphemeral.publicKey153    hI = mixHash(hI, remoteEphemeral)154    val eeI = sharedKey(initiatorEphemeral.secretKey, remoteEphemeral)155    val (ckI1, kI1) = hkdf2(ckI, eeI)156    ckI = ckI1157    val remoteStaticPlain = chachaOpen(kI1, nonceBytes(0L), encryptedResponderStatic, hI)158        ?: throw IllegalStateException("Noise XX KAT: msg2 static auth failed")159    hI = mixHash(hI, encryptedResponderStatic)160161    val esI = sharedKey(initiatorEphemeral.secretKey, remoteStaticPlain)162    val (ckI2, kI2) = hkdf2(ckI, esI)163    ckI = ckI2164    chachaOpen(kI2, nonceBytes(0L), encryptedPayload, hI)165        ?: throw IllegalStateException("Noise XX KAT: msg2 payload auth failed")166    hI = mixHash(hI, encryptedPayload)167168    // Message 3: -> s, se169    val encryptedInitiatorStatic = chachaSeal(kI2, nonceBytes(1L), initiatorStatic.publicKey, hI)170    hI = mixHash(hI, encryptedInitiatorStatic)171    val seI = sharedKey(initiatorStatic.secretKey, remoteEphemeral)172    val (ckI3, kI3) = hkdf2(ckI, seI)173    ckI = ckI3174    val encryptedFinalPayload = chachaSeal(kI3, nonceBytes(0L), ByteArray(0), hI)175    hI = mixHash(hI, encryptedFinalPayload)176177    val initiatorStaticPlain = chachaOpen(kR2, nonceBytes(1L), encryptedInitiatorStatic, hR)178        ?: throw IllegalStateException("Noise XX KAT: msg3 static auth failed")179    hR = mixHash(hR, encryptedInitiatorStatic)180    val seR = sharedKey(responderEphemeral.secretKey, initiatorStaticPlain)181    val (ckR3, kR3) = hkdf2(ckR, seR)182    ckR = ckR3183    chachaOpen(kR3, nonceBytes(0L), encryptedFinalPayload, hR)184        ?: throw IllegalStateException("Noise XX KAT: msg3 final payload auth failed")185    hR = mixHash(hR, encryptedFinalPayload)186187    check(hI.contentEquals(hR)) { "Noise XX KAT sides diverged" }188    check(ckI.contentEquals(ckR)) { "Noise XX KAT chaining keys diverged" }189    return hI190}191192suspend fun noiseXXInitiator(193    ourStaticPrivateKey: ByteArray,194    ourStaticPublicKey: ByteArray,195    send: suspend (ByteArray) -> Unit,196    receive: suspend () -> ByteArray,197): NoiseXXSession {198    var h = initHash()199    var ck = h.copyOf()200201    val e = generateKeyPair()202    h = mixHash(h, e.publicKey)203    // PCH-02-002: canonical Noise still runs EncryptAndHash(empty) for msg1.204    // No bytes are added to the wire; the transcript hash moves in lockstep205    // with peach-browser.206    h = mixHash(h, ByteArray(0))207    send(e.publicKey)208209    val msg2 = receive()210    require(msg2.size >= 32 + 32 + 16 + 16) { "Noise XX: msg2 too short" }211    val remoteEphemeral = msg2.copyOfRange(0, 32)212    val encryptedStatic = msg2.copyOfRange(32, 32 + 32 + 16)213    val encryptedPayload = msg2.copyOfRange(32 + 32 + 16, msg2.size)214215    h = mixHash(h, remoteEphemeral)216    val ee = sharedKey(e.secretKey, remoteEphemeral)217    val (ck1, k1) = hkdf2(ck, ee)218    ck = ck1219220    val remoteStaticPlain = chachaOpen(k1, nonceBytes(0L), encryptedStatic, h)221        ?: throw IllegalStateException("Noise XX: msg2 static auth failed")222    require(remoteStaticPlain.size == 32) { "Noise XX: msg2 static auth failed" }223    h = mixHash(h, encryptedStatic)224225    val es = sharedKey(e.secretKey, remoteStaticPlain)226    val (ck2, k2) = hkdf2(ck, es)227    ck = ck2228229    val payloadPlain = chachaOpen(k2, nonceBytes(0L), encryptedPayload, h)230        ?: throw IllegalStateException("Noise XX: msg2 payload auth failed")231    h = mixHash(h, encryptedPayload)232    @Suppress("UNUSED_VARIABLE")233    val _payloadPlain = payloadPlain234235    val encryptedOurStatic = chachaSeal(k2, nonceBytes(1L), ourStaticPublicKey, h)236    h = mixHash(h, encryptedOurStatic)237238    val se = sharedKey(ourStaticPrivateKey, remoteEphemeral)239    val (ck3, k3) = hkdf2(ck, se)240    ck = ck3241242    val encryptedFinalPayload = chachaSeal(k3, nonceBytes(0L), ByteArray(0), h)243    h = mixHash(h, encryptedFinalPayload)244245    val msg3 = ByteArray(encryptedOurStatic.size + encryptedFinalPayload.size)246    encryptedOurStatic.copyInto(msg3, 0)247    encryptedFinalPayload.copyInto(msg3, encryptedOurStatic.size)248    send(msg3)249250    val (initSend, respSend) = hkdf2(ck, ByteArray(0))251    val sendCipher = initSend252    val recvCipher = respSend253    var sendNonce = 0L254    var recvNonce = 0L255256    return object : NoiseXXSession {257        override fun encrypt(plaintext: ByteArray): ByteArray {258            return chachaSeal(sendCipher, nonceBytes(sendNonce++), plaintext, ByteArray(0))259        }260261        override fun decrypt(ciphertext: ByteArray): ByteArray {262            return chachaOpen(recvCipher, nonceBytes(recvNonce++), ciphertext, ByteArray(0))263                ?: throw IllegalStateException("Noise XX: decryption failed")264        }265266        override val remoteStaticPublicKey: ByteArray = remoteStaticPlain267        override val handshakeHash: ByteArray = h.copyOf()268    }269}270271suspend fun noiseXXResponder(272    ourStaticPrivateKey: ByteArray,273    ourStaticPublicKey: ByteArray,274    send: suspend (ByteArray) -> Unit,275    receive: suspend () -> ByteArray,276): NoiseXXSession {277    var h = initHash()278    var ck = h.copyOf()279280    val msg1 = receive()281    require(msg1.size >= 32) { "Noise XX: msg1 too short" }282    val remoteEphemeral = msg1.copyOfRange(0, 32)283    h = mixHash(h, remoteEphemeral)284    // PCH-02-002: mirror the initiator's empty-payload MixHash for msg1.285    h = mixHash(h, ByteArray(0))286287    val e = generateKeyPair()288    h = mixHash(h, e.publicKey)289    val ee = sharedKey(e.secretKey, remoteEphemeral)290    val (ck1, k1) = hkdf2(ck, ee)291    ck = ck1292293    val encryptedOurStatic = chachaSeal(k1, nonceBytes(0L), ourStaticPublicKey, h)294    h = mixHash(h, encryptedOurStatic)295296    val es = sharedKey(ourStaticPrivateKey, remoteEphemeral)297    val (ck2, k2) = hkdf2(ck, es)298    ck = ck2299300    val encryptedPayload = chachaSeal(k2, nonceBytes(0L), ByteArray(0), h)301    h = mixHash(h, encryptedPayload)302303    val msg2 = ByteArray(e.publicKey.size + encryptedOurStatic.size + encryptedPayload.size)304    e.publicKey.copyInto(msg2, 0)305    encryptedOurStatic.copyInto(msg2, e.publicKey.size)306    encryptedPayload.copyInto(msg2, e.publicKey.size + encryptedOurStatic.size)307    send(msg2)308309    val msg3 = receive()310    require(msg3.size >= 32 + 16 + 16) { "Noise XX: msg3 too short" }311    val encryptedRemoteStatic = msg3.copyOfRange(0, 32 + 16)312    val encryptedFinalPayload = msg3.copyOfRange(32 + 16, msg3.size)313314    val remoteStaticPlain = chachaOpen(k2, nonceBytes(1L), encryptedRemoteStatic, h)315        ?: throw IllegalStateException("Noise XX: msg3 static auth failed")316    require(remoteStaticPlain.size == 32) { "Noise XX: msg3 static auth failed" }317    h = mixHash(h, encryptedRemoteStatic)318319    val se = sharedKey(e.secretKey, remoteStaticPlain)320    val (ck3, k3) = hkdf2(ck, se)321    ck = ck3322323    val finalPayloadPlain = chachaOpen(k3, nonceBytes(0L), encryptedFinalPayload, h)324        ?: throw IllegalStateException("Noise XX: msg3 final payload auth failed")325    h = mixHash(h, encryptedFinalPayload)326    @Suppress("UNUSED_VARIABLE")327    val _finalPayloadPlain = finalPayloadPlain328329    val (initSend, respSend) = hkdf2(ck, ByteArray(0))330    val sendCipher = respSend331    val recvCipher = initSend332    var sendNonce = 0L333    var recvNonce = 0L334335    return object : NoiseXXSession {336        override fun encrypt(plaintext: ByteArray): ByteArray {337            return chachaSeal(sendCipher, nonceBytes(sendNonce++), plaintext, ByteArray(0))338        }339340        override fun decrypt(ciphertext: ByteArray): ByteArray {341            return chachaOpen(recvCipher, nonceBytes(recvNonce++), ciphertext, ByteArray(0))342                ?: throw IllegalStateException("Noise XX: decryption failed")343        }344345        override val remoteStaticPublicKey: ByteArray = remoteStaticPlain346        override val handshakeHash: ByteArray = h.copyOf()347    }348}