1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.coroutineScope5import kotlinx.coroutines.cancelAndJoin6import kotlinx.coroutines.delay7import kotlinx.coroutines.isActive8import kotlinx.coroutines.withTimeoutOrNull9import java.security.SecureRandom10import java.util.concurrent.atomic.AtomicBoolean11import org.bouncycastle.crypto.params.X25519PrivateKeyParameters1213const val CHIRP_PAIRING_PORT: Int = 491421415interface PairingTransport {16 suspend fun send(msg: ByteArray)17 suspend fun receive(): ByteArray18 suspend fun close()19}2021data class PairingEndpoint(22 val ip: String,23 val port: Int,24)2526data class PairingServer(27 val transport: PairingTransport,28 val peerIp: String,29)3031data class PairingHandshakeResult(32 val device: PairedDevice,33 val sas: String,34 35363738 val peerLicense: String? = null,39)4041enum class PairingRole {42 INITIATOR,43 RESPONDER,44}4546interface ChirpPairingDeps {47 suspend fun fetchLocalIdentity(): LocalChirpIdentity48 suspend fun completePairing(paired: PairedDevice)49 suspend fun confirmSAS(sas: String): Boolean50 suspend fun cacheLastKnownIp(pubkeyHex: String, ip: String)51 suspend fun getLocalSubnet(): String?52 suspend fun openPairingTransport(endpoint: PairingEndpoint): PairingTransport53 suspend fun openPairingServer(): PairingServer54 suspend fun openAudio(): AudioIO55 val chirpTimeoutMs: Long56 fun onProgress(progress: PairingProgress)5758 5960616263 suspend fun activatePeerLicense(license: String) {}64}6566class ChirpPairingFailure(67 val phase: PairingPhase,68 message: String,69) : Error(message)7071interface AudioIO {72 val sampleRate: Int73 suspend fun play(waveform: FloatArray)74 suspend fun pullSamples(): FloatArray75 suspend fun close()76}7778interface PairingKeyPair {79 val publicKey: ByteArray80 val secretKey: ByteArray81}8283private data class PairingX25519KeyPair(84 override val publicKey: ByteArray,85 override val secretKey: ByteArray,86) : PairingKeyPair8788data class PairingAttempt(89 val ephemeralPrivateKey: ByteArray,90 val ephemeralPublicKey: ByteArray,91 val sessionNonce: ByteArray,92 val outgoingPayload: ChirpPayload,93)9495fun createPairingAttempt(deviceType: Byte): PairingAttempt {96 val kp = generatePairingKeyPair()97 val nonce = ByteArray(16)98 secureRandom().nextBytes(nonce)99 return PairingAttempt(100 ephemeralPrivateKey = kp.secretKey,101 ephemeralPublicKey = kp.publicKey,102 sessionNonce = nonce,103 outgoingPayload = ChirpPayload(104 version = CHIRP_PROTOCOL_VERSION,105 deviceType = deviceType,106 ephemeralPublicKey = kp.publicKey,107 sessionNonce = nonce,108 ),109 )110}111112data class ChirpExchangeResult(113 val remoteChirp: ChirpPayload,114)115116data class ChirpExchangeOptions(117 val attempt: PairingAttempt,118 val timeoutMs: Long = DEFAULT_CHIRP_TIMEOUT_MS,119 val onDiscard: ((ChirpDecodeError.Kind) -> Unit)? = null,120 val onChirpPlayed: ((Int) -> Unit)? = null,121)122123enum class ChirpExchangeErrorKind {124 TIMEOUT,125 VERSION_MISMATCH,126 MIC_DENIED,127 AUDIO_UNAVAILABLE,128}129130class ChirpExchangeError(131 val kind: ChirpExchangeErrorKind,132 message: String? = null,133) : Error(message ?: kind.name.lowercase())134135private const val CHIRP_LOOP_INTERVAL_MS = 4_000L136private const val DEFAULT_CHIRP_TIMEOUT_MS = 90_000L137private const val LISTEN_FRAME_SAMPLES = 4096138139fun generatePairingKeyPair(): PairingKeyPair {140 val priv = X25519PrivateKeyParameters(secureRandom())141 return PairingX25519KeyPair(142 publicKey = priv.generatePublicKey().encoded,143 secretKey = priv.encoded,144 )145}146147private fun secureRandom(): SecureRandom = SecureRandom()148149private fun sameBytes(a: ByteArray, b: ByteArray): Boolean {150 if (a.size != b.size) return false151 for (i in a.indices) {152 if (a[i] != b[i]) return false153 }154 return true155}156157158159160161162163164suspend fun runChirpExchange(165 io: AudioIO,166 opts: ChirpExchangeOptions,167 codecFactory: ChirpCodecFactory = ::createChirpCodec,168): ChirpExchangeResult {169 return coroutineScope {170 val codec = codecFactory(171 ChirpCodecOptions(172 sampleRate = io.sampleRate,173 protocol = GgwaveProtocol.ULTRASOUND_FASTEST,174 volumePercent = 10,175 ),176 )177 val ourBytes = encodeChirpPayload(opts.attempt.outgoingPayload)178 val ourWaveform = codec.encode(ourBytes)179180 val deadline = System.currentTimeMillis() + opts.timeoutMs181 val stopped = AtomicBoolean(false)182183 val playLoop = async {184 var playCount = 0185 while (isActive && !stopped.get() && System.currentTimeMillis() < deadline) {186 try {187 io.play(ourWaveform)188 playCount++189 opts.onChirpPlayed?.invoke(playCount)190 } catch (_: Throwable) {191 192 }193 if (stopped.get()) break194 val remaining = deadline - System.currentTimeMillis()195 val wait = minOf(CHIRP_LOOP_INTERVAL_MS, maxOf(0L, remaining))196 if (wait <= 0L) break197 delay(wait)198 }199 }200201 try {202 while (System.currentTimeMillis() < deadline && !stopped.get()) {203 val samples = io.pullSamples()204 if (samples.isEmpty()) {205 delay(50)206 continue207 }208209 val decoded = codec.decode(samples)210 if (decoded == null) continue211 if (decoded.size != CHIRP_PAYLOAD_LENGTH) {212 opts.onDiscard?.invoke(ChirpDecodeError.Kind.LENGTH)213 continue214 }215216 try {217 val payload = decodeChirpPayload(decoded)218 if (sameBytes(payload.ephemeralPublicKey, opts.attempt.ephemeralPublicKey)) {219 continue220 }221 if (payload.version != CHIRP_PROTOCOL_VERSION) {222 throw ChirpExchangeError(ChirpExchangeErrorKind.VERSION_MISMATCH)223 }224 stopped.set(true)225 return@coroutineScope ChirpExchangeResult(remoteChirp = payload)226 } catch (err: ChirpExchangeError) {227 throw err228 } catch (err: ChirpDecodeError) {229 if (err.kind == ChirpDecodeError.Kind.VERSION) {230 throw ChirpExchangeError(ChirpExchangeErrorKind.VERSION_MISMATCH)231 }232 opts.onDiscard?.invoke(err.kind)233 continue234 }235 }236 throw ChirpExchangeError(ChirpExchangeErrorKind.TIMEOUT)237 } finally {238 stopped.set(true)239 codec.free()240 playLoop.cancelAndJoin()241 try {242 io.close()243 } catch (_: Throwable) {244 245 }246 }247 }248}249250suspend fun openAndroidAudioIO(): AudioIO = openPcmFloatAudioIO()251252suspend fun runChirpPairing(253 deps: ChirpPairingDeps,254 role: PairingRole = PairingRole.RESPONDER,255 codecFactory: ChirpCodecFactory = ::createChirpCodec,256): PairedDevice {257 deps.onProgress(PairingProgress(phase = PairingPhase.IDLE))258259 val localIdentity = deps.fetchLocalIdentity()260261 if (role == PairingRole.RESPONDER && localIdentity.deviceType != DeviceType.EXTENSION) {262 throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, "responder requires extension identity")263 }264 if (role == PairingRole.INITIATOR && localIdentity.deviceType == DeviceType.EXTENSION) {265 throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, "initiator requires non-extension identity")266 }267268 deps.onProgress(PairingProgress(phase = PairingPhase.LISTENING))269270 val deviceTypeByte = when (localIdentity.deviceType) {271 DeviceType.ANDROID -> deviceTypeToByte(DeviceType.ANDROID)272 DeviceType.IOS -> deviceTypeToByte(DeviceType.IOS)273 DeviceType.DESKTOP -> deviceTypeToByte(DeviceType.DESKTOP)274 DeviceType.EXTENSION -> deviceTypeToByte(DeviceType.EXTENSION)275 }276 val attempt = createPairingAttempt(deviceTypeByte)277278 val remoteChirpPublicKey: ByteArray279 try {280 val audio = deps.openAudio()281 val result = runChirpExchange(282 io = audio,283 opts = ChirpExchangeOptions(284 attempt = attempt,285 timeoutMs = deps.chirpTimeoutMs,286 ),287 codecFactory = codecFactory,288 )289 remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey290 } catch (err: ChirpExchangeError) {291 val phase = when (err.kind) {292 ChirpExchangeErrorKind.TIMEOUT -> PairingPhase.TIMEOUT293 ChirpExchangeErrorKind.VERSION_MISMATCH -> PairingPhase.VERSION_MISMATCH294 ChirpExchangeErrorKind.MIC_DENIED -> PairingPhase.MIC_DENIED295 ChirpExchangeErrorKind.AUDIO_UNAVAILABLE -> PairingPhase.HANDSHAKE_FAILED296 }297 deps.onProgress(PairingProgress(phase = phase, errorMessage = err.message))298 throw ChirpPairingFailure(phase, err.message ?: phase.wireValue)299 }300301 deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE))302303 if (role == PairingRole.RESPONDER) {304 return runResponderHandshake(deps, attempt, localIdentity)305 }306 return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity)307}308309private suspend fun runResponderHandshake(310 deps: ChirpPairingDeps,311 attempt: PairingAttempt,312 localIdentity: LocalChirpIdentity,313): PairedDevice {314 val subnet = deps.getLocalSubnet()315 if (subnet.isNullOrBlank()) {316 deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = "no_subnet"))317 throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, "could not determine local subnet")318 }319320 val peerEndpoint = firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT)321 if (peerEndpoint == null) {322 deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = "peer_not_found"))323 throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, "could not reach paired peer on subnet")324 }325326 val transport = deps.openPairingTransport(peerEndpoint)327 val result = try {328 runPairingResponder(329 ourEphemeralPrivateKey = attempt.ephemeralPrivateKey,330 ourEphemeralPublicKey = attempt.ephemeralPublicKey,331 ourIdentity = localIdentity,332 transport = transport,333 )334 } catch (err: Throwable) {335 try {336 transport.close()337 } catch (_: Throwable) {338 339 }340 deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = err.message))341 throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, err.message ?: "handshake failed")342 }343 transport.close()344345 val paired = gateOnSAS(deps, result)346 activatePeerLicenseIfAny(deps, result)347 deps.completePairing(paired)348 deps.cacheLastKnownIp(paired.longTermPublicKeyHex.orEmpty(), peerEndpoint.ip)349 deps.onProgress(PairingProgress(phase = PairingPhase.PAIRED, peerDeviceName = paired.deviceName))350 return paired351}352353private suspend fun gateOnSAS(354 deps: ChirpPairingDeps,355 result: PairingHandshakeResult,356): PairedDevice {357 val confirmed = try {358 deps.confirmSAS(result.sas)359 } catch (err: Throwable) {360 deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = err.message))361 throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, err.message ?: "handshake failed")362 }363 if (!confirmed) {364 deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = "sas_rejected"))365 throw ChirpPairingFailure(366 PairingPhase.HANDSHAKE_FAILED,367 "pairing rejected - the verification codes did not match",368 )369 }370 return result.device371}372373374375376377378private suspend fun activatePeerLicenseIfAny(379 deps: ChirpPairingDeps,380 result: PairingHandshakeResult,381) {382 val license = result.peerLicense ?: return383 try {384 deps.activatePeerLicense(license)385 } catch (_: Throwable) {386 387 }388}389390private suspend fun runInitiatorHandshake(391 deps: ChirpPairingDeps,392 remoteChirpPublicKey: ByteArray,393 localIdentity: LocalChirpIdentity,394): PairedDevice {395 val server = try {396 deps.openPairingServer()397 } catch (err: Throwable) {398 deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = err.message))399 throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, err.message ?: "handshake failed")400 }401402 val result = try {403 runPairingInitiator(remoteChirpPublicKey, localIdentity, server.transport)404 } catch (err: Throwable) {405 try {406 server.transport.close()407 } catch (_: Throwable) {408 409 }410 deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = err.message))411 throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, err.message ?: "handshake failed")412 }413 server.transport.close()414415 val paired = gateOnSAS(deps, result)416 activatePeerLicenseIfAny(deps, result)417 deps.completePairing(paired)418 deps.cacheLastKnownIp(paired.longTermPublicKeyHex.orEmpty(), server.peerIp)419 deps.onProgress(PairingProgress(phase = PairingPhase.PAIRED, peerDeviceName = paired.deviceName))420 return paired421}422423private suspend fun firstResponsivePeer(424 deps: ChirpPairingDeps,425 subnet: String,426 port: Int,427): PairingEndpoint? {428 val probeTimeoutMs = 250L429 val batch = 16430 for (start in 1..254 step batch) {431 val end = minOf(start + batch - 1, 254)432 val ips = (start..end).map { "$subnet.$it" }433 val results = coroutineScope {434 ips.map { ip ->435 async {436 try {437 val transport = withTimeoutOrNull(probeTimeoutMs) {438 deps.openPairingTransport(PairingEndpoint(ip = ip, port = port))439 }440 if (transport != null) {441 transport.close()442 PairingEndpoint(ip = ip, port = port)443 } else {444 null445 }446 } catch (_: Throwable) {447 null448 }449 }450 }.mapNotNull { it.await() }451 }452 val hit = results.firstOrNull()453 if (hit != null) return hit454 }455 return null456}457458private suspend fun runPairingInitiator(459 remoteEphemeralPublicKey: ByteArray,460 ourIdentity: LocalChirpIdentity,461 transport: PairingTransport,462): PairingHandshakeResult {463 val session = noiseNKInitiator(464 remoteEphemeralPublicKey,465 transport::send,466 transport::receive,467 )468469 transport.send(470 session.encrypt(471 encodePairingFrame(472 PairingFrame(473 type = "PAIR_IDENTITY",474 deviceId = ourIdentity.deviceId,475 deviceName = ourIdentity.deviceName,476 deviceType = ourIdentity.deviceType,477 longTermPublicKeyHex = ourIdentity.longTermPublicKeyHex,478 signingPublicKeyHex = ourIdentity.signingPublicKeyHex,479 wsPort = ourIdentity.wsPort,480 license = ourIdentity.license,481 ),482 ),483 ),484 )485486 val theirCipher = transport.receive()487 val theirPlain = session.decrypt(theirCipher)488 val theirFrame = decodePairingFrame(theirPlain)489 return PairingHandshakeResult(490 device = frameToPairedDevice(theirFrame),491 sas = SASDerivation.derive(session.handshakeHash),492 peerLicense = theirFrame.license,493 )494}495496private suspend fun runPairingResponder(497 ourEphemeralPrivateKey: ByteArray,498 ourEphemeralPublicKey: ByteArray,499 ourIdentity: LocalChirpIdentity,500 transport: PairingTransport,501): PairingHandshakeResult {502 val session = noiseNKResponder(503 ourEphemeralPrivateKey,504 ourEphemeralPublicKey,505 transport::send,506 transport::receive,507 )508509 val theirCipher = transport.receive()510 val theirPlain = session.decrypt(theirCipher)511 val theirFrame = decodePairingFrame(theirPlain)512513 transport.send(514 session.encrypt(515 encodePairingFrame(516 PairingFrame(517 type = "PAIR_IDENTITY",518 deviceId = ourIdentity.deviceId,519 deviceName = ourIdentity.deviceName,520 deviceType = ourIdentity.deviceType,521 longTermPublicKeyHex = ourIdentity.longTermPublicKeyHex,522 signingPublicKeyHex = ourIdentity.signingPublicKeyHex,523 wsPort = ourIdentity.wsPort,524 license = ourIdentity.license,525 ),526 ),527 ),528 )529530 return PairingHandshakeResult(531 device = frameToPairedDevice(theirFrame),532 sas = SASDerivation.derive(session.handshakeHash),533 peerLicense = theirFrame.license,534 )535}536537internal data class PairingFrame(538 val type: String,539 val deviceId: String,540 val deviceName: String,541 val deviceType: DeviceType,542 val longTermPublicKeyHex: String,543 val signingPublicKeyHex: String? = null,544 val wsPort: Int? = null,545 546 547 val license: String? = null,548)549550internal fun encodePairingFrame(frame: PairingFrame): ByteArray {551 val builder = StringBuilder()552 builder.append('{')553 builder.append("\"type\":\"").append(frame.type).append('\"')554 builder.append(",\"device_id\":\"").append(escapeJson(frame.deviceId)).append('\"')555 builder.append(",\"device_name\":\"").append(escapeJson(frame.deviceName)).append('\"')556 builder.append(",\"device_type\":\"").append(frame.deviceType.wireValue).append('\"')557 builder.append(",\"long_term_public_key_hex\":\"").append(frame.longTermPublicKeyHex).append('\"')558 if (!frame.signingPublicKeyHex.isNullOrBlank()) {559 builder.append(",\"signing_public_key_hex\":\"").append(frame.signingPublicKeyHex).append('\"')560 }561 if (frame.wsPort != null) {562 builder.append(",\"ws_port\":").append(frame.wsPort)563 }564 if (!frame.license.isNullOrBlank()) {565 builder.append(",\"license\":\"").append(escapeJson(frame.license)).append('\"')566 }567 builder.append('}')568 return builder.toString().encodeToByteArray()569}570571internal fun decodePairingFrame(bytes: ByteArray): PairingFrame {572 val text = bytes.decodeToString()573 val parsed = simpleJsonObject(text)574 require(parsed["type"] == "PAIR_IDENTITY") { "expected PAIR_IDENTITY frame" }575 val deviceId = parsed["device_id"] ?: error("device_id missing")576 val deviceName = parsed["device_name"] ?: error("device_name missing")577 val deviceType = when (parsed["device_type"]) {578 "extension" -> DeviceType.EXTENSION579 "android" -> DeviceType.ANDROID580 "ios" -> DeviceType.IOS581 "desktop" -> DeviceType.DESKTOP582 else -> error("device_type missing")583 }584 val longTermPublicKeyHex = parsed["long_term_public_key_hex"] ?: error("long_term_public_key_hex missing")585 require(longTermPublicKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {586 "long_term_public_key_hex is not 32-byte hex"587 }588 val signingPublicKeyHex = parsed["signing_public_key_hex"]?.takeIf { it.isNotBlank() }589 if (signingPublicKeyHex != null) {590 require(signingPublicKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {591 "signing_public_key_hex is not 32-byte hex"592 }593 }594 val wsPort = parsed["ws_port"]?.toIntOrNull()595 val license = parsed["license"]?.takeIf { it.isNotBlank() }596 return PairingFrame(597 type = "PAIR_IDENTITY",598 deviceId = deviceId,599 deviceName = deviceName,600 deviceType = deviceType,601 longTermPublicKeyHex = longTermPublicKeyHex,602 signingPublicKeyHex = signingPublicKeyHex,603 wsPort = wsPort,604 license = license,605 )606}607608private fun frameToPairedDevice(frame: PairingFrame): PairedDevice {609 return PairedDevice(610 deviceId = frame.deviceId,611 deviceName = frame.deviceName,612 deviceType = frame.deviceType.wireValue,613 longTermPublicKeyHex = frame.longTermPublicKeyHex,614 signingPublicKeyHex = frame.signingPublicKeyHex,615 trustSource = "direct",616 wsPort = frame.wsPort,617 pairedAt = System.currentTimeMillis(),618 )619}620621private fun escapeJson(value: String): String {622 return buildString(value.length) {623 for (ch in value) {624 when (ch) {625 '\\' -> append("\\\\")626 '"' -> append("\\\"")627 '\b' -> append("\\b")628 '\u000C' -> append("\\f")629 '\n' -> append("\\n")630 '\r' -> append("\\r")631 '\t' -> append("\\t")632 else -> append(ch)633 }634 }635 }636}637638private fun simpleJsonObject(text: String): Map<String, String> {639 val trimmed = text.trim()640 require(trimmed.startsWith("{") && trimmed.endsWith("}")) { "expected JSON object" }641 val body = trimmed.substring(1, trimmed.length - 1).trim()642 if (body.isEmpty()) return emptyMap()643 val out = linkedMapOf<String, String>()644 var i = 0645 while (i < body.length) {646 while (i < body.length && body[i].isWhitespace()) i++647 require(i < body.length && body[i] == '"') { "expected string key" }648 i++649 val keyStart = i650 while (i < body.length && body[i] != '"') i++651 val key = body.substring(keyStart, i)652 i++653 while (i < body.length && (body[i].isWhitespace() || body[i] == ':')) i++654 val value: String655 if (i < body.length && body[i] == '"') {656 i++657 val valueStart = i658 while (i < body.length && body[i] != '"') i++659 value = body.substring(valueStart, i)660 i++661 } else {662 val valueStart = i663 while (i < body.length && body[i] != ',') i++664 value = body.substring(valueStart, i).trim()665 }666 out[key] = value667 while (i < body.length && body[i].isWhitespace()) i++668 if (i < body.length && body[i] == ',') i++669 }670 return out671}