package com.peachpasswords.sync import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json /** * Vault adoption frame protocol — exact port of the browser `shared/vault-adopt.ts`. * * Adoption lets a brand-new device join an existing vault: the SOURCE (an already * set-up device) hands its root key to the RECEIVER over the pairing channel so * both devices end up with the same vault id and can sync. The receiver seals an * empty local vault under its own password but reuses the adopted root key * ([VaultRepository.createVaultFromRootKey]). * * Frames travel as JSON with a `type` discriminator and SNAKE_CASE keys — unlike * the camelCase [SyncMessage] sync wire — so the keys must match the browser * byte-for-byte for cross-platform adoption. Optional fields are omitted when * absent (encodeDefaults = false) to mirror `JSON.stringify` dropping `undefined`. */ @Serializable sealed class AdoptionFrame { /** * [sessionRequestId] carries the wire's `device_id`, but it is NOT an * identity: the requester generates it locally before the handshake that * establishes any long-term key, so it cannot be the device's real one * (see `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2). It is a label for the * consent prompt and nothing else — never persist it, never compare it * against a previous attempt's, never key trust on it. The only identity * that may be persisted is `long_term_public_key_hex`, which arrives later * in ADOPT_IDENTITY / ADOPT_GRANT.source. */ @Serializable @SerialName("ADOPT_REQUEST") data class Request( @SerialName("device_id") val sessionRequestId: String, @SerialName("device_name") val deviceName: String, @SerialName("device_type") val deviceType: String, val license: String? = null, @SerialName("snapshot_capable") val snapshotCapable: Boolean? = null, ) : AdoptionFrame() @Serializable @SerialName("ADOPT_CONFIRM") data class Confirm( val confirmed: Boolean, ) : AdoptionFrame() @Serializable @SerialName("ADOPT_GRANT") data class Grant( @SerialName("root_key_hex") val rootKeyHex: String, @SerialName("vault_id") val vaultId: String, val license: String? = null, val source: Source, // SOURCE echoes receiver's ADOPT_REQUEST.snapshot_capable so the receiver // can skip its 15s inline-snapshot wait when the source has nothing to send. // Default false is OMITTED on the wire (encodeDefaults = false) — the // browser always serializes explicit true OR false via JSON.stringify, but // Android only serializes true; both receivers default to "skip wait" when // the field is absent, so the divergence is invisible to the gate. // Pre-C sources never set the field and decode to false here. @SerialName("snapshot_capable") val snapshotCapable: Boolean = false, ) : AdoptionFrame() @Serializable @SerialName("ADOPT_IDENTITY") data class Identity( @SerialName("device_id") val deviceId: String, @SerialName("device_name") val deviceName: String, @SerialName("device_type") val deviceType: String, @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String, @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null, ) : AdoptionFrame() /** * Encrypted source-to-receiver acknowledgement emitted only after the source * has durably recorded the receiver. The receiver cannot truthfully report a * successful pair before this arrives. */ @Serializable @SerialName("ADOPT_COMPLETE") data object Complete : AdoptionFrame() /** Encrypted receiver-to-source acknowledgement after the source row is durable. */ @Serializable @SerialName("ADOPT_APPLIED") data object Applied : AdoptionFrame() /** Neither peer proved Pro, so the pairing must not commit. */ @Serializable @SerialName("ADOPT_REJECT") data class Reject( val reason: String, ) : AdoptionFrame() /** * One chunk of the inline vault snapshot sent by the source after * ADOPT_APPLIED over the same authenticated Noise NK channel. Entries are * opaque base64 ciphertext envelopes; tombstones ride the final chunk only. */ @Serializable @SerialName("ADOPT_SNAPSHOT") data class Snapshot( @SerialName("chunk_index") val chunkIndex: Int, @SerialName("chunk_count") val chunkCount: Int, val entries: Map, val tombstones: Map, @SerialName("duress_slot") val duressSlot: String? = null, ) : AdoptionFrame() /** Receiver acknowledgement sent after all snapshot chunks are applied. */ @Serializable @SerialName("ADOPT_SNAPSHOT_APPLIED") data class SnapshotApplied( @SerialName("entries_received") val entriesReceived: Int, @SerialName("tombstones_received") val tombstonesReceived: Int, ) : AdoptionFrame() /** Optional signed device graph appended after the snapshot (manifest v3+). */ @Serializable @SerialName("ADOPT_DEVICE_MANIFEST") data class DeviceManifestFrame( val manifest: SignedDeviceManifest, ) : AdoptionFrame() /** Receiver acknowledgement for an applied device manifest. */ @Serializable @SerialName("ADOPT_DEVICE_MANIFEST_APPLIED") data object DeviceManifestApplied : AdoptionFrame() @Serializable data class Source( @SerialName("device_id") val deviceId: String, @SerialName("device_name") val deviceName: String, @SerialName("device_type") val deviceType: String, @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String, @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null, @SerialName("ws_port") val wsPort: Int? = null, @SerialName("relay_url") val relayUrl: String? = null, ) } data class AdoptionPairState( val rendezvousSecretHex: String, val relayUrl: String? = null, ) /** * Receiver-side reassembly result from [receiveVaultSnapshot]. Carries the * aggregated entries and tombstones plus the optional browser duress slot * JSON (present on the final chunk only, when the source has duress configured). */ data class ReceivedSnapshot( val entries: Map, val tombstones: Map, val duressSlot: String? = null, ) /** * Wire guardrails for the inline snapshot. A chunk's serialized entries map * stays under [MAX_SNAPSHOT_CHUNK_BYTES]; the whole transfer is capped at * [MAX_SNAPSHOT_CHUNKS] frames so a compromised source cannot exhaust receiver * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator). */ const val MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024 // 512 KiB const val MAX_SNAPSHOT_CHUNKS = 200 // Headroom added to the per-chunk entries budget when validating an encoded // frame on the wire: JSON envelope plus the final chunk's tombstone map. private const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024 /** What the RECEIVER (joining device) needs to adopt a vault from a source. */ interface AdoptionReceiverDeps { val deviceName: String val deviceType: DeviceType fun generateDeviceId(): String /** Root-key transfer cannot begin until this device confirms the SAS. */ suspend fun confirmSas(): Boolean /** * Local safety policy evaluated after the authenticated grant is validated * but before any vault state changes. */ suspend fun validateSource(source: AdoptionFrame.Source) {} /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */ suspend fun createVaultFromRootKey(rootKeyHex: String): String suspend fun deriveLocalIdentity(): LocalDeviceIdentity /** Only called after a successful, SAS-confirmed direct pairing. */ suspend fun clearPairedDeviceRevocation(deviceId: String) {} suspend fun upsertPairedDevice(device: PairedDevice) suspend fun applyLicense(license: String) {} /** Return this receiver's locally validated Pro key, if present. */ suspend fun license(): String? = null val pairState: AdoptionPairState? get() = null fun onError(error: String) /** Apply encrypted entries + tombstones from the inline snapshot. */ suspend fun applySnapshot(entries: Map, tombstones: Map, duressSlot: String? = null) {} /** Mark the paired device as synced after a successful snapshot. */ suspend fun markSynced(deviceId: String) {} } /** What the SOURCE (existing device) needs to grant its vault to a receiver. */ interface AdoptionSourceDeps { val sourceIdentity: LocalDeviceIdentity suspend fun rootKeyHex(): String suspend fun vaultId(): String suspend fun license(): String? = null /** Validate and durably store a Pro key offered by the receiver. */ suspend fun applyLicense(license: String) {} /** * Ask the user to approve handing this vault to the requesting device. The * request's name/type/label are for the human to read; the decision must not * be made by recognising [AdoptionFrame.Request.sessionRequestId], which * carries no continuity between attempts. */ suspend fun onConsent(request: AdoptionFrame.Request): Boolean suspend fun upsertPairedDevice(device: PairedDevice) val pairState: AdoptionPairState? get() = null /** Return all encrypted entries + tombstones for the inline snapshot. */ suspend fun vaultSnapshot(): Pair, Map>? = null /** Return the signed device manifest for the graph, if available. */ suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null /** Mark the paired device as synced after a successful snapshot. */ suspend fun markSynced(deviceId: String) {} /** Browser-shaped sealed duress slot JSON for the snapshot, if duress is configured. */ suspend fun duressSlotJson(): String? = null } private val Hex64 = Regex("^[0-9a-f]{64}\$") fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex) fun isAdoptRequest(frame: AdoptionFrame?): Boolean = frame is AdoptionFrame.Request && frame.sessionRequestId.isNotEmpty() && isDeviceType(frame.deviceType) fun isAdoptGrant(frame: AdoptionFrame?): Boolean = frame is AdoptionFrame.Grant && validateRootKeyHex(frame.rootKeyHex) && Hex64.matches(frame.vaultId) && isDeviceType(frame.source.deviceType) && Hex64.matches(frame.source.longTermPublicKeyHex) && (frame.source.relayUrl == null || runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess) fun isAdoptConfirm(frame: AdoptionFrame?): Boolean = frame is AdoptionFrame.Confirm fun isAdoptIdentity(frame: AdoptionFrame?): Boolean = frame is AdoptionFrame.Identity && frame.deviceId.isNotEmpty() && isDeviceType(frame.deviceType) && Hex64.matches(frame.longTermPublicKeyHex) fun isAdoptComplete(frame: AdoptionFrame?): Boolean = frame is AdoptionFrame.Complete fun isAdoptApplied(frame: AdoptionFrame?): Boolean = frame is AdoptionFrame.Applied fun isAdoptReject(frame: AdoptionFrame?): Boolean = frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED" fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean = frame is AdoptionFrame.Snapshot && frame.chunkIndex >= 0 && frame.chunkCount >= 1 /** * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the * granted root key actually derives the advertised vault id (catching a lying or * corrupted source before it can seal a mismatched vault), seals the local vault, * applies any carried license, returns our identity, and records the source as a * paired device. Returns null on any protocol failure (reason via [deps.onError]). */ suspend fun requestAdoption( channel: PairingTransport, deps: AdoptionReceiverDeps, // Called once after a valid ADOPT_GRANT is decoded and validated, before // any side effects (vault creation, paired-device upsert). Used by the // receiver to inspect the grant's snapshot_capable flag and skip the 15s // inline-snapshot wait when the source has nothing to send. Defaults to no-op // for callers (tests, other helpers) that don't need to inspect the grant. onGrant: ((AdoptionFrame.Grant) -> Unit)? = null, nowMs: () -> Long = { System.currentTimeMillis() }, ): PairedDevice? { val receiverLicense = deps.license() // Fresh every attempt, and deliberately not our real device id: no identity // exists yet at this point in the protocol. sendFrame(channel, AdoptionFrame.Request( sessionRequestId = deps.generateDeviceId(), deviceName = deps.deviceName, deviceType = deps.deviceType.wireValue, license = receiverLicense, snapshotCapable = true, )) val confirmed = deps.confirmSas() sendFrame(channel, AdoptionFrame.Confirm(confirmed)) if (!confirmed) { deps.onError("ADOPT_SAS_REJECTED") return null } val frame = receiveFrame(channel) if (isAdoptReject(frame)) { deps.onError((frame as AdoptionFrame.Reject).reason) return null } if (!isAdoptGrant(frame)) { deps.onError("ADOPT_UNEXPECTED_FRAME") return null } val grant = frame as AdoptionFrame.Grant onGrant?.invoke(grant) val rootKeyBytes = hexToPubkey(grant.rootKeyHex) try { if (computeVaultId(rootKeyBytes) != grant.vaultId) { deps.onError("ADOPT_VAULT_ID_MISMATCH") return null } } finally { rootKeyBytes.fill(0) } if (receiverLicense == null && grant.license == null) { deps.onError("ADOPT_PRO_REQUIRED") return null } deps.validateSource(grant.source) val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex) if (vaultId != grant.vaultId) { deps.onError("ADOPT_VAULT_ID_MISMATCH") return null } if (receiverLicense == null) { grant.license?.let { deps.applyLicense(it) } } val identity = deps.deriveLocalIdentity() sendFrame(channel, AdoptionFrame.Identity( deviceId = identity.deviceId, deviceName = identity.deviceName, deviceType = identity.deviceType.wireValue, longTermPublicKeyHex = identity.longTermPublicKeyHex, signingPublicKeyHex = identity.signingPublicKeyHex, )) if (!isAdoptComplete(receiveFrame(channel))) { deps.onError("ADOPT_COMPLETION_MISSING") return null } val sourceDevice = PairedDevice( deviceId = grant.source.deviceId, deviceName = grant.source.deviceName, deviceType = grant.source.deviceType, longTermPublicKeyHex = grant.source.longTermPublicKeyHex, signingPublicKeyHex = grant.source.signingPublicKeyHex, trustSource = "direct", wsPort = grant.source.wsPort, rendezvousSecretHex = deps.pairState?.rendezvousSecretHex, relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl, pairedAt = nowMs(), ) // A physically confirmed direct re-pair is the sole path allowed to lift // terminal revocation. Clear before upsert so storage can never expose the // source as both paired and revoked, even transiently. deps.clearPairedDeviceRevocation(sourceDevice.deviceId) deps.upsertPairedDevice(sourceDevice) sendFrame(channel, AdoptionFrame.Applied) return sourceDevice } /** * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the * root key, then records the receiver's returned identity as a paired device. * Returns null if consent is denied or the receiver's identity frame is invalid. */ suspend fun grantAdoption( channel: PairingTransport, request: AdoptionFrame.Request, deps: AdoptionSourceDeps, nowMs: () -> Long = { System.currentTimeMillis() }, ): PairedDevice? { if (!deps.onConsent(request)) return null val confirmation = receiveFrame(channel) if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) { return null } var sourceLicense = deps.license() if (sourceLicense == null && request.license != null) { sourceLicense = runCatching { deps.applyLicense(request.license) deps.license() }.getOrNull() } if (sourceLicense == null) { sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED")) throw IllegalStateException("ADOPT_PRO_REQUIRED") } val rootKeyHex = deps.rootKeyHex() if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY") sendFrame(channel, AdoptionFrame.Grant( rootKeyHex = rootKeyHex, vaultId = deps.vaultId(), license = sourceLicense, source = AdoptionFrame.Source( deviceId = deps.sourceIdentity.deviceId, deviceName = deps.sourceIdentity.deviceName, deviceType = deps.sourceIdentity.deviceType.wireValue, longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex, signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex, wsPort = deps.sourceIdentity.wsPort, relayUrl = deps.pairState?.relayUrl, ), snapshotCapable = request.snapshotCapable == true, )) val identity = receiveFrame(channel) if (!isAdoptIdentity(identity)) return null identity as AdoptionFrame.Identity val device = PairedDevice( deviceId = identity.deviceId, deviceName = identity.deviceName, deviceType = identity.deviceType, longTermPublicKeyHex = identity.longTermPublicKeyHex, signingPublicKeyHex = identity.signingPublicKeyHex, trustSource = "direct", rendezvousSecretHex = deps.pairState?.rendezvousSecretHex, relayUrl = deps.pairState?.relayUrl, pairedAt = nowMs(), ) deps.upsertPairedDevice(device) sendFrame(channel, AdoptionFrame.Complete) if (!isAdoptApplied(receiveFrame(channel))) return null return device } /** * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent * optionals exactly like `JSON.stringify` drops `undefined`. */ val AdoptJson: Json = Json { classDiscriminator = "type" encodeDefaults = false ignoreUnknownKeys = true } suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) { channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8)) } /** Decode one frame, tolerating a malformed/unknown payload by returning null. */ suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? = runCatching { AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8)) }.getOrNull() /** * Source side: chunk the vault snapshot and send it over the pairing channel. * * Wire shape (v2.9.9+, duress on its own frame; mirrors browser v4.6.4): * - N entries frames, each <= [MAX_SNAPSHOT_CHUNK_BYTES] serialized entries. * Tombstones ride the last entries frame when no duress follows. * - If duressSlot is set, one extra frame after the entries carrying only * the duress slot (entries: empty, tombstones: tombstone map, * duress_slot: duressSlot). This moves duress off the entries frame so a * sizeable sealed duress vault can no longer push the entries frame raw * bytes over [SNAPSHOT_FRAME_WIRE_CEILING] (768 KiB). * * Frame budget ([MAX_SNAPSHOT_CHUNKS] = 200): * - When duress is configured, ONE of the 200 frames is reserved for the * duress slot. The entries cap is [MAX_SNAPSHOT_CHUNKS] - 1 = 199 entries * chunks; excess entries are deferred to the sync fallback pipeline (sync * carries entries and tombstones on Android too, so this is a graceful * truncation). This reservation is REQUIRED: ordinary sync does NOT * carry duress on Android (VaultRepository.exportDuressSlotForAdoption * is the only transfer path), so silently dropping a duress frame at * the cap would leave the adopted device permanently without its duress * vault. We prioritize duress delivery over completeness of entries. * * Oversized duress: duress that itself exceeds [SNAPSHOT_FRAME_WIRE_CEILING] * is rejected with a thrown error BEFORE any snapshot frame is sent. Hard stop; * the caller must split duress out of band. We do not silently truncate * duress; sync cannot restore it. * * Backward compat: pre-v2.9.9 senders put `duress_slot` on the entries * final chunk. `receiveVaultSnapshot` reads `duress_slot` off any chunk (a * duress-only frame has empty `entries` and the complete tombstone map), * so both shapes decode and both new->old / old->new directions interoperate. */ suspend fun sendVaultSnapshot( channel: PairingTransport, entries: Map, tombstones: Map, duressSlot: String? = null, ) { val includeDuress = !duressSlot.isNullOrBlank() // Reserve one frame for duress when configured so duress is never // silently dropped at the cap; ordinary sync does NOT carry duress on // Android (VaultRepository.exportDuressSlotForAdoption is the only // transfer path), so dropping a duress frame would orphan the adopted // device's duress vault. val entryCap = if (includeDuress) MAX_SNAPSHOT_CHUNKS - 1 else MAX_SNAPSHOT_CHUNKS val chunks = mutableListOf>() var current = LinkedHashMap() var currentBytes = 2 // "{}" for ((id, envelope) in entries) { // +1 for the separating comma; an overestimate only shrinks chunks. val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1 if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) { chunks.add(current) current = LinkedHashMap() currentBytes = 2 } current[id] = envelope currentBytes += entryBytes } chunks.add(current) val entriesChunkCount = minOf(chunks.size, entryCap) val sendDuress = includeDuress val totalChunks = entriesChunkCount + (if (sendDuress) 1 else 0) val duressFrame = if (sendDuress) AdoptionFrame.Snapshot( chunkIndex = entriesChunkCount, chunkCount = totalChunks, entries = emptyMap(), tombstones = tombstones, duressSlot = duressSlot, ) else null if ( duressFrame != null && AdoptJson.encodeToString(duressFrame).encodeToByteArray().size > SNAPSHOT_FRAME_WIRE_CEILING ) { throw SnapshotDuressFrameTooLargeException() } for (index in 0 until entriesChunkCount) { val isFinalEntries = index == entriesChunkCount - 1 sendFrame(channel, AdoptionFrame.Snapshot( chunkIndex = index, chunkCount = totalChunks, entries = chunks[index], // Tombstones ride the LAST wire frame: when duress follows the // entries, it goes on the duress frame; otherwise on the last // entries chunk. tombstones = if (isFinalEntries && !sendDuress) tombstones else emptyMap(), duressSlot = null, )) } if (duressFrame != null) sendFrame(channel, duressFrame) } internal class SnapshotDuressFrameTooLargeException : IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE") /** * Receiver side: receive and reassemble every snapshot chunk. Validates the * encoded frame size before parsing, then enforces sequential 0-based * chunk_index ordering, a stable chunk_count within [MAX_SNAPSHOT_CHUNKS], and * structural validity of each frame. Throws on any validation failure so the * caller can fall back to the sync pipeline. */ suspend fun receiveVaultSnapshot( channel: PairingTransport, ): ReceivedSnapshot { val entries = LinkedHashMap() val tombstones = LinkedHashMap() var duressSlot: String? = null var chunkCount = -1 var received = 0 while (chunkCount == -1 || received < chunkCount) { val raw = channel.receive() if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) { throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE") } val frame = runCatching { AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8)) }.getOrNull() if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME") frame as AdoptionFrame.Snapshot if (chunkCount == -1) { chunkCount = frame.chunkCount if (chunkCount > MAX_SNAPSHOT_CHUNKS) { throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS") } } else if (frame.chunkCount != chunkCount) { throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH") } if (frame.chunkIndex != received) { throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER") } entries.putAll(frame.entries) tombstones.putAll(frame.tombstones) if (frame.duressSlot != null) duressSlot = frame.duressSlot received++ } return ReceivedSnapshot(entries, tombstones, duressSlot) } private fun isDeviceType(value: String): Boolean = DeviceType.entries.any { it.wireValue == value }