C7-INTEGRATION.md
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._tcpmDNS +ServerSocketdesign 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
PairingServer— the brief wants a@Singleton class PairingServer. Adata class PairingServer(val transport, val peerIp)already exists inChirpPairing.kt. The draft's class would collide. Options:- (preferred) rename the draft class to
PairingListener/MdnsPairingServerand keep the existingdata class PairingServer; or - rename the existing data class (it is only used inside
ChirpPairingDeps). The draft is written asclass PairingServerper the brief; pick a rename before merge or the module will not compile.
- (preferred) rename the draft class to
PairingTransport— the brief wants a@Singleton class PairingTransport.PairingTransportis already an interface inChirpPairing.kt(send/receive/close) consumed by the existing handshake. The draft file is therefore authored asclass PairingDiscoveryClient(destination filenamePairingDiscoveryClient.kt) to avoid shadowing the interface.SocketFrameIOinPairingFrames.ktimplements that real interface, so the existingrunPairingInitiator/runPairingRespondercould 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:
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:
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(wheretransportis aSocketFrameIO) as the handshake'ssend/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 TXTpkrecord so the initiator can pin it. (The brief'sremotePublicKey"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 alsoderivePairingSAS(hash)). The brief'sderiveSAS(...)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:
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:
PairingServerrunsrunChirpSyncResponder;PairingDiscoveryClientrunsrunChirpSyncInitiator. Both build aChirpSyncSessionviachirpSessionOver(noiseSession, socketFrameIO)—send=encrypt → frame,receive=frame → decrypt.ChirpSyncSession.sendis non-suspending (matchesopenChirpSyncSession), soSocketFrameIO.sendBlockingis used there, not the suspendsend.- The adapter is built per round with
VaultSyncAdapter(repo, deviceId); pass avaultFactory: () -> 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:
@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'supdateLastSeen(id, ip)refresheslastKnownIpinstead (closest real analogue). If a real "last seen" timestamp is wanted, that is an integration delta: addval lastSeen: Long? = nulltoPairedDevice(defaulted so serialization stays backward-compatible).trustStatus: TrustStatus→ no enum exists; trust istrustSource: String?("direct" / "manifest"). Integration delta if a tri-state TRUSTED/UNVERIFIED/ REVOKED model is required: add an enum + field toPairedDevice(defaulted). The drafts settrustSource = "direct"on pair, matchingChirpPairing.frameToPairedDevice. The brief'sconfirmSAS → TRUSTED/rejectSAS → removeflow 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<PairingProgress>wherePairingProgress(phase: PairingPhase, peerDeviceName: String?, errorMessage: String?)andPairingPhase∈ {IDLE, LISTENING, CHIRPING, HANDSHAKE, PAIRED, TIMEOUT, MIC_DENIED, VERSION_MISMATCH, HANDSHAKE_FAILED} (PairingProgress.kt).val sasCode: StateFlow<String?>andfun confirmSas(accepted: Boolean)(aCompletableDeferred<Boolean>gate) — NOT separateconfirmSAS()/rejectSAS().fun startPairing()already runs the realrunChirpPairing(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:
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):
- Inject
PairedDeviceStore(andPairingDiscoveryClient/ the renamedPairingServer) intoChirpPairViewModel. - Implement
completePairing(paired)→deviceStore.upsert(paired). - Implement
openPairingTransport(endpoint)→ return aSocketFrameIOoverSocket(endpoint.ip, endpoint.port)(this satisfies the existingPairingTransportinterface and unblocksrunResponderHandshake). - Implement
openPairingServer()→ bind aServerSocket,accept()one client, wrap it inSocketFrameIO, returnPairingServer(transport, peerIp)(the existing data class). (Or fold this into the new mDNS server.) - SAS confirm already persists via
confirmSas(true)→completePairing; onconfirmSas(false)the flow throwssas_rejectedand nothing is stored (matches the brief'srejectSASintent).
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 throughsasCode. 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:
SyncStatusis{ Idle, Syncing, LastSynced(ts), Error(msg), Offline }— useSyncingwhere the brief saysBusy.- Add to the VM:
// inject PairedDeviceStore + PairingDiscoveryClient val pairedDevices: StateFlow<List<PairedDevice>> = 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 byPairedDeviceStore.buildAdapter()already exists.syncWithKnownDeviceis onPairingDiscoveryClient. (triggerSync()already returnsOfflinewhen there is nothing to dial — keep it, or route it throughsyncNow.)
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 toPeachNav.ChirpPair.- The real helpers are
SubLabel,RowLine,RowScope.Name,ValueText,SubtleButton,Hint,ProBadge— notGhostButton/TrustBadge. PeachType.bodyMono,PeachType.labelMono,PeachType.settingsLededo exist (PeachType.kt) — those three names are correct.
To show real devices: change SyncBody's signature to take the VM (or a
pairedDevices: List<PairedDevice> + callbacks) and render with the existing
helpers, e.g.:
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:
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
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:
PairedDevicelist JSON round-trip (the store's serialization layer).upsert-by-deviceIdreplaces rather than duplicates (store list semantics).writeFrame/readFrameround-trip across sizes (0, 1, 31, 64, 1500, 65537).- Full
noiseNKResponder↔noiseNKInitiatorhandshake + encryptedPAIR_IDENTITYexchange over cross-wiredPipedInputStream/PipedOutputStreamviaSocketFrameIO; asserts both sides derive the same SAS and the server decodes the client identity. - Wrong responder static key → initiator handshake rejects (NK auth tag) →
PairingError.HANDSHAKE_FAILEDpath.
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.