Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ cli/where_cli_state.json
*.swp
*.txt
*.log
TODO_SPEC_UPDATES.md
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Trust On First Use is NOT a bug; it's a choice (and accepted risk) of the
current design.

### E2EE
Uses a standard, bidirectional Double Ratchet protocol with X25519 ephemeral keys, HKDF-SHA-256 for ratcheting, and AES-256-GCM for encryption. See `docs/e2ee-location-sync.md` for the full protocol spec.
Uses a standard, bidirectional Double Ratchet protocol with X25519 ephemeral keys, HKDF-SHA-256 for ratcheting, and ChaCha20-Poly1305 for encryption. See `docs/e2ee-location-sync.md` for the full protocol spec.

---

Expand Down
4 changes: 2 additions & 2 deletions android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ android {
applicationId = "net.af0.where"
minSdk = 26
targetSdk = 35
versionCode = 95
versionName = "2026.06.10.1"
versionCode = 96
versionName = "2026.06.12.1"
manifestPlaceholders["MAPS_API_KEY"] = localProperties.getProperty("MAPS_API_KEY") ?: System.getenv("MAPS_API_KEY") ?: ""
}

Expand Down
88 changes: 62 additions & 26 deletions docs/e2ee-location-sync.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion ios/Sources/Where/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>7</string>
<string>10</string>
<key>NSCameraUsageDescription</key>
<string>Where needs the camera to scan friend invite QR codes.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
Expand Down
8 changes: 4 additions & 4 deletions ios/Where.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@
CODE_SIGN_ENTITLEMENTS = Sources/Where/Where.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 5PM2V9LTHC;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited) $(SRCROOT)/../shared/build/XCFrameworks/$(CONFIGURATION:lower)",
Expand All @@ -457,7 +457,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.14;
MARKETING_VERSION = 1.15;
PRODUCT_BUNDLE_IDENTIFIER = net.af0.WhereApp;
PROVISIONING_PROFILE_SPECIFIER = "Where AppStore";
SDKROOT = iphoneos;
Expand All @@ -473,7 +473,7 @@
CODE_SIGN_ENTITLEMENTS = Sources/Where/Where.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 10;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited) $(SRCROOT)/../shared/build/XCFrameworks/$(CONFIGURATION:lower)",
"\".\"",
Expand All @@ -485,7 +485,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.14;
MARKETING_VERSION = 1.15;
PRODUCT_BUNDLE_IDENTIFIER = net.af0.WhereApp;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
Expand Down
4 changes: 2 additions & 2 deletions ios/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ targets:
FRAMEWORK_SEARCH_PATHS: $(inherited) $(SRCROOT)/../shared/build/XCFrameworks/$(CONFIGURATION:lower)
CODE_SIGN_IDENTITY: Apple Development
CODE_SIGN_STYLE: Automatic
MARKETING_VERSION: "1.14"
CURRENT_PROJECT_VERSION: 6
MARKETING_VERSION: "1.15"
CURRENT_PROJECT_VERSION: 9

WhereTests:
type: bundle.unit-test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ internal const val MAX_MESSAGES_PER_POLL = 500
// accepted as lost; the session may need re-pairing if they contained DH keys.
// At a 30-second poll interval, 5 retries = ~2.5 minutes before force-ACK.
internal const val MAX_SILENT_DROP_RETRIES = 5
internal const val MAX_GAP = 10000
// Bound on the skipped-message-key cache. Sized to comfortably absorb a full
// MAX_MESSAGES_PER_POLL backlog (500) with headroom; peer-influenceable but
// bounded at ~60 bytes/entry ≈ 60 KB worst case.
Expand Down
6 changes: 4 additions & 2 deletions shared/src/commonMain/kotlin/net/af0/where/e2ee/Ratchet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ package net.af0.where.e2ee
* KDF_RK – DH ratchet step. Inputs the current root key as HKDF salt and a fresh DH output
* as IKM. Produces 96 bytes: [new_root_key (32) || new_chain_key (32) || new_header_key (32)].
*
* KDF_CK – Symmetric chain step. A single HKDF-SHA-256 call producing 76 bytes:
* [new_chain_key (32) || message_key (32) || message_nonce (12)].
* KDF_CK – Symmetric chain step using HMAC-SHA-256:
* message_key = HMAC(chain_key, 0x01)
* new_chain_key = HMAC(chain_key, 0x02)
* message_nonce = HKDF-SHA-256(ikm=message_key, info="Where-v1-MsgNonce", length=12)
* The old chain key MUST be discarded immediately after this call.
*/

Expand Down
29 changes: 8 additions & 21 deletions shared/src/commonMain/kotlin/net/af0/where/e2ee/Session.kt
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ object Session {
}

