Skip to content

Commit be74ebd

Browse files
Oliver Baerclaude
andcommitted
feat: Phase 5b — local network sync (mDNS + SPAKE2 + WebSocket transport)
Adds local network device sync with privacy-first design: - SPAKE2 password-authenticated key exchange for device pairing - mDNS service discovery (_aurus-sync._tcp.local.) - WebSocket transport with encrypted sync loop - Encrypted device info exchange after pairing - Transport wired into SyncManager with auto-discovery - New commands: sync_update_transcript, sync_update_agent_result - Frontend: sync-disconnected/sync-error event handling 32 Rust tests + 53 frontend tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 031c7df commit be74ebd

9 files changed

Lines changed: 1301 additions & 21 deletions

File tree

.memory/letter_20260206_0005.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Letter to Myself (Session Handoff)
2+
3+
**Date:** 2026-02-06 ~08:20 UTC
4+
5+
## 1. Executive Summary
6+
* **Goal:** Implement Phase 5b — local network sync (mDNS + SPAKE2+ + WebSocket transport) for privacy-first ephemeral device sync
7+
* **Current Status:** Phase 5b fully complete and compiling. 32 Rust tests + 53 frontend tests all passing. Ready to commit, then start Phase 5d (security hardening).
8+
9+
## 2. The "Done" List (Context Anchor)
10+
* Created `src-tauri/src/sync/pairing.rs` — SPAKE2 key exchange (PairingCreator/PairingJoiner, 3 tests)
11+
* Created `src-tauri/src/sync/discovery.rs` — mDNS announcement/browsing via mdns-sd 0.17 (1 test)
12+
* Created `src-tauri/src/sync/transport.rs` — WebSocket transport with SPAKE2 handshake, encrypted device info exchange, sync loop (4 tests)
13+
* Rewrote `src-tauri/src/sync/mod.rs` — wired transport+discovery into SyncManager:
14+
- `SyncState.doc``Arc<Mutex<SyncDocument>>` for sharing with transport tasks
15+
- Added `transport: Option<TransportHandle>`, `discovery: Option<SyncDiscovery>`
16+
- `create_sync_session` starts WS server + mDNS announcement
17+
- `join_sync_session` spawns mDNS discovery → auto-connect with 30s timeout
18+
- `leave_sync_session` drops transport (triggers goodbye) + unannounces mDNS
19+
- New commands: `sync_update_transcript`, `sync_update_agent_result`
20+
* Updated `src-tauri/src/lib.rs` — registered 2 new sync commands
21+
* Updated `app/hooks/useSync.ts` — added `sync-disconnected`/`sync-error` listeners, `syncTranscript()` and `syncAgentResult()` actions
22+
* Updated MEMORY.md with mdns-sd 0.17, SPAKE2, async-tungstenite gotchas
23+
24+
## 3. The "Pain" Log (CRITICAL)
25+
* **Tried:** `accept_async(TokioAdapter::new(stream))` in transport.rs
26+
* **Failed:** Double-wrap error — `async_tungstenite::tokio::accept_async` already wraps in TokioAdapter internally
27+
* **Workaround:** Pass `stream` directly to `accept_async(stream)`
28+
29+
* **Tried:** `peer_from_service_info(info: &ServiceInfo)` for mDNS resolved services
30+
* **Failed:** mdns-sd 0.17 `ServiceEvent::ServiceResolved` wraps `Box<ResolvedService>` NOT `ServiceInfo`
31+
* **Workaround:** Renamed to `peer_from_resolved_service(info: &ResolvedService)`, use public fields directly (`info.port`, `info.addresses`, `info.txt_properties`)
32+
33+
* **Tried:** `daemon.shutdown()` expecting `Result<(), _>`
34+
* **Failed:** Returns `Result<Receiver<DaemonStatus>, _>` not `Result<(), _>`
35+
* **Workaround:** Add `?; Ok(())` to discard the receiver
36+
37+
* **Tried:** Calling `creator.finish(&joiner.outbound_msg)` then `joiner.finish(&creator.outbound_msg)` in tests
38+
* **Failed:** `finish()` consumes `self`, so `creator.outbound_msg` is moved
39+
* **Workaround:** Save `outbound_msg.clone()` before calling `finish()`
40+
41+
## 4. Active Variable State
42+
* Cargo deps: yrs 0.21, ring 0.17, rand 0.8, spake2 0.4, mdns-sd 0.17, uuid 1 (v4+serde)
43+
* Git: Phase 5a committed as `031c7df` on main. Phase 5b changes unstaged.
44+
* Test counts: 32 Rust, 53 frontend = 85 total
45+
* Sync modules: mod.rs, document.rs, encryption.rs, pairing.rs, discovery.rs, transport.rs
46+
47+
## 5. Immediate Next Steps
48+
1. [ ] Commit Phase 5b changes
49+
2. [ ] Start Phase 5d: security hardening
50+
- [ ] Heartbeat every 5s, disconnect after 15s missing
51+
- [ ] Session timeout (4h max, warning at 3h45m)
52+
- [ ] Forward secrecy: HKDF key ratchet every 30 minutes
53+
- [ ] Mobile background survival (foreground service / audio mode)
54+
3. [ ] After 5d: Phase 5c (cross-network WebRTC with signaling relay)

