C7-INTEGRATION.md

17.6 KB · raw

rendered · source

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:

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:

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:

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):

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:

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):

  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:

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:

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:

  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.