val stepsNeeded = seq - speculativeState.recvSeq
if (stepsNeeded > MAX_GAP + 1) {
if (stepsNeeded > MAX_SKIPPED_KEYS + 1) {
throw ProtocolGapException("gap too large: stepsNeeded $stepsNeeded")
}

Expand Down Expand Up @@ -256,24 +256,13 @@ object Session {
try {
aeadDecrypt(finalStep.messageKey, finalStep.messageNonce, message.ct, aad)
} catch (e: Exception) {
// Decryption failure. We persist the ratcheted state if the header
// authenticated, to prevent permanent DH desync (§5.5), AND cache the
// message key for `seq` itself so that a later genuine copy of the same
// message — e.g. the clean original after a malicious server bit-flipped
// the first delivery — can still decrypt via the skipped-key cache path
// instead of failing the recvSeq replay check.
// (Deliberate deviation from spec §8.3.1(4); see spec note.)
val seqCacheKey = remoteDhPub.toHex() + ":" + seq
derivationSkippedKeys[seqCacheKey] =
finalStep.messageKey + finalStep.messageNonce + longToBeBytes(now)
if (derivationSkippedKeys.size > MAX_SKIPPED_KEYS) {
val oldestKey = derivationSkippedKeys.keys.first()
if (oldestKey != seqCacheKey) {
derivationSkippedKeys[oldestKey]?.zeroize()
derivationSkippedKeys.remove(oldestKey)
}
}

// Body AEAD failed despite a valid header. Advance recvSeq and the chain
// key to prevent permanent DH desync — without this, a server that drops
// the message entirely and a server that delivers a corrupted copy would
// have different effects on session state, breaking the ratchet.
// The failed message's key is NOT cached: caching it would keep MK_n alive
// for up to 7 days with no benefit, since a server willing to deliver a
// corrupted copy can equally just drop the message.
val failedState =
speculativeState.deepCopy().copy(
recvChainKey = chainKey.copyOf(),
Expand All @@ -282,8 +271,6 @@ object Session {
needsRatchet = cleanState.needsRatchet || isNewDhEpoch,
)
// Wipe any speculative intermediate keys derived during this failed call.
// The seq cache entry above is intentionally NOT in addedSkippedKeys —
// failedState already holds a copy of it.
addedSkippedKeys.forEach { it.zeroize() }
chainKey.zeroize()
throw DecryptionExceptionWithState(failedState, e)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package net.af0.where.e2ee

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.test.fail

Expand All @@ -12,17 +12,13 @@ class ReceiveRatchetFailureTest {
}

/**
* Issue #2: a malicious server bit-flips the body of a genuine message.
* The header still authenticates (header keys are secret), so decryptMessage
* advances the receive ratchet (per §5.5 — to prevent permanent DH desync).
* Without the seq-key cache, when the clean original later arrives in the
* same batch, it is `seq <= recvSeq` → ReplayException and permanently lost.
*
* This test exercises the within-batch case (Bob's pre-decrypted header is
* reused across both decryption attempts, matching E2eeProtocol.decryptBatch).
* A body-AEAD failure after a valid header must advance recvSeq and the chain key
* to prevent permanent DH desync (§8.3.1(4)). The failed message's key must NOT
* be cached — there is no robustness benefit to caching it, since a server willing
* to deliver a corrupted copy can equally just drop the message.
*/
@Test
fun cleanCopyDecryptsAfterTamperedAdvanceCachesSeqKey() {
fun bodyFailAdvancesStateWithoutCachingSeqKey() {
val (qr, aliceEkPriv) = KeyExchange.aliceCreateQrPayload("Alice")
val (msg, bobSession) = KeyExchange.bobProcessQr(qr, "Bob")
val aliceSession = KeyExchange.aliceProcessInit(msg, aliceEkPriv, qr.ekPub)
Expand All @@ -32,17 +28,13 @@ class ReceiveRatchetFailureTest {
MessagePlaintext.Location(1.0, 2.0, 3.0, 4L),
)

// Pre-decrypt the header once (matching the batch path). Both the
// tampered and the clean copy share the same envelope, so this header
// applies to both.
val sessionAad = bobSession.aliceFp + bobSession.bobFp
val header = try {
Session.decryptHeader(bobSession.headerKey, original.envelope, sessionAad)
} catch (_: Exception) {
Session.decryptHeader(bobSession.nextHeaderKey, original.envelope, sessionAad)
}

// Simulate a malicious server bit-flipping the body.
val tampered = original.copy(
ct = original.ct.copyOf().also { it[it.size - 1] = (it.last().toInt() xor 0xFF).toByte() },
)
Expand All @@ -54,20 +46,18 @@ class ReceiveRatchetFailureTest {
e.newState
}

// recvSeq must advance so the ratchet state stays consistent.
assertTrue(bobAfterFailure.recvSeq >= 1, "recvSeq should have advanced past the failed message")

// The clean original now arrives. The cached seq key must rescue it
// from the recvSeq replay rejection.
val (bobFinal, plaintext) = Session.decryptMessage(bobAfterFailure, original, header)
assertIs<MessagePlaintext.Location>(plaintext)
assertEquals(1.0, plaintext.lat)
assertEquals(2.0, plaintext.lng)
assertEquals(4L, plaintext.ts)

// Cache entry for this seq was consumed by the successful decryption.
assertTrue(
bobFinal.skippedMessageKeys.keys.none { it.endsWith(":${header.seq}") },
"seq=${header.seq} cache entry should have been consumed",
// The seq key must NOT be cached — the message is lost, equivalent to a drop.
assertFalse(
bobAfterFailure.skippedMessageKeys.keys.any { it.endsWith(":${header.seq}") },
"seq=${header.seq} key must not be cached after body-fail",
)

// A subsequent attempt to decrypt the (uncorrupted) original is rejected as a replay.
assertFailsWith<ReplayException> {
Session.decryptMessage(bobAfterFailure, original, header)
}
}
}
Loading