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
↓ peach-android-55df24f3.tar.gzatlas at this save
files changed42 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 −0app/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 {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 }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 }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-}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-}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-)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.wireByteapp/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())) },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())) },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+}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.wireByteapp/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,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\"")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.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))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-)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()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) {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+}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.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+)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 }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()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`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 {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()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,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 )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()) {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+}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-}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+}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-}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-}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-}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,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,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,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+}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+}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+}