123cd1686b6b

feat: verify pairing key proof (v2.3.73)

time
2026-07-26 03:12 UTC · 3 months ago
machine
koh2
agent
codex
session
peach relay sync implementation
parent
e8eabfde0283
5 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+22 −0app/src/main/java/com/peachpasswords/sync/NoiseNK.kt+7 −5app/src/main/java/com/peachpasswords/sync/PairingAuth.kt+131 −0app/src/test/java/com/peachpasswords/sync/PairingAuthTest.kt+111 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23720020-        versionName "2.3.72"19+        versionCode 23730020+        versionName "2.3.73"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+22 −0
@@ -205,10 +205,32 @@205205                 { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },206206             )207207 208+            // E1: the initiator proves it holds the private half of the key it209+            // will advertise. Bind that proof to this NK transcript before any210+            // identity frame is accepted or SAS consent is requested.211+            val authenticatedInitiatorKey = performResponderPairingAuth(212+                session = session,213+                ourPrivateKey = ourStaticPrivateKey,214+                ourPublicKey = ourStaticPublicKey,215+                sendEncryptedJson = { plaintext -> ws.send(session.encrypt(plaintext)) },216+                receiveEncryptedJson = {217+                    session.decrypt(218+                        (ws.receive() as? WsMessage.Binary)?.bytes219+                            ?: throw IllegalStateException("peer closed"),220+                    )221+                },222+            )223+208224             // Browser first, then us — matching the contract's ordering.209225             val theirIdentity = parsePairIdentityFrame(210226                 session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed")),211227             )228+            require(229+                java.security.MessageDigest.isEqual(230+                    authenticatedInitiatorKey,231+                    hexToPubkey(theirIdentity.longTermPublicKeyHex),232+                ),233+            ) { "PAIR_IDENTITY key does not match proven initiator key" }212234             ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))213235 214236             val sas = SASDerivation.derive(session.handshakeHash)

view file

app/src/main/java/com/peachpasswords/sync/NoiseNK.kt+7 −5
@@ -77,11 +77,13 @@7777     )7878 }7979 80-private fun sharedKey(privateKey: ByteArray, publicKey: ByteArray): ByteArray {80+internal fun x25519SharedKey(privateKey: ByteArray, publicKey: ByteArray): ByteArray {81+    require(privateKey.size == 32 && publicKey.size == 32) { "X25519 keys must be 32 bytes" }8182     val priv = X25519PrivateKeyParameters(privateKey, 0)8283     val pub = X25519PublicKeyParameters(publicKey, 0)8384     val out = ByteArray(32)8485     priv.generateSecret(pub, out, 0)86+    require(out.any { it.toInt() != 0 }) { "X25519 low-order point rejected" }8587     return out8688 }8789 @@ -119,7 +121,7 @@119121 120122     val ephemeral = generateKeyPair()121123     h = mixHash(h, ephemeral.publicKey)122-    val es = sharedKey(ephemeral.secretKey, responderStaticPublicKey)124+    val es = x25519SharedKey(ephemeral.secretKey, responderStaticPublicKey)123125     val (ck1, k1) = hkdf2(ck, es)124126     ck = ck1125127 @@ -140,7 +142,7 @@140142     val encrypted2 = msg2.copyOfRange(32, msg2.size)141143 142144     h = mixHash(h, remoteEphemeralPublic)143-    val ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic)145+    val ee = x25519SharedKey(ephemeral.secretKey, remoteEphemeralPublic)144146     val (ck2, k2) = hkdf2(ck, ee)145147     ck = ck2146148 @@ -185,7 +187,7 @@185187     val encrypted1 = msg1.copyOfRange(32, msg1.size)186188 187189     h = mixHash(h, remoteEphemeralPublic)188-    val es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic)190+    val es = x25519SharedKey(ourStaticPrivateKey, remoteEphemeralPublic)189191     val (ck1, k1) = hkdf2(ck, es)190192     ck = ck1191193 @@ -195,7 +197,7 @@195197 196198     val ephemeral = generateKeyPair()197199     h = mixHash(h, ephemeral.publicKey)198-    val ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic)200+    val ee = x25519SharedKey(ephemeral.secretKey, remoteEphemeralPublic)199201     val (ck2, k2) = hkdf2(ck, ee)200202     ck = ck2201203 

view file

