1package com.peachpasswords.sync23import kotlinx.serialization.SerialName4import kotlinx.serialization.Serializable5import kotlinx.serialization.encodeToString6import kotlinx.serialization.json.Json78910111213141516171819202122@Serializable23sealed class AdoptionFrame {24 25262728293031323334 @Serializable35 @SerialName("ADOPT_REQUEST")36 data class Request(37 @SerialName("device_id") val sessionRequestId: String,38 @SerialName("device_name") val deviceName: String,39 @SerialName("device_type") val deviceType: String,40 val license: String? = null,41 @SerialName("snapshot_capable") val snapshotCapable: Boolean? = null,42 ) : AdoptionFrame()4344 @Serializable45 @SerialName("ADOPT_CONFIRM")46 data class Confirm(47 val confirmed: Boolean,48 495051525354 val license: String? = null,55 ) : AdoptionFrame()5657 @Serializable58 @SerialName("ADOPT_GRANT")59 data class Grant(60 @SerialName("root_key_hex") val rootKeyHex: String,61 @SerialName("vault_id") val vaultId: String,62 val license: String? = null,63 val source: Source,64 65 @SerialName("snapshot_capable") val snapshotCapable: Boolean = false,66 ) : AdoptionFrame()6768 @Serializable69 @SerialName("ADOPT_IDENTITY")70 data class Identity(71 @SerialName("device_id") val deviceId: String,72 @SerialName("device_name") val deviceName: String,73 @SerialName("device_type") val deviceType: String,74 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,75 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,76 ) : AdoptionFrame()7778 7980818283 @Serializable84 @SerialName("ADOPT_COMPLETE")85 data object Complete : AdoptionFrame()8687 88 @Serializable89 @SerialName("ADOPT_APPLIED")90 data object Applied : AdoptionFrame()9192 93 @Serializable94 @SerialName("ADOPT_REJECT")95 data class Reject(96 val reason: String,97 ) : AdoptionFrame()9899 100101102103104 @Serializable105 @SerialName("ADOPT_SNAPSHOT")106 data class Snapshot(107 @SerialName("chunk_index") val chunkIndex: Int,108 @SerialName("chunk_count") val chunkCount: Int,109 val entries: Map<String, String>,110 val tombstones: Map<String, Long>,111 @SerialName("duress_slot") val duressSlot: String? = null,112 ) : AdoptionFrame()113114 115 @Serializable116 @SerialName("ADOPT_SNAPSHOT_APPLIED")117 data class SnapshotApplied(118 @SerialName("entries_received") val entriesReceived: Int,119 @SerialName("tombstones_received") val tombstonesReceived: Int,120 ) : AdoptionFrame()121122 123 @Serializable124 @SerialName("ADOPT_DEVICE_MANIFEST")125 data class DeviceManifestFrame(126 val manifest: SignedDeviceManifest,127 ) : AdoptionFrame()128129 130 @Serializable131 @SerialName("ADOPT_DEVICE_MANIFEST_APPLIED")132 data object DeviceManifestApplied : AdoptionFrame()133 @Serializable134 @SerialName("ADOPT_REQUEST_V2")135 data class RequestV2(136 @SerialName("adoptionVersion") val adoptionVersion: Int,137 @SerialName("device_id") val deviceId: String,138 @SerialName("device_name") val deviceName: String,139 @SerialName("device_type") val deviceType: String,140 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,141 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String,142 val license: String? = null,143 @SerialName("snapshot_capable") val snapshotCapable: Boolean? = null,144 ) : AdoptionFrame()145146 @Serializable @SerialName("ADOPT_CONFIRM_REQUIRED")147 data object ConfirmRequired : AdoptionFrame()148149 @Serializable @SerialName("ADOPT_PREPARE")150 data class Prepare(151 @SerialName("device_id") val deviceId: String,152 @SerialName("device_name") val deviceName: String,153 @SerialName("device_type") val deviceType: String,154 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,155 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String,156 @SerialName("vault_id") val vaultId: String,157 @SerialName("ws_port") val wsPort: Int? = null,158 @SerialName("relay_url") val relayUrl: String? = null,159 val license: String? = null,160 @SerialName("snapshot_capable") val snapshotCapable: Boolean? = null,161 ) : AdoptionFrame()162 @Serializable @SerialName("ADOPT_READY")163 data object Ready : AdoptionFrame()164 @Serializable165 data class Source(166 @SerialName("device_id") val deviceId: String,167 @SerialName("device_name") val deviceName: String,168 @SerialName("device_type") val deviceType: String,169 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,170 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,171 @SerialName("ws_port") val wsPort: Int? = null,172 @SerialName("relay_url") val relayUrl: String? = null,173 )174}175176data class AdoptionPairState(177 val rendezvousSecretHex: String,178 val relayUrl: String? = null,179)180181182183184185186data class ReceivedSnapshot(187 val entries: Map<String, String>,188 val tombstones: Map<String, Long>,189 val duressSlot: String? = null,190)191192193194195196197198const val MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024 199const val MAX_SNAPSHOT_CHUNKS = 200200201202203internal const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024204internal val MAX_PADDED_SNAPSHOT_FRAME_BYTES = SyncPadding.bucket(SNAPSHOT_FRAME_WIRE_CEILING + 4)205206207208209210211212const val MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024 213const val MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000214const val MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000215216const val MAX_ADOPT_SNAPSHOT_ID_CHARS = SyncLimits.MAX_ID_LENGTH217218219220221222const val MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES = SyncLimits.MAX_ENTRY_ENVELOPE_BYTES223224225226227228class AdoptSnapshotBudget {229 val entries = LinkedHashMap<String, String>()230 val tombstones = LinkedHashMap<String, Long>()231 var duressSlot: String? = null232 233 var bytes: Int = 0234 var entryCount: Int = 0235 var tombstoneCount: Int = 0236}237238fun createAdoptSnapshotBudget(): AdoptSnapshotBudget = AdoptSnapshotBudget()239240241242243244245246247248249250fun applyAdoptSnapshotFrame(251 acc: AdoptSnapshotBudget,252 entries: Map<String, String>,253 tombstones: Map<String, Long>,254 duressSlot: String?,255 frameBytes: Int,256) {257 if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {258 throw IllegalStateException("ADOPT_SNAPSHOT_OVER_BUDGET")259 }260 for ((id, envelope) in entries) {261 if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw IllegalStateException("ADOPT_SNAPSHOT_ID_TOO_LONG")262 if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) {263 throw IllegalStateException("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE")264 }265 if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) {266 throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES")267 }268 acc.entryCount++269 acc.entries[id] = envelope270 }271 for ((id, ts) in tombstones) {272 if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw IllegalStateException("ADOPT_SNAPSHOT_ID_TOO_LONG")273 274 275 if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) {276 throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES")277 }278 acc.tombstoneCount++279 acc.tombstones[id] = ts280 }281 if (!duressSlot.isNullOrEmpty()) {282 283 284 if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) {285 throw IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")286 }287 acc.duressSlot = duressSlot288 }289 acc.bytes += frameBytes290}291292293interface AdoptionReceiverDeps {294 val deviceName: String295 val deviceType: DeviceType296 fun generateDeviceId(): String297 298 suspend fun confirmSas(): Boolean299 300301302303 suspend fun validateSource(source: AdoptionFrame.Source) {}304305 306307308309310 suspend fun hasSourceIdentityCollision(source: AdoptionFrame.Source): Boolean = false311312 313 suspend fun createVaultFromRootKey(rootKeyHex: String): String314 315 suspend fun deriveLocalIdentity(): LocalDeviceIdentity316 317 suspend fun upsertPairedDeviceAndClearRevocation(device: PairedDevice) {318 upsertPairedDevice(device)319 clearPairedDeviceRevocation(device.deviceId)320 }321 suspend fun clearPairedDeviceRevocation(deviceId: String) {}322 suspend fun upsertPairedDevice(device: PairedDevice)323 suspend fun applyLicense(license: String) {}324 325 suspend fun license(): String? = null326 val pairState: AdoptionPairState? get() = null327 fun onError(error: String)328 329 suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String? = null) {}330 331332333334335336337338 suspend fun applyIncomingDeviceManifest(339 manifest: SignedDeviceManifest,340 authenticatedRemoteStaticPublicKey: ByteArray,341 ) {}342 343 suspend fun markSynced(deviceId: String) {}344}345346347interface AdoptionSourceDeps {348 val sourceIdentity: LocalDeviceIdentity349 suspend fun rootKeyHex(): String350 suspend fun vaultId(): String351 suspend fun license(): String? = null352 353 suspend fun applyLicense(license: String) {}354 355356357358359360 suspend fun onConsent(request: AdoptionFrame.Request): Boolean361 suspend fun persistPendingHolder(request: AdoptionFrame.Request, nowMs: Long): Boolean = true362 suspend fun completePendingHolder(request: AdoptionFrame.Request, device: PairedDevice): Boolean = true363 suspend fun upsertPairedDevice(device: PairedDevice)364 fun hasDeviceIdentityCollision(device: PairedDevice): Boolean = false365 366367368369370 suspend fun rotateSourceIdentity(): LocalDeviceIdentity = error("rotateSourceIdentity not wired")371 val pairState: AdoptionPairState? get() = null372 373 suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null374 375 suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null376 377 suspend fun markSynced(deviceId: String) {}378 379 suspend fun duressSlotJson(): String? = null380}381382internal val Hex64 = Regex("^[0-9a-f]{64}$")383384fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)385386fun isAdoptRequest(frame: AdoptionFrame?): Boolean =387 frame is AdoptionFrame.Request &&388 frame.sessionRequestId.isNotEmpty() &&389 isDeviceType(frame.deviceType)390391fun isAdoptGrant(frame: AdoptionFrame?): Boolean =392 frame is AdoptionFrame.Grant &&393 validateRootKeyHex(frame.rootKeyHex) &&394 Hex64.matches(frame.vaultId) &&395 isDeviceType(frame.source.deviceType) &&396 Hex64.matches(frame.source.longTermPublicKeyHex) &&397 (frame.source.relayUrl == null ||398 runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)399400fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =401 frame is AdoptionFrame.Confirm402403fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =404 frame is AdoptionFrame.Identity &&405 frame.deviceId.isNotEmpty() &&406 isDeviceType(frame.deviceType) &&407 Hex64.matches(frame.longTermPublicKeyHex)408409fun isAdoptComplete(frame: AdoptionFrame?): Boolean =410 frame is AdoptionFrame.Complete411412fun isAdoptApplied(frame: AdoptionFrame?): Boolean =413 frame is AdoptionFrame.Applied414415fun isAdoptReject(frame: AdoptionFrame?): Boolean =416 frame is AdoptionFrame.Reject &&417 (frame.reason == "ADOPT_PRO_REQUIRED" || frame.reason == "ADOPT_DEVICE_ID_COLLISION")418419fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =420 frame is AdoptionFrame.Snapshot &&421 frame.chunkIndex >= 0 &&422 frame.chunkCount >= 1423424425426427428429430431suspend fun requestAdoption(432 channel: PairingTransport,433 deps: AdoptionReceiverDeps,434 435 436 437 438 439 onGrant: ((AdoptionFrame.Grant) -> Unit)? = null,440 nowMs: () -> Long = { System.currentTimeMillis() },441): PairedDevice? {442 val receiverLicense = deps.license()443 444 445 sendFrame(channel, AdoptionFrame.Request(446 sessionRequestId = deps.generateDeviceId(),447 deviceName = deps.deviceName,448 deviceType = deps.deviceType.wireValue,449 snapshotCapable = true,450 ))451452 val confirmed = deps.confirmSas()453 454 455 sendFrame(channel, AdoptionFrame.Confirm(confirmed, license = receiverLicense))456 if (!confirmed) {457 deps.onError("ADOPT_SAS_REJECTED")458 return null459 }460461 val frame = receiveFrame(channel)462 if (isAdoptReject(frame)) {463 deps.onError((frame as AdoptionFrame.Reject).reason)464 return null465 }466 if (!isAdoptGrant(frame)) {467 deps.onError("ADOPT_UNEXPECTED_FRAME")468 return null469 }470 val grant = frame as AdoptionFrame.Grant471 onGrant?.invoke(grant)472473 val rootKeyBytes = hexToPubkey(grant.rootKeyHex)474 try {475 if (computeVaultId(rootKeyBytes) != grant.vaultId) {476 deps.onError("ADOPT_VAULT_ID_MISMATCH")477 return null478 }479 } finally {480 rootKeyBytes.fill(0)481 }482 if (receiverLicense == null && grant.license == null) {483 deps.onError("ADOPT_PRO_REQUIRED")484 return null485 }486487 var acceptedGrant = grant488 var acceptedSource = grant.source ?: run {489 deps.onError("ADOPT_UNEXPECTED_FRAME")490 return null491 }492 deps.validateSource(acceptedSource)493494 495 496 497 498 499 if (deps.hasSourceIdentityCollision(acceptedSource)) {500 sendFrame(channel, AdoptionFrame.Reject("ADOPT_DEVICE_ID_COLLISION"))501 val retry = receiveFrame(channel)502 if (!isAdoptGrant(retry)) {503 deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED")504 return null505 }506 acceptedGrant = retry as AdoptionFrame.Grant507 acceptedSource = acceptedGrant.source ?: run {508 deps.onError("ADOPT_UNEXPECTED_FRAME")509 return null510 }511 if (deps.hasSourceIdentityCollision(acceptedSource)) {512 sendFrame(channel, AdoptionFrame.Reject("ADOPT_DEVICE_ID_COLLISION"))513 deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED")514 return null515 }516 517 val freshRootBytes = hexToPubkey(acceptedGrant.rootKeyHex)518 try {519 if (computeVaultId(freshRootBytes) != acceptedGrant.vaultId) {520 deps.onError("ADOPT_VAULT_ID_MISMATCH")521 return null522 }523 } finally {524 freshRootBytes.fill(0)525 }526 }527 if (receiverLicense == null && acceptedGrant.license == null) {528 deps.onError("ADOPT_PRO_REQUIRED")529 return null530 }531532533 if (receiverLicense == null) {534 acceptedGrant.license?.let { deps.applyLicense(it) }535 }536 val vaultId = deps.createVaultFromRootKey(acceptedGrant.rootKeyHex)537 if (vaultId != acceptedGrant.vaultId) {538 deps.onError("ADOPT_VAULT_ID_MISMATCH")539 return null540 }541542543 val identity = deps.deriveLocalIdentity()544 sendFrame(channel, AdoptionFrame.Identity(545 deviceId = identity.deviceId,546 deviceName = identity.deviceName,547 deviceType = identity.deviceType.wireValue,548 longTermPublicKeyHex = identity.longTermPublicKeyHex,549 signingPublicKeyHex = identity.signingPublicKeyHex,550 ))551552 if (!isAdoptComplete(receiveFrame(channel))) {553 deps.onError("ADOPT_COMPLETION_MISSING")554 return null555 }556557 val sourceDevice = PairedDevice(558 deviceId = acceptedSource.deviceId,559 deviceName = acceptedSource.deviceName,560 deviceType = acceptedSource.deviceType,561 longTermPublicKeyHex = acceptedSource.longTermPublicKeyHex,562 signingPublicKeyHex = acceptedSource.signingPublicKeyHex,563 trustSource = "direct",564 wsPort = acceptedSource.wsPort,565 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,566 relayUrl = deps.pairState?.relayUrl ?: acceptedSource.relayUrl,567 pairedAt = nowMs(),568 )569 570 571 572 deps.upsertPairedDeviceAndClearRevocation(sourceDevice)573 sendFrame(channel, AdoptionFrame.Applied)574 return sourceDevice575}576577578579580581582suspend fun grantAdoption(583 channel: PairingTransport,584 request: AdoptionFrame.Request,585 deps: AdoptionSourceDeps,586 nowMs: () -> Long = { System.currentTimeMillis() },587): PairedDevice? {588 if (!deps.onConsent(request)) return null589590 val confirmation = receiveFrame(channel)591 val confirmedFrame = confirmation as AdoptionFrame.Confirm592 if (!isAdoptConfirm(confirmation) || !confirmedFrame.confirmed) {593 return null594 }595596 var sourceLicense = deps.license()597 598 599 600 val receiverLicense = confirmedFrame.license ?: request.license601 if (sourceLicense == null && receiverLicense != null) {602 sourceLicense = runCatching {603 deps.applyLicense(receiverLicense)604 deps.license()605 }.getOrNull()606 }607 if (sourceLicense == null) {608 sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))609 throw IllegalStateException("ADOPT_PRO_REQUIRED")610 }611612 if (!deps.persistPendingHolder(request, nowMs())) return null613 val rootKeyHex = deps.rootKeyHex()614 if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")615616 var sourceIdentity = deps.sourceIdentity617 var grantAttempts = 0618 while (true) {619 sendFrame(channel, AdoptionFrame.Grant(620 rootKeyHex = rootKeyHex,621 vaultId = deps.vaultId(),622 license = sourceLicense,623 source = AdoptionFrame.Source(624 deviceId = sourceIdentity.deviceId,625 deviceName = sourceIdentity.deviceName,626 deviceType = sourceIdentity.deviceType.wireValue,627 longTermPublicKeyHex = sourceIdentity.longTermPublicKeyHex,628 signingPublicKeyHex = sourceIdentity.signingPublicKeyHex,629 wsPort = sourceIdentity.wsPort,630 relayUrl = deps.pairState?.relayUrl,631 ),632 snapshotCapable = request.snapshotCapable == true,633 ))634635 var identity = receiveFrame(channel)636637 638 639 if (identity is AdoptionFrame.Reject &&640 identity.reason == "ADOPT_DEVICE_ID_COLLISION" &&641 grantAttempts == 0642 ) {643 sourceIdentity = deps.rotateSourceIdentity()644 grantAttempts++645 continue646 }647648 if (!isAdoptIdentity(identity)) return null649 identity as AdoptionFrame.Identity650651 var device = PairedDevice(652 deviceId = identity.deviceId,653 deviceName = identity.deviceName,654 deviceType = identity.deviceType,655 longTermPublicKeyHex = identity.longTermPublicKeyHex,656 signingPublicKeyHex = identity.signingPublicKeyHex,657 trustSource = "direct",658 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,659 relayUrl = deps.pairState?.relayUrl,660 pairedAt = nowMs(),661 )662 if (deps.hasDeviceIdentityCollision(device)) {663 sendFrame(channel, AdoptionFrame.Reject("ADOPT_DEVICE_ID_COLLISION"))664 identity = receiveFrame(channel)665 if (!isAdoptIdentity(identity)) return null666 identity as AdoptionFrame.Identity667 device = device.copy(668 deviceId = identity.deviceId,669 deviceName = identity.deviceName,670 deviceType = identity.deviceType,671 longTermPublicKeyHex = identity.longTermPublicKeyHex,672 signingPublicKeyHex = identity.signingPublicKeyHex,673 )674 if (deps.hasDeviceIdentityCollision(device)) {675 sendFrame(channel, AdoptionFrame.Reject("ADOPT_DEVICE_ID_COLLISION"))676 return null677 }678 }679 deps.upsertPairedDevice(device)680 sendFrame(channel, AdoptionFrame.Complete)681 if (!isAdoptApplied(receiveFrame(channel))) return null682 if (!deps.completePendingHolder(request, device)) return null683 return device684 }685}686687688689690691692val AdoptJson: Json = Json {693 classDiscriminator = "type"694 encodeDefaults = false695 ignoreUnknownKeys = true696}697698suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {699 channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))700}701702703suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =704 runCatching {705 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))706 }.getOrNull()707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738suspend fun sendVaultSnapshot(739 channel: PairingTransport,740 entries: Map<String, String>,741 tombstones: Map<String, Long>,742 duressSlot: String? = null,743) {744 val frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot)745 746 747 748 749 750 val acc = createAdoptSnapshotBudget()751 val encoded = frames.map {752 AdoptJson.encodeToString(AdoptionFrame.serializer(), it).encodeToByteArray()753 }754 for (i in frames.indices) {755 val frame = frames[i]756 applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duressSlot, encoded[i].size)757 }758 for (raw in encoded) channel.send(SyncPadding.pad(raw))759}760761762763764765766private fun buildAdoptSnapshotFrames(767 entries: Map<String, String>,768 tombstones: Map<String, Long>,769 duressSlot: String?,770): List<AdoptionFrame.Snapshot> {771 val includeDuress = !duressSlot.isNullOrBlank()772 773 774 val entryCap = MAX_SNAPSHOT_CHUNKS - 1775776 val chunks = mutableListOf<Map<String, String>>()777 var current = LinkedHashMap<String, String>()778 var currentBytes = 2 779 for ((id, envelope) in entries) {780 781 val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1782 if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {783 chunks.add(current)784 current = LinkedHashMap()785 currentBytes = 2786 }787 current[id] = envelope788 currentBytes += entryBytes789 }790 chunks.add(current)791792 val entriesChunkCount = minOf(chunks.size, entryCap)793 val totalChunks = entriesChunkCount + 1794 val supplementalFrame = AdoptionFrame.Snapshot(795 chunkIndex = entriesChunkCount,796 chunkCount = totalChunks,797 entries = emptyMap(),798 tombstones = tombstones,799 duressSlot = duressSlot.takeIf { includeDuress },800 )801802 if (803 AdoptJson.encodeToString(AdoptionFrame.serializer(), supplementalFrame)804 .encodeToByteArray().size > SNAPSHOT_FRAME_WIRE_CEILING805 ) {806 if (includeDuress) throw SnapshotDuressFrameTooLargeException()807 throw IllegalStateException("ADOPT_SNAPSHOT_SUPPLEMENTAL_FRAME_TOO_LARGE")808 }809810 val frames = mutableListOf<AdoptionFrame.Snapshot>()811 for (index in 0 until entriesChunkCount) {812 frames.add(AdoptionFrame.Snapshot(813 chunkIndex = index,814 chunkCount = totalChunks,815 entries = chunks[index],816 tombstones = emptyMap(),817 duressSlot = null,818 ))819 }820 frames.add(supplementalFrame)821 return frames822}823824internal class SnapshotDuressFrameTooLargeException :825 IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")826827828829830831832833834835836837838suspend fun receiveVaultSnapshot(839 channel: PairingTransport,840): ReceivedSnapshot {841 val acc = createAdoptSnapshotBudget()842 var chunkCount = -1843 var received = 0844 while (chunkCount == -1 || received < chunkCount) {845 val wire = channel.receive()846 if (wire.size > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {847 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")848 }849 val raw = runCatching { SyncPadding.unpad(wire) }850 .getOrElse { throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME", it) }851 if (raw.size > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {852 throw IllegalStateException("ADOPT_SNAPSHOT_OVER_BUDGET")853 }854 if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {855 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")856 }857 val frame = runCatching {858 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))859 }.getOrNull()860 if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")861 frame as AdoptionFrame.Snapshot862 if (chunkCount == -1) {863 chunkCount = frame.chunkCount864 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {865 throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")866 }867 } else if (frame.chunkCount != chunkCount) {868 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")869 }870 if (frame.chunkIndex != received) {871 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")872 }873 applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duressSlot, raw.size)874 received++875 }876 return ReceivedSnapshot(acc.entries, acc.tombstones, acc.duressSlot)877}878879fun isDeviceType(value: String): Boolean =880 DeviceType.entries.any { it.wireValue == value }