Primary adversaries:
- Passive network eavesdropping - WiFi sniffers, ISP monitoring, network operators
- Active MITM during pairing - Compromised signaling server attempting to impersonate a device
- Unauthorized device access - Someone trying to pair without physical access to an existing device
Not defending against:
- Device compromise (malware, unlocked phone left unattended)
- State-level targeted attacks requiring infrastructure compromise
- Timing/traffic analysis attacks
- Perfect forward secrecy for historical data if device is compromised
- Users can physically verify pairing codes or QR codes on trusted devices
- WebRTC's DTLS encryption is secure
- Device local storage is secure (protected by OS/browser)
- Users will notice if someone unauthorized pairs a device
What it provides:
- All data encrypted in transit with DTLS 1.2+
- Perfect forward secrecy
- Authenticated encryption (AES-GCM)
- Protection against passive eavesdropping
What it doesn't provide:
- Protection against MITM during initial WebRTC negotiation
- End-to-end encryption if signaling server is compromised
What it provides:
- MITM-resistant device pairing via out-of-band verification
- Device authentication using public key cryptography
- Trusted device registry
Implementation: QR code or manual verification code
What it provides:
- Zero-trust architecture (signaling server and Nostr relays never see plaintext)
- Content encrypted with AES-256-GCM before leaving device
- Ledger Encryption Key (LEK) shared across paired devices
- Deterministic Nostr keypair derived from LEK for cross-device identity
Status: Fully implemented. All bookmark data is encrypted before publishing to Nostr relays. See Nostr Sync Architecture for details.
- Authenticate devices - Ensure you're pairing with the intended device
- Prevent MITM - Signaling server cannot impersonate a device
- User-friendly - One-time setup, works with/without camera
- Forward-compatible - Can add application-level E2EE later without breaking
┌──────────────┐ ┌──────────────┐
│ Device A │ │ Device B │
│ (Existing) │ │ (New) │
└──────┬───────┘ └──────┬───────┘
│ │
│ 1. Generate pairing token │
│ - Peer ID │
│ - Device public key │
│ - Pairing verification code (6 digits) │
│ │
│ 2. Display as QR code │
│ OR show text for manual entry │
│ │
│◄──────────────────────────────────────────────┤ 3. Scan QR
│ │ OR paste text
│ │
│ │ 4. Show verification
│ │ code on screen
│ │
│ 5. User verifies codes match │
│ on both devices │
│ │
│ 6. WebRTC connection established │
│◄─────────────────────────────────────────────►│
│ │
│ 7. Device B sends its public key │
│◄──────────────────────────────────────────────┤
│ │
│ 8. Both save each other as authorized │
│ │
When user clicks "Pair New Device":
constpairingToken={version: 1,peerId: myPeerId,// PeerJS ID (e.g., "abc123xyz")publicKey: myPublicKey,// Device's Ed25519 public key (32 bytes)timestamp: Date.now(),// Token expiryverificationCode: generateCode()// 6-digit numeric code}// SerializeconsttokenString=base64url.encode(JSON.stringify(pairingToken))// Generate verification code (deterministic from token)constverificationCode=HKDF(hash(tokenString),salt: "hypermark-pairing-v1",outputLength: 6digits)// e.g., "482193"Token format:
eyJ2ZXJzaW9uIjoxLCJwZWVySWQiOiJhYmMxMjMiLCJwdWJsaWNLZXkiOiIuLi4iLCJ0aW1lc3RhbXAiOjE3MDk4NTAwMDB9
Primary method (QR code):
- Display QR code containing
tokenString - Display verification code prominently: "482193"
- Show expiry countdown (5 minutes)
Fallback method (no camera):
- Show "No camera? Click here"
- Display
tokenStringas copyable text - Display verification code: "482193"
Primary method:
- Scan QR code
- Parse
tokenString - Validate:
- Version matches
- Timestamp is fresh (< 5 minutes old)
- Peer ID format is valid
- Public key is valid Ed25519 key
Fallback method:
- Show text input field
- User pastes
tokenString - Same validation
Extract and display the verification code:
constverificationCode=HKDF(hash(tokenString),salt: "hypermark-pairing-v1",outputLength: 6digits)// Show on screen in large font:"Does Device A show: 482193 ?"[Yes][No]User compares codes:
- Device A shows: 482193
- Device B shows: "Does Device A show: 482193?"
If codes match: User taps "Yes" → proceed If codes don't match: MITM attack detected → abort
Why this works:
- Signaling server can't predict verification code without
tokenString tokenStringis transmitted out-of-band (QR/physical paste)- Attacker would need to intercept QR code or clipboard, not just network
Device B initiates connection:
peer.connect(pairingToken.peerId)Note: At this point, WebRTC DTLS encryption kicks in. Even if signaling is compromised, the verification code check in step 5 prevents MITM.
Once WebRTC connection is open:
Device B sends:
connection.send({type: 'pairing-handshake',publicKey: myPublicKey,peerId: myPeerId,deviceName: "John's iPhone"// User-set or default})Device A receives and validates:
- Check signature (future: can sign messages with private key)
- Verify device isn't already paired
- Store authorized device
Both devices save each other:
authorizedDevices=[{peerId: "abc123",publicKey: "...",deviceName: "John's Laptop",pairedAt: 1709850000,lastSeen: 1709850000}]localStorage.setItem('authorized-devices',JSON.stringify(authorizedDevices))Future: Can use public keys to verify message signatures, detect impersonation.
✅ MITM-resistant pairing - Attacker can't impersonate Device A without showing same verification code
✅ Out-of-band verification - QR code or physical paste bypasses network attacker
✅ User-visible security - User sees and verifies code, detects mismatches
✅ Device authentication - After pairing, devices know each other's public keys
✅ Revocation - User can unpair devices from UI
✅ Works offline - QR code/paste works on same LAN without internet
❌ Perfect forward secrecy per-message - WebRTC provides PFS, but Nostr events use static LEK-derived keys
❌ Anonymous pairing - Signaling server knows which peer IDs are connecting (metadata leak)
❌ Anonymous Nostr sync - Relays see the same pubkey for all devices with same LEK (identity correlation)
❌ Protection after device compromise - If attacker steals device, they have access (use device lock screen)
Attacker: WiFi sniffer at coffee shop
Protection: WebRTC DTLS encryption
Result: ✅ Defended - Attacker sees encrypted traffic only
Attacker: Malicious or hacked PeerJS server
Attack flow:
Device A ──[wants to pair]──> PeerJS (attacker)
│
▼
Attacker creates fake
Device B connection
Protection: Verification code mismatch
What happens:
- Device A shows QR with verification code
482193 - Attacker intercepts, tries to MITM
- Device B (real) receives different WebRTC connection parameters
- Verification code computed from different values → different code
591847 - User sees mismatch, aborts pairing
Result: ✅ Defended - User detects attack
Attacker: Takes photo of QR code over user's shoulder
What attacker gets:
- Peer ID
- Public key (not private!)
- Verification code
Can they pair?
- Attacker can try to connect using peer ID
- But Device A will receive connection from unknown peer ID (attacker's ID)
- Device A shows verification code for attacker's connection
- Codes won't match original QR code
Result: ✅ Defended - Original pairing proceeds, attacker's connection rejected
Attacker: Uses expired QR code from trash
Protection: Timestamp validation
Result: ✅ Defended - Device B rejects expired token (> 5 minutes old)
Attacker: PeerJS logs connections
What they learn:
- Peer IDs connecting to each other
- Connection times
- Approximate data volume (encrypted)
What they don't learn:
- Bookmark content
- Document structure
- Which devices belong to same user (unless correlation)
Result:
Key generation:
// Use WebCrypto APIconstkeyPair=awaitcrypto.subtle.generateKey({name: "ECDSA",namedCurve: "P-256"// Or Ed25519 when broadly supported},false,// Non-extractable for security["sign","verify"])Verification code derivation:
functionderiveVerificationCode(tokenString){consthash=sha256(tokenString)consthkdf=HKDF(hash,"hypermark-pairing-v1",32)constnumeric=bytesToBigInt(hkdf)%1000000returnnumeric.toString().padStart(6,'0')// "482193"}Pairing tokens expire after 5 minutes:
- Forces attacker to act quickly if they intercept QR
- Reduces window for attacks
- User-friendly (most pairings complete in < 30 seconds)
On expiry:
- User must generate new QR code
- Old tokens rejected by Device B
Problem: User pairs "iPhone" three times, can't tell them apart
Solution:
deviceName=userInput||`${platform} (${peerId.slice(0,6)})`// e.g., "iPhone (abc123)" or "Linux (xyz789)"Soft revocation (MVP):
// Remove from authorized listauthorizedDevices=authorizedDevices.filter(d=>d.peerId!==targetPeerId)// Close active connectionconnections.get(targetPeerId)?.close()Limitation: Revoked device keeps cached data. For strong revocation, need to rotate ledger encryption key (future feature).
Application-level E2EE is now implemented via Nostr sync:
- LEK is exchanged during pairing via ECDH-derived session key
- All bookmark content is encrypted with AES-256-GCM before publishing
- Nostr keypair is deterministically derived from LEK
- See
src/services/nostr-sync.jsandsrc/services/nostr-crypto.js
Sign all messages to prevent impersonation after pairing:
// Senderconstsignature=awaitcrypto.subtle.sign({name: "ECDSA",hash: "SHA-256"},privateKey,JSON.stringify(message))connection.send({ message, signature })// ReceiverconstisValid=awaitcrypto.subtle.verify({name: "ECDSA",hash: "SHA-256"},senderPublicKey,signature,JSON.stringify(message))Replace PeerJS with Raspberry Pi relay:
- Eliminates metadata leakage to third party
- Full control over infrastructure
- Same pairing protocol applies
MVP (Completed):
- Implement QR code pairing with verification code display
- Add manual text fallback for devices without cameras
- Validate token timestamps (5 minute expiry)
- Generate device keypairs using WebCrypto (non-extractable)
- Store authorized devices in IndexedDB
- Show clear "Verify this code" UI with verification words
- Add device removal (unpair) functionality
- Test verification code mismatch scenario
- Ensure verification words are deterministic from session
- Add user-facing device names for identification
- Implement application-level E2EE via Nostr sync
Future work:
- Security audit of pairing protocol
- Pen test MITM scenarios
- Add rate limiting for pairing attempts
- Implement device signature verification
- Perfect forward secrecy for Nostr events
Similar approaches:
- Signal: Safety numbers (fingerprint verification)
- WhatsApp: QR code pairing with security codes
- Telegram: End-to-end encryption with verification
- PAKE protocols: SRP, OPAQUE (overkill for our threat model)
Standards:
- WebRTC Security Architecture: RFC 8827
- DTLS 1.2: RFC 6347
- HKDF: RFC 5869
// Device A: Generate pairing tokenasyncfunctiongeneratePairingToken(){consttoken={version: 1,peerId: peer.id,publicKey: awaitexportPublicKey(deviceKeyPair.publicKey),timestamp: Date.now()}consttokenString=base64url.encode(JSON.stringify(token))constverificationCode=awaitderiveVerificationCode(tokenString)// Display QR code and verification codeshowQRCode(tokenString)showVerificationCode(verificationCode)return{ tokenString, verificationCode }}// Device B: Parse and verify tokenasyncfunctionparsePairingToken(tokenString){consttoken=JSON.parse(base64url.decode(tokenString))// Validateif(token.version!==1)thrownewError('Invalid version')if(Date.now()-token.timestamp>5*60*1000){thrownewError('Token expired')}// Compute verification codeconstverificationCode=awaitderiveVerificationCode(tokenString)// Show to user for verificationconstuserConfirmed=awaitshowVerificationPrompt(verificationCode)if(!userConfirmed){thrownewError('User rejected pairing')}// Proceed with connectionreturntoken}// Derive verification code (6 digits)asyncfunctionderiveVerificationCode(input){constencoder=newTextEncoder()constdata=encoder.encode(input)consthashBuffer=awaitcrypto.subtle.digest('SHA-256',data)consthashArray=newUint8Array(hashBuffer)// Take first 4 bytes, convert to number, mod 1Mconstnum=newDataView(hashArray.buffer).getUint32(0)%1000000returnnum.toString().padStart(6,'0')}