app/src/main/java/com/peachpasswords/sync/PairingAuth.kt+131 −0
@@ -0,0 +1,131 @@1+package com.peachpasswords.sync2+3+import org.json.JSONObject4+import java.security.MessageDigest5+import java.security.SecureRandom6+import javax.crypto.Mac7+import javax.crypto.spec.SecretKeySpec8+9+private val PAIRING_AUTH_DOMAIN = "peach-sync-v1-initiator-auth".encodeToByteArray()10+11+/**12+ * E1 post-NK proof:13+ * HMAC-SHA256(X25519(initiatorPriv, responderPub),14+ *   handshakeHash || domain || initiatorPub || responderPub || nonce)15+ */16+internal fun computeInitiatorAuthProof(17+    initiatorPrivateKey: ByteArray,18+    initiatorPublicKey: ByteArray,19+    responderPublicKey: ByteArray,20+    nonce: ByteArray,21+    handshakeHash: ByteArray,22+): ByteArray {23+    require(initiatorPrivateKey.size == 32) { "initiator private key must be 32 bytes" }24+    require(initiatorPublicKey.size == 32) { "initiator public key must be 32 bytes" }25+    require(responderPublicKey.size == 32) { "responder public key must be 32 bytes" }26+    require(nonce.size == 32) { "auth nonce must be 32 bytes" }27+    require(handshakeHash.size == 32) { "handshake hash must be 32 bytes" }28+29+    return computeAuthProofForPeer(30+        localPrivateKey = initiatorPrivateKey,31+        remotePublicKey = responderPublicKey,32+        initiatorPublicKey = initiatorPublicKey,33+        responderPublicKey = responderPublicKey,34+        nonce = nonce,35+        handshakeHash = handshakeHash,36+    )37+}38+39+private fun computeAuthProofForPeer(40+    localPrivateKey: ByteArray,41+    remotePublicKey: ByteArray,42+    initiatorPublicKey: ByteArray,43+    responderPublicKey: ByteArray,44+    nonce: ByteArray,45+    handshakeHash: ByteArray,46+): ByteArray {47+    val dh = x25519SharedKey(localPrivateKey, remotePublicKey)48+    return Mac.getInstance("HmacSHA256").run {49+        init(SecretKeySpec(dh, "HmacSHA256"))50+        update(handshakeHash)51+        update(PAIRING_AUTH_DOMAIN)52+        update(initiatorPublicKey)53+        update(responderPublicKey)54+        update(nonce)55+        doFinal()56+    }57+}58+59+/**60+ * Browser/Android initiator half, useful for native-to-native first pairing.61+ * The challenge's responder key must match the key already authenticated by NK.62+ */63+internal suspend fun performInitiatorPairingAuth(64+    session: NoiseNKSession,65+    ourPrivateKey: ByteArray,66+    ourPublicKey: ByteArray,67+    responderPublicKey: ByteArray,68+    sendEncryptedJson: suspend (ByteArray) -> Unit,69+    receiveEncryptedJson: suspend () -> ByteArray,70+) {71+    val challenge = JSONObject(String(receiveEncryptedJson(), Charsets.UTF_8))72+    require(challenge.optString("type") == "CHALLENGE") { "expected CHALLENGE frame" }73+    val nonce = hexToPubkey(challenge.getString("nonce_hex"))74+    val challengedResponder = hexToPubkey(challenge.getString("responder_public_key_hex"))75+    require(MessageDigest.isEqual(challengedResponder, responderPublicKey)) {76+        "responder key in challenge does not match Noise handshake"77+    }78+    val proof = computeInitiatorAuthProof(79+        ourPrivateKey,80+        ourPublicKey,81+        responderPublicKey,82+        nonce,83+        session.handshakeHash,84+    )85+    sendEncryptedJson(86+        JSONObject()87+            .put("type", "PROOF")88+            .put("initiator_public_key_hex", pubkeyToHex(ourPublicKey))89+            .put("proof_hex", pubkeyToHex(proof))90+            .toString()91+            .toByteArray(Charsets.UTF_8),92+    )93+}94+95+/**96+ * Android responder half. Returns the initiator key whose private half was97+ * proven, so the caller can bind it to the following PAIR_IDENTITY frame.98+ */99+internal suspend fun performResponderPairingAuth(100+    session: NoiseNKSession,101+    ourPrivateKey: ByteArray,102+    ourPublicKey: ByteArray,103+    sendEncryptedJson: suspend (ByteArray) -> Unit,104+    receiveEncryptedJson: suspend () -> ByteArray,105+    random: SecureRandom = SecureRandom(),106+): ByteArray {107+    val nonce = ByteArray(32).also(random::nextBytes)108+    sendEncryptedJson(109+        JSONObject()110+            .put("type", "CHALLENGE")111+            .put("nonce_hex", pubkeyToHex(nonce))112+            .put("responder_public_key_hex", pubkeyToHex(ourPublicKey))113+            .toString()114+            .toByteArray(Charsets.UTF_8),115+    )116+117+    val frame = JSONObject(String(receiveEncryptedJson(), Charsets.UTF_8))118+    require(frame.optString("type") == "PROOF") { "expected PROOF frame" }119+    val initiatorPublicKey = hexToPubkey(frame.getString("initiator_public_key_hex"))120+    val claimedProof = hexToPubkey(frame.getString("proof_hex"))121+    val expectedProof = computeAuthProofForPeer(122+        localPrivateKey = ourPrivateKey,123+        remotePublicKey = initiatorPublicKey,124+        initiatorPublicKey = initiatorPublicKey,125+        responderPublicKey = ourPublicKey,126+        nonce = nonce,127+        handshakeHash = session.handshakeHash,128+    )129+    require(MessageDigest.isEqual(expectedProof, claimedProof)) { "auth proof did not verify" }130+    return initiatorPublicKey131+}

