# C7 — Chirp Sync Transport (TCP + mDNS + Noise NK): Integration Note Draft implementation of spec C7. Five Kotlin files plus this note, all under `app/drafts/C7/`. Package is already `com.peachpasswords.sync`, so the source files drop straight into the sync tree. **No existing file was edited** — every shared file (ChirpPairing.kt, NoiseNK.kt, VaultSyncAdapter.kt, ChirpSyncCoordinator.kt, ChirpPairViewModel.kt, SyncViewModel.kt, SettingsCardBodies.kt, VaultModule.kt, AndroidManifest.xml, build.gradle) was read only. > **Read this first.** The C7 brief substantially overstates what is missing and > invents the entire crypto surface. The transport primitives the brief assumes > are absent (Noise NK handshake, identity frames, per-entry sync, SAS, > PairedDevice) **all already exist** in `com.peachpasswords.sync`. The brief's > proposed `_peach._tcp` mDNS + `ServerSocket` design is also an **alternative > transport** to the one already shipped (audio-chirp ephemeral key exchange + > WebSocket/subnet-probe sync). These drafts add the TCP+mDNS transport on top of > the **real** crypto/sync APIs, and flag every divergence below. --- ## Files to MOVE into the source set | Draft file | Destination | Contents | |---|---|---| | `PairedDeviceStore.kt` | `sync/PairedDeviceStore.kt` | `@Singleton` EncryptedSharedPreferences registry of the REAL `PairedDevice`; `getAll/get/upsert/remove/clear/updateLastSeen/updateLastSync/observeAll` | | `PairingFrames.kt` | `sync/PairingFrames.kt` | `writeFrame/readFrame` (4-byte BE length prefix), `SocketFrameIO` (implements the real `PairingTransport` interface over a TCP socket), `sendEncrypted/receiveEncrypted` | | `PairingServer.kt` | `sync/PairingServer.kt` | TCP `ServerSocket` + `NsdManager` `_peach._tcp` advertisement; `noiseNKResponder` handshake; `runChirpSyncResponder` round; `PairingServerEvent`. **Name clash, see below.** | | `PairingTransport.kt` | `sync/PairingDiscoveryClient.kt` | `NsdManager` discovery (`callbackFlow`) + TCP `connectAndSync` initiator; `DiscoveredPeer`, `PairingResult`, `PairingError`. **Renamed, see below.** | | `C7-ChirpTransportTest.kt` | `app/src/test/java/com/peachpasswords/sync/C7ChirpTransportTest.kt` | 5 pure-JVM/Piped-stream tests | `C7-INTEGRATION.md` stays in drafts — it is not source. ### Name collisions the orchestrator MUST resolve 1. **`PairingServer`** — the brief wants a `@Singleton class PairingServer`. A `data class PairingServer(val transport, val peerIp)` **already exists** in `ChirpPairing.kt`. The draft's class would collide. Options: - (preferred) rename the draft class to **`PairingListener`** / **`MdnsPairingServer`** and keep the existing `data class PairingServer`; or - rename the existing data class (it is only used inside `ChirpPairingDeps`). The draft is written as `class PairingServer` per the brief; pick a rename before merge or the module will not compile. 2. **`PairingTransport`** — the brief wants a `@Singleton class PairingTransport`. `PairingTransport` is **already an interface** in `ChirpPairing.kt` (`send/receive/close`) consumed by the existing handshake. The draft file is therefore authored as **`class PairingDiscoveryClient`** (destination filename `PairingDiscoveryClient.kt`) to avoid shadowing the interface. `SocketFrameIO` in `PairingFrames.kt` **implements that real interface**, so the existing `runPairingInitiator`/`runPairingResponder` could drive a TCP socket unchanged if those private functions are promoted (see item 4 below). --- ## TOP API DISCREPANCIES (brief vs reality) ### 1. NoiseNK — the single most important reconciliation The brief assumes a stateful `NoiseNK` object: ```kotlin val handshake = noiseNK.acceptHandshake(stream, out) // server val handshake = noiseNK.initiateHandshake(stream, out, remotePub) // client handshake.sendCipher; handshake.recvCipher; handshake.handshakeHash ``` **None of this exists.** `NoiseNK.kt` exposes two **suspend free functions**: ```kotlin suspend fun noiseNKInitiator( responderStaticPublicKey: ByteArray, send: suspend (ByteArray) -> Unit, receive: suspend () -> ByteArray, ): NoiseNKSession suspend fun noiseNKResponder( ourStaticPrivateKey: ByteArray, ourStaticPublicKey: ByteArray, send: suspend (ByteArray) -> Unit, receive: suspend () -> ByteArray, ): NoiseNKSession ``` `NoiseNKSession` has **one bidirectional cipher** — `encrypt(plaintext)`, `decrypt(ciphertext)`, and `val handshakeHash` — **not** split `sendCipher`/`recvCipher`. Bound to: - The drafts pass `transport::send` / `transport::receive` (where `transport` is a `SocketFrameIO`) as the handshake's `send`/`receive`. The handshake messages are themselves length-prefixed frames. - All post-handshake encryption uses `session.encrypt` / `session.decrypt`. - Noise **NK** = responder has a static key, initiator does not; the initiator must already KNOW the responder's static public key. The drafts use the device's **long-term sync identity** (`deriveSyncIdentityKeypair(masterKey, deviceId)`) as the responder static, and publish its public half in the mDNS TXT `pk` record so the initiator can pin it. (The brief's `remotePublicKey` "from TXT pk" maps onto this exactly — but it is the *responder's static*, not an ephemeral.) - SAS: `SASDerivation.derive(session.handshakeHash)` (the existing helper; there is also `derivePairingSAS(hash)`). The brief's `deriveSAS(...)` does not exist. ### 2. VaultSyncAdapter — no buildRequest/applyRequest/buildResponse/applyResponse The brief calls `syncAdapter.buildResponse(req)` / `applyRequest(req)` / `buildRequest()` / `applyResponse(resp)`. **None exist.** `VaultSyncAdapter` implements `ChirpSyncVaultAdapter` (`computeVaultId`, `getOurDeviceId`, `getLocalVersion`, `buildEntryManifest`, `buildTombstoneManifest`, `getEncryptedEntries`, `applyEntries`, `applyTombstones`). The actual sync is a **full multi-message round** driven by the existing free functions: ```kotlin suspend fun runChirpSyncInitiator(session: ChirpSyncSession, vault: ChirpSyncVaultAdapter): SyncOutcome suspend fun runChirpSyncResponder(session: ChirpSyncSession, vault: ChirpSyncVaultAdapter): SyncOutcome ``` over a `ChirpSyncSession(remoteStaticPublicKey, send, receive, close)`. Bound to: - `PairingServer` runs `runChirpSyncResponder`; `PairingDiscoveryClient` runs `runChirpSyncInitiator`. Both build a `ChirpSyncSession` via `chirpSessionOver(noiseSession, socketFrameIO)` — `send` = `encrypt → frame`, `receive` = `frame → decrypt`. `ChirpSyncSession.send` is **non-suspending** (matches `openChirpSyncSession`), so `SocketFrameIO.sendBlocking` is used there, not the suspend `send`. - The adapter is built per round with `VaultSyncAdapter(repo, deviceId)`; pass a `vaultFactory: () -> ChirpSyncVaultAdapter?` (null when the vault is locked). ### 3. PairedDevice / TrustStatus — already exist, different shape The brief redefines `PairedDevice(id, name, publicKey: ByteArray, lastSeen, trustStatus, lastSyncAt)` and a `TrustStatus { TRUSTED, UNVERIFIED, REVOKED }` enum. **The real `PairedDevice` (PairedDevice.kt) is reused verbatim** and has a different shape: ```kotlin @Serializable data class PairedDevice( val deviceId: String, val deviceName: String?, val deviceType: String?, val longTermPublicKeyHex: String?, val signingPublicKeyHex: String?, val trustSource: String?, val wsPort: Int?, val pairedAt: Long, val lastSyncedAt: Long?, val lastKnownIp: String?, ) ``` Field mapping (brief → real): - `id` → `deviceId`; `name` → `deviceName`; `publicKey: ByteArray` → `longTermPublicKeyHex: String?` (hex, not Base64/ByteArray); `lastSyncAt` → `lastSyncedAt`. - `lastSeen` → **no equivalent**. The store's `updateLastSeen(id, ip)` refreshes `lastKnownIp` instead (closest real analogue). If a real "last seen" timestamp is wanted, **that is an integration delta**: add `val lastSeen: Long? = null` to `PairedDevice` (defaulted so serialization stays backward-compatible). - `trustStatus: TrustStatus` → **no enum exists**; trust is `trustSource: String?` ("direct" / "manifest"). **Integration delta if a tri-state TRUSTED/UNVERIFIED/ REVOKED model is required:** add an enum + field to `PairedDevice` (defaulted). The drafts set `trustSource = "direct"` on pair, matching `ChirpPairing.frameToPairedDevice`. The brief's `confirmSAS → TRUSTED` / `rejectSAS → remove` flow maps onto: keep the device on confirm, `remove(id)` on reject. If you add the enum, set it to TRUSTED on confirm. `TrustBadge`, `GhostButton`, `ChirpPairUiState`, `SyncStatus.Busy`, and `syncAdapter.applyResponse` referenced by the brief's UI/VM snippets **do not exist either** — see the wiring sections. ### 4. (architectural) The mDNS+ServerSocket transport is NEW; the chirp transport already ships `ChirpPairing.runChirpPairing(...)` already implements an end-to-end pairing flow: ggwave **audio chirp** carries the peer's ephemeral X25519 key, then `noiseNKInitiator/Responder` runs over the `PairingTransport` interface, identity `PAIR_IDENTITY` frames are exchanged (`encodePairingFrame`/`decodePairingFrame`, both **file-private**), and `SASDerivation.derive` produces the SAS. Sync uses `openChirpSyncSession` (WebSocket) + subnet probe (`probeSubnetForPeer`). The C7 brief's mDNS+`ServerSocket` path is a **second discovery/transport** that reuses the same Noise+sync primitives. The drafts re-implement the `PAIR_IDENTITY` encode/parse as `internal` shims (`encodePairIdentityFrame`/ `parsePairIdentityFrame` in `PairingServer.kt`) **because the originals are private**. **Cleaner integration:** promote `ChirpPairing`'s `encodePairingFrame`/`decodePairingFrame`/`frameToPairedDevice` from `private` to `internal` and delete the draft shims, so there is exactly one identity-frame codec. Likewise consider promoting `runPairingInitiator`/`runPairingResponder` to `internal` so the TCP transport can reuse them via `SocketFrameIO` instead of re-deriving the handshake-then-identity sequence. --- ## Shared-file edits the orchestrator must make ### ChirpPairViewModel (`ui/pairing/ChirpPairViewModel.kt`) The brief's rewrite (`ChirpPairUiState.Listening/Handshake/VerifySAS/Paired/...`, `uiState =`) does **not** match reality. The real VM exposes: - `val progress: StateFlow` where `PairingProgress(phase: PairingPhase, peerDeviceName: String?, errorMessage: String?)` and `PairingPhase` ∈ {IDLE, LISTENING, CHIRPING, HANDSHAKE, PAIRED, TIMEOUT, MIC_DENIED, VERSION_MISMATCH, HANDSHAKE_FAILED} (PairingProgress.kt). - `val sasCode: StateFlow` and `fun confirmSas(accepted: Boolean)` (a `CompletableDeferred` gate) — NOT separate `confirmSAS()/rejectSAS()`. - `fun startPairing()` already runs the real `runChirpPairing(deps(master), role = INITIATOR)`. The brief's "`runChirpPairing()` throws UnsupportedOperationException" is **stale** — `runChirpPairing` is fully implemented. What is actually stubbed is two methods on the VM's `ChirpPairingDeps`: ```kotlin override suspend fun completePairing(paired: PairedDevice) { /* TODO */ } override suspend fun openPairingTransport(endpoint): PairingTransport = throw UnsupportedOperationException("...until sync persistence") override suspend fun openPairingServer(): PairingServer = throw UnsupportedOperationException("...until sync persistence") ``` **Minimal real wiring (no UI-state rewrite needed):** 1. Inject `PairedDeviceStore` (and `PairingDiscoveryClient` / the renamed `PairingServer`) into `ChirpPairViewModel`. 2. Implement `completePairing(paired)` → `deviceStore.upsert(paired)`. 3. Implement `openPairingTransport(endpoint)` → return a `SocketFrameIO` over `Socket(endpoint.ip, endpoint.port)` (this satisfies the existing `PairingTransport` interface and unblocks `runResponderHandshake`). 4. Implement `openPairingServer()` → bind a `ServerSocket`, `accept()` one client, wrap it in `SocketFrameIO`, return `PairingServer(transport, peerIp)` (the existing data class). (Or fold this into the new mDNS server.) 5. SAS confirm already persists via `confirmSas(true)` → `completePairing`; on `confirmSas(false)` the flow throws `sas_rejected` and nothing is stored (matches the brief's `rejectSAS` intent). > If the product genuinely wants the brief's mDNS pairing instead of chirp, that > is a larger VM change: add a path that calls > `PairingDiscoveryClient.discoverPeers().first()` → `connectAndSync(...)` → > surface SAS through `sasCode`. That is **net-new UI behavior**, not a one-line > swap; flag for product before building. ### SyncViewModel (`ui/sync/SyncViewModel.kt`) The brief's `SyncStatus.Busy`, `deviceStore.observeAll().stateIn(...)`, `transport.discoverPeers(5_000L)`, `connectAndSync(peer)` map onto reality as: - `SyncStatus` is `{ Idle, Syncing, LastSynced(ts), Error(msg), Offline }` — use `Syncing` where the brief says `Busy`. - Add to the VM: ```kotlin // inject PairedDeviceStore + PairingDiscoveryClient val pairedDevices: StateFlow> = deviceStore.observeAll() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) } fun syncNow() = viewModelScope.launch { val master = vault.syncMasterKey() ?: return@launch val identity = buildLocalIdentity(context, master) _status.value = SyncStatus.Syncing pairedDevices.value.forEach { dev -> discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter) } VaultCrypto.zero(master) _status.value = SyncStatus.Idle } ``` `observeAll()` is provided by `PairedDeviceStore`. `buildAdapter()` already exists. `syncWithKnownDevice` is on `PairingDiscoveryClient`. (`triggerSync()` already returns `Offline` when there is nothing to dial — keep it, or route it through `syncNow`.) ### SettingsCardBodies.SyncBody (`ui/settings/SettingsCardBodies.kt`) The brief's `GhostButton`, `TrustBadge`, `PeachType.bodyMono/labelMono/ settingsLede`, `pairedDevices.forEach`, `vm.syncNow()` snippet does **not** match the file's vocabulary. Reality: - `SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit)` is a **private @Composable** with NO ViewModel and NO device list today — it shows static PAIRING/DEVICE FLOW rows and navigates to `PeachNav.ChirpPair`. - The real helpers are `SubLabel`, `RowLine`, `RowScope.Name`, `ValueText`, `SubtleButton`, `Hint`, `ProBadge` — **not** `GhostButton`/`TrustBadge`. - `PeachType.bodyMono`, `PeachType.labelMono`, `PeachType.settingsLede` **do exist** (PeachType.kt) — those three names are correct. **To show real devices:** change `SyncBody`'s signature to take the VM (or a `pairedDevices: List` + callbacks) and render with the existing helpers, e.g.: ```kotlin if (pairedDevices.isEmpty()) { Text("No paired devices yet.", color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 12.sp) } else pairedDevices.forEach { d -> RowLine(fg) { Name(d.deviceName ?: d.deviceId, fg) ValueText(d.lastSyncedAt?.let { "synced" } ?: "never", fg) } } if (pairedDevices.isNotEmpty()) RowLine(fg) { Name("Sync", fg); SubtleButton("sync now", fg) { vm.syncNow() } } ``` Use a `SubtleButton` (real) rather than `GhostButton` (nonexistent). Trust state: render `d.trustSource` as text, or add a real `TrustBadge` composable if the enum delta (§3) is adopted. The caller of `SyncBody` lives in this same file's card dispatcher — pass the VM/state through. ### Hilt (`di/VaultModule.kt` or a new module) `PairedDeviceStore`, `PairingServer`/`PairingListener`, and `PairingDiscoveryClient` are all `@Singleton class ... @Inject constructor(...)` with only `@ApplicationContext Context` + other `@Inject` singletons — so Hilt constructs them with **no `@Provides`/`@Binds` needed**. `VaultModule` is a `@Binds` module and needs no change. (Add providers only if you want to inject them behind an interface.) ### AndroidManifest.xml `INTERNET` is **already present** (line 5). Add only: ```xml ``` `CHANGE_WIFI_MULTICAST_STATE` is required for mDNS/NSD multicast on Wi-Fi. (NsdManager on recent Android also benefits from acquiring a `WifiManager.MulticastLock` at runtime — consider adding one around discovery.) ### build.gradle **No new dependencies.** `androidx.security:security-crypto:1.1.0-alpha06` (line 87) and `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1` (line 107) are present; `NsdManager`/`ServerSocket`/`Socket` are framework/JDK. The test file uses only JUnit + kotlinx-coroutines + BouncyCastle, all already on the test classpath (see `ChirpSyncTransportTest`). --- ## Tests (`C7-ChirpTransportTest.kt`) Pure-JVM, no Robolectric, matching `ChirpSyncTransportTest`'s idiom: 1. `PairedDevice` list JSON round-trip (the store's serialization layer). 2. `upsert`-by-`deviceId` replaces rather than duplicates (store list semantics). 3. `writeFrame`/`readFrame` round-trip across sizes (0, 1, 31, 64, 1500, 65537). 4. Full `noiseNKResponder`↔`noiseNKInitiator` handshake + encrypted `PAIR_IDENTITY` exchange over cross-wired `PipedInputStream/PipedOutputStream` via `SocketFrameIO`; asserts both sides derive the **same SAS** and the server decodes the client identity. 5. Wrong responder static key → initiator handshake rejects (NK auth tag) → `PairingError.HANDSHAKE_FAILED` path. `PairedDeviceStore`'s on-disk persistence (EncryptedSharedPreferences) needs an Android `Context`; cover that in an instrumentation test (`androidTest`) — out of scope for the unit suite, consistent with how the project tests other EncryptedSharedPreferences stores.