55df24f34e6e

point Android feedback at the same Web3Forms inbox the extension already uses, finish the chirp-era rename the browser did months ago, delete the dead audio-frame and pairing-phase files, and tell the user when a pull-to-refresh has nobody to sync with (v2.5.3)

time
2026-07-26 17:51 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
feedback wiring and chirp-era cleanup
parent
98aa064967a2
42 files VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/feedback/FeedbackRepository.kt+40 −29app/src/main/java/com/peachpasswords/sync/ChirpAdopt.kt+0 −299app/src/main/java/com/peachpasswords/sync/ChirpIdentity.kt+0 −78app/src/main/java/com/peachpasswords/sync/ChirpSyncCoordinator.kt+0 −291app/src/main/java/com/peachpasswords/sync/ChirpSyncSession.kt+0 −14app/src/main/java/com/peachpasswords/sync/ChirpTypes.kt+0 −20app/src/main/java/com/peachpasswords/sync/CloudRelayDialer.kt+4 −4app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+8 −8app/src/main/java/com/peachpasswords/sync/DeviceIdentity.kt+82 −0app/src/main/java/com/peachpasswords/sync/DeviceType.kt+26 −0app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+1 −1app/src/main/java/com/peachpasswords/sync/PairIdentityFrames.kt+1 −1app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+1 −1app/src/main/java/com/peachpasswords/sync/PairingFrames.kt+9 −9app/src/main/java/com/peachpasswords/sync/PairingProgress.kt+0 −19app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+3 −3app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+1 −1app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+291 −0app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+1 −1app/src/main/java/com/peachpasswords/sync/SyncSession.kt+14 −0app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+299 −0app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+3 −3app/src/main/java/com/peachpasswords/ui/components/PairingBirds.kt+1 −1app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+2 −2app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+4 −4app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+2 −2app/src/main/java/com/peachpasswords/ui/theme/PeachType.kt+0 −3app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+12 −0app/src/test/java/com/peachpasswords/feedback/FeedbackSubmissionTest.kt+46 −0app/src/test/java/com/peachpasswords/sync/C7ChirpTransportTest.kt+0 −224app/src/test/java/com/peachpasswords/sync/C7TransportTest.kt+224 −0app/src/test/java/com/peachpasswords/sync/ChirpAdoptTest.kt+0 −304app/src/test/java/com/peachpasswords/sync/ChirpSyncCoordinatorTest.kt+0 −148app/src/test/java/com/peachpasswords/sync/ChirpSyncSuiteTest.kt+0 −208app/src/test/java/com/peachpasswords/sync/CloudRelayDialerTest.kt+1 −1app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+3 −3app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+1 −1app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+148 −0app/src/test/java/com/peachpasswords/sync/SyncSuiteTest.kt+208 −0app/src/test/java/com/peachpasswords/sync/VaultAdoptTest.kt+304 −0
VERSION+1 −1
@@ -1 +1 @@1-2.5.21+2.5.3

view file