view file

app/src/test/java/com/peachpasswords/sync/PairingAuthTest.kt+111 −0
@@ -0,0 +1,111 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.async4+import kotlinx.coroutines.channels.Channel5+import kotlinx.coroutines.runBlocking6+import org.bouncycastle.crypto.params.X25519PrivateKeyParameters7+import org.junit.Assert.assertArrayEquals8+import org.junit.Assert.assertEquals9+import org.junit.Test10+import java.security.SecureRandom11+12+class PairingAuthTest {13+    private data class Keys(val privateKey: ByteArray, val publicKey: ByteArray)14+15+    private fun keys(): Keys {16+        val privateKey = X25519PrivateKeyParameters(SecureRandom())17+        return Keys(privateKey.encoded, privateKey.generatePublicKey().encoded)18+    }19+20+    private fun session(hash: ByteArray) = object : NoiseNKSession {21+        override fun encrypt(plaintext: ByteArray): ByteArray = plaintext22+        override fun decrypt(ciphertext: ByteArray): ByteArray = ciphertext23+        override val handshakeHash: ByteArray = hash24+    }25+26+    @Test27+    fun `auth proof matches browser cross-platform vector`() {28+        val proof = computeInitiatorAuthProof(29+            initiatorPrivateKey = ByteArray(32) { 0x01 },30+            initiatorPublicKey = hexToPubkey(31+                "a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a209",32+            ),33+            responderPublicKey = hexToPubkey(34+                "ce8d3ad1ccb633ec7b70c17814a5c76ecd029685050d344745ba05870e587d59",35+            ),36+            nonce = ByteArray(32) { 0x03 },37+            handshakeHash = ByteArray(32) { 0x04 },38+        )39+40+        assertEquals(41+            "d947d11a82892a65fdde94da5bbe6ca6034cf1f7e3268fc3ce5851b55f5813ce",42+            pubkeyToHex(proof),43+        )44+    }45+46+    @Test47+    fun `initiator proof round trips and returns authenticated key`() = runBlocking {48+        val initiator = keys()49+        val responder = keys()50+        val hash = ByteArray(32) { 0x07 }51+        val initToResponder = Channel<ByteArray>(Channel.UNLIMITED)52+        val responderToInit = Channel<ByteArray>(Channel.UNLIMITED)53+54+        val prove = async {55+            performInitiatorPairingAuth(56+                session(hash),57+                initiator.privateKey,58+                initiator.publicKey,59+                responder.publicKey,60+                { initToResponder.send(it) },61+                { responderToInit.receive() },62+            )63+        }64+        val verify = async {65+            performResponderPairingAuth(66+                session(hash),67+                responder.privateKey,68+                responder.publicKey,69+                { responderToInit.send(it) },70+                { initToResponder.receive() },71+            )72+        }73+74+        prove.await()75+        assertArrayEquals(initiator.publicKey, verify.await())76+    }77+78+    @Test79+    fun `proof from a different handshake hash is rejected`() = runBlocking {80+        val initiator = keys()81+        val responder = keys()82+        val initToResponder = Channel<ByteArray>(Channel.UNLIMITED)83+        val responderToInit = Channel<ByteArray>(Channel.UNLIMITED)84+85+        val prove = async {86+            performInitiatorPairingAuth(87+                session(ByteArray(32) { 0x07 }),88+                initiator.privateKey,89+                initiator.publicKey,90+                responder.publicKey,91+                { initToResponder.send(it) },92+                { responderToInit.receive() },93+            )94+        }95+        val verify = async {96+            runCatching {97+                performResponderPairingAuth(98+                    session(ByteArray(32) { 0x09 }),99+                    responder.privateKey,100+                    responder.publicKey,101+                    { responderToInit.send(it) },102+                    { initToResponder.receive() },103+                )104+            }105+        }106+107+        prove.await()108+        val failure = verify.await().exceptionOrNull()109+        assertEquals("auth proof did not verify", failure?.message)110+    }111+}

view file