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 val license: String? = null,40 ) : AdoptionFrame()4142 @Serializable43 @SerialName("ADOPT_CONFIRM")44 data class Confirm(45 val confirmed: Boolean,46 ) : AdoptionFrame()4748 @Serializable49 @SerialName("ADOPT_GRANT")50 data class Grant(51 @SerialName("root_key_hex") val rootKeyHex: String,52 @SerialName("vault_id") val vaultId: String,53 val license: String? = null,54 val source: Source,55 ) : AdoptionFrame()5657 @Serializable58 @SerialName("ADOPT_IDENTITY")59 data class Identity(60 @SerialName("device_id") val deviceId: String,61 @SerialName("device_name") val deviceName: String,62 @SerialName("device_type") val deviceType: String,63 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,64 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,65 ) : AdoptionFrame()6667 6869707172 @Serializable73 @SerialName("ADOPT_COMPLETE")74 data object Complete : AdoptionFrame()7576 77 @Serializable78 @SerialName("ADOPT_APPLIED")79 data object Applied : AdoptionFrame()8081 82 @Serializable83 @SerialName("ADOPT_REJECT")84 data class Reject(85 val reason: String,86 ) : AdoptionFrame()8788 @Serializable89 data class Source(90 @SerialName("device_id") val deviceId: String,91 @SerialName("device_name") val deviceName: String,92 @SerialName("device_type") val deviceType: String,93 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,94 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,95 @SerialName("ws_port") val wsPort: Int? = null,96 @SerialName("relay_url") val relayUrl: String? = null,97 )98}99100data class AdoptionPairState(101 val rendezvousSecretHex: String,102 val relayUrl: String? = null,103)104105106interface AdoptionReceiverDeps {107 val deviceName: String108 val deviceType: DeviceType109 fun generateDeviceId(): String110 111 suspend fun confirmSas(): Boolean112 113114115116 suspend fun validateSource(source: AdoptionFrame.Source) {}117118 119 suspend fun createVaultFromRootKey(rootKeyHex: String): String120 suspend fun deriveLocalIdentity(): LocalDeviceIdentity121 122 suspend fun clearPairedDeviceRevocation(deviceId: String) {}123 suspend fun upsertPairedDevice(device: PairedDevice)124 suspend fun applyLicense(license: String) {}125 126 suspend fun license(): String? = null127 val pairState: AdoptionPairState? get() = null128 fun onError(error: String)129}130131132interface AdoptionSourceDeps {133 val sourceIdentity: LocalDeviceIdentity134 suspend fun rootKeyHex(): String135 suspend fun vaultId(): String136 suspend fun license(): String? = null137 138 suspend fun applyLicense(license: String) {}139140 141142143144145146 suspend fun onConsent(request: AdoptionFrame.Request): Boolean147 suspend fun upsertPairedDevice(device: PairedDevice)148 val pairState: AdoptionPairState? get() = null149}150151private val Hex64 = Regex("^[0-9a-f]{64}\$")152153fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)154155fun isAdoptRequest(frame: AdoptionFrame?): Boolean =156 frame is AdoptionFrame.Request &&157 frame.sessionRequestId.isNotEmpty() &&158 isDeviceType(frame.deviceType)159160fun isAdoptGrant(frame: AdoptionFrame?): Boolean =161 frame is AdoptionFrame.Grant &&162 validateRootKeyHex(frame.rootKeyHex) &&163 Hex64.matches(frame.vaultId) &&164 isDeviceType(frame.source.deviceType) &&165 Hex64.matches(frame.source.longTermPublicKeyHex) &&166 (frame.source.relayUrl == null ||167 runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)168169fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =170 frame is AdoptionFrame.Confirm171172fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =173 frame is AdoptionFrame.Identity &&174 frame.deviceId.isNotEmpty() &&175 isDeviceType(frame.deviceType) &&176 Hex64.matches(frame.longTermPublicKeyHex)177178fun isAdoptComplete(frame: AdoptionFrame?): Boolean =179 frame is AdoptionFrame.Complete180181fun isAdoptApplied(frame: AdoptionFrame?): Boolean =182 frame is AdoptionFrame.Applied183184fun isAdoptReject(frame: AdoptionFrame?): Boolean =185 frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"186187188189190191192193194suspend fun requestAdoption(195 channel: PairingTransport,196 deps: AdoptionReceiverDeps,197 nowMs: () -> Long = { System.currentTimeMillis() },198): PairedDevice? {199 val receiverLicense = deps.license()200 201 202 sendFrame(channel, AdoptionFrame.Request(203 sessionRequestId = deps.generateDeviceId(),204 deviceName = deps.deviceName,205 deviceType = deps.deviceType.wireValue,206 license = receiverLicense,207 ))208209 val confirmed = deps.confirmSas()210 sendFrame(channel, AdoptionFrame.Confirm(confirmed))211 if (!confirmed) {212 deps.onError("ADOPT_SAS_REJECTED")213 return null214 }215216 val frame = receiveFrame(channel)217 if (isAdoptReject(frame)) {218 deps.onError((frame as AdoptionFrame.Reject).reason)219 return null220 }221 if (!isAdoptGrant(frame)) {222 deps.onError("ADOPT_UNEXPECTED_FRAME")223 return null224 }225 val grant = frame as AdoptionFrame.Grant226227 val rootKeyBytes = hexToPubkey(grant.rootKeyHex)228 try {229 if (computeVaultId(rootKeyBytes) != grant.vaultId) {230 deps.onError("ADOPT_VAULT_ID_MISMATCH")231 return null232 }233 } finally {234 rootKeyBytes.fill(0)235 }236 if (receiverLicense == null && grant.license == null) {237 deps.onError("ADOPT_PRO_REQUIRED")238 return null239 }240241 deps.validateSource(grant.source)242 val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)243 if (vaultId != grant.vaultId) {244 deps.onError("ADOPT_VAULT_ID_MISMATCH")245 return null246 }247248 if (receiverLicense == null) {249 grant.license?.let { deps.applyLicense(it) }250 }251252 val identity = deps.deriveLocalIdentity()253 sendFrame(channel, AdoptionFrame.Identity(254 deviceId = identity.deviceId,255 deviceName = identity.deviceName,256 deviceType = identity.deviceType.wireValue,257 longTermPublicKeyHex = identity.longTermPublicKeyHex,258 signingPublicKeyHex = identity.signingPublicKeyHex,259 ))260261 if (!isAdoptComplete(receiveFrame(channel))) {262 deps.onError("ADOPT_COMPLETION_MISSING")263 return null264 }265266 val sourceDevice = PairedDevice(267 deviceId = grant.source.deviceId,268 deviceName = grant.source.deviceName,269 deviceType = grant.source.deviceType,270 longTermPublicKeyHex = grant.source.longTermPublicKeyHex,271 signingPublicKeyHex = grant.source.signingPublicKeyHex,272 trustSource = "direct",273 wsPort = grant.source.wsPort,274 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,275 relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,276 pairedAt = nowMs(),277 )278 279 280 281 deps.clearPairedDeviceRevocation(sourceDevice.deviceId)282 deps.upsertPairedDevice(sourceDevice)283 sendFrame(channel, AdoptionFrame.Applied)284 return sourceDevice285}286287288289290291292suspend fun grantAdoption(293 channel: PairingTransport,294 request: AdoptionFrame.Request,295 deps: AdoptionSourceDeps,296 nowMs: () -> Long = { System.currentTimeMillis() },297): PairedDevice? {298 if (!deps.onConsent(request)) return null299300 val confirmation = receiveFrame(channel)301 if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {302 return null303 }304305 var sourceLicense = deps.license()306 if (sourceLicense == null && request.license != null) {307 sourceLicense = runCatching {308 deps.applyLicense(request.license)309 deps.license()310 }.getOrNull()311 }312 if (sourceLicense == null) {313 sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))314 throw IllegalStateException("ADOPT_PRO_REQUIRED")315 }316317 val rootKeyHex = deps.rootKeyHex()318 if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")319320 sendFrame(channel, AdoptionFrame.Grant(321 rootKeyHex = rootKeyHex,322 vaultId = deps.vaultId(),323 license = sourceLicense,324 source = AdoptionFrame.Source(325 deviceId = deps.sourceIdentity.deviceId,326 deviceName = deps.sourceIdentity.deviceName,327 deviceType = deps.sourceIdentity.deviceType.wireValue,328 longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,329 signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,330 wsPort = deps.sourceIdentity.wsPort,331 relayUrl = deps.pairState?.relayUrl,332 ),333 ))334335 val identity = receiveFrame(channel)336 if (!isAdoptIdentity(identity)) return null337 identity as AdoptionFrame.Identity338339 val device = PairedDevice(340 deviceId = identity.deviceId,341 deviceName = identity.deviceName,342 deviceType = identity.deviceType,343 longTermPublicKeyHex = identity.longTermPublicKeyHex,344 signingPublicKeyHex = identity.signingPublicKeyHex,345 trustSource = "direct",346 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,347 relayUrl = deps.pairState?.relayUrl,348 pairedAt = nowMs(),349 )350 deps.upsertPairedDevice(device)351 sendFrame(channel, AdoptionFrame.Complete)352 if (!isAdoptApplied(receiveFrame(channel))) return null353 return device354}355356357358359360361val AdoptJson: Json = Json {362 classDiscriminator = "type"363 encodeDefaults = false364 ignoreUnknownKeys = true365}366367suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {368 channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))369}370371372suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =373 runCatching {374 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))375 }.getOrNull()376377private fun isDeviceType(value: String): Boolean =378 DeviceType.entries.any { it.wireValue == value }