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
↓ peach-android-123cd168.tar.gzatlas at this save
files changed5 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 −0app/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 {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)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 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+}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+}