1# C7 — Chirp Sync Transport (TCP + mDNS + Noise NK): Integration Note23Draft implementation of spec C7. Five Kotlin files plus this note, all under4`app/drafts/C7/`. Package is already `com.peachpasswords.sync`, so the source5files drop straight into the sync tree. **No existing file was edited** — every6shared file (ChirpPairing.kt, NoiseNK.kt, VaultSyncAdapter.kt,7ChirpSyncCoordinator.kt, ChirpPairViewModel.kt, SyncViewModel.kt,8SettingsCardBodies.kt, VaultModule.kt, AndroidManifest.xml, build.gradle) was9read only.1011> **Read this first.** The C7 brief substantially overstates what is missing and12> invents the entire crypto surface. The transport primitives the brief assumes13> are absent (Noise NK handshake, identity frames, per-entry sync, SAS,14> PairedDevice) **all already exist** in `com.peachpasswords.sync`. The brief's15> proposed `_peach._tcp` mDNS + `ServerSocket` design is also an **alternative16> transport** to the one already shipped (audio-chirp ephemeral key exchange +17> WebSocket/subnet-probe sync). These drafts add the TCP+mDNS transport on top of18> the **real** crypto/sync APIs, and flag every divergence below.1920---2122## Files to MOVE into the source set2324| Draft file | Destination | Contents |25|---|---|---|26| `PairedDeviceStore.kt` | `sync/PairedDeviceStore.kt` | `@Singleton` EncryptedSharedPreferences registry of the REAL `PairedDevice`; `getAll/get/upsert/remove/clear/updateLastSeen/updateLastSync/observeAll` |27| `PairingFrames.kt` | `sync/PairingFrames.kt` | `writeFrame/readFrame` (4-byte BE length prefix), `SocketFrameIO` (implements the real `PairingTransport` interface over a TCP socket), `sendEncrypted/receiveEncrypted` |28| `PairingServer.kt` | `sync/PairingServer.kt` | TCP `ServerSocket` + `NsdManager` `_peach._tcp` advertisement; `noiseNKResponder` handshake; `runChirpSyncResponder` round; `PairingServerEvent`. **Name clash, see below.** |29| `PairingTransport.kt` | `sync/PairingDiscoveryClient.kt` | `NsdManager` discovery (`callbackFlow`) + TCP `connectAndSync` initiator; `DiscoveredPeer`, `PairingResult`, `PairingError`. **Renamed, see below.** |30| `C7-ChirpTransportTest.kt` | `app/src/test/java/com/peachpasswords/sync/C7ChirpTransportTest.kt` | 5 pure-JVM/Piped-stream tests |3132`C7-INTEGRATION.md` stays in drafts — it is not source.3334### Name collisions the orchestrator MUST resolve35361. **`PairingServer`** — the brief wants a `@Singleton class PairingServer`. A37 `data class PairingServer(val transport, val peerIp)` **already exists** in38 `ChirpPairing.kt`. The draft's class would collide. Options:39 - (preferred) rename the draft class to **`PairingListener`** /40 **`MdnsPairingServer`** and keep the existing `data class PairingServer`; or41 - rename the existing data class (it is only used inside `ChirpPairingDeps`).42 The draft is written as `class PairingServer` per the brief; pick a rename43 before merge or the module will not compile.442. **`PairingTransport`** — the brief wants a `@Singleton class PairingTransport`.45 `PairingTransport` is **already an interface** in `ChirpPairing.kt`46 (`send/receive/close`) consumed by the existing handshake. The draft file is47 therefore authored as **`class PairingDiscoveryClient`** (destination filename48 `PairingDiscoveryClient.kt`) to avoid shadowing the interface. `SocketFrameIO`49 in `PairingFrames.kt` **implements that real interface**, so the existing50 `runPairingInitiator`/`runPairingResponder` could drive a TCP socket unchanged51 if those private functions are promoted (see item 4 below).5253---5455## TOP API DISCREPANCIES (brief vs reality)5657### 1. NoiseNK — the single most important reconciliation5859The brief assumes a stateful `NoiseNK` object:60```kotlin61val handshake = noiseNK.acceptHandshake(stream, out) // server62val handshake = noiseNK.initiateHandshake(stream, out, remotePub) // client63handshake.sendCipher; handshake.recvCipher; handshake.handshakeHash64```65**None of this exists.** `NoiseNK.kt` exposes two **suspend free functions**:66```kotlin67suspend fun noiseNKInitiator(68 responderStaticPublicKey: ByteArray,69 send: suspend (ByteArray) -> Unit,70 receive: suspend () -> ByteArray,71): NoiseNKSession7273suspend fun noiseNKResponder(74 ourStaticPrivateKey: ByteArray,75 ourStaticPublicKey: ByteArray,76 send: suspend (ByteArray) -> Unit,77 receive: suspend () -> ByteArray,78): NoiseNKSession79```80`NoiseNKSession` has **one bidirectional cipher** — `encrypt(plaintext)`,81`decrypt(ciphertext)`, and `val handshakeHash` — **not** split82`sendCipher`/`recvCipher`. Bound to:83- The drafts pass `transport::send` / `transport::receive` (where `transport` is84 a `SocketFrameIO`) as the handshake's `send`/`receive`. The handshake messages85 are themselves length-prefixed frames.86- All post-handshake encryption uses `session.encrypt` / `session.decrypt`.87- Noise **NK** = responder has a static key, initiator does not; the initiator88 must already KNOW the responder's static public key. The drafts use the89 device's **long-term sync identity** (`deriveSyncIdentityKeypair(masterKey,90 deviceId)`) as the responder static, and publish its public half in the mDNS91 TXT `pk` record so the initiator can pin it. (The brief's `remotePublicKey`92 "from TXT pk" maps onto this exactly — but it is the *responder's static*, not93 an ephemeral.)94- SAS: `SASDerivation.derive(session.handshakeHash)` (the existing helper; there95 is also `derivePairingSAS(hash)`). The brief's `deriveSAS(...)` does not exist.9697### 2. VaultSyncAdapter — no buildRequest/applyRequest/buildResponse/applyResponse9899The brief calls `syncAdapter.buildResponse(req)` / `applyRequest(req)` /100`buildRequest()` / `applyResponse(resp)`. **None exist.** `VaultSyncAdapter`101implements `ChirpSyncVaultAdapter` (`computeVaultId`, `getOurDeviceId`,102`getLocalVersion`, `buildEntryManifest`, `buildTombstoneManifest`,103`getEncryptedEntries`, `applyEntries`, `applyTombstones`). The actual sync is a104**full multi-message round** driven by the existing free functions:105```kotlin106suspend fun runChirpSyncInitiator(session: ChirpSyncSession, vault: ChirpSyncVaultAdapter): SyncOutcome107suspend fun runChirpSyncResponder(session: ChirpSyncSession, vault: ChirpSyncVaultAdapter): SyncOutcome108```109over a `ChirpSyncSession(remoteStaticPublicKey, send, receive, close)`. Bound to:110- `PairingServer` runs `runChirpSyncResponder`; `PairingDiscoveryClient` runs111 `runChirpSyncInitiator`. Both build a `ChirpSyncSession` via112 `chirpSessionOver(noiseSession, socketFrameIO)` — `send` = `encrypt → frame`,113 `receive` = `frame → decrypt`. `ChirpSyncSession.send` is **non-suspending**114 (matches `openChirpSyncSession`), so `SocketFrameIO.sendBlocking` is used115 there, not the suspend `send`.116- The adapter is built per round with `VaultSyncAdapter(repo, deviceId)`; pass a117 `vaultFactory: () -> ChirpSyncVaultAdapter?` (null when the vault is locked).118119### 3. PairedDevice / TrustStatus — already exist, different shape120121The brief redefines `PairedDevice(id, name, publicKey: ByteArray, lastSeen,122trustStatus, lastSyncAt)` and a `TrustStatus { TRUSTED, UNVERIFIED, REVOKED }`123enum. **The real `PairedDevice` (PairedDevice.kt) is reused verbatim** and has a124different shape:125```kotlin126@Serializable data class PairedDevice(127 val deviceId: String, val deviceName: String?, val deviceType: String?,128 val longTermPublicKeyHex: String?, val signingPublicKeyHex: String?,129 val trustSource: String?, val wsPort: Int?, val pairedAt: Long,130 val lastSyncedAt: Long?, val lastKnownIp: String?,131)132```133Field mapping (brief → real):134- `id` → `deviceId`; `name` → `deviceName`; `publicKey: ByteArray` →135 `longTermPublicKeyHex: String?` (hex, not Base64/ByteArray); `lastSyncAt` →136 `lastSyncedAt`.137- `lastSeen` → **no equivalent**. The store's `updateLastSeen(id, ip)` refreshes138 `lastKnownIp` instead (closest real analogue). If a real "last seen" timestamp139 is wanted, **that is an integration delta**: add `val lastSeen: Long? = null`140 to `PairedDevice` (defaulted so serialization stays backward-compatible).141- `trustStatus: TrustStatus` → **no enum exists**; trust is `trustSource: String?`142 ("direct" / "manifest"). **Integration delta if a tri-state TRUSTED/UNVERIFIED/143 REVOKED model is required:** add an enum + field to `PairedDevice` (defaulted).144 The drafts set `trustSource = "direct"` on pair, matching145 `ChirpPairing.frameToPairedDevice`. The brief's `confirmSAS → TRUSTED` /146 `rejectSAS → remove` flow maps onto: keep the device on confirm, `remove(id)`147 on reject. If you add the enum, set it to TRUSTED on confirm.148149`TrustBadge`, `GhostButton`, `ChirpPairUiState`, `SyncStatus.Busy`, and150`syncAdapter.applyResponse` referenced by the brief's UI/VM snippets **do not151exist either** — see the wiring sections.152153### 4. (architectural) The mDNS+ServerSocket transport is NEW; the chirp transport already ships154155`ChirpPairing.runChirpPairing(...)` already implements an end-to-end pairing156flow: ggwave **audio chirp** carries the peer's ephemeral X25519 key, then157`noiseNKInitiator/Responder` runs over the `PairingTransport` interface, identity158`PAIR_IDENTITY` frames are exchanged (`encodePairingFrame`/`decodePairingFrame`,159both **file-private**), and `SASDerivation.derive` produces the SAS. Sync uses160`openChirpSyncSession` (WebSocket) + subnet probe (`probeSubnetForPeer`). The C7161brief's mDNS+`ServerSocket` path is a **second discovery/transport** that reuses162the same Noise+sync primitives. The drafts re-implement the `PAIR_IDENTITY`163encode/parse as `internal` shims (`encodePairIdentityFrame`/164`parsePairIdentityFrame` in `PairingServer.kt`) **because the originals are165private**. **Cleaner integration:** promote `ChirpPairing`'s166`encodePairingFrame`/`decodePairingFrame`/`frameToPairedDevice` from `private` to167`internal` and delete the draft shims, so there is exactly one identity-frame168codec. Likewise consider promoting `runPairingInitiator`/`runPairingResponder` to169`internal` so the TCP transport can reuse them via `SocketFrameIO` instead of170re-deriving the handshake-then-identity sequence.171172---173174## Shared-file edits the orchestrator must make175176### ChirpPairViewModel (`ui/pairing/ChirpPairViewModel.kt`)177178The brief's rewrite (`ChirpPairUiState.Listening/Handshake/VerifySAS/Paired/...`,179`uiState =`) does **not** match reality. The real VM exposes:180- `val progress: StateFlow<PairingProgress>` where181 `PairingProgress(phase: PairingPhase, peerDeviceName: String?, errorMessage:182 String?)` and `PairingPhase` ∈ {IDLE, LISTENING, CHIRPING, HANDSHAKE, PAIRED,183 TIMEOUT, MIC_DENIED, VERSION_MISMATCH, HANDSHAKE_FAILED} (PairingProgress.kt).184- `val sasCode: StateFlow<String?>` and `fun confirmSas(accepted: Boolean)`185 (a `CompletableDeferred<Boolean>` gate) — NOT separate `confirmSAS()/rejectSAS()`.186- `fun startPairing()` already runs the real `runChirpPairing(deps(master),187 role = INITIATOR)`.188189The brief's "`runChirpPairing()` throws UnsupportedOperationException" is **stale**190— `runChirpPairing` is fully implemented. What is actually stubbed is two methods191on the VM's `ChirpPairingDeps`:192```kotlin193override suspend fun completePairing(paired: PairedDevice) { /* TODO */ }194override suspend fun openPairingTransport(endpoint): PairingTransport =195 throw UnsupportedOperationException("...until sync persistence")196override suspend fun openPairingServer(): PairingServer =197 throw UnsupportedOperationException("...until sync persistence")198```199**Minimal real wiring (no UI-state rewrite needed):**2001. Inject `PairedDeviceStore` (and `PairingDiscoveryClient` / the renamed201 `PairingServer`) into `ChirpPairViewModel`.2022. Implement `completePairing(paired)` → `deviceStore.upsert(paired)`.2033. Implement `openPairingTransport(endpoint)` → return a `SocketFrameIO` over204 `Socket(endpoint.ip, endpoint.port)` (this satisfies the existing205 `PairingTransport` interface and unblocks `runResponderHandshake`).2064. Implement `openPairingServer()` → bind a `ServerSocket`, `accept()` one207 client, wrap it in `SocketFrameIO`, return `PairingServer(transport, peerIp)`208 (the existing data class). (Or fold this into the new mDNS server.)2095. SAS confirm already persists via `confirmSas(true)` → `completePairing`; on210 `confirmSas(false)` the flow throws `sas_rejected` and nothing is stored211 (matches the brief's `rejectSAS` intent).212213> If the product genuinely wants the brief's mDNS pairing instead of chirp, that214> is a larger VM change: add a path that calls215> `PairingDiscoveryClient.discoverPeers().first()` → `connectAndSync(...)` →216> surface SAS through `sasCode`. That is **net-new UI behavior**, not a one-line217> swap; flag for product before building.218219### SyncViewModel (`ui/sync/SyncViewModel.kt`)220221The brief's `SyncStatus.Busy`, `deviceStore.observeAll().stateIn(...)`,222`transport.discoverPeers(5_000L)`, `connectAndSync(peer)` map onto reality as:223- `SyncStatus` is `{ Idle, Syncing, LastSynced(ts), Error(msg), Offline }` — use224 `Syncing` where the brief says `Busy`.225- Add to the VM:226 ```kotlin227 // inject PairedDeviceStore + PairingDiscoveryClient228 val pairedDevices: StateFlow<List<PairedDevice>> =229 deviceStore.observeAll()230 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())231232 fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }233234 fun syncNow() = viewModelScope.launch {235 val master = vault.syncMasterKey() ?: return@launch236 val identity = buildLocalIdentity(context, master)237 _status.value = SyncStatus.Syncing238 pairedDevices.value.forEach { dev ->239 discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter)240 }241 VaultCrypto.zero(master)242 _status.value = SyncStatus.Idle243 }244 ```245 `observeAll()` is provided by `PairedDeviceStore`. `buildAdapter()` already246 exists. `syncWithKnownDevice` is on `PairingDiscoveryClient`. (`triggerSync()`247 already returns `Offline` when there is nothing to dial — keep it, or route it248 through `syncNow`.)249250### SettingsCardBodies.SyncBody (`ui/settings/SettingsCardBodies.kt`)251252The brief's `GhostButton`, `TrustBadge`, `PeachType.bodyMono/labelMono/253settingsLede`, `pairedDevices.forEach`, `vm.syncNow()` snippet does **not** match254the file's vocabulary. Reality:255- `SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit)` is a **private256 @Composable** with NO ViewModel and NO device list today — it shows static257 PAIRING/DEVICE FLOW rows and navigates to `PeachNav.ChirpPair`.258- The real helpers are `SubLabel`, `RowLine`, `RowScope.Name`, `ValueText`,259 `SubtleButton`, `Hint`, `ProBadge` — **not** `GhostButton`/`TrustBadge`.260- `PeachType.bodyMono`, `PeachType.labelMono`, `PeachType.settingsLede` **do261 exist** (PeachType.kt) — those three names are correct.262263**To show real devices:** change `SyncBody`'s signature to take the VM (or a264`pairedDevices: List<PairedDevice>` + callbacks) and render with the existing265helpers, e.g.:266```kotlin267if (pairedDevices.isEmpty()) {268 Text("No paired devices yet.", color = fg.copy(alpha = 0.7f),269 fontFamily = PeachType.Body, fontSize = 12.sp)270} else pairedDevices.forEach { d ->271 RowLine(fg) {272 Name(d.deviceName ?: d.deviceId, fg)273 ValueText(d.lastSyncedAt?.let { "synced" } ?: "never", fg)274 }275}276if (pairedDevices.isNotEmpty()) RowLine(fg) { Name("Sync", fg); SubtleButton("sync now", fg) { vm.syncNow() } }277```278Use a `SubtleButton` (real) rather than `GhostButton` (nonexistent). Trust state:279render `d.trustSource` as text, or add a real `TrustBadge` composable if the enum280delta (§3) is adopted. The caller of `SyncBody` lives in this same file's card281dispatcher — pass the VM/state through.282283### Hilt (`di/VaultModule.kt` or a new module)284285`PairedDeviceStore`, `PairingServer`/`PairingListener`, and286`PairingDiscoveryClient` are all `@Singleton class ... @Inject constructor(...)`287with only `@ApplicationContext Context` + other `@Inject` singletons — so Hilt288constructs them with **no `@Provides`/`@Binds` needed**. `VaultModule` is a289`@Binds` module and needs no change. (Add providers only if you want to inject290them behind an interface.)291292### AndroidManifest.xml293294`INTERNET` is **already present** (line 5). Add only:295```xml296<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />297<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />298```299`CHANGE_WIFI_MULTICAST_STATE` is required for mDNS/NSD multicast on Wi-Fi.300(NsdManager on recent Android also benefits from acquiring a301`WifiManager.MulticastLock` at runtime — consider adding one around discovery.)302303### build.gradle304305**No new dependencies.** `androidx.security:security-crypto:1.1.0-alpha06`306(line 87) and `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1` (line 107)307are present; `NsdManager`/`ServerSocket`/`Socket` are framework/JDK. The test308file uses only JUnit + kotlinx-coroutines + BouncyCastle, all already on the test309classpath (see `ChirpSyncTransportTest`).310311---312313## Tests (`C7-ChirpTransportTest.kt`)314315Pure-JVM, no Robolectric, matching `ChirpSyncTransportTest`'s idiom:3161. `PairedDevice` list JSON round-trip (the store's serialization layer).3172. `upsert`-by-`deviceId` replaces rather than duplicates (store list semantics).3183. `writeFrame`/`readFrame` round-trip across sizes (0, 1, 31, 64, 1500, 65537).3194. Full `noiseNKResponder`↔`noiseNKInitiator` handshake + encrypted320 `PAIR_IDENTITY` exchange over cross-wired `PipedInputStream/PipedOutputStream`321 via `SocketFrameIO`; asserts both sides derive the **same SAS** and the server322 decodes the client identity.3235. Wrong responder static key → initiator handshake rejects (NK auth tag) →324 `PairingError.HANDSHAKE_FAILED` path.325326`PairedDeviceStore`'s on-disk persistence (EncryptedSharedPreferences) needs an327Android `Context`; cover that in an instrumentation test (`androidTest`) — out of328scope for the unit suite, consistent with how the project tests other329EncryptedSharedPreferences stores.