-
-
Notifications
You must be signed in to change notification settings - Fork 0
Peer Sync Security
MoodHaven Journal syncs encrypted journal entries directly between devices on the same LAN — no cloud server, no relay, no accounts required. This page covers the security design for contributors and security reviewers.
For the general overview → README
┌─────────────────────────────────────────────────────────────┐
│ Layer 4: Encrypted Sync Engine (TCP, port 44000–44999) │
│ [4-byte length][12-byte nonce][AES-256-GCM(JSON payload)] │
├─────────────────────────────────────────────────────────────┤
│ Layer 3: Secure Pairing │
│ 6-digit PIN + QR code → mutual device trust │
├─────────────────────────────────────────────────────────────┤
│ Layer 2: Peer Discovery │
│ mDNS/DNS-SD: _moodhaven._tcp.local │
│ Tauri events: peer:discovered, peer:lost │
├─────────────────────────────────────────────────────────────┤
│ Layer 1: Device Identity │
│ Ed25519 key pair in device.json + peer_key.bin │
│ deviceId = first 16 hex chars of SHA-256(publicKey) │
└─────────────────────────────────────────────────────────────┘
| Threat | Mitigation |
|---|---|
| Passive eavesdropper on LAN | All sync payloads AES-256-GCM encrypted on the wire |
| Unknown device attempting sync | Only trusted (paired) devices accepted; others disconnected at HELLO |
| Device ID spoofing | Transport key derived from both public keys; cannot be derived without genuine private key |
| Replay attack | Per-session nonce (12 bytes, random) included in every encrypted frame |
| Compromised peer reads journal | Journal content is encrypted end-to-end; sync engine moves ciphertext, not plaintext |
| Password mismatch between devices | Blobs sync correctly; frontend decrypt fails gracefully — no data corruption |
| MitM during pairing | PIN must be verified out-of-band (user reads PIN off Device A and enters it on Device B) |
ed25519-dalek: generate_keypair() → (private_key, public_key)
private_key → {app_data_dir}/peer_key.bin (0600 permissions)
public_key → {app_data_dir}/device.json
deviceId = hex(SHA-256(public_key))[0..16]
The private key never leaves the device. If device.json is deleted, the deviceId changes and all peers must re-pair.
Broadcast announces: service type _moodhaven._tcp.local, port, TXT records device_id + device_name.
Not exposed on the network: public key, journal content, password.
Device A Device B
peer_generate_pairing_token()
→ generates 6-digit PIN
→ starts pairing server (HTTP, random port)
→ shows QR: { host, port, deviceId, pin }
peer_accept_pairing(host, peer_id, pin)
→ connects to Device A's pairing server
→ exchanges public keys
→ verifies PIN
→ stores Device A in trusted_devices.json
receives Device B's public key
verifies PIN
stores Device B in trusted_devices.json
closes pairing server
Trusted device storage ({app_data_dir}/trusted_devices.json):
[{
"deviceId": "9a4f0b2c...",
"deviceName": "Ken's MacBook",
"publicKey": "base64url-ed25519-public-key",
"pairedAt": "2026-03-18T10:00:00Z"
}]PIN is 6 digits, valid only for the current pairing session, communicated out-of-band. Trust revocation: peer_revoke_device(deviceId) removes the entry — the device is rejected at HELLO on the next connection.
port = 44000 + (parseInt(deviceId[0..4], 16) % 1000) // range: 44000–44999
[4 bytes: BE uint32 payload length]
[12 bytes: random AES-GCM nonce]
[N bytes: AES-256-GCM ciphertext of JSON payload]
Both sides derive the same key without transmitting it:
transport_key = SHA-256(
"moodhaven-sync-v1:" +
sort_lexicographic([base64(pubKeyA), base64(pubKeyB)])
)
Device A (client) Device B (server)
HELLO (plaintext) ─────────────▶ { deviceId, protocolVersion }
◀───────────────── HELLO (plain) { deviceId, protocolVersion }
[Both verify peer is in trusted_devices.json]
[Both independently derive transport_key]
MANIFEST (encrypted) ──────────▶ { entries: [{id, updated_at}] }
◀──────────── MANIFEST (encrypted) { entries: [{id, updated_at}] }
[Each side computes what the other is missing]
ENTRIES (encrypted) ───────────▶ [{id, content (ciphertext), mood, …}]
◀─────────────── ENTRIES (encrypted)
DONE (encrypted) ──────────────▶
◀──────────────────── DONE_ACK
[Both update peer_sync_state (last_sync_at)]
Last-write-wins (LWW) by updated_at timestamp. Conservative but safe — no data is deleted, but concurrent edits on two devices result in one version being kept. Peer sync is designed for "merge missing entries," not collaborative real-time editing.
Plaintext (intentional): entry ID, updated_at, mood (1–5), privacy_mode, book_id, pinned.
Ciphertext (opaque blobs): entry content ({ iv, data, salt }).
Not synced: settings, 2FA config, WebDAV credentials, Oura PAT, device identity/pairing data, media attachments.
-
peer:discoveredevent for a trusted device, if 30+ seconds since last sync - Immediately after a new device is paired
| File | What to review |
|---|---|
src-tauri/src/commands/peer_sync_engine.rs |
TCP server, protocol, encryption/decryption |
src-tauri/src/commands/peer_pairing.rs |
PIN generation, key exchange, trust storage |
src-tauri/src/commands/peer_identity.rs |
Key generation, persistence |
src-tauri/src/commands/peer_discovery.rs |
mDNS broadcast content |
src/lib/peerSyncEngineService.ts |
Frontend orchestration |
src-tauri/capabilities/default.json |
ACL for sync commands |
Full reference: docs/peer-sync-security.md