app/hooks/useSync.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,28 @@ export function useSync() {
116116
}
117117
);
118118
listeners.push(unlistenSnapshot);
119+
120+
// Listen for unexpected disconnects
121+
const unlistenDisconnect = await listen<void>(
122+
'sync-disconnected',
123+
() => {
124+
setSyncStatus('disconnected');
125+
setPairingCode(null);
126+
setPairedDeviceName(null);
127+
setSyncPeer(null);
128+
}
129+
);
130+
listeners.push(unlistenDisconnect);
131+
132+
// Listen for sync errors
133+
const unlistenError = await listen<string>(
134+
'sync-error',
135+
(event) => {
136+
console.error('Sync error:', event.payload);
137+
setSyncStatus('disconnected');
138+
}
139+
);
140+
listeners.push(unlistenError);
119141
} catch (error) {
120142
console.log('Sync events not available:', error);
121143
}
@@ -176,5 +198,23 @@ export function useSync() {
176198
}
177199
}, []);
178200

179-
return { createSession, joinSession, leaveSession };
201+
const syncTranscript = useCallback(async (transcript: string): Promise<void> => {
202+
try {
203+
const { invoke } = await import('@tauri-apps/api/core');
204+
await invoke('sync_update_transcript', { transcript });
205+
} catch {
206+
// Silently ignore — sync may not be connected
207+
}
208+
}, []);
209+
210+
const syncAgentResult = useCallback(async (agent: string, result: string): Promise<void> => {
211+
try {
212+
const { invoke } = await import('@tauri-apps/api/core');
213+
await invoke('sync_update_agent_result', { agent, result });
214+
} catch {
215+
// Silently ignore
216+
}
217+
}, []);
218+
219+
return { createSession, joinSession, leaveSession, syncTranscript, syncAgentResult };
180220
}

src-tauri/Cargo.lock

Lines changed: 103 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ yrs = "0.21"
4242
ring = "0.17"
4343
rand = "0.8"
4444

45+
# Pairing (SPAKE2 password-authenticated key exchange)
46+
spake2 = "0.4"
47+
48+
# Local network discovery
49+
mdns-sd = "0.17"
50+
4551
# Identifiers
4652
uuid = { version = "1", features = ["v4", "serde"] }
4753

src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ pub fn run() {
154154
sync::leave_sync_session,
155155
sync::get_sync_status,
156156
sync::get_pairing_code,
157+
sync::sync_update_transcript,
158+
sync::sync_update_agent_result,
157159
])
158160
.run(tauri::generate_context!())
159161
.expect("error while running tauri application");

0 commit comments

Comments
 (0)