1package com.peachpasswords.sync23import kotlinx.serialization.SerialName4import kotlinx.serialization.Serializable5import kotlinx.serialization.json.Json6789101112131415161718192021@Serializable22sealed class AdoptionFrame {23 24252627282930313233 @Serializable34 @SerialName("ADOPT_REQUEST")35 data class Request(36 @SerialName("device_id") val sessionRequestId: String,37 @SerialName("device_name") val deviceName: String,38 @SerialName("device_type") val deviceType: String,39 ) : AdoptionFrame()4041 @Serializable42 @SerialName("ADOPT_CONFIRM")43 data class Confirm(44 val confirmed: Boolean,45 ) : AdoptionFrame()4647 @Serializable48 @SerialName("ADOPT_GRANT")49 data class Grant(50 @SerialName("root_key_hex") val rootKeyHex: String,51 @SerialName("vault_id") val vaultId: String,52 val license: String? = null,53 val source: Source,54 ) : AdoptionFrame()5556 @Serializable57 @SerialName("ADOPT_IDENTITY")58 data class Identity(59 @SerialName("device_id") val deviceId: String,60 @SerialName("device_name") val deviceName: String,61 @SerialName("device_type") val deviceType: String,62 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,63 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,64 ) : AdoptionFrame()6566 @Serializable67 data class Source(68 @SerialName("device_id") val deviceId: String,69 @SerialName("device_name") val deviceName: String,70 @SerialName("device_type") val deviceType: String,71 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,72 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,73 @SerialName("ws_port") val wsPort: Int? = null,74 @SerialName("relay_url") val relayUrl: String? = null,75 )76}7778data class AdoptionPairState(79 val rendezvousSecretHex: String,80 val relayUrl: String? = null,81)828384interface AdoptionReceiverDeps {85 val deviceName: String86 val deviceType: DeviceType87 fun generateDeviceId(): String88 89 suspend fun confirmSas(): Boolean9091 92 suspend fun createVaultFromRootKey(rootKeyHex: String): String93 suspend fun deriveLocalIdentity(): LocalDeviceIdentity94 95 suspend fun clearPairedDeviceRevocation(deviceId: String) {}96 suspend fun upsertPairedDevice(device: PairedDevice)97 suspend fun applyLicense(license: String) {}98 val pairState: AdoptionPairState? get() = null99 fun onError(error: String)100}101102103interface AdoptionSourceDeps {104 val sourceIdentity: LocalDeviceIdentity105 suspend fun rootKeyHex(): String106 suspend fun vaultId(): String107 suspend fun license(): String? = null108109 110111112113114115 suspend fun onConsent(request: AdoptionFrame.Request): Boolean116 suspend fun upsertPairedDevice(device: PairedDevice)117 val pairState: AdoptionPairState? get() = null118}119120private val Hex64 = Regex("^[0-9a-f]{64}\$")121122fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)123124fun isAdoptRequest(frame: AdoptionFrame?): Boolean =125 frame is AdoptionFrame.Request &&126 frame.sessionRequestId.isNotEmpty() &&127 isDeviceType(frame.deviceType)128129fun isAdoptGrant(frame: AdoptionFrame?): Boolean =130 frame is AdoptionFrame.Grant &&131 validateRootKeyHex(frame.rootKeyHex) &&132 Hex64.matches(frame.vaultId) &&133 isDeviceType(frame.source.deviceType) &&134 Hex64.matches(frame.source.longTermPublicKeyHex) &&135 (frame.source.relayUrl == null ||136 runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)137138fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =139 frame is AdoptionFrame.Confirm140141fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =142 frame is AdoptionFrame.Identity &&143 frame.deviceId.isNotEmpty() &&144 isDeviceType(frame.deviceType) &&145 Hex64.matches(frame.longTermPublicKeyHex)146147148149150151152153154suspend fun requestAdoption(155 channel: PairingTransport,156 deps: AdoptionReceiverDeps,157 nowMs: () -> Long = { System.currentTimeMillis() },158): PairedDevice? {159 160 161 sendFrame(channel, AdoptionFrame.Request(162 sessionRequestId = deps.generateDeviceId(),163 deviceName = deps.deviceName,164 deviceType = deps.deviceType.wireValue,165 ))166167 val confirmed = deps.confirmSas()168 sendFrame(channel, AdoptionFrame.Confirm(confirmed))169 if (!confirmed) {170 deps.onError("ADOPT_SAS_REJECTED")171 return null172 }173174 val frame = receiveFrame(channel)175 if (!isAdoptGrant(frame)) {176 deps.onError("ADOPT_UNEXPECTED_FRAME")177 return null178 }179 val grant = frame as AdoptionFrame.Grant180181 val rootKeyBytes = hexToPubkey(grant.rootKeyHex)182 try {183 if (computeVaultId(rootKeyBytes) != grant.vaultId) {184 deps.onError("ADOPT_VAULT_ID_MISMATCH")185 return null186 }187 } finally {188 rootKeyBytes.fill(0)189 }190191 val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)192 if (vaultId != grant.vaultId) {193 deps.onError("ADOPT_VAULT_ID_MISMATCH")194 return null195 }196197 grant.license?.let { deps.applyLicense(it) }198199 val identity = deps.deriveLocalIdentity()200 sendFrame(channel, AdoptionFrame.Identity(201 deviceId = identity.deviceId,202 deviceName = identity.deviceName,203 deviceType = identity.deviceType.wireValue,204 longTermPublicKeyHex = identity.longTermPublicKeyHex,205 signingPublicKeyHex = identity.signingPublicKeyHex,206 ))207208 val sourceDevice = PairedDevice(209 deviceId = grant.source.deviceId,210 deviceName = grant.source.deviceName,211 deviceType = grant.source.deviceType,212 longTermPublicKeyHex = grant.source.longTermPublicKeyHex,213 signingPublicKeyHex = grant.source.signingPublicKeyHex,214 trustSource = "direct",215 wsPort = grant.source.wsPort,216 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,217 relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,218 pairedAt = nowMs(),219 )220 221 222 223 deps.clearPairedDeviceRevocation(sourceDevice.deviceId)224 deps.upsertPairedDevice(sourceDevice)225 return sourceDevice226}227228229230231232233suspend fun grantAdoption(234 channel: PairingTransport,235 request: AdoptionFrame.Request,236 deps: AdoptionSourceDeps,237 nowMs: () -> Long = { System.currentTimeMillis() },238): PairedDevice? {239 if (!deps.onConsent(request)) return null240241 val confirmation = receiveFrame(channel)242 if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {243 return null244 }245246 val rootKeyHex = deps.rootKeyHex()247 if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")248249 sendFrame(channel, AdoptionFrame.Grant(250 rootKeyHex = rootKeyHex,251 vaultId = deps.vaultId(),252 license = deps.license(),253 source = AdoptionFrame.Source(254 deviceId = deps.sourceIdentity.deviceId,255 deviceName = deps.sourceIdentity.deviceName,256 deviceType = deps.sourceIdentity.deviceType.wireValue,257 longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,258 signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,259 wsPort = deps.sourceIdentity.wsPort,260 relayUrl = deps.pairState?.relayUrl,261 ),262 ))263264 val identity = receiveFrame(channel)265 if (!isAdoptIdentity(identity)) return null266 identity as AdoptionFrame.Identity267268 val device = PairedDevice(269 deviceId = identity.deviceId,270 deviceName = identity.deviceName,271 deviceType = identity.deviceType,272 longTermPublicKeyHex = identity.longTermPublicKeyHex,273 signingPublicKeyHex = identity.signingPublicKeyHex,274 trustSource = "direct",275 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,276 relayUrl = deps.pairState?.relayUrl,277 pairedAt = nowMs(),278 )279 deps.upsertPairedDevice(device)280 return device281}282283284285286287288val AdoptJson: Json = Json {289 classDiscriminator = "type"290 encodeDefaults = false291 ignoreUnknownKeys = true292}293294suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {295 channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))296}297298299suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =300 runCatching {301 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))302 }.getOrNull()303304private fun isDeviceType(value: String): Boolean =305 DeviceType.entries.any { it.wireValue == value }