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 6768697071 @Serializable72 @SerialName("ADOPT_COMPLETE")73 data object Complete : AdoptionFrame()7475 76 @Serializable77 @SerialName("ADOPT_APPLIED")78 data object Applied : AdoptionFrame()7980 @Serializable81 data class Source(82 @SerialName("device_id") val deviceId: String,83 @SerialName("device_name") val deviceName: String,84 @SerialName("device_type") val deviceType: String,85 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,86 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,87 @SerialName("ws_port") val wsPort: Int? = null,88 @SerialName("relay_url") val relayUrl: String? = null,89 )90}9192data class AdoptionPairState(93 val rendezvousSecretHex: String,94 val relayUrl: String? = null,95)969798interface AdoptionReceiverDeps {99 val deviceName: String100 val deviceType: DeviceType101 fun generateDeviceId(): String102 103 suspend fun confirmSas(): Boolean104 105106107108 suspend fun validateSource(source: AdoptionFrame.Source) {}109110 111 suspend fun createVaultFromRootKey(rootKeyHex: String): String112 suspend fun deriveLocalIdentity(): LocalDeviceIdentity113 114 suspend fun clearPairedDeviceRevocation(deviceId: String) {}115 suspend fun upsertPairedDevice(device: PairedDevice)116 suspend fun applyLicense(license: String) {}117 val pairState: AdoptionPairState? get() = null118 fun onError(error: String)119}120121122interface AdoptionSourceDeps {123 val sourceIdentity: LocalDeviceIdentity124 suspend fun rootKeyHex(): String125 suspend fun vaultId(): String126 suspend fun license(): String? = null127128 129130131132133134 suspend fun onConsent(request: AdoptionFrame.Request): Boolean135 suspend fun upsertPairedDevice(device: PairedDevice)136 val pairState: AdoptionPairState? get() = null137}138139private val Hex64 = Regex("^[0-9a-f]{64}\$")140141fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)142143fun isAdoptRequest(frame: AdoptionFrame?): Boolean =144 frame is AdoptionFrame.Request &&145 frame.sessionRequestId.isNotEmpty() &&146 isDeviceType(frame.deviceType)147148fun isAdoptGrant(frame: AdoptionFrame?): Boolean =149 frame is AdoptionFrame.Grant &&150 validateRootKeyHex(frame.rootKeyHex) &&151 Hex64.matches(frame.vaultId) &&152 isDeviceType(frame.source.deviceType) &&153 Hex64.matches(frame.source.longTermPublicKeyHex) &&154 (frame.source.relayUrl == null ||155 runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)156157fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =158 frame is AdoptionFrame.Confirm159160fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =161 frame is AdoptionFrame.Identity &&162 frame.deviceId.isNotEmpty() &&163 isDeviceType(frame.deviceType) &&164 Hex64.matches(frame.longTermPublicKeyHex)165166fun isAdoptComplete(frame: AdoptionFrame?): Boolean =167 frame is AdoptionFrame.Complete168169fun isAdoptApplied(frame: AdoptionFrame?): Boolean =170 frame is AdoptionFrame.Applied171172173174175176177178179suspend fun requestAdoption(180 channel: PairingTransport,181 deps: AdoptionReceiverDeps,182 nowMs: () -> Long = { System.currentTimeMillis() },183): PairedDevice? {184 185 186 sendFrame(channel, AdoptionFrame.Request(187 sessionRequestId = deps.generateDeviceId(),188 deviceName = deps.deviceName,189 deviceType = deps.deviceType.wireValue,190 ))191192 val confirmed = deps.confirmSas()193 sendFrame(channel, AdoptionFrame.Confirm(confirmed))194 if (!confirmed) {195 deps.onError("ADOPT_SAS_REJECTED")196 return null197 }198199 val frame = receiveFrame(channel)200 if (!isAdoptGrant(frame)) {201 deps.onError("ADOPT_UNEXPECTED_FRAME")202 return null203 }204 val grant = frame as AdoptionFrame.Grant205206 val rootKeyBytes = hexToPubkey(grant.rootKeyHex)207 try {208 if (computeVaultId(rootKeyBytes) != grant.vaultId) {209 deps.onError("ADOPT_VAULT_ID_MISMATCH")210 return null211 }212 } finally {213 rootKeyBytes.fill(0)214 }215216 deps.validateSource(grant.source)217 val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)218 if (vaultId != grant.vaultId) {219 deps.onError("ADOPT_VAULT_ID_MISMATCH")220 return null221 }222223 grant.license?.let { deps.applyLicense(it) }224225 val identity = deps.deriveLocalIdentity()226 sendFrame(channel, AdoptionFrame.Identity(227 deviceId = identity.deviceId,228 deviceName = identity.deviceName,229 deviceType = identity.deviceType.wireValue,230 longTermPublicKeyHex = identity.longTermPublicKeyHex,231 signingPublicKeyHex = identity.signingPublicKeyHex,232 ))233234 if (!isAdoptComplete(receiveFrame(channel))) {235 deps.onError("ADOPT_COMPLETION_MISSING")236 return null237 }238239 val sourceDevice = PairedDevice(240 deviceId = grant.source.deviceId,241 deviceName = grant.source.deviceName,242 deviceType = grant.source.deviceType,243 longTermPublicKeyHex = grant.source.longTermPublicKeyHex,244 signingPublicKeyHex = grant.source.signingPublicKeyHex,245 trustSource = "direct",246 wsPort = grant.source.wsPort,247 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,248 relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,249 pairedAt = nowMs(),250 )251 252 253 254 deps.clearPairedDeviceRevocation(sourceDevice.deviceId)255 deps.upsertPairedDevice(sourceDevice)256 sendFrame(channel, AdoptionFrame.Applied)257 return sourceDevice258}259260261262263264265suspend fun grantAdoption(266 channel: PairingTransport,267 request: AdoptionFrame.Request,268 deps: AdoptionSourceDeps,269 nowMs: () -> Long = { System.currentTimeMillis() },270): PairedDevice? {271 if (!deps.onConsent(request)) return null272273 val confirmation = receiveFrame(channel)274 if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {275 return null276 }277278 val rootKeyHex = deps.rootKeyHex()279 if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")280281 sendFrame(channel, AdoptionFrame.Grant(282 rootKeyHex = rootKeyHex,283 vaultId = deps.vaultId(),284 license = deps.license(),285 source = AdoptionFrame.Source(286 deviceId = deps.sourceIdentity.deviceId,287 deviceName = deps.sourceIdentity.deviceName,288 deviceType = deps.sourceIdentity.deviceType.wireValue,289 longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,290 signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,291 wsPort = deps.sourceIdentity.wsPort,292 relayUrl = deps.pairState?.relayUrl,293 ),294 ))295296 val identity = receiveFrame(channel)297 if (!isAdoptIdentity(identity)) return null298 identity as AdoptionFrame.Identity299300 val device = PairedDevice(301 deviceId = identity.deviceId,302 deviceName = identity.deviceName,303 deviceType = identity.deviceType,304 longTermPublicKeyHex = identity.longTermPublicKeyHex,305 signingPublicKeyHex = identity.signingPublicKeyHex,306 trustSource = "direct",307 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,308 relayUrl = deps.pairState?.relayUrl,309 pairedAt = nowMs(),310 )311 deps.upsertPairedDevice(device)312 sendFrame(channel, AdoptionFrame.Complete)313 if (!isAdoptApplied(receiveFrame(channel))) return null314 return device315}316317318319320321322val AdoptJson: Json = Json {323 classDiscriminator = "type"324 encodeDefaults = false325 ignoreUnknownKeys = true326}327328suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {329 channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))330}331332333suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =334 runCatching {335 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))336 }.getOrNull()337338private fun isDeviceType(value: String): Boolean =339 DeviceType.entries.any { it.wireValue == value }