app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24010220-        versionName "2.5.2"19+        versionCode 24010320+        versionName "2.5.3"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/feedback/FeedbackRepository.kt+40 −29
@@ -12,17 +12,20 @@1212 import javax.inject.Singleton1313 1414 /**15- * Submits user feedback to the Peach feedback endpoint. Payload shape mirrors16- * the browser extension's `SUBMIT_FEEDBACK` handler exactly17- * (`src/background/messages.ts`): a JSON body of18- * `{ category, message, version, browser, source }`, POSTed as19- * `application/json`.15+ * Submits user feedback through Web3Forms — the same inbox and the same access16+ * key the browser extension posts to (`peach-browser/src/shared/config.ts`).17+ * Android had no endpoint at all and said so on every attempt; there is no18+ * reason for the two clients to reach different places.2019  *21- * MN-7: [FEEDBACK_ENDPOINT] is empty until a real backend exists. The browser22- * returns ok:true (a silent no-op) in this case, but here we deliberately diverge23- * and throw [FeedbackUnavailableException] so the UI tells the user feedback is24- * unavailable rather than falsely reporting it was sent. The `source` discriminator25- * is `"peach-android"` (the browser sends `"peach-extension"`).20+ * The access key is a client-side, write-only form identifier, not a secret.21+ * It cannot read prior submissions or reach anything else, and the only abuse22+ * it enables is spamming Peach's own feedback inbox — the same trade already23+ * accepted for the extension bundle.24+ *25+ * The payload carries the category and the user-typed message only. No vault26+ * data, keys, credentials, or device identifiers are included; the app version27+ * rides in the subject line so a report can be placed without asking the user28+ * who they are.2629  */2730 class FeedbackUnavailableException : Exception("feedback_endpoint_not_configured")2831 @@ -33,35 +36,43 @@3336 3437     suspend fun submit(type: String, message: String) {3538         if (message.isBlank()) throw IllegalArgumentException("message_empty")39+        if (WEB3FORMS_KEY.isBlank()) throw FeedbackUnavailableException()3640 37-        // No endpoint → be honest, never fake a successful send (MN-7).38-        if (FEEDBACK_ENDPOINT.isBlank()) throw FeedbackUnavailableException()39-4041         withContext(Dispatchers.IO) {41-            val payload = JSONObject().apply {42-                put("category", type)43-                put("message", message)44-                put("version", BuildConfig.VERSION_NAME)45-                put("browser", "android")46-                put("source", "peach-android")47-            }48-            val body = payload.toString().toRequestBody(JSON)49-            val request = Request.Builder()50-                .url(FEEDBACK_ENDPOINT)51-                .post(body)52-                .build()42+            val body = buildSubmission(type, message).toRequestBody(JSON)43+            val request = Request.Builder().url(ENDPOINT).post(body).build()5344             client.newCall(request).execute().use { res ->45+                val text = res.body?.string().orEmpty()5446                 if (!res.isSuccessful) throw java.io.IOException("HTTP ${res.code}")47+                // Web3Forms answers 200 with {"success": false} when it rejects48+                // a submission, so status alone would report a send that never49+                // happened. Never tell the user it went if it did not.50+                val accepted = runCatching { JSONObject(text).optBoolean("success") }51+                    .getOrDefault(false)52+                if (!accepted) throw java.io.IOException("feedback_rejected")5553             }5654         }5755     }5856 5957     companion object {60-        // Set to the real Peach feedback endpoint when it exists (the browser source61-        // also ships this empty). While empty, submit() throws so the UI reports the62-        // feature as unavailable rather than silently pretending success (MN-7).63-        const val FEEDBACK_ENDPOINT: String = ""58+        const val ENDPOINT = "https://api.web3forms.com/submit"6459 60+        /** Mirrors `WEB3FORMS_KEY` in the browser's `src/shared/config.ts`. */61+        const val WEB3FORMS_KEY = "5707c8d7-d85a-4edd-9b66-95da8f25557e"62+6563         private val JSON = "application/json; charset=utf-8".toMediaType()64+65+        internal fun buildSubmission(66+            type: String,67+            message: String,68+            version: String = BuildConfig.VERSION_NAME,69+        ): String = JSONObject().apply {70+            put("access_key", WEB3FORMS_KEY)71+            put("subject", "Peach Feedback - $type (Android $version)")72+            put("message", message)73+            put("from_name", "Peach User")74+            // Honeypot — Web3Forms drops the submission if this arrives filled.75+            put("botcheck", "")76+        }.toString()6677     }6778 }

view file

app/src/main/java/com/peachpasswords/sync/ChirpAdopt.kt+0 −299
@@ -1,299 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.serialization.SerialName4-import kotlinx.serialization.Serializable5-import kotlinx.serialization.json.Json6-7-/**8- * Vault adoption frame protocol — exact port of the browser `shared/chirp-adopt.ts`.9- *10- * Adoption lets a brand-new device join an existing vault: the SOURCE (an already11- * set-up device) hands its root key to the RECEIVER over the pairing channel so12- * both devices end up with the same vault id and can sync. The receiver seals an13- * empty local vault under its own password but reuses the adopted root key14- * ([VaultRepository.createVaultFromRootKey]).15- *16- * Frames travel as JSON with a `type` discriminator and SNAKE_CASE keys — unlike17- * the camelCase [SyncMessage] sync wire — so the keys must match the browser18- * byte-for-byte for cross-platform adoption. Optional fields are omitted when19- * absent (encodeDefaults = false) to mirror `JSON.stringify` dropping `undefined`.20- */21-@Serializable22-sealed class AdoptionFrame {23-    /**24-     * [sessionRequestId] carries the wire's `device_id`, but it is NOT an25-     * identity: the requester generates it locally before the handshake that26-     * establishes any long-term key, so it cannot be the device's real one27-     * (see `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2). It is a label for the28-     * consent prompt and nothing else — never persist it, never compare it29-     * against a previous attempt's, never key trust on it. The only identity30-     * that may be persisted is `long_term_public_key_hex`, which arrives later31-     * in ADOPT_IDENTITY / ADOPT_GRANT.source.32-     */33-    @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()40-41-    @Serializable42-    @SerialName("ADOPT_CONFIRM")43-    data class Confirm(44-        val confirmed: Boolean,45-    ) : AdoptionFrame()46-47-    @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()55-56-    @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()65-66-    @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-}77-78-data class AdoptionPairState(79-    val rendezvousSecretHex: String,80-    val relayUrl: String? = null,81-)82-83-/** What the RECEIVER (joining device) needs to adopt a vault from a source. */84-interface AdoptionReceiverDeps {85-    val deviceName: String86-    val deviceType: DeviceType87-    fun generateDeviceId(): String88-    /** Root-key transfer cannot begin until this device confirms the SAS. */89-    suspend fun confirmSas(): Boolean90-91-    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */92-    suspend fun createVaultFromRootKey(rootKeyHex: String): String93-    suspend fun deriveLocalIdentity(): LocalChirpIdentity94-    suspend fun upsertPairedDevice(device: PairedDevice)95-    suspend fun applyLicense(license: String) {}96-    val pairState: AdoptionPairState? get() = null97-    fun onError(error: String)98-}99-100-/** What the SOURCE (existing device) needs to grant its vault to a receiver. */101-interface AdoptionSourceDeps {102-    val sourceIdentity: LocalChirpIdentity103-    suspend fun rootKeyHex(): String104-    suspend fun vaultId(): String105-    suspend fun license(): String? = null106-107-    /**108-     * Ask the user to approve handing this vault to the requesting device. The109-     * request's name/type/label are for the human to read; the decision must not110-     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which111-     * carries no continuity between attempts.112-     */113-    suspend fun onConsent(request: AdoptionFrame.Request): Boolean114-    suspend fun upsertPairedDevice(device: PairedDevice)115-    val pairState: AdoptionPairState? get() = null116-}117-118-private val Hex64 = Regex("^[0-9a-f]{64}\$")119-120-fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)121-122-fun isAdoptRequest(frame: AdoptionFrame?): Boolean =123-    frame is AdoptionFrame.Request &&124-        frame.sessionRequestId.isNotEmpty() &&125-        isDeviceType(frame.deviceType)126-127-fun 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)135-136-fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =137-    frame is AdoptionFrame.Confirm138-139-fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =140-    frame is AdoptionFrame.Identity &&141-        frame.deviceId.isNotEmpty() &&142-        isDeviceType(frame.deviceType) &&143-        Hex64.matches(frame.longTermPublicKeyHex)144-145-/**146- * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the147- * granted root key actually derives the advertised vault id (catching a lying or148- * corrupted source before it can seal a mismatched vault), seals the local vault,149- * applies any carried license, returns our identity, and records the source as a150- * paired device. Returns null on any protocol failure (reason via [deps.onError]).151- */152-suspend fun requestAdoption(153-    channel: PairingTransport,154-    deps: AdoptionReceiverDeps,155-    nowMs: () -> Long = { System.currentTimeMillis() },156-): PairedDevice? {157-    // Fresh every attempt, and deliberately not our real device id: no identity158-    // exists yet at this point in the protocol.159-    sendFrame(channel, AdoptionFrame.Request(160-        sessionRequestId = deps.generateDeviceId(),161-        deviceName = deps.deviceName,162-        deviceType = deps.deviceType.wireValue,163-    ))164-165-    val confirmed = deps.confirmSas()166-    sendFrame(channel, AdoptionFrame.Confirm(confirmed))167-    if (!confirmed) {168-        deps.onError("ADOPT_SAS_REJECTED")169-        return null170-    }171-172-    val frame = receiveFrame(channel)173-    if (!isAdoptGrant(frame)) {174-        deps.onError("ADOPT_UNEXPECTED_FRAME")175-        return null176-    }177-    val grant = frame as AdoptionFrame.Grant178-179-    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-    }188-189-    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)190-    if (vaultId != grant.vaultId) {191-        deps.onError("ADOPT_VAULT_ID_MISMATCH")192-        return null193-    }194-195-    grant.license?.let { deps.applyLicense(it) }196-197-    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-    ))205-206-    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-}221-222-/**223- * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the224- * root key, then records the receiver's returned identity as a paired device.225- * Returns null if consent is denied or the receiver's identity frame is invalid.226- */227-suspend 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 null234-235-    val confirmation = receiveFrame(channel)236-    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {237-        return null238-    }239-240-    val rootKeyHex = deps.rootKeyHex()241-    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")242-243-    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-    ))257-258-    val identity = receiveFrame(channel)259-    if (!isAdoptIdentity(identity)) return null260-    identity as AdoptionFrame.Identity261-262-    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-}276-277-/**278- * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat279- * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent280- * optionals exactly like `JSON.stringify` drops `undefined`.281- */282-val AdoptJson: Json = Json {283-    classDiscriminator = "type"284-    encodeDefaults = false285-    ignoreUnknownKeys = true286-}287-288-suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {289-    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))290-}291-292-/** Decode one frame, tolerating a malformed/unknown payload by returning null. */293-suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =294-    runCatching {295-        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))296-    }.getOrNull()297-298-private fun isDeviceType(value: String): Boolean =299-    DeviceType.entries.any { it.wireValue == value }

view file at the parent

app/src/main/java/com/peachpasswords/sync/ChirpIdentity.kt+0 −78
@@ -1,78 +0,0 @@1-package com.peachpasswords.sync2-3-import android.content.Context4-import java.util.UUID5-6-private const val DEVICE_ID_KEY = "peach_chirp_device_id"7-private const val DEVICE_NAME_KEY = "peach_chirp_device_name"8-private const val PREFS_FILE = "peach_chirp_identity"9-10-data class LocalChirpIdentity(11-    val deviceId: String,12-    val deviceName: String,13-    val deviceType: DeviceType,14-    val longTermPublicKeyHex: String,15-    val signingPublicKeyHex: String,16-    val wsPort: Int? = null,17-    val relayUrl: String = Rendezvous.DEFAULT_RELAY_URL,18-    /**19-     * This device's signed Pro license blob (`PPK-…`), set only when Pro. Sent20-     * in the outbound PAIR_IDENTITY so the peer can activate it after SAS — one21-     * Pro device grants Pro to the other when pairing. Either direction.22-     */23-    val license: String? = null,24-)25-26-fun getOrCreateDeviceId(context: Context): String {27-    val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)28-    val current = prefs.getString(DEVICE_ID_KEY, null)29-    if (!current.isNullOrEmpty()) return current30-    val fresh = UUID.randomUUID().toString()31-    prefs.edit().putString(DEVICE_ID_KEY, fresh).apply()32-    return fresh33-}34-35-fun getDeviceName(context: Context): String {36-    val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)37-    return prefs.getString(DEVICE_NAME_KEY, null).orEmpty().ifBlank { defaultDeviceName() }38-}39-40-fun setDeviceName(context: Context, name: String) {41-    val trimmed = name.trim()42-    require(trimmed.isNotEmpty()) { "device name must not be empty" }43-    require(trimmed.length <= 64) { "device name must be 64 chars or fewer" }44-    context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)45-        .edit()46-        .putString(DEVICE_NAME_KEY, trimmed)47-        .apply()48-}49-50-fun buildLocalIdentity(context: Context, masterKey: ByteArray): LocalChirpIdentity {51-    val deviceId = getOrCreateDeviceId(context)52-    val deviceName = getDeviceName(context)53-    val kp = deriveSyncIdentityKeypair(masterKey, deviceId)54-    val signing = deriveSyncSigningKeypair(masterKey, deviceId)55-    return LocalChirpIdentity(56-        deviceId = deviceId,57-        deviceName = deviceName,58-        deviceType = extensionDeviceType(),59-        longTermPublicKeyHex = pubkeyToHex(kp.publicKey),60-        signingPublicKeyHex = pubkeyToHex(signing.publicKeyRaw),61-        // No LAN port is advertised: peers reconnect through the relay62-        // (docs/sync-lan-retirement.md), never by dialling this device.63-        wsPort = null,64-        relayUrl = RelaySettingsStore.readRelayUrl(context),65-    )66-}67-68-fun deriveLocalSyncKeypair(masterKey: ByteArray, deviceId: String): SyncIdentityKeypair {69-    return deriveSyncIdentityKeypair(masterKey, deviceId)70-}71-72-private fun defaultDeviceName(): String {73-    return "Android"74-}75-76-private fun extensionDeviceType(): DeviceType {77-    return DeviceType.ANDROID78-}

view file at the parent

app/src/main/java/com/peachpasswords/sync/ChirpSyncCoordinator.kt+0 −291
@@ -1,291 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.coroutines.TimeoutCancellationException4-import kotlinx.coroutines.withTimeout5-6-/**7- * Chirp Sync — Phase 2 manifest-diff coordinator. Exact port of the browser8- * `chirp-sync-coordinator.ts`, with the complementary responder the browser9- * leaves to "the mobile-side coordinator that calls this same protocol the10- * other way".11- *12- * After reconnect Noise KK authenticates both locally-paired static keys, the13- * two sides drive:14- *15- *   ->  SYNC_HELLO { vaultId, deviceId, version, deviceTime }16- *   <-  SYNC_HELLO                                  (mismatch → vault_mismatch)17- *   <-> SYNC_MANIFEST { entries, tombstones }       (each side sends one)18- *   ->  SYNC_REQUEST { entryIds }                   (entries newer on the peer)19- *   <-  SYNC_REQUEST20- *   <-> SYNC_ENTRIES   (sender unconditional; receiver reads iff it requested)21- *   <-> SYNC_TOMBSTONES22- *   <-> SYNC_DEVICE_MANIFEST                         (optional, both opt in)23- *   <-> SYNC_DONE24- *25- * Entries are opaque base64 ciphertext — the coordinator never decrypts.26- * Last-write-wins at the entry level by mtime; tombstones override updates with27- * a later deletion timestamp.28- */29-private const val SESSION_TIMEOUT_MS = 30_000L30-private const val CLOCK_SKEW_WARN_MS = 120_000L31-32-/**33- * Vault-side dependency of the coordinator. The vault holds the canonical34- * entry/tombstone tables; the coordinator pulls a manifest, requests entries by35- * id, applies remote entries, and applies remote tombstones. Mirrors the36- * browser `ChirpSyncVaultAdapter`.37- */38-interface ChirpSyncVaultAdapter {39-    suspend fun computeVaultId(): String40-    suspend fun getOurDeviceId(): String41-42-    /** The vault's monotonic version (`peach_last_modified_at`). */43-    suspend fun getLocalVersion(): Long44-45-    /** Per-entry mtimes for our active entries. */46-    suspend fun buildEntryManifest(): Map<String, Long>47-48-    /** Per-entry deletion timestamps for our tombstones. */49-    suspend fun buildTombstoneManifest(): Map<String, Long>50-51-    /** Base64 ciphertext for each requested entry id. */52-    suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String>53-54-    /** Apply incoming encrypted entries; the vault decides per-entry overwrite. */55-    suspend fun applyEntries(entries: Map<String, String>)56-57-    /** Apply incoming tombstones; the vault retains the latest deletion ts. */58-    suspend fun applyTombstones(tombstones: Map<String, Long>)59-60-    /** Optional device-manifest exchange (v2 brief §4.3). Null disables it. */61-    suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? = null62-    suspend fun applyIncomingDeviceManifest(63-        manifest: SignedDeviceManifest,64-        authenticatedRemoteStaticPublicKey: ByteArray,65-    ) {}66-67-    /** Bind user-visible sync alerts to a stored, authenticated peer record. */68-    fun bindAuthenticatedPeer(peer: PairedDevice) {}69-70-    /** Both ends must opt in for the device-manifest step to run. */71-    val supportsDeviceManifest: Boolean get() = false72-}73-74-enum class SyncOutcomeStatus { COMPLETED, VAULT_MISMATCH, ERRORED, TIMEOUT }75-76-data class SyncOutcome(77-    val status: SyncOutcomeStatus,78-    val entriesReceived: Int = 0,79-    val entriesSent: Int = 0,80-    val tombstonesReceived: Int = 0,81-    val tombstonesSent: Int = 0,82-    val error: String? = null,83-)84-85-/**86- * Run one sync round as the WebSocket client (initiator). The extension is87- * always the initiator; Android initiates when it dials another device.88- */89-suspend fun runChirpSyncInitiator(90-    session: ChirpSyncSession,91-    vault: ChirpSyncVaultAdapter,92-    nowMs: Long = System.currentTimeMillis(),93-): SyncOutcome {94-    var entriesReceived = 095-    var entriesSent = 096-    var tombstonesReceived = 097-    var tombstonesSent = 098-99-    return try {100-        withTimeout(SESSION_TIMEOUT_MS) {101-            val ourVaultId = vault.computeVaultId()102-            val localVersion = vault.getLocalVersion()103-104-            session.sendMsg(105-                SyncMessage.Hello(ourVaultId, vault.getOurDeviceId(), localVersion, nowMs),106-            )107-            val hello = session.recvAs<SyncMessage.Hello>("SYNC_HELLO")108-            if (hello.vaultId != ourVaultId) {109-                session.sendMsg(SyncMessage.Error("vault_id_mismatch"))110-                return@withTimeout SyncOutcome(SyncOutcomeStatus.VAULT_MISMATCH)111-            }112-            warnOnSkew(nowMs, hello.deviceTime)113-114-            if (hello.version == localVersion) {115-                session.sendMsg(SyncMessage.Done(0, 0))116-                session.recvAs<SyncMessage.Done>("SYNC_DONE")117-                return@withTimeout SyncOutcome(SyncOutcomeStatus.COMPLETED)118-            }119-120-            val ourEntries = vault.buildEntryManifest()121-            val ourTombstones = vault.buildTombstoneManifest()122-            session.sendMsg(SyncMessage.Manifest(ourEntries, ourTombstones))123-            val peerManifest = session.recvAs<SyncMessage.Manifest>("SYNC_MANIFEST")124-125-            val wantFromPeer = diffNewer(peerManifest.entries, ourEntries)126-            session.sendMsg(SyncMessage.Request(wantFromPeer))127-            val peerRequest = session.recvAs<SyncMessage.Request>("SYNC_REQUEST")128-129-            val ourPayload = vault.getEncryptedEntries(peerRequest.entryIds)130-            session.sendMsg(SyncMessage.Entries(ourPayload))131-            entriesSent = ourPayload.size132-133-            if (wantFromPeer.isNotEmpty()) {134-                val incoming = session.recvAs<SyncMessage.Entries>("SYNC_ENTRIES")135-                vault.applyEntries(incoming.entries)136-                entriesReceived = incoming.entries.size137-            }138-139-            val newerForPeer = newerTombstones(ourTombstones, peerManifest.tombstones)140-            session.sendMsg(SyncMessage.Tombstones(newerForPeer))141-            tombstonesSent = newerForPeer.size142-143-            val peerTombstones = session.recvAs<SyncMessage.Tombstones>("SYNC_TOMBSTONES")144-            vault.applyTombstones(peerTombstones.tombstones)145-            tombstonesReceived = peerTombstones.tombstones.size146-147-            if (vault.supportsDeviceManifest) {148-                session.sendMsg(SyncMessage.DeviceManifest(vault.buildOutgoingDeviceManifest()))149-                val theirs = session.recvAs<SyncMessage.DeviceManifest>("SYNC_DEVICE_MANIFEST")150-                if (theirs.manifest != null) {151-                    vault.applyIncomingDeviceManifest(152-                        theirs.manifest,153-                        session.remoteStaticPublicKey,154-                    )155-                }156-            }157-158-            session.sendMsg(SyncMessage.Done(entriesReceived, entriesSent))159-            session.recvAs<SyncMessage.Done>("SYNC_DONE")160-            SyncOutcome(161-                SyncOutcomeStatus.COMPLETED,162-                entriesReceived, entriesSent, tombstonesReceived, tombstonesSent,163-            )164-        }165-    } catch (_: TimeoutCancellationException) {166-        SyncOutcome(SyncOutcomeStatus.TIMEOUT, error = "session timed out after 30s")167-    } catch (e: Exception) {168-        SyncOutcome(SyncOutcomeStatus.ERRORED, error = e.message ?: "sync_failed")169-    }170-}171-172-/**173- * Run one sync round as the WebSocket server (responder) — the exact174- * complement of [runChirpSyncInitiator]. Send/recv are mirrored so the two175- * halves never deadlock and stale frames never leak between phases: the176- * responder reads the initiator's unconditional SYNC_ENTRIES, but only emits177- * its own when the initiator actually requested something.178- */179-suspend fun runChirpSyncResponder(180-    session: ChirpSyncSession,181-    vault: ChirpSyncVaultAdapter,182-    nowMs: Long = System.currentTimeMillis(),183-): SyncOutcome {184-    var entriesReceived = 0185-    var entriesSent = 0186-    var tombstonesReceived = 0187-    var tombstonesSent = 0188-189-    return try {190-        withTimeout(SESSION_TIMEOUT_MS) {191-            val ourVaultId = vault.computeVaultId()192-            val localVersion = vault.getLocalVersion()193-194-            val hello = session.recvAs<SyncMessage.Hello>("SYNC_HELLO")195-            // Always answer HELLO so the initiator's recv completes, even on a196-            // mismatch where we bail immediately after.197-            session.sendMsg(198-                SyncMessage.Hello(ourVaultId, vault.getOurDeviceId(), localVersion, nowMs),199-            )200-            if (hello.vaultId != ourVaultId) {201-                return@withTimeout SyncOutcome(SyncOutcomeStatus.VAULT_MISMATCH)202-            }203-            warnOnSkew(nowMs, hello.deviceTime)204-205-            if (hello.version == localVersion) {206-                session.recvAs<SyncMessage.Done>("SYNC_DONE")207-                session.sendMsg(SyncMessage.Done(0, 0))208-                return@withTimeout SyncOutcome(SyncOutcomeStatus.COMPLETED)209-            }210-211-            val ourEntries = vault.buildEntryManifest()212-            val ourTombstones = vault.buildTombstoneManifest()213-            val peerManifest = session.recvAs<SyncMessage.Manifest>("SYNC_MANIFEST")214-            session.sendMsg(SyncMessage.Manifest(ourEntries, ourTombstones))215-216-            val initiatorWants = session.recvAs<SyncMessage.Request>("SYNC_REQUEST").entryIds217-            val wantFromPeer = diffNewer(peerManifest.entries, ourEntries)218-            session.sendMsg(SyncMessage.Request(wantFromPeer))219-220-            // Drain the initiator's unconditional SYNC_ENTRIES first.221-            val incoming = session.recvAs<SyncMessage.Entries>("SYNC_ENTRIES")222-            if (wantFromPeer.isNotEmpty()) {223-                vault.applyEntries(incoming.entries)224-                entriesReceived = incoming.entries.size225-            }226-            // Only emit ours when the initiator asked — its recv is conditional.227-            if (initiatorWants.isNotEmpty()) {228-                val ourPayload = vault.getEncryptedEntries(initiatorWants)229-                session.sendMsg(SyncMessage.Entries(ourPayload))230-                entriesSent = ourPayload.size231-            }232-233-            val peerTombstones = session.recvAs<SyncMessage.Tombstones>("SYNC_TOMBSTONES")234-            vault.applyTombstones(peerTombstones.tombstones)235-            tombstonesReceived = peerTombstones.tombstones.size236-237-            val newerForPeer = newerTombstones(ourTombstones, peerManifest.tombstones)238-            session.sendMsg(SyncMessage.Tombstones(newerForPeer))239-            tombstonesSent = newerForPeer.size240-241-            if (vault.supportsDeviceManifest) {242-                val theirs = session.recvAs<SyncMessage.DeviceManifest>("SYNC_DEVICE_MANIFEST")243-                session.sendMsg(SyncMessage.DeviceManifest(vault.buildOutgoingDeviceManifest()))244-                if (theirs.manifest != null) {245-                    vault.applyIncomingDeviceManifest(246-                        theirs.manifest,247-                        session.remoteStaticPublicKey,248-                    )249-                }250-            }251-252-            session.recvAs<SyncMessage.Done>("SYNC_DONE")253-            session.sendMsg(SyncMessage.Done(entriesReceived, entriesSent))254-            SyncOutcome(255-                SyncOutcomeStatus.COMPLETED,256-                entriesReceived, entriesSent, tombstonesReceived, tombstonesSent,257-            )258-        }259-    } catch (_: TimeoutCancellationException) {260-        SyncOutcome(SyncOutcomeStatus.TIMEOUT, error = "session timed out after 30s")261-    } catch (e: Exception) {262-        SyncOutcome(SyncOutcomeStatus.ERRORED, error = e.message ?: "sync_failed")263-    }264-}265-266-/** Entry ids the peer holds at a strictly newer mtime than we do. */267-private fun diffNewer(remote: Map<String, Long>, local: Map<String, Long>): List<String> =268-    remote.filter { (id, ts) -> local[id]?.let { ts > it } ?: true }.keys.toList()269-270-/** Our tombstones the peer has no newer deletion record for. */271-private fun newerTombstones(ours: Map<String, Long>, peer: Map<String, Long>): Map<String, Long> =272-    ours.filter { (id, ts) -> ts > (peer[id] ?: 0L) }273-274-private fun warnOnSkew(localTime: Long, remoteTime: Long) {275-    if (kotlin.math.abs(localTime - remoteTime) > CLOCK_SKEW_WARN_MS) {276-        android.util.Log.w("chirp-sync", "clock skew detected: local=$localTime remote=$remoteTime")277-    }278-}279-280-private suspend fun ChirpSyncSession.sendMsg(msg: SyncMessage) {281-    val bytes = msg.encodeToBytes()282-    requireSyncFrameSize(bytes.size)283-    send(bytes)284-}285-286-private suspend inline fun <reified T : SyncMessage> ChirpSyncSession.recvAs(expected: String): T {287-    val bytes = receive()288-    requireSyncFrameSize(bytes.size)289-    val msg = decodeSyncMessage(bytes)290-    return msg as? T ?: throw IllegalStateException("expected $expected, got ${msg::class.simpleName}")291-}

view file at the parent

app/src/main/java/com/peachpasswords/sync/ChirpSyncSession.kt+0 −14
@@ -1,14 +0,0 @@1-package com.peachpasswords.sync2-3-/**4- * Transport-neutral session consumed by the sync coordinator.5- *6- * Live reconnect transports use Noise KK, so both locally-paired static keys7- * are authenticated before this session reaches the coordinator.8- */9-data class ChirpSyncSession(10-    val remoteStaticPublicKey: ByteArray,11-    val send: (ByteArray) -> Unit,12-    val receive: suspend () -> ByteArray,13-    val close: suspend () -> Unit,14-)

view file at the parent

app/src/main/java/com/peachpasswords/sync/ChirpTypes.kt+0 −20
@@ -1,20 +0,0 @@1-package com.peachpasswords.sync2-3-enum class DeviceType(val wireValue: String, val wireByte: Byte) {4-    EXTENSION("extension", 0x01),5-    ANDROID("android", 0x02),6-    IOS("ios", 0x03),7-}8-9-val CHIRP_MAGIC_BYTES: ByteArray = byteArrayOf(0x50, 0x45, 0x41, 0x43)10-const val CHIRP_PROTOCOL_VERSION: Int = 0x0111-const val CHIRP_PAYLOAD_LENGTH: Int = 6412-13-fun deviceTypeFromByte(b: Byte): DeviceType = when (b.toInt() and 0xff) {14-    0x01 -> DeviceType.EXTENSION15-    0x02 -> DeviceType.ANDROID16-    0x03 -> DeviceType.IOS17-    else -> throw IllegalArgumentException("unknown device type byte 0x${(b.toInt() and 0xff).toString(16)}")18-}19-20-fun deviceTypeToByte(t: DeviceType): Byte = t.wireByte

view file at the parent

app/src/main/java/com/peachpasswords/sync/CloudRelayDialer.kt+4 −4
@@ -21,8 +21,8 @@2121     rendezvousId: String,2222     ourStaticPrivateKey: ByteArray,2323     ourStaticPublicKey: ByteArray,24-    localIdentity: LocalChirpIdentity,25-    vaultFactory: () -> ChirpSyncVaultAdapter?,24+    localIdentity: LocalDeviceIdentity,25+    vaultFactory: () -> SyncVaultAdapter?,2626     onSyncComplete: () -> Unit,2727 ) {2828     val responderKeyHex = known.longTermPublicKeyHex@@ -56,8 +56,8 @@5656 5757         val vault = vaultFactory() ?: return5858         vault.bindAuthenticatedPeer(known)59-        val outcome = runChirpSyncInitiator(60-            ChirpSyncSession(59+        val outcome = runSyncInitiator(60+            SyncSession(6161                 remoteStaticPublicKey = responderKey.copyOf(),6262                 send = { plaintext -> socket.send(session.encrypt(SyncPadding.pad(plaintext))) },6363                 receive = { SyncPadding.unpad(session.decrypt(socket.receive())) },

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+8 −8
@@ -64,8 +64,8 @@6464         scope: CoroutineScope,6565         ourStaticPrivateKey: ByteArray,6666         ourStaticPublicKey: ByteArray,67-        localIdentity: LocalChirpIdentity,68-        vaultFactory: () -> ChirpSyncVaultAdapter?,67+        localIdentity: LocalDeviceIdentity,68+        vaultFactory: () -> SyncVaultAdapter?,6969     ) {7070         if (supervisor?.isActive == true) return7171         val privateKey = ourStaticPrivateKey.copyOf()@@ -111,8 +111,8 @@111111         scope: CoroutineScope,112112         privateKey: ByteArray,113113         publicKey: ByteArray,114-        localIdentity: LocalChirpIdentity,115-        vaultFactory: () -> ChirpSyncVaultAdapter?,114+        localIdentity: LocalDeviceIdentity,115+        vaultFactory: () -> SyncVaultAdapter?,116116     ) {117117         if (!relaySettings.isSyncEnabled()) {118118             watchers.values.forEach(Job::cancel)@@ -230,8 +230,8 @@230230     registeredRendezvousId: String,231231     ourStaticPrivateKey: ByteArray,232232     ourStaticPublicKey: ByteArray,233-    localIdentity: LocalChirpIdentity,234-    vaultFactory: () -> ChirpSyncVaultAdapter?,233+    localIdentity: LocalDeviceIdentity,234+    vaultFactory: () -> SyncVaultAdapter?,235235     onSyncComplete: () -> Unit,236236 ) {237237     val selector = JSONObject(String(socket.receive(), Charsets.UTF_8))@@ -278,8 +278,8 @@278278 279279         val vault = vaultFactory() ?: return280280         vault.bindAuthenticatedPeer(known)281-        val outcome = runChirpSyncResponder(282-            ChirpSyncSession(281+        val outcome = runSyncResponder(282+            SyncSession(283283                 remoteStaticPublicKey = initiatorKey.copyOf(),284284                 send = { plaintext -> socket.send(session.encrypt(SyncPadding.pad(plaintext))) },285285                 receive = { SyncPadding.unpad(session.decrypt(socket.receive())) },

view file

app/src/main/java/com/peachpasswords/sync/DeviceIdentity.kt+82 −0
@@ -0,0 +1,82 @@1+package com.peachpasswords.sync2+3+import android.content.Context4+import java.util.UUID5+6+// The persisted keys keep their original names on purpose. Renaming them7+// would orphan this device's stored identity, which means a new device id and8+// a new sync keypair — every existing pairing would silently stop working on9+// upgrade. The name is stale; the cost of fixing it is not worth paying.10+private const val DEVICE_ID_KEY = "peach_chirp_device_id"11+private const val DEVICE_NAME_KEY = "peach_chirp_device_name"12+private const val PREFS_FILE = "peach_chirp_identity"13+14+data class LocalDeviceIdentity(15+    val deviceId: String,16+    val deviceName: String,17+    val deviceType: DeviceType,18+    val longTermPublicKeyHex: String,19+    val signingPublicKeyHex: String,20+    val wsPort: Int? = null,21+    val relayUrl: String = Rendezvous.DEFAULT_RELAY_URL,22+    /**23+     * This device's signed Pro license blob (`PPK-…`), set only when Pro. Sent24+     * in the outbound PAIR_IDENTITY so the peer can activate it after SAS — one25+     * Pro device grants Pro to the other when pairing. Either direction.26+     */27+    val license: String? = null,28+)29+30+fun getOrCreateDeviceId(context: Context): String {31+    val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)32+    val current = prefs.getString(DEVICE_ID_KEY, null)33+    if (!current.isNullOrEmpty()) return current34+    val fresh = UUID.randomUUID().toString()35+    prefs.edit().putString(DEVICE_ID_KEY, fresh).apply()36+    return fresh37+}38+39+fun getDeviceName(context: Context): String {40+    val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)41+    return prefs.getString(DEVICE_NAME_KEY, null).orEmpty().ifBlank { defaultDeviceName() }42+}43+44+fun setDeviceName(context: Context, name: String) {45+    val trimmed = name.trim()46+    require(trimmed.isNotEmpty()) { "device name must not be empty" }47+    require(trimmed.length <= 64) { "device name must be 64 chars or fewer" }48+    context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)49+        .edit()50+        .putString(DEVICE_NAME_KEY, trimmed)51+        .apply()52+}53+54+fun buildLocalIdentity(context: Context, masterKey: ByteArray): LocalDeviceIdentity {55+    val deviceId = getOrCreateDeviceId(context)56+    val deviceName = getDeviceName(context)57+    val kp = deriveSyncIdentityKeypair(masterKey, deviceId)58+    val signing = deriveSyncSigningKeypair(masterKey, deviceId)59+    return LocalDeviceIdentity(60+        deviceId = deviceId,61+        deviceName = deviceName,62+        deviceType = extensionDeviceType(),63+        longTermPublicKeyHex = pubkeyToHex(kp.publicKey),64+        signingPublicKeyHex = pubkeyToHex(signing.publicKeyRaw),65+        // No LAN port is advertised: peers reconnect through the relay66+        // (docs/sync-lan-retirement.md), never by dialling this device.67+        wsPort = null,68+        relayUrl = RelaySettingsStore.readRelayUrl(context),69+    )70+}71+72+fun deriveLocalSyncKeypair(masterKey: ByteArray, deviceId: String): SyncIdentityKeypair {73+    return deriveSyncIdentityKeypair(masterKey, deviceId)74+}75+76+private fun defaultDeviceName(): String {77+    return "Android"78+}79+80+private fun extensionDeviceType(): DeviceType {81+    return DeviceType.ANDROID82+}

view file

app/src/main/java/com/peachpasswords/sync/DeviceType.kt+26 −0
@@ -0,0 +1,26 @@1+package com.peachpasswords.sync2+3+/**4+ * What kind of device a peer is, on the wire and in the paired-device store.5+ *6+ * The byte values are a cross-client contract: the browser's `DeviceType`7+ * (`src/shared/sync-types.ts`) uses the same mapping, and `0x04` is desktop8+ * there. Do not renumber.9+ *10+ * Lived in `ChirpTypes.kt` alongside the audio-pairing frame constants, which11+ * went with the audio transport (docs/sync-lan-retirement.md).12+ */13+enum class DeviceType(val wireValue: String, val wireByte: Byte) {14+    EXTENSION("extension", 0x01),15+    ANDROID("android", 0x02),16+    IOS("ios", 0x03),17+}18+19+fun deviceTypeFromByte(b: Byte): DeviceType = when (b.toInt() and 0xff) {20+    0x01 -> DeviceType.EXTENSION21+    0x02 -> DeviceType.ANDROID22+    0x03 -> DeviceType.IOS23+    else -> throw IllegalArgumentException("unknown device type byte 0x${(b.toInt() and 0xff).toString(16)}")24+}25+26+fun deviceTypeToByte(t: DeviceType): Byte = t.wireByte

view file

app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+1 −1
@@ -39,7 +39,7 @@3939         PeachSyncService.start(context)4040     }4141 42-    private fun buildAdapter(): ChirpSyncVaultAdapter? {42+    private fun buildAdapter(): SyncVaultAdapter? {4343         if (!vault.isUnlocked.value) return null4444         return VaultSyncAdapter(4545             repo = vault,

view file

app/src/main/java/com/peachpasswords/sync/PairIdentityFrames.kt+1 −1
@@ -22,7 +22,7 @@2222     val relayUrl: String?,2323 )2424 25-internal fun encodePairIdentityFrame(id: LocalChirpIdentity): ByteArray {25+internal fun encodePairIdentityFrame(id: LocalDeviceIdentity): ByteArray {2626     val sb = StringBuilder()2727     sb.append('{')2828     sb.append("\"type\":\"PAIR_IDENTITY\"")

view file

app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+1 −1
@@ -25,7 +25,7 @@2525  *    the public key as `longTermPublicKeyHex` (hex, not ByteArray), and tracks2626  *    `pairedAt` / `lastSyncedAt` / `lastKnownIp`. This store reuses it verbatim;2727  *    Base64 publicKey storage is therefore unnecessary — the hex field is the28- *    canonical wire form already produced by ChirpPairing.frameToPairedDevice.28+ *    canonical wire form already produced by PairingFrames.frameToPairedDevice.2929  *  - The brief's `TrustStatus` enum does not exist either. The real model uses a3030  *    nullable `trustSource: String?` (e.g. "direct", "manifest"). Trust state is3131  *    expressed there. See C7-INTEGRATION.md for the trust-status delta.

view file

app/src/main/java/com/peachpasswords/sync/PairingFrames.kt+9 −9
@@ -26,7 +26,7 @@2626  * and [PairingTransport] so neither duplicates the wire format.2727  *2828  * Frame = [4-byte big-endian length][payload]. This is the framing the existing29- * ChirpPairing.runPairingInitiator/Responder already assume implicitly when they29+ * PairingFrames.runPairingInitiator/Responder already assume implicitly when they3030  * call `transport.send(bytes)` / `transport.receive()` — the WebSocket transport3131  * gets framing for free from the protocol; a raw TCP socket does not, so we add3232  * it here.@@ -68,13 +68,13 @@6868  * crypto stack expects:6969  *   - noiseNKInitiator / noiseNKResponder take `send: suspend (ByteArray)->Unit`7070  *     and `receive: suspend ()->ByteArray` (see NoiseNK.kt).71- *   - ChirpPairing's [PairingTransport] interface is `send/receive/close`.71+ *   - PairingFrames's [PairingTransport] interface is `send/receive/close`.7272  *7373  * Both the Noise handshake messages and the post-handshake encrypted payloads7474  * travel as length-prefixed frames over the same socket, so one [SocketFrameIO]7575  * serves the whole session. All IO hops to Dispatchers.IO.7676  *77- * This implements the EXISTING [PairingTransport] interface (ChirpPairing.kt),77+ * This implements the EXISTING [PairingTransport] interface (PairingFrames.kt),7878  * which is the contract `runPairingInitiator` / `runPairingResponder` consume —7979  * so a TCP socket can drive the already-written handshake unchanged.8080  */@@ -86,8 +86,8 @@8686     private val buffered = DataInputStream(input)8787 8888     /**89-     * Synchronous frame write. [ChirpSyncSession.send] is non-suspending (see90-     * ChirpSyncTransport.kt), so the sync-exchange bridge needs a blocking send89+     * Synchronous frame write. [SyncSession.send] is non-suspending (see90+     * SyncTransport.kt), so the sync-exchange bridge needs a blocking send9191      * that does NOT wrap a coroutine. Socket writes are bounded by soTimeout.9292      */9393     fun sendBlocking(msg: ByteArray) {@@ -112,11 +112,11 @@112112  * [NoiseNKSession] already owns the cipher (encrypt/decrypt); these just splice113113  * it onto the frame layer so call sites read clearly.114114  *115- * NOTE: the existing ChirpPairing handshake calls `session.encrypt` /115+ * NOTE: the existing PairingFrames handshake calls `session.encrypt` /116116  * `session.decrypt` directly around `transport.send` / `transport.receive`; the117- * post-pairing SYNC exchange (runChirpSyncInitiator/Responder) does the same via118- * a [ChirpSyncSession]. These helpers exist for the raw-frame paths in119- * PairingServer/PairingTransport that don't go through a ChirpSyncSession.117+ * post-pairing SYNC exchange (runSyncInitiator/Responder) does the same via118+ * a [SyncSession]. These helpers exist for the raw-frame paths in119+ * PairingServer/PairingTransport that don't go through a SyncSession.120120  */121121 suspend fun PairingTransport.sendEncrypted(session: NoiseNKSession, plaintext: ByteArray) {122122     send(session.encrypt(plaintext))

view file

app/src/main/java/com/peachpasswords/sync/PairingProgress.kt+0 −19
@@ -1,19 +0,0 @@1-package com.peachpasswords.sync2-3-enum class PairingPhase(val wireValue: String) {4-    IDLE("idle"),5-    LISTENING("listening"),6-    CHIRPING("chirping"),7-    HANDSHAKE("handshake"),8-    PAIRED("paired"),9-    TIMEOUT("timeout"),10-    MIC_DENIED("mic_denied"),11-    VERSION_MISMATCH("version_mismatch"),12-    HANDSHAKE_FAILED("handshake_failed"),13-}14-15-data class PairingProgress(16-    val phase: PairingPhase,17-    val peerDeviceName: String? = null,18-    val errorMessage: String? = null,19-)

view file at the parent

app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+3 −3
@@ -37,7 +37,7 @@3737         scope: CoroutineScope,3838         ourStaticPrivateKey: ByteArray,3939         ourStaticPublicKey: ByteArray,40-        localIdentity: LocalChirpIdentity,40+        localIdentity: LocalDeviceIdentity,4141         sourceDeps: AdoptionSourceDeps,4242         windowMs: Long = PAIRING_WINDOW_MS,4343     ): Flow<RelayAdoptionEvent> = callbackFlow {@@ -128,7 +128,7 @@128128     relayUrl: String,129129     ourStaticPrivateKey: ByteArray,130130     ourStaticPublicKey: ByteArray,131-    localIdentity: LocalChirpIdentity,131+    localIdentity: LocalDeviceIdentity,132132     sourceDeps: AdoptionSourceDeps,133133     confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,134134 ): PairedDevice? {@@ -177,7 +177,7 @@177177             relayUrl = Rendezvous.normalizeRelayUrl(relayUrl),178178         )179179         val boundDeps = object : AdoptionSourceDeps {180-            override val sourceIdentity: LocalChirpIdentity = localIdentity180+            override val sourceIdentity: LocalDeviceIdentity = localIdentity181181             override val pairState: AdoptionPairState = pairState182182             override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()183183             override suspend fun vaultId(): String = sourceDeps.vaultId()

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+1 −1
@@ -92,7 +92,7 @@9292             override suspend fun createVaultFromRootKey(rootKeyHex: String): String =9393                 receiverDeps.createVaultFromRootKey(rootKeyHex)9494 95-            override suspend fun deriveLocalIdentity(): LocalChirpIdentity =95+            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity =9696                 receiverDeps.deriveLocalIdentity()9797 9898             override suspend fun upsertPairedDevice(device: PairedDevice) {

view file

app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+291 −0
@@ -0,0 +1,291 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.TimeoutCancellationException4+import kotlinx.coroutines.withTimeout5+6+/**7+ * Sync — Phase 2 manifest-diff coordinator. Exact port of the browser8+ * `sync-coordinator.ts`, with the complementary responder the browser9+ * leaves to "the mobile-side coordinator that calls this same protocol the10+ * other way".11+ *12+ * After reconnect Noise KK authenticates both locally-paired static keys, the13+ * two sides drive:14+ *15+ *   ->  SYNC_HELLO { vaultId, deviceId, version, deviceTime }16+ *   <-  SYNC_HELLO                                  (mismatch → vault_mismatch)17+ *   <-> SYNC_MANIFEST { entries, tombstones }       (each side sends one)18+ *   ->  SYNC_REQUEST { entryIds }                   (entries newer on the peer)19+ *   <-  SYNC_REQUEST20+ *   <-> SYNC_ENTRIES   (sender unconditional; receiver reads iff it requested)21+ *   <-> SYNC_TOMBSTONES22+ *   <-> SYNC_DEVICE_MANIFEST                         (optional, both opt in)23+ *   <-> SYNC_DONE24+ *25+ * Entries are opaque base64 ciphertext — the coordinator never decrypts.26+ * Last-write-wins at the entry level by mtime; tombstones override updates with27+ * a later deletion timestamp.28+ */29+private const val SESSION_TIMEOUT_MS = 30_000L30+private const val CLOCK_SKEW_WARN_MS = 120_000L31+32+/**33+ * Vault-side dependency of the coordinator. The vault holds the canonical34+ * entry/tombstone tables; the coordinator pulls a manifest, requests entries by35+ * id, applies remote entries, and applies remote tombstones. Mirrors the36+ * browser `SyncVaultAdapter`.37+ */38+interface SyncVaultAdapter {39+    suspend fun computeVaultId(): String40+    suspend fun getOurDeviceId(): String41+42+    /** The vault's monotonic version (`peach_last_modified_at`). */43+    suspend fun getLocalVersion(): Long44+45+    /** Per-entry mtimes for our active entries. */46+    suspend fun buildEntryManifest(): Map<String, Long>47+48+    /** Per-entry deletion timestamps for our tombstones. */49+    suspend fun buildTombstoneManifest(): Map<String, Long>50+51+    /** Base64 ciphertext for each requested entry id. */52+    suspend fun getEncryptedEntries(entryIds: List<String>): Map<String, String>53+54+    /** Apply incoming encrypted entries; the vault decides per-entry overwrite. */55+    suspend fun applyEntries(entries: Map<String, String>)56+57+    /** Apply incoming tombstones; the vault retains the latest deletion ts. */58+    suspend fun applyTombstones(tombstones: Map<String, Long>)59+60+    /** Optional device-manifest exchange (v2 brief §4.3). Null disables it. */61+    suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? = null62+    suspend fun applyIncomingDeviceManifest(63+        manifest: SignedDeviceManifest,64+        authenticatedRemoteStaticPublicKey: ByteArray,65+    ) {}66+67+    /** Bind user-visible sync alerts to a stored, authenticated peer record. */68+    fun bindAuthenticatedPeer(peer: PairedDevice) {}69+70+    /** Both ends must opt in for the device-manifest step to run. */71+    val supportsDeviceManifest: Boolean get() = false72+}73+74+enum class SyncOutcomeStatus { COMPLETED, VAULT_MISMATCH, ERRORED, TIMEOUT }75+76+data class SyncOutcome(77+    val status: SyncOutcomeStatus,78+    val entriesReceived: Int = 0,79+    val entriesSent: Int = 0,80+    val tombstonesReceived: Int = 0,81+    val tombstonesSent: Int = 0,82+    val error: String? = null,83+)84+85+/**86+ * Run one sync round as the WebSocket client (initiator). The extension is87+ * always the initiator; Android initiates when it dials another device.88+ */89+suspend fun runSyncInitiator(90+    session: SyncSession,91+    vault: SyncVaultAdapter,92+    nowMs: Long = System.currentTimeMillis(),93+): SyncOutcome {94+    var entriesReceived = 095+    var entriesSent = 096+    var tombstonesReceived = 097+    var tombstonesSent = 098+99+    return try {100+        withTimeout(SESSION_TIMEOUT_MS) {101+            val ourVaultId = vault.computeVaultId()102+            val localVersion = vault.getLocalVersion()103+104+            session.sendMsg(105+                SyncMessage.Hello(ourVaultId, vault.getOurDeviceId(), localVersion, nowMs),106+            )107+            val hello = session.recvAs<SyncMessage.Hello>("SYNC_HELLO")108+            if (hello.vaultId != ourVaultId) {109+                session.sendMsg(SyncMessage.Error("vault_id_mismatch"))110+                return@withTimeout SyncOutcome(SyncOutcomeStatus.VAULT_MISMATCH)111+            }112+            warnOnSkew(nowMs, hello.deviceTime)113+114+            if (hello.version == localVersion) {115+                session.sendMsg(SyncMessage.Done(0, 0))116+                session.recvAs<SyncMessage.Done>("SYNC_DONE")117+                return@withTimeout SyncOutcome(SyncOutcomeStatus.COMPLETED)118+            }119+120+            val ourEntries = vault.buildEntryManifest()121+            val ourTombstones = vault.buildTombstoneManifest()122+            session.sendMsg(SyncMessage.Manifest(ourEntries, ourTombstones))123+            val peerManifest = session.recvAs<SyncMessage.Manifest>("SYNC_MANIFEST")124+125+            val wantFromPeer = diffNewer(peerManifest.entries, ourEntries)126+            session.sendMsg(SyncMessage.Request(wantFromPeer))127+            val peerRequest = session.recvAs<SyncMessage.Request>("SYNC_REQUEST")128+129+            val ourPayload = vault.getEncryptedEntries(peerRequest.entryIds)130+            session.sendMsg(SyncMessage.Entries(ourPayload))131+            entriesSent = ourPayload.size132+133+            if (wantFromPeer.isNotEmpty()) {134+                val incoming = session.recvAs<SyncMessage.Entries>("SYNC_ENTRIES")135+                vault.applyEntries(incoming.entries)136+                entriesReceived = incoming.entries.size137+            }138+139+            val newerForPeer = newerTombstones(ourTombstones, peerManifest.tombstones)140+            session.sendMsg(SyncMessage.Tombstones(newerForPeer))141+            tombstonesSent = newerForPeer.size142+143+            val peerTombstones = session.recvAs<SyncMessage.Tombstones>("SYNC_TOMBSTONES")144+            vault.applyTombstones(peerTombstones.tombstones)145+            tombstonesReceived = peerTombstones.tombstones.size146+147+            if (vault.supportsDeviceManifest) {148+                session.sendMsg(SyncMessage.DeviceManifest(vault.buildOutgoingDeviceManifest()))149+                val theirs = session.recvAs<SyncMessage.DeviceManifest>("SYNC_DEVICE_MANIFEST")150+                if (theirs.manifest != null) {151+                    vault.applyIncomingDeviceManifest(152+                        theirs.manifest,153+                        session.remoteStaticPublicKey,154+                    )155+                }156+            }157+158+            session.sendMsg(SyncMessage.Done(entriesReceived, entriesSent))159+            session.recvAs<SyncMessage.Done>("SYNC_DONE")160+            SyncOutcome(161+                SyncOutcomeStatus.COMPLETED,162+                entriesReceived, entriesSent, tombstonesReceived, tombstonesSent,163+            )164+        }165+    } catch (_: TimeoutCancellationException) {166+        SyncOutcome(SyncOutcomeStatus.TIMEOUT, error = "session timed out after 30s")167+    } catch (e: Exception) {168+        SyncOutcome(SyncOutcomeStatus.ERRORED, error = e.message ?: "sync_failed")169+    }170+}171+172+/**173+ * Run one sync round as the WebSocket server (responder) — the exact174+ * complement of [runSyncInitiator]. Send/recv are mirrored so the two175+ * halves never deadlock and stale frames never leak between phases: the176+ * responder reads the initiator's unconditional SYNC_ENTRIES, but only emits177+ * its own when the initiator actually requested something.178+ */179+suspend fun runSyncResponder(180+    session: SyncSession,181+    vault: SyncVaultAdapter,182+    nowMs: Long = System.currentTimeMillis(),183+): SyncOutcome {184+    var entriesReceived = 0185+    var entriesSent = 0186+    var tombstonesReceived = 0187+    var tombstonesSent = 0188+189+    return try {190+        withTimeout(SESSION_TIMEOUT_MS) {191+            val ourVaultId = vault.computeVaultId()192+            val localVersion = vault.getLocalVersion()193+194+            val hello = session.recvAs<SyncMessage.Hello>("SYNC_HELLO")195+            // Always answer HELLO so the initiator's recv completes, even on a196+            // mismatch where we bail immediately after.197+            session.sendMsg(198+                SyncMessage.Hello(ourVaultId, vault.getOurDeviceId(), localVersion, nowMs),199+            )200+            if (hello.vaultId != ourVaultId) {201+                return@withTimeout SyncOutcome(SyncOutcomeStatus.VAULT_MISMATCH)202+            }203+            warnOnSkew(nowMs, hello.deviceTime)204+205+            if (hello.version == localVersion) {206+                session.recvAs<SyncMessage.Done>("SYNC_DONE")207+                session.sendMsg(SyncMessage.Done(0, 0))208+                return@withTimeout SyncOutcome(SyncOutcomeStatus.COMPLETED)209+            }210+211+            val ourEntries = vault.buildEntryManifest()212+            val ourTombstones = vault.buildTombstoneManifest()213+            val peerManifest = session.recvAs<SyncMessage.Manifest>("SYNC_MANIFEST")214+            session.sendMsg(SyncMessage.Manifest(ourEntries, ourTombstones))215+216+            val initiatorWants = session.recvAs<SyncMessage.Request>("SYNC_REQUEST").entryIds217+            val wantFromPeer = diffNewer(peerManifest.entries, ourEntries)218+            session.sendMsg(SyncMessage.Request(wantFromPeer))219+220+            // Drain the initiator's unconditional SYNC_ENTRIES first.221+            val incoming = session.recvAs<SyncMessage.Entries>("SYNC_ENTRIES")222+            if (wantFromPeer.isNotEmpty()) {223+                vault.applyEntries(incoming.entries)224+                entriesReceived = incoming.entries.size225+            }226+            // Only emit ours when the initiator asked — its recv is conditional.227+            if (initiatorWants.isNotEmpty()) {228+                val ourPayload = vault.getEncryptedEntries(initiatorWants)229+                session.sendMsg(SyncMessage.Entries(ourPayload))230+                entriesSent = ourPayload.size231+            }232+233+            val peerTombstones = session.recvAs<SyncMessage.Tombstones>("SYNC_TOMBSTONES")234+            vault.applyTombstones(peerTombstones.tombstones)235+            tombstonesReceived = peerTombstones.tombstones.size236+237+            val newerForPeer = newerTombstones(ourTombstones, peerManifest.tombstones)238+            session.sendMsg(SyncMessage.Tombstones(newerForPeer))239+            tombstonesSent = newerForPeer.size240+241+            if (vault.supportsDeviceManifest) {242+                val theirs = session.recvAs<SyncMessage.DeviceManifest>("SYNC_DEVICE_MANIFEST")243+                session.sendMsg(SyncMessage.DeviceManifest(vault.buildOutgoingDeviceManifest()))244+                if (theirs.manifest != null) {245+                    vault.applyIncomingDeviceManifest(246+                        theirs.manifest,247+                        session.remoteStaticPublicKey,248+                    )249+                }250+            }251+252+            session.recvAs<SyncMessage.Done>("SYNC_DONE")253+            session.sendMsg(SyncMessage.Done(entriesReceived, entriesSent))254+            SyncOutcome(255+                SyncOutcomeStatus.COMPLETED,256+                entriesReceived, entriesSent, tombstonesReceived, tombstonesSent,257+            )258+        }259+    } catch (_: TimeoutCancellationException) {260+        SyncOutcome(SyncOutcomeStatus.TIMEOUT, error = "session timed out after 30s")261+    } catch (e: Exception) {262+        SyncOutcome(SyncOutcomeStatus.ERRORED, error = e.message ?: "sync_failed")263+    }264+}265+266+/** Entry ids the peer holds at a strictly newer mtime than we do. */267+private fun diffNewer(remote: Map<String, Long>, local: Map<String, Long>): List<String> =268+    remote.filter { (id, ts) -> local[id]?.let { ts > it } ?: true }.keys.toList()269+270+/** Our tombstones the peer has no newer deletion record for. */271+private fun newerTombstones(ours: Map<String, Long>, peer: Map<String, Long>): Map<String, Long> =272+    ours.filter { (id, ts) -> ts > (peer[id] ?: 0L) }273+274+private fun warnOnSkew(localTime: Long, remoteTime: Long) {275+    if (kotlin.math.abs(localTime - remoteTime) > CLOCK_SKEW_WARN_MS) {276+        android.util.Log.w("peach-sync", "clock skew detected: local=$localTime remote=$remoteTime")277+    }278+}279+280+private suspend fun SyncSession.sendMsg(msg: SyncMessage) {281+    val bytes = msg.encodeToBytes()282+    requireSyncFrameSize(bytes.size)283+    send(bytes)284+}285+286+private suspend inline fun <reified T : SyncMessage> SyncSession.recvAs(expected: String): T {287+    val bytes = receive()288+    requireSyncFrameSize(bytes.size)289+    val msg = decodeSyncMessage(bytes)290+    return msg as? T ?: throw IllegalStateException("expected $expected, got ${msg::class.simpleName}")291+}

view file

app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+1 −1
@@ -5,7 +5,7 @@55 import kotlinx.serialization.json.Json66 77 /**8- * Chirp Sync wire protocol — exact port of the browser `ChirpSyncMessage`8+ * Sync wire protocol — exact port of the browser `SyncMessage`99  * union (shared/sync-types.ts). Each frame is JSON with a `type` discriminator,1010  * sent over the Noise-NK-encrypted session. Entries travel as opaque base641111  * ciphertext envelopes; the coordinator never sees plaintext.

view file

app/src/main/java/com/peachpasswords/sync/SyncSession.kt+14 −0
@@ -0,0 +1,14 @@1+package com.peachpasswords.sync2+3+/**4+ * Transport-neutral session consumed by the sync coordinator.5+ *6+ * Live reconnect transports use Noise KK, so both locally-paired static keys7+ * are authenticated before this session reaches the coordinator.8+ */9+data class SyncSession(10+    val remoteStaticPublicKey: ByteArray,11+    val send: (ByteArray) -> Unit,12+    val receive: suspend () -> ByteArray,13+    val close: suspend () -> Unit,14+)

view file

app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+299 −0
@@ -0,0 +1,299 @@1+package com.peachpasswords.sync2+3+import kotlinx.serialization.SerialName4+import kotlinx.serialization.Serializable5+import kotlinx.serialization.json.Json6+7+/**8+ * Vault adoption frame protocol — exact port of the browser `shared/vault-adopt.ts`.9+ *10+ * Adoption lets a brand-new device join an existing vault: the SOURCE (an already11+ * set-up device) hands its root key to the RECEIVER over the pairing channel so12+ * both devices end up with the same vault id and can sync. The receiver seals an13+ * empty local vault under its own password but reuses the adopted root key14+ * ([VaultRepository.createVaultFromRootKey]).15+ *16+ * Frames travel as JSON with a `type` discriminator and SNAKE_CASE keys — unlike17+ * the camelCase [SyncMessage] sync wire — so the keys must match the browser18+ * byte-for-byte for cross-platform adoption. Optional fields are omitted when19+ * absent (encodeDefaults = false) to mirror `JSON.stringify` dropping `undefined`.20+ */21+@Serializable22+sealed class AdoptionFrame {23+    /**24+     * [sessionRequestId] carries the wire's `device_id`, but it is NOT an25+     * identity: the requester generates it locally before the handshake that26+     * establishes any long-term key, so it cannot be the device's real one27+     * (see `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2). It is a label for the28+     * consent prompt and nothing else — never persist it, never compare it29+     * against a previous attempt's, never key trust on it. The only identity30+     * that may be persisted is `long_term_public_key_hex`, which arrives later31+     * in ADOPT_IDENTITY / ADOPT_GRANT.source.32+     */33+    @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()40+41+    @Serializable42+    @SerialName("ADOPT_CONFIRM")43+    data class Confirm(44+        val confirmed: Boolean,45+    ) : AdoptionFrame()46+47+    @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()55+56+    @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()65+66+    @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+}77+78+data class AdoptionPairState(79+    val rendezvousSecretHex: String,80+    val relayUrl: String? = null,81+)82+83+/** What the RECEIVER (joining device) needs to adopt a vault from a source. */84+interface AdoptionReceiverDeps {85+    val deviceName: String86+    val deviceType: DeviceType87+    fun generateDeviceId(): String88+    /** Root-key transfer cannot begin until this device confirms the SAS. */89+    suspend fun confirmSas(): Boolean90+91+    /** Seal a fresh local vault under the receiver's password, reusing [rootKeyHex]. Returns the vault id. */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+}99+100+/** What the SOURCE (existing device) needs to grant its vault to a receiver. */101+interface AdoptionSourceDeps {102+    val sourceIdentity: LocalDeviceIdentity103+    suspend fun rootKeyHex(): String104+    suspend fun vaultId(): String105+    suspend fun license(): String? = null106+107+    /**108+     * Ask the user to approve handing this vault to the requesting device. The109+     * request's name/type/label are for the human to read; the decision must not110+     * be made by recognising [AdoptionFrame.Request.sessionRequestId], which111+     * carries no continuity between attempts.112+     */113+    suspend fun onConsent(request: AdoptionFrame.Request): Boolean114+    suspend fun upsertPairedDevice(device: PairedDevice)115+    val pairState: AdoptionPairState? get() = null116+}117+118+private val Hex64 = Regex("^[0-9a-f]{64}\$")119+120+fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)121+122+fun isAdoptRequest(frame: AdoptionFrame?): Boolean =123+    frame is AdoptionFrame.Request &&124+        frame.sessionRequestId.isNotEmpty() &&125+        isDeviceType(frame.deviceType)126+127+fun 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)135+136+fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =137+    frame is AdoptionFrame.Confirm138+139+fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =140+    frame is AdoptionFrame.Identity &&141+        frame.deviceId.isNotEmpty() &&142+        isDeviceType(frame.deviceType) &&143+        Hex64.matches(frame.longTermPublicKeyHex)144+145+/**146+ * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the147+ * granted root key actually derives the advertised vault id (catching a lying or148+ * corrupted source before it can seal a mismatched vault), seals the local vault,149+ * applies any carried license, returns our identity, and records the source as a150+ * paired device. Returns null on any protocol failure (reason via [deps.onError]).151+ */152+suspend fun requestAdoption(153+    channel: PairingTransport,154+    deps: AdoptionReceiverDeps,155+    nowMs: () -> Long = { System.currentTimeMillis() },156+): PairedDevice? {157+    // Fresh every attempt, and deliberately not our real device id: no identity158+    // exists yet at this point in the protocol.159+    sendFrame(channel, AdoptionFrame.Request(160+        sessionRequestId = deps.generateDeviceId(),161+        deviceName = deps.deviceName,162+        deviceType = deps.deviceType.wireValue,163+    ))164+165+    val confirmed = deps.confirmSas()166+    sendFrame(channel, AdoptionFrame.Confirm(confirmed))167+    if (!confirmed) {168+        deps.onError("ADOPT_SAS_REJECTED")169+        return null170+    }171+172+    val frame = receiveFrame(channel)173+    if (!isAdoptGrant(frame)) {174+        deps.onError("ADOPT_UNEXPECTED_FRAME")175+        return null176+    }177+    val grant = frame as AdoptionFrame.Grant178+179+    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+    }188+189+    val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)190+    if (vaultId != grant.vaultId) {191+        deps.onError("ADOPT_VAULT_ID_MISMATCH")192+        return null193+    }194+195+    grant.license?.let { deps.applyLicense(it) }196+197+    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+    ))205+206+    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+}221+222+/**223+ * SOURCE side: respond to an ADOPT_REQUEST. Gates on user consent, hands over the224+ * root key, then records the receiver's returned identity as a paired device.225+ * Returns null if consent is denied or the receiver's identity frame is invalid.226+ */227+suspend 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 null234+235+    val confirmation = receiveFrame(channel)236+    if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {237+        return null238+    }239+240+    val rootKeyHex = deps.rootKeyHex()241+    if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")242+243+    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+    ))257+258+    val identity = receiveFrame(channel)259+    if (!isAdoptIdentity(identity)) return null260+    identity as AdoptionFrame.Identity261+262+    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+}276+277+/**278+ * Wire codec. `classDiscriminator = "type"` reproduces the browser's flat279+ * `{ "type": "ADOPT_…", … }` shape; `encodeDefaults = false` omits absent280+ * optionals exactly like `JSON.stringify` drops `undefined`.281+ */282+val AdoptJson: Json = Json {283+    classDiscriminator = "type"284+    encodeDefaults = false285+    ignoreUnknownKeys = true286+}287+288+suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {289+    channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))290+}291+292+/** Decode one frame, tolerating a malformed/unknown payload by returning null. */293+suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =294+    runCatching {295+        AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))296+    }.getOrNull()297+298+private fun isDeviceType(value: String): Boolean =299+    DeviceType.entries.any { it.wireValue == value }

view file

app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+3 −3
@@ -14,8 +14,8 @@1414 import javax.crypto.spec.SecretKeySpec1515 1616 /**17- * Production [ChirpSyncVaultAdapter] backing the coordinator with the live18- * [VaultRepository]. Exact port of the browser `chirp-sync-vault-adapter.ts`:17+ * Production [SyncVaultAdapter] backing the coordinator with the live18+ * [VaultRepository]. Exact port of the browser `sync-vault-adapter.ts`:1919  * the coordinator only ever moves opaque base64 envelopes; plaintext crosses2020  * this boundary and nowhere else.2121  *@@ -36,7 +36,7 @@3636     private val syncAlerts: SyncAlertRecorder? = null,3737     private var remoteDeviceName: String = "paired device",3838     override val supportsDeviceManifest: Boolean = pairedDevices != null && pendingVouched != null,39-) : ChirpSyncVaultAdapter {39+) : SyncVaultAdapter {4040 4141     override suspend fun computeVaultId(): String =4242         repo.syncVaultId() ?: throw VaultLockedException()

view file

app/src/main/java/com/peachpasswords/ui/components/PairingBirds.kt+1 −1
@@ -25,7 +25,7 @@2525 2626 /**2727  * The two pairing birds, ported path-for-path from the browser extension's28- * `ChirpPairingFlow.tsx` (a finch and a swallow) rather than redrawn by eye.28+ * `PairDeviceFlow.tsx` (a finch and a swallow) rather than redrawn by eye.2929  *3030  * Android previously carried its own simplified blob-and-triangle bird, which3131  * read as a different, worse character next to the extension's. The SVG `d`

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+2 −2
@@ -6,7 +6,7 @@66 import com.peachpasswords.license.LicenseRepository77 import com.peachpasswords.sync.AdoptionReceiverDeps88 import com.peachpasswords.sync.DeviceType9-import com.peachpasswords.sync.LocalChirpIdentity9+import com.peachpasswords.sync.LocalDeviceIdentity1010 import com.peachpasswords.sync.PairedDevice1111 import com.peachpasswords.sync.PairedDeviceStore1212 import com.peachpasswords.sync.PeachSyncService@@ -199,7 +199,7 @@199199                 }200200             }201201 202-            override suspend fun deriveLocalIdentity(): LocalChirpIdentity {202+            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {203203                 val master = vault.syncMasterKey()204204                     ?: throw IllegalStateException("adopted_vault_unavailable")205205                 return try {

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+4 −4
@@ -14,7 +14,7 @@1414 import com.peachpasswords.sync.SyncMode1515 import com.peachpasswords.sync.AdoptionSourceDeps1616 import com.peachpasswords.sync.AdoptionFrame17-import com.peachpasswords.sync.LocalChirpIdentity17+import com.peachpasswords.sync.LocalDeviceIdentity1818 import com.peachpasswords.sync.PairedDevice1919 import com.peachpasswords.sync.PairedDeviceStore2020 import com.peachpasswords.sync.buildLocalIdentity@@ -96,7 +96,7 @@9696     private fun startRelay(9797         privateKey: ByteArray,9898         publicKey: ByteArray,99-        identity: LocalChirpIdentity,99+        identity: LocalDeviceIdentity,100100         sourceDeps: AdoptionSourceDeps,101101     ) {102102         collectJob = viewModelScope.launch {@@ -138,9 +138,9 @@138138         }139139     }140140 141-    private fun sourceDeps(identity: LocalChirpIdentity): AdoptionSourceDeps =141+    private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =142142         object : AdoptionSourceDeps {143-            override val sourceIdentity: LocalChirpIdentity = identity143+            override val sourceIdentity: LocalDeviceIdentity = identity144144 145145             override suspend fun rootKeyHex(): String {146146                 val root = vault.syncMasterKey()

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+2 −2
@@ -3,7 +3,7 @@33 import android.content.Context44 import androidx.lifecycle.ViewModel55 import androidx.lifecycle.viewModelScope6-import com.peachpasswords.sync.ChirpSyncVaultAdapter6+import com.peachpasswords.sync.SyncVaultAdapter77 import com.peachpasswords.sync.PairedDevice88 import com.peachpasswords.sync.PairedDeviceStore99 import com.peachpasswords.sync.PendingVouchedDevice@@ -125,7 +125,7 @@125125     }126126 127127     /** Adapter over the live vault; null while the vault is locked. */128-    fun buildAdapter(): ChirpSyncVaultAdapter? {128+    fun buildAdapter(): SyncVaultAdapter? {129129         if (!vault.isUnlocked.value) return null130130         return VaultSyncAdapter(131131             repo = vault,

view file

app/src/main/java/com/peachpasswords/ui/theme/PeachType.kt+0 −3
@@ -82,9 +82,6 @@8282     val totpCountdown = TextStyle(8383         fontFamily = Mono, fontSize = 22.sp, letterSpacing = 0.04.em,8484     )85-    val chirpCode = TextStyle(86-        fontFamily = Mono, fontSize = 36.sp, letterSpacing = 0.25.em,87-    )8885     val sortPill = TextStyle(8986         fontFamily = Mono, fontSize = 9.sp, letterSpacing = 0.08.em,9087     )

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+12 −0
@@ -168,6 +168,18 @@168168         if (result == SnackbarResult.ActionPerformed) vm.restoreEntries(ev.ids)169169     }170170 171+    // Pulling to refresh with nothing paired used to retract in silence, which172+    // reads as "sync ran and found nothing" rather than "there is nobody to173+    // sync with". Say which.174+    LaunchedEffect(syncStatus) {175+        if (syncStatus is SyncStatus.Offline) {176+            snackbarHostState.showSnackbar(177+                message = "No devices paired yet — add one in Settings › Sync",178+                duration = SnackbarDuration.Short,179+            )180+        }181+    }182+171183     ShellBackground(modifier) {172184         Box(Modifier.fillMaxSize()) {173185         Column(Modifier.fillMaxSize()) {

view file

app/src/test/java/com/peachpasswords/feedback/FeedbackSubmissionTest.kt+46 −0
@@ -0,0 +1,46 @@1+package com.peachpasswords.feedback2+3+import org.json.JSONObject4+import org.junit.Assert.assertEquals5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+/**9+ * The feedback payload is the one place user-written text leaves the device.10+ * These pin its shape so a later edit cannot quietly widen it.11+ */12+class FeedbackSubmissionTest {13+14+    private fun submission(type: String = "bug", message: String = "the thing broke") =15+        JSONObject(FeedbackRepository.buildSubmission(type, message, version = "9.9.9"))16+17+    @Test18+    fun `carries the access key, the category and the message, and nothing else`() {19+        val json = submission()20+        assertEquals(21+            setOf("access_key", "subject", "message", "from_name", "botcheck"),22+            json.keys().asSequence().toSet(),23+        )24+        assertEquals(FeedbackRepository.WEB3FORMS_KEY, json.getString("access_key"))25+        assertEquals("the thing broke", json.getString("message"))26+        assertEquals("Peach Feedback - bug (Android 9.9.9)", json.getString("subject"))27+    }28+29+    @Test30+    fun `sends no device or user identifier`() {31+        val body = FeedbackRepository.buildSubmission("bug", "hello").lowercase()32+        for (leak in listOf("device_id", "deviceid", "vault", "email", "androidid", "serial")) {33+            assertTrue("payload must not carry $leak", !body.contains(leak))34+        }35+    }36+37+    @Test38+    fun `the honeypot ships empty, or Web3Forms drops the submission`() {39+        assertEquals("", submission().getString("botcheck"))40+    }41+42+    @Test43+    fun `posts to the same inbox as the extension`() {44+        assertEquals("https://api.web3forms.com/submit", FeedbackRepository.ENDPOINT)45+    }46+}

view file

app/src/test/java/com/peachpasswords/sync/C7ChirpTransportTest.kt+0 −224
@@ -1,224 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.coroutines.Dispatchers4-import kotlinx.coroutines.launch5-import kotlinx.coroutines.runBlocking6-import kotlinx.serialization.builtins.ListSerializer7-import kotlinx.serialization.json.Json8-import org.bouncycastle.crypto.params.X25519PrivateKeyParameters9-import org.junit.Assert.assertArrayEquals10-import org.junit.Assert.assertEquals11-import org.junit.Assert.assertNotNull12-import org.junit.Assert.assertThrows13-import org.junit.Assert.assertFalse14-import org.junit.Assert.assertTrue15-import org.junit.Test16-import java.io.PipedInputStream17-import java.io.PipedOutputStream18-import java.security.SecureRandom19-20-/**21- * C7 transport tests. Mirrors the existing ChirpSyncTransportTest idiom: plain22- * JUnit + runBlocking, no Robolectric. PairedDeviceStore itself needs an Android23- * Context (EncryptedSharedPreferences), so its persistence is exercised by the24- * orchestrator's instrumentation tests; here we test the pure-JVM serialization25- * layer the store round-trips through (test 1) plus its upsert/replace semantics26- * against an in-memory list (test 2), the frame codec (test 3), the first-pair27- * Noise-NK primitive over frames (tests 4–5), and the local-state key pin used28- * by the live KK reconnect discovery path (test 6).29- */30-class C7ChirpTransportTest {31-32-    private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }33-    private val listSerializer = ListSerializer(PairedDevice.serializer())34-35-    private fun newKeyPair(): Pair<ByteArray, ByteArray> {36-        val priv = X25519PrivateKeyParameters(SecureRandom())37-        return priv.encoded to priv.generatePublicKey().encoded38-    }39-40-    private fun device(id: String, name: String = "Peer") = PairedDevice(41-        deviceId = id,42-        deviceName = name,43-        deviceType = "android",44-        longTermPublicKeyHex = "ab".repeat(32),45-        trustSource = "direct",46-        pairedAt = 1_000L,47-        lastSyncedAt = null,48-    )49-50-    /** Test 1: PairedDevice list survives JSON round-trip (the store's wire form). */51-    @Test52-    fun `paired device list round-trips through json`() {53-        val devices = listOf(device("a", "Alice"), device("b", "Bob"))54-        val encoded = json.encodeToString(listSerializer, devices)55-        val decoded = json.decodeFromString(listSerializer, encoded)56-        assertEquals(devices, decoded)57-        assertEquals("Alice", decoded[0].deviceName)58-        assertEquals("ab".repeat(32), decoded[1].longTermPublicKeyHex)59-    }60-61-    /** Test 2: upsert replaces an existing entry by deviceId (store semantics). */62-    @Test63-    fun `upsert by deviceId replaces rather than duplicates`() {64-        // Mirror PairedDeviceStore.upsert's pure list transform.65-        fun upsert(list: List<PairedDevice>, d: PairedDevice) =66-            list.filterNot { it.deviceId == d.deviceId } + d67-68-        var list = listOf(device("a", "Alice"))69-        list = upsert(list, device("b", "Bob"))70-        assertEquals(2, list.size)71-        list = upsert(list, device("a", "Alice-renamed"))72-        assertEquals(2, list.size)73-        assertEquals("Alice-renamed", list.first { it.deviceId == "a" }.deviceName)74-    }75-76-    /** Test 3: writeFrame/readFrame round-trip across payload sizes. */77-    @Test78-    fun `frame codec round-trips for various sizes`() {79-        for (size in intArrayOf(0, 1, 31, 64, 1500, 65_537)) {80-            val payload = ByteArray(size) { (it % 251).toByte() }81-            val pin = PipedInputStream(1 shl 18)82-            val pout = PipedOutputStream(pin)83-            writeFrame(pout, payload)84-            val read = readFrame(pin)85-            assertArrayEquals("size=$size", payload, read)86-        }87-    }88-89-    /**90-     * Test 4: the first-pair Noise NK primitive + identity frame exchange over91-     * piped frames. Live reconnect uses KK; this pins the separate first-pair92-     * pattern that remains valid when the initiator is not known yet.93-     */94-    @Test95-    fun `noise NK handshake over frames yields matching SAS and decodable identity`() = runBlocking {96-        val (responderPriv, responderPub) = newKeyPair()97-98-        // Cross-wired pipes: initiator's out -> responder's in and vice versa.99-        val i2r = PipedInputStream(1 shl 18); val i2rOut = PipedOutputStream(i2r)100-        val r2i = PipedInputStream(1 shl 18); val r2iOut = PipedOutputStream(r2i)101-102-        val initiator = SocketFrameIO(input = r2i, output = i2rOut)103-        val responder = SocketFrameIO(input = i2r, output = r2iOut)104-105-        val localId = LocalChirpIdentity(106-            deviceId = "client-1",107-            deviceName = "Client",108-            deviceType = DeviceType.ANDROID,109-            longTermPublicKeyHex = "cd".repeat(32),110-            signingPublicKeyHex = "ef".repeat(32),111-        )112-        val serverId = localId.copy(deviceId = "server-1", deviceName = "Server")113-114-        var initiatorSas: String? = null115-        var responderSas: String? = null116-        var receivedByServer: PairIdentity? = null117-118-        val responderJob = launch(Dispatchers.IO) {119-            val session = noiseNKResponder(responderPriv, responderPub, responder::send, responder::receive)120-            receivedByServer = parsePairIdentityFrame(session.decrypt(responder.receive()))121-            responder.send(session.encrypt(encodePairIdentityFrame(serverId)))122-            responderSas = SASDerivation.derive(session.handshakeHash)123-        }124-125-        val initiatorJob = launch(Dispatchers.IO) {126-            val session = noiseNKInitiator(responderPub, initiator::send, initiator::receive)127-            initiator.send(session.encrypt(encodePairIdentityFrame(localId)))128-            session.decrypt(initiator.receive()) // server identity (discarded here)129-            initiatorSas = SASDerivation.derive(session.handshakeHash)130-        }131-132-        initiatorJob.join()133-        responderJob.join()134-135-        assertNotNull(initiatorSas)136-        assertEquals("both sides derive the same SAS", initiatorSas, responderSas)137-        assertEquals("client-1", receivedByServer?.deviceId)138-        assertEquals("cd".repeat(32), receivedByServer?.longTermPublicKeyHex)139-    }140-141-    /**142-     * Test 5: a wrong responder static key fails the first-pair NK primitive.143-     */144-    @Test145-    fun `handshake fails when responder static key is wrong`() = runBlocking {146-        val (responderPriv, responderPub) = newKeyPair()147-        val (_, wrongPub) = newKeyPair()148-149-        val i2r = PipedInputStream(1 shl 18); val i2rOut = PipedOutputStream(i2r)150-        val r2i = PipedInputStream(1 shl 18); val r2iOut = PipedOutputStream(r2i)151-        val initiator = SocketFrameIO(input = r2i, output = i2rOut)152-        val responder = SocketFrameIO(input = i2r, output = r2iOut)153-154-        // The initiator pins the WRONG static key, so the responder rejects msg1 (its155-        // own ES differs) and never sends msg2. Close the responder's write end on156-        // failure so the initiator's receive() hits EOF and fails fast instead of157-        // blocking forever — the raw-socket analogue is the soTimeout connectAndSync158-        // surfaces as an IO error. (A coroutine timeout can't interrupt the blocking159-        // pipe read, so EOF is the deterministic way to unblock it.)160-        val responderJob = launch(Dispatchers.IO) {161-            runCatching { noiseNKResponder(responderPriv, responderPub, responder::send, responder::receive) }162-            runCatching { r2iOut.close() }163-        }164-165-        var initiatorFailed = false166-        val initiatorJob = launch(Dispatchers.IO) {167-            try {168-                noiseNKInitiator(wrongPub, initiator::send, initiator::receive)169-            } catch (_: Throwable) {170-                initiatorFailed = true171-            }172-        }173-174-        initiatorJob.join()175-        responderJob.join()176-        assertTrue("a wrong responder static key must fail the initiator handshake", initiatorFailed)177-    }178-179-    @Test180-    fun `a presented identity cannot replace the locally paired key`() {181-        // Was `reconnect discovery cannot replace the locally paired responder182-        // key`, which pinned a LAN-discovered endpoint. Discovery is gone with183-        // LAN sync (docs/sync-lan-retirement.md), but the property it protected184-        // is not: a peer's self-asserted PAIR_IDENTITY must never override the185-        // paired record, and is only believed when it matches the static key186-        // Noise actually authenticated.187-        val (_, storedPublic) = newKeyPair()188-        val (_, spoofedPublic) = newKeyPair()189-        val paired = device("phone").copy(190-            longTermPublicKeyHex = pubkeyToHex(storedPublic),191-        )192-193-        val honest = PairIdentity(194-            deviceId = paired.deviceId,195-            deviceName = "Phone",196-            deviceType = DeviceType.ANDROID,197-            longTermPublicKeyHex = pubkeyToHex(storedPublic),198-            signingPublicKeyHex = null,199-            wsPort = null,200-            relayUrl = null,201-        )202-        assertTrue(identityMatchesAuthenticatedPeer(paired, honest, storedPublic))203-204-        // Claims another device's id.205-        assertFalse(206-            identityMatchesAuthenticatedPeer(207-                paired,208-                honest.copy(deviceId = "different-device"),209-                storedPublic,210-            ),211-        )212-        // Advertises a key we never paired with.213-        assertFalse(214-            identityMatchesAuthenticatedPeer(215-                paired,216-                honest.copy(longTermPublicKeyHex = pubkeyToHex(spoofedPublic)),217-                spoofedPublic,218-            ),219-        )220-        // Claims the paired key while Noise authenticated a different one — the221-        // spoof this anchoring exists to stop.222-        assertFalse(identityMatchesAuthenticatedPeer(paired, honest, spoofedPublic))223-    }224-}

view file at the parent

app/src/test/java/com/peachpasswords/sync/C7TransportTest.kt+224 −0
@@ -0,0 +1,224 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.Dispatchers4+import kotlinx.coroutines.launch5+import kotlinx.coroutines.runBlocking6+import kotlinx.serialization.builtins.ListSerializer7+import kotlinx.serialization.json.Json8+import org.bouncycastle.crypto.params.X25519PrivateKeyParameters9+import org.junit.Assert.assertArrayEquals10+import org.junit.Assert.assertEquals11+import org.junit.Assert.assertNotNull12+import org.junit.Assert.assertThrows13+import org.junit.Assert.assertFalse14+import org.junit.Assert.assertTrue15+import org.junit.Test16+import java.io.PipedInputStream17+import java.io.PipedOutputStream18+import java.security.SecureRandom19+20+/**21+ * C7 transport tests. Mirrors the existing SyncTransportTest idiom: plain22+ * JUnit + runBlocking, no Robolectric. PairedDeviceStore itself needs an Android23+ * Context (EncryptedSharedPreferences), so its persistence is exercised by the24+ * orchestrator's instrumentation tests; here we test the pure-JVM serialization25+ * layer the store round-trips through (test 1) plus its upsert/replace semantics26+ * against an in-memory list (test 2), the frame codec (test 3), the first-pair27+ * Noise-NK primitive over frames (tests 4–5), and the local-state key pin used28+ * by the live KK reconnect discovery path (test 6).29+ */30+class C7TransportTest {31+32+    private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }33+    private val listSerializer = ListSerializer(PairedDevice.serializer())34+35+    private fun newKeyPair(): Pair<ByteArray, ByteArray> {36+        val priv = X25519PrivateKeyParameters(SecureRandom())37+        return priv.encoded to priv.generatePublicKey().encoded38+    }39+40+    private fun device(id: String, name: String = "Peer") = PairedDevice(41+        deviceId = id,42+        deviceName = name,43+        deviceType = "android",44+        longTermPublicKeyHex = "ab".repeat(32),45+        trustSource = "direct",46+        pairedAt = 1_000L,47+        lastSyncedAt = null,48+    )49+50+    /** Test 1: PairedDevice list survives JSON round-trip (the store's wire form). */51+    @Test52+    fun `paired device list round-trips through json`() {53+        val devices = listOf(device("a", "Alice"), device("b", "Bob"))54+        val encoded = json.encodeToString(listSerializer, devices)55+        val decoded = json.decodeFromString(listSerializer, encoded)56+        assertEquals(devices, decoded)57+        assertEquals("Alice", decoded[0].deviceName)58+        assertEquals("ab".repeat(32), decoded[1].longTermPublicKeyHex)59+    }60+61+    /** Test 2: upsert replaces an existing entry by deviceId (store semantics). */62+    @Test63+    fun `upsert by deviceId replaces rather than duplicates`() {64+        // Mirror PairedDeviceStore.upsert's pure list transform.65+        fun upsert(list: List<PairedDevice>, d: PairedDevice) =66+            list.filterNot { it.deviceId == d.deviceId } + d67+68+        var list = listOf(device("a", "Alice"))69+        list = upsert(list, device("b", "Bob"))70+        assertEquals(2, list.size)71+        list = upsert(list, device("a", "Alice-renamed"))72+        assertEquals(2, list.size)73+        assertEquals("Alice-renamed", list.first { it.deviceId == "a" }.deviceName)74+    }75+76+    /** Test 3: writeFrame/readFrame round-trip across payload sizes. */77+    @Test78+    fun `frame codec round-trips for various sizes`() {79+        for (size in intArrayOf(0, 1, 31, 64, 1500, 65_537)) {80+            val payload = ByteArray(size) { (it % 251).toByte() }81+            val pin = PipedInputStream(1 shl 18)82+            val pout = PipedOutputStream(pin)83+            writeFrame(pout, payload)84+            val read = readFrame(pin)85+            assertArrayEquals("size=$size", payload, read)86+        }87+    }88+89+    /**90+     * Test 4: the first-pair Noise NK primitive + identity frame exchange over91+     * piped frames. Live reconnect uses KK; this pins the separate first-pair92+     * pattern that remains valid when the initiator is not known yet.93+     */94+    @Test95+    fun `noise NK handshake over frames yields matching SAS and decodable identity`() = runBlocking {96+        val (responderPriv, responderPub) = newKeyPair()97+98+        // Cross-wired pipes: initiator's out -> responder's in and vice versa.99+        val i2r = PipedInputStream(1 shl 18); val i2rOut = PipedOutputStream(i2r)100+        val r2i = PipedInputStream(1 shl 18); val r2iOut = PipedOutputStream(r2i)101+102+        val initiator = SocketFrameIO(input = r2i, output = i2rOut)103+        val responder = SocketFrameIO(input = i2r, output = r2iOut)104+105+        val localId = LocalDeviceIdentity(106+            deviceId = "client-1",107+            deviceName = "Client",108+            deviceType = DeviceType.ANDROID,109+            longTermPublicKeyHex = "cd".repeat(32),110+            signingPublicKeyHex = "ef".repeat(32),111+        )112+        val serverId = localId.copy(deviceId = "server-1", deviceName = "Server")113+114+        var initiatorSas: String? = null115+        var responderSas: String? = null116+        var receivedByServer: PairIdentity? = null117+118+        val responderJob = launch(Dispatchers.IO) {119+            val session = noiseNKResponder(responderPriv, responderPub, responder::send, responder::receive)120+            receivedByServer = parsePairIdentityFrame(session.decrypt(responder.receive()))121+            responder.send(session.encrypt(encodePairIdentityFrame(serverId)))122+            responderSas = SASDerivation.derive(session.handshakeHash)123+        }124+125+        val initiatorJob = launch(Dispatchers.IO) {126+            val session = noiseNKInitiator(responderPub, initiator::send, initiator::receive)127+            initiator.send(session.encrypt(encodePairIdentityFrame(localId)))128+            session.decrypt(initiator.receive()) // server identity (discarded here)129+            initiatorSas = SASDerivation.derive(session.handshakeHash)130+        }131+132+        initiatorJob.join()133+        responderJob.join()134+135+        assertNotNull(initiatorSas)136+        assertEquals("both sides derive the same SAS", initiatorSas, responderSas)137+        assertEquals("client-1", receivedByServer?.deviceId)138+        assertEquals("cd".repeat(32), receivedByServer?.longTermPublicKeyHex)139+    }140+141+    /**142+     * Test 5: a wrong responder static key fails the first-pair NK primitive.143+     */144+    @Test145+    fun `handshake fails when responder static key is wrong`() = runBlocking {146+        val (responderPriv, responderPub) = newKeyPair()147+        val (_, wrongPub) = newKeyPair()148+149+        val i2r = PipedInputStream(1 shl 18); val i2rOut = PipedOutputStream(i2r)150+        val r2i = PipedInputStream(1 shl 18); val r2iOut = PipedOutputStream(r2i)151+        val initiator = SocketFrameIO(input = r2i, output = i2rOut)152+        val responder = SocketFrameIO(input = i2r, output = r2iOut)153+154+        // The initiator pins the WRONG static key, so the responder rejects msg1 (its155+        // own ES differs) and never sends msg2. Close the responder's write end on156+        // failure so the initiator's receive() hits EOF and fails fast instead of157+        // blocking forever — the raw-socket analogue is the soTimeout connectAndSync158+        // surfaces as an IO error. (A coroutine timeout can't interrupt the blocking159+        // pipe read, so EOF is the deterministic way to unblock it.)160+        val responderJob = launch(Dispatchers.IO) {161+            runCatching { noiseNKResponder(responderPriv, responderPub, responder::send, responder::receive) }162+            runCatching { r2iOut.close() }163+        }164+165+        var initiatorFailed = false166+        val initiatorJob = launch(Dispatchers.IO) {167+            try {168+                noiseNKInitiator(wrongPub, initiator::send, initiator::receive)169+            } catch (_: Throwable) {170+                initiatorFailed = true171+            }172+        }173+174+        initiatorJob.join()175+        responderJob.join()176+        assertTrue("a wrong responder static key must fail the initiator handshake", initiatorFailed)177+    }178+179+    @Test180+    fun `a presented identity cannot replace the locally paired key`() {181+        // Was `reconnect discovery cannot replace the locally paired responder182+        // key`, which pinned a LAN-discovered endpoint. Discovery is gone with183+        // LAN sync (docs/sync-lan-retirement.md), but the property it protected184+        // is not: a peer's self-asserted PAIR_IDENTITY must never override the185+        // paired record, and is only believed when it matches the static key186+        // Noise actually authenticated.187+        val (_, storedPublic) = newKeyPair()188+        val (_, spoofedPublic) = newKeyPair()189+        val paired = device("phone").copy(190+            longTermPublicKeyHex = pubkeyToHex(storedPublic),191+        )192+193+        val honest = PairIdentity(194+            deviceId = paired.deviceId,195+            deviceName = "Phone",196+            deviceType = DeviceType.ANDROID,197+            longTermPublicKeyHex = pubkeyToHex(storedPublic),198+            signingPublicKeyHex = null,199+            wsPort = null,200+            relayUrl = null,201+        )202+        assertTrue(identityMatchesAuthenticatedPeer(paired, honest, storedPublic))203+204+        // Claims another device's id.205+        assertFalse(206+            identityMatchesAuthenticatedPeer(207+                paired,208+                honest.copy(deviceId = "different-device"),209+                storedPublic,210+            ),211+        )212+        // Advertises a key we never paired with.213+        assertFalse(214+            identityMatchesAuthenticatedPeer(215+                paired,216+                honest.copy(longTermPublicKeyHex = pubkeyToHex(spoofedPublic)),217+                spoofedPublic,218+            ),219+        )220+        // Claims the paired key while Noise authenticated a different one — the221+        // spoof this anchoring exists to stop.222+        assertFalse(identityMatchesAuthenticatedPeer(paired, honest, spoofedPublic))223+    }224+}

view file

app/src/test/java/com/peachpasswords/sync/ChirpAdoptTest.kt+0 −304
@@ -1,304 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.coroutines.async4-import kotlinx.coroutines.channels.Channel5-import kotlinx.coroutines.runBlocking6-import kotlinx.coroutines.withTimeoutOrNull7-import org.junit.Assert.assertEquals8-import org.junit.Assert.assertFalse9-import org.junit.Assert.assertNull10-import org.junit.Assert.assertTrue11-import org.junit.Test12-13-/**14- * Adoption frame protocol parity with the browser `chirp-adopt.test.ts`. The15- * receiver must verify the granted root key derives the advertised vault id16- * BEFORE sealing a vault, and a denied consent must hand over nothing.17- */18-class ChirpAdoptTest {19-20-    private fun memoryPair(): Pair<PairingTransport, PairingTransport> {21-        val a2b = Channel<ByteArray>(Channel.UNLIMITED)22-        val b2a = Channel<ByteArray>(Channel.UNLIMITED)23-        val a = object : PairingTransport {24-            override suspend fun send(msg: ByteArray) { a2b.send(msg) }25-            override suspend fun receive(): ByteArray = b2a.receive()26-            override suspend fun close() {}27-        }28-        val b = object : PairingTransport {29-            override suspend fun send(msg: ByteArray) { b2a.send(msg) }30-            override suspend fun receive(): ByteArray = a2b.receive()31-            override suspend fun close() {}32-        }33-        return a to b34-    }35-36-    private fun identity(name: String) = LocalChirpIdentity(37-        deviceId = "$name-id",38-        deviceName = name,39-        deviceType = DeviceType.EXTENSION,40-        longTermPublicKeyHex = "ab".repeat(32),41-        signingPublicKeyHex = "cd".repeat(32),42-    )43-44-    private fun vaultIdFor(rootKeyHex: String): String {45-        val bytes = hexToPubkey(rootKeyHex)46-        return try {47-            computeVaultId(bytes)48-        } finally {49-            bytes.fill(0)50-        }51-    }52-53-    private class RecorderReceiver(54-        override val deviceName: String,55-        override val deviceType: DeviceType,56-        private val vaultId: String?,57-        private val createThrows: Boolean = false,58-        private val derived: LocalChirpIdentity? = null,59-        private val sasConfirmed: Boolean = true,60-    ) : AdoptionReceiverDeps {61-        var createCalledWith: String? = null62-        var licenseApplied: String? = null63-        val upserted = mutableListOf<PairedDevice>()64-        val errors = mutableListOf<String>()65-        override fun generateDeviceId(): String = "receiver-ephemeral"66-        override suspend fun confirmSas(): Boolean = sasConfirmed67-        override suspend fun createVaultFromRootKey(rootKeyHex: String): String {68-            createCalledWith = rootKeyHex69-            if (createThrows) throw IllegalStateException("commit_failed")70-            return vaultId ?: error("no vault id")71-        }72-        override suspend fun deriveLocalIdentity(): LocalChirpIdentity = derived ?: error("no identity")73-        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }74-        override suspend fun applyLicense(license: String) { licenseApplied = license }75-        override fun onError(error: String) { errors += error }76-    }77-78-    private class RecorderSource(79-        override val sourceIdentity: LocalChirpIdentity,80-        private val rootKeyHex: String,81-        private val vaultId: String,82-        private val license: String? = null,83-        private val consent: Boolean = true,84-    ) : AdoptionSourceDeps {85-        val upserted = mutableListOf<PairedDevice>()86-        var consentAsked = false87-        var rootRequested = false88-        override suspend fun rootKeyHex(): String {89-            rootRequested = true90-            return rootKeyHex91-        }92-        override suspend fun vaultId(): String = vaultId93-        override suspend fun license(): String? = license94-        override suspend fun onConsent(request: AdoptionFrame.Request): Boolean {95-            consentAsked = true96-            return consent97-        }98-        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }99-    }100-101-    @Test fun completes_request_grant_identity_round_trip() = runBlocking {102-        val (receiverCh, sourceCh) = memoryPair()103-        val rootKeyHex = "11".repeat(32)104-        val vaultId = vaultIdFor(rootKeyHex)105-        val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)106-        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, derived = receiverIdentity)107-        val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")108-109-        val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } }110-        val grantJob = async {111-            val request = receiveFrame(sourceCh) as AdoptionFrame.Request112-            grantAdoption(sourceCh, request, source) { 222L }113-        }114-        recvJob.await()115-        grantJob.await()116-117-        assertEquals(rootKeyHex, receiver.createCalledWith)118-        assertEquals("PPK-test", receiver.licenseApplied)119-        assertEquals("source-id", receiver.upserted.single().deviceId)120-        assertEquals("receiver-id", source.upserted.single().deviceId)121-        assertEquals("android", source.upserted.single().deviceType)122-    }123-124-    /**125-     * The constraint from `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2: the label126-     * on ADOPT_REQUEST is generated before any key exists, so persisting it as127-     * an identity would key trust on a value with no continuity between128-     * attempts. What lands in the store must come from ADOPT_IDENTITY.129-     */130-    @Test fun request_label_is_never_what_gets_persisted() = runBlocking {131-        val (receiverCh, sourceCh) = memoryPair()132-        val rootKeyHex = "55".repeat(32)133-        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))134-        // Deliberately unlike the identity the receiver will present.135-        val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension")136-137-        val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } }138-        sendFrame(receiverCh, AdoptionFrame.Confirm(true))139-        receiveFrame(receiverCh) // the grant140-        sendFrame(141-            receiverCh,142-            AdoptionFrame.Identity(143-                deviceId = "receiver-id",144-                deviceName = "Receiver",145-                deviceType = "extension",146-                longTermPublicKeyHex = "aa".repeat(32),147-                signingPublicKeyHex = "bb".repeat(32),148-            ),149-        )150-        grantJob.await()151-152-        val persisted = source.upserted.single()153-        assertEquals("receiver-id", persisted.deviceId)154-        assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex)155-        assertTrue(156-            "the ephemeral request label must not reach the paired-device store",157-            source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" },158-        )159-    }160-161-    @Test fun request_carries_receiver_device_type() = runBlocking {162-        val (receiverCh, sourceCh) = memoryPair()163-        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)164-        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }165-        val request = receiveFrame(sourceCh) as AdoptionFrame.Request166-        recvJob.cancel()167-168-        assertEquals("android", request.deviceType)169-        assertEquals("receiver-ephemeral", request.sessionRequestId)170-        assertEquals("Receiver", request.deviceName)171-    }172-173-    @Test fun denied_consent_grants_nothing() = runBlocking {174-        val (receiverCh, sourceCh) = memoryPair()175-        val rootKeyHex = "33".repeat(32)176-        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))177-        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)178-179-        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }180-        val request = receiveFrame(sourceCh) as AdoptionFrame.Request181-        val granted = grantAdoption(sourceCh, request, source) { 0L }182-        recvJob.cancel()183-184-        assertNull(granted)185-        assertTrue(source.consentAsked)186-        assertFalse(source.rootRequested)187-        assertTrue(source.upserted.isEmpty())188-        assertNull(receiver.createCalledWith)189-    }190-191-    @Test fun receiver_sas_rejection_grants_no_root_key() = runBlocking {192-        val (receiverCh, sourceCh) = memoryPair()193-        val rootKeyHex = "66".repeat(32)194-        val receiver = RecorderReceiver(195-            "Receiver",196-            DeviceType.ANDROID,197-            vaultIdFor(rootKeyHex),198-            sasConfirmed = false,199-        )200-        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))201-202-        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }203-        val request = receiveFrame(sourceCh) as AdoptionFrame.Request204-        val sourceJob = async { grantAdoption(sourceCh, request, source) { 0L } }205-206-        assertNull(receiverJob.await())207-        assertNull(sourceJob.await())208-        assertFalse(source.rootRequested)209-        assertNull(receiver.createCalledWith)210-        assertEquals(listOf("ADOPT_SAS_REJECTED"), receiver.errors)211-    }212-213-    @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {214-        val (receiverCh, sourceCh) = memoryPair()215-        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)216-        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }217-        receiveFrame(sourceCh)218-        sendFrame(sourceCh, AdoptionFrame.Grant(219-            rootKeyHex = "bad",220-            vaultId = "00".repeat(32),221-            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),222-        ))223-        recvJob.await()224-225-        assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)226-        assertNull(receiver.createCalledWith)227-    }228-229-    @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {230-        val (receiverCh, sourceCh) = memoryPair()231-        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)232-        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }233-        receiveFrame(sourceCh)234-        sendFrame(sourceCh, AdoptionFrame.Grant(235-            rootKeyHex = "22".repeat(32),236-            vaultId = "00".repeat(32),237-            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),238-        ))239-        recvJob.await()240-241-        assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)242-        assertNull(receiver.createCalledWith)243-    }244-245-    @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {246-        val (receiverCh, sourceCh) = memoryPair()247-        val rootKeyHex = "33".repeat(32)248-        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))249-        val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")250-251-        sendFrame(receiverCh, AdoptionFrame.Confirm(true))252-        val result = withTimeoutOrNull(150) {253-            // No receiver answers the ADOPT_IDENTITY read, so grant must hang, not persist.254-            grantAdoption(sourceCh, request, source) { 0L }255-        }256-257-        assertNull(result)258-        assertTrue(source.upserted.isEmpty())259-    }260-261-    @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {262-        val (receiverCh, sourceCh) = memoryPair()263-        val rootKeyHex = "44".repeat(32)264-        val vaultId = vaultIdFor(rootKeyHex)265-        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true)266-        val recvJob = async {267-            runCatching { requestAdoption(receiverCh, receiver) { 0L } }268-        }269-        receiveFrame(sourceCh)270-        sendFrame(sourceCh, AdoptionFrame.Grant(271-            rootKeyHex = rootKeyHex,272-            vaultId = vaultId,273-            license = "PPK-test",274-            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),275-        ))276-        val outcome = recvJob.await()277-278-        assertTrue(outcome.isFailure)279-        assertNull(receiver.licenseApplied)280-    }281-282-    @Test fun validator_rejects_short_and_uppercase_root_keys() {283-        assertTrue(validateRootKeyHex("ab".repeat(32)))284-        assertFalse(validateRootKeyHex("ab".repeat(31)))285-        assertFalse(validateRootKeyHex("AB".repeat(32)))286-        assertFalse(validateRootKeyHex("zz".repeat(32)))287-        assertFalse(validateRootKeyHex(null))288-    }289-290-    @Test fun grant_omits_absent_optionals_like_json_stringify() {291-        val grant = AdoptionFrame.Grant(292-            rootKeyHex = "11".repeat(32),293-            vaultId = "22".repeat(32),294-            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),295-        )296-        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)297-298-        assertFalse(json.contains("license"))299-        assertFalse(json.contains("ws_port"))300-        assertFalse(json.contains("signing_public_key_hex"))301-        assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))302-        assertTrue(json.contains("\"root_key_hex\""))303-    }304-}

view file at the parent

app/src/test/java/com/peachpasswords/sync/ChirpSyncCoordinatorTest.kt+0 −148
@@ -1,148 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.coroutines.async4-import kotlinx.coroutines.channels.Channel5-import kotlinx.coroutines.runBlocking6-import org.junit.Assert.assertArrayEquals7-import org.junit.Assert.assertEquals8-import org.junit.Assert.assertFalse9-import org.junit.Assert.assertNull10-import org.junit.Assert.assertTrue11-import org.junit.Test12-13-/** Two in-memory [ChirpSyncSession]s wired back to back. */14-private fun duplex(): Pair<ChirpSyncSession, ChirpSyncSession> {15-    val a2b = Channel<ByteArray>(Channel.UNLIMITED)16-    val b2a = Channel<ByteArray>(Channel.UNLIMITED)17-    val pk = ByteArray(32)18-    val a = ChirpSyncSession(pk, send = { a2b.trySend(it); Unit }, receive = { b2a.receive() }, close = {})19-    val b = ChirpSyncSession(pk, send = { b2a.trySend(it); Unit }, receive = { a2b.receive() }, close = {})20-    return a to b21-}22-23-/**24- * Fake adapter. The blob is just the mtime as text, so [applyEntries] can keep25- * its manifest in step — enough to prove the diff/transfer logic moved data.26- */27-private class FakeAdapter(28-    val deviceId: String,29-    var version: Long,30-    val vaultId: String = "VAULT",31-    override val supportsDeviceManifest: Boolean = false,32-) : ChirpSyncVaultAdapter {33-    val entries = HashMap<String, Long>()34-    val blobs = HashMap<String, String>()35-    val tombstones = HashMap<String, Long>()36-    var outgoingDeviceManifest: SignedDeviceManifest? = null37-    var incomingDeviceManifest: SignedDeviceManifest? = null38-    var incomingAuthenticatedKey: ByteArray? = null39-40-    fun put(id: String, mtime: Long) { entries[id] = mtime; blobs[id] = mtime.toString() }41-42-    override suspend fun computeVaultId() = vaultId43-    override suspend fun getOurDeviceId() = deviceId44-    override suspend fun getLocalVersion() = version45-    override suspend fun buildEntryManifest() = entries.toMap()46-    override suspend fun buildTombstoneManifest() = tombstones.toMap()47-    override suspend fun getEncryptedEntries(entryIds: List<String>) =48-        entryIds.filter { blobs.containsKey(it) }.associateWith { blobs[it]!! }49-    override suspend fun applyEntries(entries: Map<String, String>) {50-        for ((id, blob) in entries) { blobs[id] = blob; this.entries[id] = blob.toLong() }51-    }52-    override suspend fun applyTombstones(tombstones: Map<String, Long>) {53-        for ((id, ts) in tombstones) this.tombstones[id] = ts54-    }55-    override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? = outgoingDeviceManifest56-    override suspend fun applyIncomingDeviceManifest(57-        manifest: SignedDeviceManifest,58-        authenticatedRemoteStaticPublicKey: ByteArray,59-    ) {60-        incomingDeviceManifest = manifest61-        incomingAuthenticatedKey = authenticatedRemoteStaticPublicKey.copyOf()62-    }63-}64-65-class ChirpSyncCoordinatorTest {66-67-    @Test fun full_round_trip_exchanges_entries_both_ways() = runBlocking {68-        val (sa, sb) = duplex()69-        val a = FakeAdapter("A", version = 1).apply { put("x", 100) }70-        val b = FakeAdapter("B", version = 2).apply { put("y", 200) }71-72-        val ra = async { runChirpSyncInitiator(sa, a, nowMs = 1_000) }73-        val rb = async { runChirpSyncResponder(sb, b, nowMs = 1_000) }74-        val (outA, outB) = ra.await() to rb.await()75-76-        assertEquals(SyncOutcomeStatus.COMPLETED, outA.status)77-        assertEquals(SyncOutcomeStatus.COMPLETED, outB.status)78-        // A pulled y; B pulled x.79-        assertTrue(a.blobs.containsKey("y"))80-        assertTrue(b.blobs.containsKey("x"))81-        assertEquals(1, outA.entriesReceived)82-        assertEquals(1, outA.entriesSent)83-    }84-85-    @Test fun equal_versions_short_circuit_without_transfer() = runBlocking {86-        val (sa, sb) = duplex()87-        val a = FakeAdapter("A", version = 7).apply { put("x", 100) }88-        val b = FakeAdapter("B", version = 7).apply { put("y", 200) }89-90-        val ra = async { runChirpSyncInitiator(sa, a, nowMs = 0) }91-        val rb = async { runChirpSyncResponder(sb, b, nowMs = 0) }92-        val (outA, outB) = ra.await() to rb.await()93-94-        assertEquals(SyncOutcomeStatus.COMPLETED, outA.status)95-        assertEquals(SyncOutcomeStatus.COMPLETED, outB.status)96-        assertEquals(0, outA.entriesReceived)97-        assertFalse(a.blobs.containsKey("y"))98-        assertFalse(b.blobs.containsKey("x"))99-    }100-101-    @Test fun vault_id_mismatch_aborts_both_sides() = runBlocking {102-        val (sa, sb) = duplex()103-        val a = FakeAdapter("A", version = 1, vaultId = "V1")104-        val b = FakeAdapter("B", version = 2, vaultId = "V2")105-106-        val ra = async { runChirpSyncInitiator(sa, a, nowMs = 0) }107-        val rb = async { runChirpSyncResponder(sb, b, nowMs = 0) }108-        val (outA, outB) = ra.await() to rb.await()109-110-        assertEquals(SyncOutcomeStatus.VAULT_MISMATCH, outA.status)111-        assertEquals(SyncOutcomeStatus.VAULT_MISMATCH, outB.status)112-    }113-114-    @Test fun tombstone_propagates_to_peer() = runBlocking {115-        val (sa, sb) = duplex()116-        val a = FakeAdapter("A", version = 3).apply { tombstones["gone"] = 500 }117-        val b = FakeAdapter("B", version = 4).apply { put("gone", 100) }118-119-        val ra = async { runChirpSyncInitiator(sa, a, nowMs = 0) }120-        val rb = async { runChirpSyncResponder(sb, b, nowMs = 0) }121-        ra.await(); rb.await()122-123-        assertEquals(500L, b.tombstones["gone"])124-    }125-126-    @Test fun absent_device_manifest_is_explicit_null_and_peer_key_is_bound() = runBlocking {127-        val (sa, sb) = duplex()128-        val manifest = SignedDeviceManifest(129-            body = DeviceManifestBody(1, "A", 1_000L, emptyList()),130-            signatureHex = "ab".repeat(64),131-        )132-        val a = FakeAdapter("A", version = 1, supportsDeviceManifest = true).apply {133-            outgoingDeviceManifest = manifest134-        }135-        val b = FakeAdapter("B", version = 2, supportsDeviceManifest = true)136-137-        val ra = async { runChirpSyncInitiator(sa, a, nowMs = 1_000L) }138-        val rb = async { runChirpSyncResponder(sb, b, nowMs = 1_000L) }139-        assertEquals(SyncOutcomeStatus.COMPLETED, ra.await().status)140-        assertEquals(SyncOutcomeStatus.COMPLETED, rb.await().status)141-142-        assertNull(a.incomingDeviceManifest)143-        assertEquals(manifest, b.incomingDeviceManifest)144-        assertArrayEquals(ByteArray(32), b.incomingAuthenticatedKey!!)145-        val wire = decodeSyncMessage(SyncMessage.DeviceManifest(null).encodeToBytes())146-        assertEquals(SyncMessage.DeviceManifest(null), wire)147-    }148-}

view file at the parent

app/src/test/java/com/peachpasswords/sync/ChirpSyncSuiteTest.kt+0 −208
@@ -1,208 +0,0 @@1-package com.peachpasswords.sync2-3-import com.peachpasswords.vault.StoredVault4-import com.peachpasswords.vault.UnlockResult5-import com.peachpasswords.vault.VaultEntry6-import com.peachpasswords.vault.VaultRepository7-import com.peachpasswords.vault.VaultSettings8-import com.peachpasswords.vault.VaultStorage9-import kotlinx.coroutines.async10-import kotlinx.coroutines.runBlocking11-import kotlinx.coroutines.yield12-import org.junit.Assert.assertArrayEquals13-import org.junit.Assert.assertEquals14-import org.junit.Assert.assertFalse15-import org.junit.Assert.assertTrue16-import org.junit.Test17-import java.io.ByteArrayInputStream18-import java.io.ByteArrayOutputStream19-20-/**21- * ChirpSync suite (brief Suite 13): length-prefixed pairing frames, SAS22- * derivation KAT, and cross-device entry sync through the [VaultSyncAdapter]23- * (two repositories made to share a sync root key via the Codex blob).24- */25-class ChirpSyncSuiteTest {26-27-    private class Storage : VaultStorage {28-        var slotA: StoredVault? = null29-        var slotB: String? = null30-        private var ver: Long = 031-        private var settings = VaultSettings()32-        override fun loadSlotA() = slotA33-        override fun hasVault() = slotA != null34-        override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }35-        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }36-        var decoy: StoredVault? = null37-        override fun loadDecoySlot() = decoy38-        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }39-        override fun hasDecoySlot() = decoy != null40-        override fun clearDecoySlot() { decoy = null }41-        override fun version() = ver42-        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }43-        override fun loadSettings() = settings44-        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }45-        override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }46-    }47-48-    // ── Pairing frames ────────────────────────────────────────────────────────49-50-    private fun roundTripFrame(payload: ByteArray): ByteArray {51-        val out = ByteArrayOutputStream()52-        writeFrame(out, payload)53-        return readFrame(ByteArrayInputStream(out.toByteArray()))54-    }55-56-    @Test fun frame_round_trips_small_payload() {57-        val p = "noise-handshake-msg".toByteArray()58-        assertArrayEquals(p, roundTripFrame(p))59-    }60-61-    @Test fun frame_round_trips_empty_payload() {62-        assertArrayEquals(ByteArray(0), roundTripFrame(ByteArray(0)))63-    }64-65-    @Test fun frame_round_trips_various_sizes() {66-        for (n in intArrayOf(1, 100, 4096, 65536)) {67-            val p = ByteArray(n) { (it % 251).toByte() }68-            assertArrayEquals("size $n", p, roundTripFrame(p))69-        }70-    }71-72-    @Test fun frame_round_trips_at_max_size() {73-        val p = ByteArray(MAX_PAIRING_FRAME_BYTES) { (it % 7).toByte() }74-        assertEquals(MAX_PAIRING_FRAME_BYTES, roundTripFrame(p).size)75-    }76-77-    @Test fun write_frame_rejects_oversize_payload() {78-        val out = ByteArrayOutputStream()79-        var threw = false80-        try { writeFrame(out, ByteArray(MAX_PAIRING_FRAME_BYTES + 1)) } catch (_: IllegalArgumentException) { threw = true }81-        assertTrue(threw)82-    }83-84-    @Test fun read_frame_rejects_truncated_body() {85-        // A header claiming 100 bytes but only 3 present must fail (EOF / mismatch).86-        val bytes = byteArrayOf(0, 0, 0, 100, 1, 2, 3)87-        var threw = false88-        try { readFrame(ByteArrayInputStream(bytes)) } catch (_: Exception) { threw = true }89-        assertTrue(threw)90-    }91-92-    // ── SAS derivation ────────────────────────────────────────────────────────93-94-    @Test fun sas_is_eight_digits_zero_padded() {95-        assertEquals("00000000", SASDerivation.derive(ByteArray(8)))96-        assertEquals("00000005", SASDerivation.derive(byteArrayOf(0, 0, 0, 0, 0, 0, 0, 5)))97-    }98-99-    @Test fun sas_kat_known_vectors() {100-        // First 8 bytes as an unsigned big-endian integer, mod 100_000_000.101-        assertEquals(102-            "52306183",103-            SASDerivation.derive(byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7)),104-        )105-        assertEquals("09551615", SASDerivation.derive(ByteArray(8) { 0xFF.toByte() }))106-    }107-108-    @Test fun sas_ignores_bytes_past_the_first_eight() {109-        val a = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9))110-        val b = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 0))111-        assertEquals(a, b)112-    }113-114-    @Test fun sas_requires_at_least_eight_bytes() {115-        var threw = false116-        try { SASDerivation.derive(ByteArray(7)) } catch (_: IllegalArgumentException) { threw = true }117-        assertTrue(threw)118-    }119-120-    @Test fun `HV-1 a far-future remote modifiedAt is clamped into the skew window`() = runBlocking {121-        val (a, _, _) = pairedDevices()122-        val local = a.addEntry(123-            VaultEntry(id = "", title = "Local", type = "login", password = "local-pw", createdAt = 0, modifiedAt = 0),124-        )125-126-        // A hostile peer claims an edit ten years in the future, trying to pin127-        // its version against every honest local edit for a decade.128-        val farFuture = System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000129-        a.applyMergedRemoteEntries(130-            listOf(local.copy(title = "Injected", password = "attacker-pw", modifiedAt = farFuture)),131-            emptyList(),132-        )133-134-        // The edit still lands (it is genuinely newer) but only within the skew135-        // window, so the monotonic local entry clock can always advance past it.136-        // The overtake itself is not exercised here: updateEntry would push137-        // VaultCrypto's process-global monotonic counter forward and skew every138-        // later test in the same JVM.139-        val pinned = a.getEntry(local.id)!!140-        assertTrue(141-            "remote modifiedAt must be clamped into the skew window",142-            pinned.modifiedAt <= System.currentTimeMillis() + SyncLimits.MAX_CLOCK_SKEW_MS,143-        )144-        assertTrue("clamped value must be far below the claim", pinned.modifiedAt < farFuture)145-    }146-147-    @Test fun sas_gate_never_completes_without_explicit_user_confirmation() = runBlocking {148-        val session = PairingSession()149-        val waiter = async { session.await() }150-        yield()151-        assertFalse("SAS session must not auto-confirm", waiter.isCompleted)152-        session.confirm(true)153-        assertTrue(waiter.await())154-    }155-156-    // ── Cross-device entry sync (shared root key via Codex) ───────────────────157-158-    private fun pairedDevices(): Triple<VaultRepository, VaultRepository, Pair<VaultSyncAdapter, VaultSyncAdapter>> {159-        val a = VaultRepository(Storage())160-        runBlocking { a.createVault("shared-master-pass") }161-        // Device B restores A's blob → identical sync root key, so entry keys match.162-        val b = VaultRepository(Storage())163-        runBlocking { assertEquals(UnlockResult.Success, b.restoreFromCodex(a.codexVaultBlobBase64()!!, "shared-master-pass")) }164-        return Triple(a, b, VaultSyncAdapter(a, "dev-a") to VaultSyncAdapter(b, "dev-b"))165-    }166-167-    @Test fun paired_devices_share_a_vault_id() = runBlocking {168-        val (_, _, adapters) = pairedDevices()169-        assertEquals(adapters.first.computeVaultId(), adapters.second.computeVaultId())170-    }171-172-    @Test fun entry_added_on_a_syncs_to_b() = runBlocking {173-        val (a, b, adapters) = pairedDevices()174-        val (adapterA, adapterB) = adapters175-        val e = a.addEntry(VaultEntry(id = "", title = "GitHub", type = "login", password = "p", createdAt = 0, modifiedAt = 0))176-        val envelopes = adapterA.getEncryptedEntries(listOf(e.id))177-        adapterB.applyEntries(envelopes)178-        assertEquals("GitHub", b.getEntry(e.id)?.title)179-        assertEquals("p", b.getEntry(e.id)?.password)180-    }181-182-    @Test fun tombstone_from_a_deletes_on_b() = runBlocking {183-        val (a, b, adapters) = pairedDevices()184-        val (adapterA, adapterB) = adapters185-        val e = a.addEntry(VaultEntry(id = "", title = "Temp", type = "login", password = "x", createdAt = 0, modifiedAt = 0))186-        adapterB.applyEntries(adapterA.getEncryptedEntries(listOf(e.id)))187-        assertEquals(1, b.getEntries().size)188-        a.deleteEntry(e.id)189-        adapterB.applyTombstones(adapterA.buildTombstoneManifest())190-        assertEquals(0, b.getEntries().size)191-    }192-193-    @Test fun apply_tombstones_ignores_non_positive_timestamps() = runBlocking {194-        val (a, _, adapters) = pairedDevices()195-        val e = a.addEntry(VaultEntry(id = "", title = "Keep", type = "login", password = "x", createdAt = 0, modifiedAt = 0))196-        // A zero/negative tombstone timestamp is filtered out, so the entry survives.197-        adapters.first.applyTombstones(mapOf(e.id to 0L))198-        assertEquals(1, a.getEntries().size)199-    }200-201-    @Test fun swapped_envelope_under_wrong_id_is_dropped() = runBlocking {202-        val (a, b, adapters) = pairedDevices()203-        val e = a.addEntry(VaultEntry(id = "", title = "Bank", type = "login", password = "x", createdAt = 0, modifiedAt = 0))204-        val blob = adapters.first.getEncryptedEntries(listOf(e.id)).getValue(e.id)205-        adapters.second.applyEntries(mapOf("forged-id" to blob))206-        assertFalse(b.getEntries().any { it.id == "forged-id" })207-    }208-}

view file at the parent

app/src/test/java/com/peachpasswords/sync/CloudRelayDialerTest.kt+1 −1
@@ -44,7 +44,7 @@4444         return socket(leftInbox, rightInbox) to socket(rightInbox, leftInbox)4545     }4646 47-    private fun identity(id: String, name: String, publicKeyHex: String) = LocalChirpIdentity(47+    private fun identity(id: String, name: String, publicKeyHex: String) = LocalDeviceIdentity(4848         deviceId = id,4949         deviceName = name,5050         deviceType = DeviceType.ANDROID,

view file

app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+3 −3
@@ -54,14 +54,14 @@5454             rendezvousSecretHex = secretHex,5555             pairedAt = 1L,5656         )57-        val phoneIdentity = LocalChirpIdentity(57+        val phoneIdentity = LocalDeviceIdentity(5858             deviceId = "phone",5959             deviceName = "Pixel",6060             deviceType = DeviceType.ANDROID,6161             longTermPublicKeyHex = pubkeyToHex(phoneKeys.publicKey),6262             signingPublicKeyHex = "22".repeat(32),6363         )64-        val browserIdentity = LocalChirpIdentity(64+        val browserIdentity = LocalDeviceIdentity(6565             deviceId = "browser",6666             deviceName = "Firefox",6767             deviceType = DeviceType.EXTENSION,@@ -133,7 +133,7 @@133133                 registeredRendezvousId = rendezvousId,134134                 ourStaticPrivateKey = phoneKeys.privateKey,135135                 ourStaticPublicKey = phoneKeys.publicKey,136-                localIdentity = LocalChirpIdentity(136+                localIdentity = LocalDeviceIdentity(137137                     deviceId = "phone",138138                     deviceName = "Pixel",139139                     deviceType = DeviceType.ANDROID,

view file

app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+1 −1
@@ -34,7 +34,7 @@3434             MemoryRelaySocket(leftToRight, rightToLeft)3535     }3636 37-    private fun identity(name: String, publicKey: ByteArray) = LocalChirpIdentity(37+    private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(3838         deviceId = "$name-id",3939         deviceName = name,4040         deviceType = if (name == "source") DeviceType.ANDROID else DeviceType.EXTENSION,

view file

app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+148 −0
@@ -0,0 +1,148 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.async4+import kotlinx.coroutines.channels.Channel5+import kotlinx.coroutines.runBlocking6+import org.junit.Assert.assertArrayEquals7+import org.junit.Assert.assertEquals8+import org.junit.Assert.assertFalse9+import org.junit.Assert.assertNull10+import org.junit.Assert.assertTrue11+import org.junit.Test12+13+/** Two in-memory [SyncSession]s wired back to back. */14+private fun duplex(): Pair<SyncSession, SyncSession> {15+    val a2b = Channel<ByteArray>(Channel.UNLIMITED)16+    val b2a = Channel<ByteArray>(Channel.UNLIMITED)17+    val pk = ByteArray(32)18+    val a = SyncSession(pk, send = { a2b.trySend(it); Unit }, receive = { b2a.receive() }, close = {})19+    val b = SyncSession(pk, send = { b2a.trySend(it); Unit }, receive = { a2b.receive() }, close = {})20+    return a to b21+}22+23+/**24+ * Fake adapter. The blob is just the mtime as text, so [applyEntries] can keep25+ * its manifest in step — enough to prove the diff/transfer logic moved data.26+ */27+private class FakeAdapter(28+    val deviceId: String,29+    var version: Long,30+    val vaultId: String = "VAULT",31+    override val supportsDeviceManifest: Boolean = false,32+) : SyncVaultAdapter {33+    val entries = HashMap<String, Long>()34+    val blobs = HashMap<String, String>()35+    val tombstones = HashMap<String, Long>()36+    var outgoingDeviceManifest: SignedDeviceManifest? = null37+    var incomingDeviceManifest: SignedDeviceManifest? = null38+    var incomingAuthenticatedKey: ByteArray? = null39+40+    fun put(id: String, mtime: Long) { entries[id] = mtime; blobs[id] = mtime.toString() }41+42+    override suspend fun computeVaultId() = vaultId43+    override suspend fun getOurDeviceId() = deviceId44+    override suspend fun getLocalVersion() = version45+    override suspend fun buildEntryManifest() = entries.toMap()46+    override suspend fun buildTombstoneManifest() = tombstones.toMap()47+    override suspend fun getEncryptedEntries(entryIds: List<String>) =48+        entryIds.filter { blobs.containsKey(it) }.associateWith { blobs[it]!! }49+    override suspend fun applyEntries(entries: Map<String, String>) {50+        for ((id, blob) in entries) { blobs[id] = blob; this.entries[id] = blob.toLong() }51+    }52+    override suspend fun applyTombstones(tombstones: Map<String, Long>) {53+        for ((id, ts) in tombstones) this.tombstones[id] = ts54+    }55+    override suspend fun buildOutgoingDeviceManifest(): SignedDeviceManifest? = outgoingDeviceManifest56+    override suspend fun applyIncomingDeviceManifest(57+        manifest: SignedDeviceManifest,58+        authenticatedRemoteStaticPublicKey: ByteArray,59+    ) {60+        incomingDeviceManifest = manifest61+        incomingAuthenticatedKey = authenticatedRemoteStaticPublicKey.copyOf()62+    }63+}64+65+class SyncCoordinatorTest {66+67+    @Test fun full_round_trip_exchanges_entries_both_ways() = runBlocking {68+        val (sa, sb) = duplex()69+        val a = FakeAdapter("A", version = 1).apply { put("x", 100) }70+        val b = FakeAdapter("B", version = 2).apply { put("y", 200) }71+72+        val ra = async { runSyncInitiator(sa, a, nowMs = 1_000) }73+        val rb = async { runSyncResponder(sb, b, nowMs = 1_000) }74+        val (outA, outB) = ra.await() to rb.await()75+76+        assertEquals(SyncOutcomeStatus.COMPLETED, outA.status)77+        assertEquals(SyncOutcomeStatus.COMPLETED, outB.status)78+        // A pulled y; B pulled x.79+        assertTrue(a.blobs.containsKey("y"))80+        assertTrue(b.blobs.containsKey("x"))81+        assertEquals(1, outA.entriesReceived)82+        assertEquals(1, outA.entriesSent)83+    }84+85+    @Test fun equal_versions_short_circuit_without_transfer() = runBlocking {86+        val (sa, sb) = duplex()87+        val a = FakeAdapter("A", version = 7).apply { put("x", 100) }88+        val b = FakeAdapter("B", version = 7).apply { put("y", 200) }89+90+        val ra = async { runSyncInitiator(sa, a, nowMs = 0) }91+        val rb = async { runSyncResponder(sb, b, nowMs = 0) }92+        val (outA, outB) = ra.await() to rb.await()93+94+        assertEquals(SyncOutcomeStatus.COMPLETED, outA.status)95+        assertEquals(SyncOutcomeStatus.COMPLETED, outB.status)96+        assertEquals(0, outA.entriesReceived)97+        assertFalse(a.blobs.containsKey("y"))98+        assertFalse(b.blobs.containsKey("x"))99+    }100+101+    @Test fun vault_id_mismatch_aborts_both_sides() = runBlocking {102+        val (sa, sb) = duplex()103+        val a = FakeAdapter("A", version = 1, vaultId = "V1")104+        val b = FakeAdapter("B", version = 2, vaultId = "V2")105+106+        val ra = async { runSyncInitiator(sa, a, nowMs = 0) }107+        val rb = async { runSyncResponder(sb, b, nowMs = 0) }108+        val (outA, outB) = ra.await() to rb.await()109+110+        assertEquals(SyncOutcomeStatus.VAULT_MISMATCH, outA.status)111+        assertEquals(SyncOutcomeStatus.VAULT_MISMATCH, outB.status)112+    }113+114+    @Test fun tombstone_propagates_to_peer() = runBlocking {115+        val (sa, sb) = duplex()116+        val a = FakeAdapter("A", version = 3).apply { tombstones["gone"] = 500 }117+        val b = FakeAdapter("B", version = 4).apply { put("gone", 100) }118+119+        val ra = async { runSyncInitiator(sa, a, nowMs = 0) }120+        val rb = async { runSyncResponder(sb, b, nowMs = 0) }121+        ra.await(); rb.await()122+123+        assertEquals(500L, b.tombstones["gone"])124+    }125+126+    @Test fun absent_device_manifest_is_explicit_null_and_peer_key_is_bound() = runBlocking {127+        val (sa, sb) = duplex()128+        val manifest = SignedDeviceManifest(129+            body = DeviceManifestBody(1, "A", 1_000L, emptyList()),130+            signatureHex = "ab".repeat(64),131+        )132+        val a = FakeAdapter("A", version = 1, supportsDeviceManifest = true).apply {133+            outgoingDeviceManifest = manifest134+        }135+        val b = FakeAdapter("B", version = 2, supportsDeviceManifest = true)136+137+        val ra = async { runSyncInitiator(sa, a, nowMs = 1_000L) }138+        val rb = async { runSyncResponder(sb, b, nowMs = 1_000L) }139+        assertEquals(SyncOutcomeStatus.COMPLETED, ra.await().status)140+        assertEquals(SyncOutcomeStatus.COMPLETED, rb.await().status)141+142+        assertNull(a.incomingDeviceManifest)143+        assertEquals(manifest, b.incomingDeviceManifest)144+        assertArrayEquals(ByteArray(32), b.incomingAuthenticatedKey!!)145+        val wire = decodeSyncMessage(SyncMessage.DeviceManifest(null).encodeToBytes())146+        assertEquals(SyncMessage.DeviceManifest(null), wire)147+    }148+}

view file

app/src/test/java/com/peachpasswords/sync/SyncSuiteTest.kt+208 −0
@@ -0,0 +1,208 @@1+package com.peachpasswords.sync2+3+import com.peachpasswords.vault.StoredVault4+import com.peachpasswords.vault.UnlockResult5+import com.peachpasswords.vault.VaultEntry6+import com.peachpasswords.vault.VaultRepository7+import com.peachpasswords.vault.VaultSettings8+import com.peachpasswords.vault.VaultStorage9+import kotlinx.coroutines.async10+import kotlinx.coroutines.runBlocking11+import kotlinx.coroutines.yield12+import org.junit.Assert.assertArrayEquals13+import org.junit.Assert.assertEquals14+import org.junit.Assert.assertFalse15+import org.junit.Assert.assertTrue16+import org.junit.Test17+import java.io.ByteArrayInputStream18+import java.io.ByteArrayOutputStream19+20+/**21+ * Sync suite (brief Suite 13): length-prefixed pairing frames, SAS22+ * derivation KAT, and cross-device entry sync through the [VaultSyncAdapter]23+ * (two repositories made to share a sync root key via the Codex blob).24+ */25+class SyncSuiteTest {26+27+    private class Storage : VaultStorage {28+        var slotA: StoredVault? = null29+        var slotB: String? = null30+        private var ver: Long = 031+        private var settings = VaultSettings()32+        override fun loadSlotA() = slotA33+        override fun hasVault() = slotA != null34+        override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }35+        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }36+        var decoy: StoredVault? = null37+        override fun loadDecoySlot() = decoy38+        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }39+        override fun hasDecoySlot() = decoy != null40+        override fun clearDecoySlot() { decoy = null }41+        override fun version() = ver42+        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }43+        override fun loadSettings() = settings44+        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }45+        override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }46+    }47+48+    // ── Pairing frames ────────────────────────────────────────────────────────49+50+    private fun roundTripFrame(payload: ByteArray): ByteArray {51+        val out = ByteArrayOutputStream()52+        writeFrame(out, payload)53+        return readFrame(ByteArrayInputStream(out.toByteArray()))54+    }55+56+    @Test fun frame_round_trips_small_payload() {57+        val p = "noise-handshake-msg".toByteArray()58+        assertArrayEquals(p, roundTripFrame(p))59+    }60+61+    @Test fun frame_round_trips_empty_payload() {62+        assertArrayEquals(ByteArray(0), roundTripFrame(ByteArray(0)))63+    }64+65+    @Test fun frame_round_trips_various_sizes() {66+        for (n in intArrayOf(1, 100, 4096, 65536)) {67+            val p = ByteArray(n) { (it % 251).toByte() }68+            assertArrayEquals("size $n", p, roundTripFrame(p))69+        }70+    }71+72+    @Test fun frame_round_trips_at_max_size() {73+        val p = ByteArray(MAX_PAIRING_FRAME_BYTES) { (it % 7).toByte() }74+        assertEquals(MAX_PAIRING_FRAME_BYTES, roundTripFrame(p).size)75+    }76+77+    @Test fun write_frame_rejects_oversize_payload() {78+        val out = ByteArrayOutputStream()79+        var threw = false80+        try { writeFrame(out, ByteArray(MAX_PAIRING_FRAME_BYTES + 1)) } catch (_: IllegalArgumentException) { threw = true }81+        assertTrue(threw)82+    }83+84+    @Test fun read_frame_rejects_truncated_body() {85+        // A header claiming 100 bytes but only 3 present must fail (EOF / mismatch).86+        val bytes = byteArrayOf(0, 0, 0, 100, 1, 2, 3)87+        var threw = false88+        try { readFrame(ByteArrayInputStream(bytes)) } catch (_: Exception) { threw = true }89+        assertTrue(threw)90+    }91+92+    // ── SAS derivation ────────────────────────────────────────────────────────93+94+    @Test fun sas_is_eight_digits_zero_padded() {95+        assertEquals("00000000", SASDerivation.derive(ByteArray(8)))96+        assertEquals("00000005", SASDerivation.derive(byteArrayOf(0, 0, 0, 0, 0, 0, 0, 5)))97+    }98+99+    @Test fun sas_kat_known_vectors() {100+        // First 8 bytes as an unsigned big-endian integer, mod 100_000_000.101+        assertEquals(102+            "52306183",103+            SASDerivation.derive(byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7)),104+        )105+        assertEquals("09551615", SASDerivation.derive(ByteArray(8) { 0xFF.toByte() }))106+    }107+108+    @Test fun sas_ignores_bytes_past_the_first_eight() {109+        val a = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9))110+        val b = SASDerivation.derive(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 0))111+        assertEquals(a, b)112+    }113+114+    @Test fun sas_requires_at_least_eight_bytes() {115+        var threw = false116+        try { SASDerivation.derive(ByteArray(7)) } catch (_: IllegalArgumentException) { threw = true }117+        assertTrue(threw)118+    }119+120+    @Test fun `HV-1 a far-future remote modifiedAt is clamped into the skew window`() = runBlocking {121+        val (a, _, _) = pairedDevices()122+        val local = a.addEntry(123+            VaultEntry(id = "", title = "Local", type = "login", password = "local-pw", createdAt = 0, modifiedAt = 0),124+        )125+126+        // A hostile peer claims an edit ten years in the future, trying to pin127+        // its version against every honest local edit for a decade.128+        val farFuture = System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000129+        a.applyMergedRemoteEntries(130+            listOf(local.copy(title = "Injected", password = "attacker-pw", modifiedAt = farFuture)),131+            emptyList(),132+        )133+134+        // The edit still lands (it is genuinely newer) but only within the skew135+        // window, so the monotonic local entry clock can always advance past it.136+        // The overtake itself is not exercised here: updateEntry would push137+        // VaultCrypto's process-global monotonic counter forward and skew every138+        // later test in the same JVM.139+        val pinned = a.getEntry(local.id)!!140+        assertTrue(141+            "remote modifiedAt must be clamped into the skew window",142+            pinned.modifiedAt <= System.currentTimeMillis() + SyncLimits.MAX_CLOCK_SKEW_MS,143+        )144+        assertTrue("clamped value must be far below the claim", pinned.modifiedAt < farFuture)145+    }146+147+    @Test fun sas_gate_never_completes_without_explicit_user_confirmation() = runBlocking {148+        val session = PairingSession()149+        val waiter = async { session.await() }150+        yield()151+        assertFalse("SAS session must not auto-confirm", waiter.isCompleted)152+        session.confirm(true)153+        assertTrue(waiter.await())154+    }155+156+    // ── Cross-device entry sync (shared root key via Codex) ───────────────────157+158+    private fun pairedDevices(): Triple<VaultRepository, VaultRepository, Pair<VaultSyncAdapter, VaultSyncAdapter>> {159+        val a = VaultRepository(Storage())160+        runBlocking { a.createVault("shared-master-pass") }161+        // Device B restores A's blob → identical sync root key, so entry keys match.162+        val b = VaultRepository(Storage())163+        runBlocking { assertEquals(UnlockResult.Success, b.restoreFromCodex(a.codexVaultBlobBase64()!!, "shared-master-pass")) }164+        return Triple(a, b, VaultSyncAdapter(a, "dev-a") to VaultSyncAdapter(b, "dev-b"))165+    }166+167+    @Test fun paired_devices_share_a_vault_id() = runBlocking {168+        val (_, _, adapters) = pairedDevices()169+        assertEquals(adapters.first.computeVaultId(), adapters.second.computeVaultId())170+    }171+172+    @Test fun entry_added_on_a_syncs_to_b() = runBlocking {173+        val (a, b, adapters) = pairedDevices()174+        val (adapterA, adapterB) = adapters175+        val e = a.addEntry(VaultEntry(id = "", title = "GitHub", type = "login", password = "p", createdAt = 0, modifiedAt = 0))176+        val envelopes = adapterA.getEncryptedEntries(listOf(e.id))177+        adapterB.applyEntries(envelopes)178+        assertEquals("GitHub", b.getEntry(e.id)?.title)179+        assertEquals("p", b.getEntry(e.id)?.password)180+    }181+182+    @Test fun tombstone_from_a_deletes_on_b() = runBlocking {183+        val (a, b, adapters) = pairedDevices()184+        val (adapterA, adapterB) = adapters185+        val e = a.addEntry(VaultEntry(id = "", title = "Temp", type = "login", password = "x", createdAt = 0, modifiedAt = 0))186+        adapterB.applyEntries(adapterA.getEncryptedEntries(listOf(e.id)))187+        assertEquals(1, b.getEntries().size)188+        a.deleteEntry(e.id)189+        adapterB.applyTombstones(adapterA.buildTombstoneManifest())190+        assertEquals(0, b.getEntries().size)191+    }192+193+    @Test fun apply_tombstones_ignores_non_positive_timestamps() = runBlocking {194+        val (a, _, adapters) = pairedDevices()195+        val e = a.addEntry(VaultEntry(id = "", title = "Keep", type = "login", password = "x", createdAt = 0, modifiedAt = 0))196+        // A zero/negative tombstone timestamp is filtered out, so the entry survives.197+        adapters.first.applyTombstones(mapOf(e.id to 0L))198+        assertEquals(1, a.getEntries().size)199+    }200+201+    @Test fun swapped_envelope_under_wrong_id_is_dropped() = runBlocking {202+        val (a, b, adapters) = pairedDevices()203+        val e = a.addEntry(VaultEntry(id = "", title = "Bank", type = "login", password = "x", createdAt = 0, modifiedAt = 0))204+        val blob = adapters.first.getEncryptedEntries(listOf(e.id)).getValue(e.id)205+        adapters.second.applyEntries(mapOf("forged-id" to blob))206+        assertFalse(b.getEntries().any { it.id == "forged-id" })207+    }208+}

view file

app/src/test/java/com/peachpasswords/sync/VaultAdoptTest.kt+304 −0
@@ -0,0 +1,304 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.async4+import kotlinx.coroutines.channels.Channel5+import kotlinx.coroutines.runBlocking6+import kotlinx.coroutines.withTimeoutOrNull7+import org.junit.Assert.assertEquals8+import org.junit.Assert.assertFalse9+import org.junit.Assert.assertNull10+import org.junit.Assert.assertTrue11+import org.junit.Test12+13+/**14+ * Adoption frame protocol parity with the browser `vault-adopt.test.ts`. The15+ * receiver must verify the granted root key derives the advertised vault id16+ * BEFORE sealing a vault, and a denied consent must hand over nothing.17+ */18+class VaultAdoptTest {19+20+    private fun memoryPair(): Pair<PairingTransport, PairingTransport> {21+        val a2b = Channel<ByteArray>(Channel.UNLIMITED)22+        val b2a = Channel<ByteArray>(Channel.UNLIMITED)23+        val a = object : PairingTransport {24+            override suspend fun send(msg: ByteArray) { a2b.send(msg) }25+            override suspend fun receive(): ByteArray = b2a.receive()26+            override suspend fun close() {}27+        }28+        val b = object : PairingTransport {29+            override suspend fun send(msg: ByteArray) { b2a.send(msg) }30+            override suspend fun receive(): ByteArray = a2b.receive()31+            override suspend fun close() {}32+        }33+        return a to b34+    }35+36+    private fun identity(name: String) = LocalDeviceIdentity(37+        deviceId = "$name-id",38+        deviceName = name,39+        deviceType = DeviceType.EXTENSION,40+        longTermPublicKeyHex = "ab".repeat(32),41+        signingPublicKeyHex = "cd".repeat(32),42+    )43+44+    private fun vaultIdFor(rootKeyHex: String): String {45+        val bytes = hexToPubkey(rootKeyHex)46+        return try {47+            computeVaultId(bytes)48+        } finally {49+            bytes.fill(0)50+        }51+    }52+53+    private class RecorderReceiver(54+        override val deviceName: String,55+        override val deviceType: DeviceType,56+        private val vaultId: String?,57+        private val createThrows: Boolean = false,58+        private val derived: LocalDeviceIdentity? = null,59+        private val sasConfirmed: Boolean = true,60+    ) : AdoptionReceiverDeps {61+        var createCalledWith: String? = null62+        var licenseApplied: String? = null63+        val upserted = mutableListOf<PairedDevice>()64+        val errors = mutableListOf<String>()65+        override fun generateDeviceId(): String = "receiver-ephemeral"66+        override suspend fun confirmSas(): Boolean = sasConfirmed67+        override suspend fun createVaultFromRootKey(rootKeyHex: String): String {68+            createCalledWith = rootKeyHex69+            if (createThrows) throw IllegalStateException("commit_failed")70+            return vaultId ?: error("no vault id")71+        }72+        override suspend fun deriveLocalIdentity(): LocalDeviceIdentity = derived ?: error("no identity")73+        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }74+        override suspend fun applyLicense(license: String) { licenseApplied = license }75+        override fun onError(error: String) { errors += error }76+    }77+78+    private class RecorderSource(79+        override val sourceIdentity: LocalDeviceIdentity,80+        private val rootKeyHex: String,81+        private val vaultId: String,82+        private val license: String? = null,83+        private val consent: Boolean = true,84+    ) : AdoptionSourceDeps {85+        val upserted = mutableListOf<PairedDevice>()86+        var consentAsked = false87+        var rootRequested = false88+        override suspend fun rootKeyHex(): String {89+            rootRequested = true90+            return rootKeyHex91+        }92+        override suspend fun vaultId(): String = vaultId93+        override suspend fun license(): String? = license94+        override suspend fun onConsent(request: AdoptionFrame.Request): Boolean {95+            consentAsked = true96+            return consent97+        }98+        override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }99+    }100+101+    @Test fun completes_request_grant_identity_round_trip() = runBlocking {102+        val (receiverCh, sourceCh) = memoryPair()103+        val rootKeyHex = "11".repeat(32)104+        val vaultId = vaultIdFor(rootKeyHex)105+        val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)106+        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, derived = receiverIdentity)107+        val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")108+109+        val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } }110+        val grantJob = async {111+            val request = receiveFrame(sourceCh) as AdoptionFrame.Request112+            grantAdoption(sourceCh, request, source) { 222L }113+        }114+        recvJob.await()115+        grantJob.await()116+117+        assertEquals(rootKeyHex, receiver.createCalledWith)118+        assertEquals("PPK-test", receiver.licenseApplied)119+        assertEquals("source-id", receiver.upserted.single().deviceId)120+        assertEquals("receiver-id", source.upserted.single().deviceId)121+        assertEquals("android", source.upserted.single().deviceType)122+    }123+124+    /**125+     * The constraint from `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2: the label126+     * on ADOPT_REQUEST is generated before any key exists, so persisting it as127+     * an identity would key trust on a value with no continuity between128+     * attempts. What lands in the store must come from ADOPT_IDENTITY.129+     */130+    @Test fun request_label_is_never_what_gets_persisted() = runBlocking {131+        val (receiverCh, sourceCh) = memoryPair()132+        val rootKeyHex = "55".repeat(32)133+        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))134+        // Deliberately unlike the identity the receiver will present.135+        val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension")136+137+        val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } }138+        sendFrame(receiverCh, AdoptionFrame.Confirm(true))139+        receiveFrame(receiverCh) // the grant140+        sendFrame(141+            receiverCh,142+            AdoptionFrame.Identity(143+                deviceId = "receiver-id",144+                deviceName = "Receiver",145+                deviceType = "extension",146+                longTermPublicKeyHex = "aa".repeat(32),147+                signingPublicKeyHex = "bb".repeat(32),148+            ),149+        )150+        grantJob.await()151+152+        val persisted = source.upserted.single()153+        assertEquals("receiver-id", persisted.deviceId)154+        assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex)155+        assertTrue(156+            "the ephemeral request label must not reach the paired-device store",157+            source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" },158+        )159+    }160+161+    @Test fun request_carries_receiver_device_type() = runBlocking {162+        val (receiverCh, sourceCh) = memoryPair()163+        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)164+        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }165+        val request = receiveFrame(sourceCh) as AdoptionFrame.Request166+        recvJob.cancel()167+168+        assertEquals("android", request.deviceType)169+        assertEquals("receiver-ephemeral", request.sessionRequestId)170+        assertEquals("Receiver", request.deviceName)171+    }172+173+    @Test fun denied_consent_grants_nothing() = runBlocking {174+        val (receiverCh, sourceCh) = memoryPair()175+        val rootKeyHex = "33".repeat(32)176+        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))177+        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)178+179+        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }180+        val request = receiveFrame(sourceCh) as AdoptionFrame.Request181+        val granted = grantAdoption(sourceCh, request, source) { 0L }182+        recvJob.cancel()183+184+        assertNull(granted)185+        assertTrue(source.consentAsked)186+        assertFalse(source.rootRequested)187+        assertTrue(source.upserted.isEmpty())188+        assertNull(receiver.createCalledWith)189+    }190+191+    @Test fun receiver_sas_rejection_grants_no_root_key() = runBlocking {192+        val (receiverCh, sourceCh) = memoryPair()193+        val rootKeyHex = "66".repeat(32)194+        val receiver = RecorderReceiver(195+            "Receiver",196+            DeviceType.ANDROID,197+            vaultIdFor(rootKeyHex),198+            sasConfirmed = false,199+        )200+        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))201+202+        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }203+        val request = receiveFrame(sourceCh) as AdoptionFrame.Request204+        val sourceJob = async { grantAdoption(sourceCh, request, source) { 0L } }205+206+        assertNull(receiverJob.await())207+        assertNull(sourceJob.await())208+        assertFalse(source.rootRequested)209+        assertNull(receiver.createCalledWith)210+        assertEquals(listOf("ADOPT_SAS_REJECTED"), receiver.errors)211+    }212+213+    @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {214+        val (receiverCh, sourceCh) = memoryPair()215+        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)216+        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }217+        receiveFrame(sourceCh)218+        sendFrame(sourceCh, AdoptionFrame.Grant(219+            rootKeyHex = "bad",220+            vaultId = "00".repeat(32),221+            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),222+        ))223+        recvJob.await()224+225+        assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)226+        assertNull(receiver.createCalledWith)227+    }228+229+    @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {230+        val (receiverCh, sourceCh) = memoryPair()231+        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)232+        val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }233+        receiveFrame(sourceCh)234+        sendFrame(sourceCh, AdoptionFrame.Grant(235+            rootKeyHex = "22".repeat(32),236+            vaultId = "00".repeat(32),237+            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),238+        ))239+        recvJob.await()240+241+        assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)242+        assertNull(receiver.createCalledWith)243+    }244+245+    @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {246+        val (receiverCh, sourceCh) = memoryPair()247+        val rootKeyHex = "33".repeat(32)248+        val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))249+        val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")250+251+        sendFrame(receiverCh, AdoptionFrame.Confirm(true))252+        val result = withTimeoutOrNull(150) {253+            // No receiver answers the ADOPT_IDENTITY read, so grant must hang, not persist.254+            grantAdoption(sourceCh, request, source) { 0L }255+        }256+257+        assertNull(result)258+        assertTrue(source.upserted.isEmpty())259+    }260+261+    @Test fun does_not_apply_license_when_vault_creation_fails() = runBlocking {262+        val (receiverCh, sourceCh) = memoryPair()263+        val rootKeyHex = "44".repeat(32)264+        val vaultId = vaultIdFor(rootKeyHex)265+        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true)266+        val recvJob = async {267+            runCatching { requestAdoption(receiverCh, receiver) { 0L } }268+        }269+        receiveFrame(sourceCh)270+        sendFrame(sourceCh, AdoptionFrame.Grant(271+            rootKeyHex = rootKeyHex,272+            vaultId = vaultId,273+            license = "PPK-test",274+            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),275+        ))276+        val outcome = recvJob.await()277+278+        assertTrue(outcome.isFailure)279+        assertNull(receiver.licenseApplied)280+    }281+282+    @Test fun validator_rejects_short_and_uppercase_root_keys() {283+        assertTrue(validateRootKeyHex("ab".repeat(32)))284+        assertFalse(validateRootKeyHex("ab".repeat(31)))285+        assertFalse(validateRootKeyHex("AB".repeat(32)))286+        assertFalse(validateRootKeyHex("zz".repeat(32)))287+        assertFalse(validateRootKeyHex(null))288+    }289+290+    @Test fun grant_omits_absent_optionals_like_json_stringify() {291+        val grant = AdoptionFrame.Grant(292+            rootKeyHex = "11".repeat(32),293+            vaultId = "22".repeat(32),294+            source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),295+        )296+        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)297+298+        assertFalse(json.contains("license"))299+        assertFalse(json.contains("ws_port"))300+        assertFalse(json.contains("signing_public_key_hex"))301+        assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))302+        assertTrue(json.contains("\"root_key_hex\""))303+    }304+}

view file