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 suspend fun upsertPairedDevice(device: PairedDevice)95 suspend fun applyLicense(license: String) {}96 val pairState: AdoptionPairState? get() = null97 fun onError(error: String)98}99100101interface AdoptionSourceDeps {102 val sourceIdentity: LocalDeviceIdentity103 suspend fun rootKeyHex(): String104 suspend fun vaultId(): String105 suspend fun license(): String? = null106107 108109110111112113 suspend fun onConsent(request: AdoptionFrame.Request): Boolean114 suspend fun upsertPairedDevice(device: PairedDevice)115 val pairState: AdoptionPairState? get() = null116}117118private val Hex64 = Regex("^[0-9a-f]{64}\$")119120fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)121122fun isAdoptRequest(frame: AdoptionFrame?): Boolean =123 frame is AdoptionFrame.Request &&124 frame.sessionRequestId.isNotEmpty() &&125 isDeviceType(frame.deviceType)126127fun isAdoptGrant(frame: AdoptionFrame?): Boolean =128 frame is AdoptionFrame.Grant &&129 validateRootKeyHex(frame.rootKeyHex) &&130 Hex64.matches(frame.vaultId) &&131 isDeviceType(frame.source.deviceType) &&132 Hex64.matches(frame.source.longTermPublicKeyHex) &&133 (frame.source.relayUrl == null ||134 runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)135136fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =137 frame is AdoptionFrame.Confirm138139fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =140 frame is AdoptionFrame.Identity &&141 frame.deviceId.isNotEmpty() &&142 isDeviceType(frame.deviceType) &&143 Hex64.matches(frame.longTermPublicKeyHex)144145146147148149150151152suspend fun requestAdoption(153 channel: PairingTransport,154 deps: AdoptionReceiverDeps,155 nowMs: () -> Long = { System.currentTimeMillis() },156): PairedDevice? {157 158 159 sendFrame(channel, AdoptionFrame.Request(160 sessionRequestId = deps.generateDeviceId(),161 deviceName = deps.deviceName,162 deviceType = deps.deviceType.wireValue,163 ))164165 val confirmed = deps.confirmSas()166 sendFrame(channel, AdoptionFrame.Confirm(confirmed))167 if (!confirmed) {168 deps.onError("ADOPT_SAS_REJECTED")169 return null170 }171172 val frame = receiveFrame(channel)173 if (!isAdoptGrant(frame)) {174 deps.onError("ADOPT_UNEXPECTED_FRAME")175 return null176 }177 val grant = frame as AdoptionFrame.Grant178179 val rootKeyBytes = hexToPubkey(grant.rootKeyHex)180 try {181 if (computeVaultId(rootKeyBytes) != grant.vaultId) {182 deps.onError("ADOPT_VAULT_ID_MISMATCH")183 return null184 }185 } finally {186 rootKeyBytes.fill(0)187 }188189 val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)190 if (vaultId != grant.vaultId) {191 deps.onError("ADOPT_VAULT_ID_MISMATCH")192 return null193 }194195 grant.license?.let { deps.applyLicense(it) }196197 val identity = deps.deriveLocalIdentity()198 sendFrame(channel, AdoptionFrame.Identity(199 deviceId = identity.deviceId,200 deviceName = identity.deviceName,201 deviceType = identity.deviceType.wireValue,202 longTermPublicKeyHex = identity.longTermPublicKeyHex,203 signingPublicKeyHex = identity.signingPublicKeyHex,204 ))205206 val sourceDevice = PairedDevice(207 deviceId = grant.source.deviceId,208 deviceName = grant.source.deviceName,209 deviceType = grant.source.deviceType,210 longTermPublicKeyHex = grant.source.longTermPublicKeyHex,211 signingPublicKeyHex = grant.source.signingPublicKeyHex,212 trustSource = "direct",213 wsPort = grant.source.wsPort,214 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,215 relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,216 pairedAt = nowMs(),217 )218 deps.upsertPairedDevice(sourceDevice)219 return sourceDevice220}221222223224225226227suspend fun grantAdoption(228 channel: PairingTransport,229 request: AdoptionFrame.Request,230 deps: AdoptionSourceDeps,231 nowMs: () -> Long = { System.currentTimeMillis() },232): PairedDevice? {233 if (!deps.onConsent(request)) return null234235 val confirmation = receiveFrame(channel)236 if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {237 return null238 }239240 val rootKeyHex = deps.rootKeyHex()241 if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")242243 sendFrame(channel, AdoptionFrame.Grant(244 rootKeyHex = rootKeyHex,245 vaultId = deps.vaultId(),246 license = deps.license(),247 source = AdoptionFrame.Source(248 deviceId = deps.sourceIdentity.deviceId,249 deviceName = deps.sourceIdentity.deviceName,250 deviceType = deps.sourceIdentity.deviceType.wireValue,251 longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,252 signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,253 wsPort = deps.sourceIdentity.wsPort,254 relayUrl = deps.pairState?.relayUrl,255 ),256 ))257258 val identity = receiveFrame(channel)259 if (!isAdoptIdentity(identity)) return null260 identity as AdoptionFrame.Identity261262 val device = PairedDevice(263 deviceId = identity.deviceId,264 deviceName = identity.deviceName,265 deviceType = identity.deviceType,266 longTermPublicKeyHex = identity.longTermPublicKeyHex,267 signingPublicKeyHex = identity.signingPublicKeyHex,268 trustSource = "direct",269 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,270 relayUrl = deps.pairState?.relayUrl,271 pairedAt = nowMs(),272 )273 deps.upsertPairedDevice(device)274 return device275}276277278279280281282val AdoptJson: Json = Json {283 classDiscriminator = "type"284 encodeDefaults = false285 ignoreUnknownKeys = true286}287288suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {289 channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))290}291292293suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =294 runCatching {295 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))296 }.getOrNull()297298private fun isDeviceType(value: String): Boolean =299 DeviceType.entries.any { it.wireValue == value }