diff --git a/README.md b/README.md index 4ed48ab9..21702d46 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Full guides for [Authentication](https://androidpoet.github.io/supabase-kmp/auth - **Session management** — Single-flight auto-refresh, pluggable persistence (`SessionStorage`), `SessionState` via `StateFlow` - **Realtime WebSocket** — Phoenix protocol with auto-reconnection, exponential backoff, presence, offline send buffering, binary broadcast (raw `ByteArray`) - **Secure by default** — Credential headers redacted from logs, smart retries (`429`/`5xx` + `Retry-After`) -- **End-to-end encryption** — Optional `supabase-e2ee`: derive a shared AES-256-GCM key on-device (ECDH → HKDF) so Supabase only ever stores ciphertext +- **End-to-end encryption** — Optional `supabase-e2ee`: derive a shared AES-256-GCM key on-device (ECDH → HKDF) so Supabase only ever stores ciphertext. Includes a `KeyDirectory` (publish/fetch public keys), out-of-band `safetyNumber()` verification (MITM protection), and `EncryptedRoom` — a verify-first encrypt-on-send / decrypt-on-receive chat room over `supabase-realtime` - **16 platform targets** — Android, iOS, macOS, tvOS, watchOS, JVM, Linux, Windows, and WasmJs ## Setup @@ -143,7 +143,7 @@ Errors carry a `category` (`CONFLICT`, `NOT_FOUND`, `UNAUTHORIZED`, `RATE_LIMITE | **supabase-storage** | `io.github.androidpoet:supabase-storage` | Bucket CRUD, file upload/download, signed & public URLs | | **supabase-realtime** | `io.github.androidpoet:supabase-realtime` | WebSocket (Phoenix protocol), auto-reconnect, broadcast, presence | | **supabase-functions** | `io.github.androidpoet:supabase-functions` | Edge function invocation with typed responses | -| **supabase-e2ee** | `io.github.androidpoet:supabase-e2ee` | Optional client-side E2E encryption (ECDH → HKDF → AES-256-GCM) | +| **supabase-e2ee** | `io.github.androidpoet:supabase-e2ee` | Optional client-side E2E encryption (ECDH → HKDF → AES-256-GCM) + key directory, safety-number verification & `EncryptedRoom` over realtime | | **supabase-sync-core** | `io.github.androidpoet:supabase-sync-core` | Offline-first sync engine — pull/merge/push, conflict resolution, keyset cursor (transport-agnostic) | | **supabase-sync-sqldelight** | `io.github.androidpoet:supabase-sync-sqldelight` | On-device local store over SQLDelight — typed-table SSOT, outbox, cursor, pagination | | **supabase-sync** | `io.github.androidpoet:supabase-sync` | Supabase remote source — incremental pull/push over PostgREST + live Realtime deltas | diff --git a/supabase-e2ee/README.md b/supabase-e2ee/README.md index dd6caef6..d02a329b 100644 --- a/supabase-e2ee/README.md +++ b/supabase-e2ee/README.md @@ -45,3 +45,78 @@ val session = (restored.deriveSelfSession() as SupabaseResult.Success).value // **Targets:** Android, JVM, iOS, macOS, tvOS, watchOS, Linux, Windows, WasmJs. **Note:** `publicKey` is raw-encoded and safe to publish; the private key stays inside `E2eeKeyPair` — never upload it. P-256 (not X25519) is used so every provider, including browser WebCrypto, is supported. + +--- + +## Verified encrypted chat: KeyDirectory + EncryptedRoom + +The crypto box above gives you encrypt/decrypt; this layer adds the plumbing for +a real plug-and-play encrypted chat: publishing/fetching public keys, +**verifying** them against tampering, and a live encrypted room over +`supabase-realtime`. + +Apply the migration `supabase/migrations/20260628_add_e2ee_tables.sql` +(`device_keys` + `e2ee_messages`, both RLS-guarded). + +```kotlin +val room = openEncryptedRoom( + database = createDatabaseClient(supabase), + realtime = createRealtimeClient(supabase, RealtimeConfig()), + keyDirectory = SupabaseKeyDirectory(createDatabaseClient(supabase)), + myKeyPair = keyPair, + myUserId = myId, + peerUserId = peerId, + roomId = roomId, + // trustStore = persist your own for durable verifications + // requireVerified = true (default — strict) +).getOrThrow() + +// 1) Verify the peer OUT OF BAND (read the number aloud / compare a QR), then: +val number = room.safetyNumber() // identical on both devices +if (userConfirmedItMatches) room.markVerified() + +// 2) Send — Failure(E2eeErrorCodes.UNVERIFIED) if not verified in strict mode. +room.send("hello, end-to-end 🔐") + +// 3) Read — history() + live messages(), both decrypt automatically. +val past = room.history().getOrThrow() +room.messages().collect { msg -> println(msg.plaintext ?: "🔒 (cannot decrypt)") } +``` + +Because the shared key is symmetric, **both peers decrypt the same rows** — +including their own sent messages (no encrypt-to-self workaround needed). + +### Security model + +The server is treated as **untrusted**. This module guarantees Supabase only +ever stores ciphertext, and hardens the two classic weak points: + +- **MITM → safety numbers.** Key distribution flows through the server, so raw + ECDH alone is man-in-the-middle-able. `safetyNumber()` returns the *same* + number on both sides; comparing it out of band and calling `markVerified()` is + what authenticates the peer. Strict mode blocks `send` until then. +- **Key-change rejection.** A peer's key changing after you trusted it (a + tampered directory) is rejected as `E2eeErrorCodes.IDENTITY_CHANGED` until you + re-verify (`TrustStore.remove` then re-open). + +#### ⚠️ Honest caveat — no forward secrecy + +The shared key is **static**. If a private key is ever extracted, an attacker +can decrypt **all** past and future messages. That's sufficient for "the server +can't read it" (the dominant commercial threat), but it is **not** Signal-grade +— do **not** advertise forward secrecy. For a Double Ratchet you need a real +libsignal binding (which is **AGPL**). + +### API surface (chat plumbing) + +| Symbol | Purpose | +|---|---| +| `safetyNumber(localPub, peerPub)` | out-of-band verification number (same on both sides) | +| `TrustStore` / `InMemoryTrustStore` / `TrustLevel` / `TrustEntry` | per-peer trust ledger (BYO persistence) | +| `KeyDirectory` / `SupabaseKeyDirectory` / `InMemoryKeyDirectory` | publish / fetch public keys | +| `openEncryptedRoom(...)` → `EncryptedRoom` | verify-first encrypted chat room | +| `EncryptedRoom.safetyNumber` / `markVerified` / `isVerified` / `send` / `history` / `messages` | room operations | +| `DecryptedMessage` / `E2eeErrorCodes` | decrypted result / error codes | + +Bring your own persistence for `TrustStore` and key storage — this module never +bundles a platform secure-storage dependency. diff --git a/supabase-e2ee/api/android/supabase-e2ee.api b/supabase-e2ee/api/android/supabase-e2ee.api index 574f2b29..cdfcd5c5 100644 --- a/supabase-e2ee/api/android/supabase-e2ee.api +++ b/supabase-e2ee/api/android/supabase-e2ee.api @@ -1,3 +1,19 @@ +public final class io/github/androidpoet/supabase/e2ee/DecryptedMessage { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)V + public final fun getCreatedAt ()Ljava/lang/String; + public final fun getDecryptFailed ()Z + public final fun getId ()Ljava/lang/String; + public final fun getPlaintext ()Ljava/lang/String; + public final fun getSenderId ()Ljava/lang/String; +} + +public final class io/github/androidpoet/supabase/e2ee/E2eeErrorCodes { + public static final field IDENTITY_CHANGED Ljava/lang/String; + public static final field INSTANCE Lio/github/androidpoet/supabase/e2ee/E2eeErrorCodes; + public static final field MISSING_KEYS Ljava/lang/String; + public static final field UNVERIFIED Ljava/lang/String; +} + public final class io/github/androidpoet/supabase/e2ee/E2eeKeyPair { public final fun getPublicKey ()[B } @@ -17,3 +33,71 @@ public final class io/github/androidpoet/supabase/e2ee/E2eeSession { public final fun encrypt ([BLkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class io/github/androidpoet/supabase/e2ee/EncryptedRoom { + public final fun getMyUserId ()Ljava/lang/String; + public final fun getPeerUserId ()Ljava/lang/String; + public final fun getRoomId ()Ljava/lang/String; + public final fun history (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun isVerified (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun markVerified (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun messages ()Lkotlinx/coroutines/flow/Flow; + public final fun safetyNumber (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun send (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class io/github/androidpoet/supabase/e2ee/EncryptedRoomKt { + public static final fun openEncryptedRoom (Lio/github/androidpoet/supabase/database/DatabaseClient;Lio/github/androidpoet/supabase/realtime/RealtimeClient;Lio/github/androidpoet/supabase/e2ee/KeyDirectory;Lio/github/androidpoet/supabase/e2ee/E2eeKeyPair;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/github/androidpoet/supabase/e2ee/TrustStore;ZLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun openEncryptedRoom$default (Lio/github/androidpoet/supabase/database/DatabaseClient;Lio/github/androidpoet/supabase/realtime/RealtimeClient;Lio/github/androidpoet/supabase/e2ee/KeyDirectory;Lio/github/androidpoet/supabase/e2ee/E2eeKeyPair;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/github/androidpoet/supabase/e2ee/TrustStore;ZLjava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public final class io/github/androidpoet/supabase/e2ee/FingerprintKt { + public static final fun safetyNumber ([B[BLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class io/github/androidpoet/supabase/e2ee/InMemoryKeyDirectory : io/github/androidpoet/supabase/e2ee/KeyDirectory { + public fun ()V + public fun fetch (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun publish (Ljava/lang/String;[BLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class io/github/androidpoet/supabase/e2ee/InMemoryTrustStore : io/github/androidpoet/supabase/e2ee/TrustStore { + public fun ()V + public fun get (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun put (Ljava/lang/String;Lio/github/androidpoet/supabase/e2ee/TrustEntry;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun remove (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class io/github/androidpoet/supabase/e2ee/KeyDirectory { + public abstract fun fetch (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun publish (Ljava/lang/String;[BLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class io/github/androidpoet/supabase/e2ee/SupabaseKeyDirectory : io/github/androidpoet/supabase/e2ee/KeyDirectory { + public fun (Lio/github/androidpoet/supabase/database/DatabaseClient;Ljava/lang/String;)V + public synthetic fun (Lio/github/androidpoet/supabase/database/DatabaseClient;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun fetch (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun publish (Ljava/lang/String;[BLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class io/github/androidpoet/supabase/e2ee/TrustEntry { + public fun ([BLio/github/androidpoet/supabase/e2ee/TrustLevel;)V + public final fun getLevel ()Lio/github/androidpoet/supabase/e2ee/TrustLevel; + public final fun getPublicKey ()[B + public final fun withLevel (Lio/github/androidpoet/supabase/e2ee/TrustLevel;)Lio/github/androidpoet/supabase/e2ee/TrustEntry; +} + +public final class io/github/androidpoet/supabase/e2ee/TrustLevel : java/lang/Enum { + public static final field UNKNOWN Lio/github/androidpoet/supabase/e2ee/TrustLevel; + public static final field UNVERIFIED Lio/github/androidpoet/supabase/e2ee/TrustLevel; + public static final field VERIFIED Lio/github/androidpoet/supabase/e2ee/TrustLevel; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lio/github/androidpoet/supabase/e2ee/TrustLevel; + public static fun values ()[Lio/github/androidpoet/supabase/e2ee/TrustLevel; +} + +public abstract interface class io/github/androidpoet/supabase/e2ee/TrustStore { + public abstract fun get (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun put (Ljava/lang/String;Lio/github/androidpoet/supabase/e2ee/TrustEntry;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun remove (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + diff --git a/supabase-e2ee/api/jvm/supabase-e2ee.api b/supabase-e2ee/api/jvm/supabase-e2ee.api index 574f2b29..cdfcd5c5 100644 --- a/supabase-e2ee/api/jvm/supabase-e2ee.api +++ b/supabase-e2ee/api/jvm/supabase-e2ee.api @@ -1,3 +1,19 @@ +public final class io/github/androidpoet/supabase/e2ee/DecryptedMessage { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)V + public final fun getCreatedAt ()Ljava/lang/String; + public final fun getDecryptFailed ()Z + public final fun getId ()Ljava/lang/String; + public final fun getPlaintext ()Ljava/lang/String; + public final fun getSenderId ()Ljava/lang/String; +} + +public final class io/github/androidpoet/supabase/e2ee/E2eeErrorCodes { + public static final field IDENTITY_CHANGED Ljava/lang/String; + public static final field INSTANCE Lio/github/androidpoet/supabase/e2ee/E2eeErrorCodes; + public static final field MISSING_KEYS Ljava/lang/String; + public static final field UNVERIFIED Ljava/lang/String; +} + public final class io/github/androidpoet/supabase/e2ee/E2eeKeyPair { public final fun getPublicKey ()[B } @@ -17,3 +33,71 @@ public final class io/github/androidpoet/supabase/e2ee/E2eeSession { public final fun encrypt ([BLkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class io/github/androidpoet/supabase/e2ee/EncryptedRoom { + public final fun getMyUserId ()Ljava/lang/String; + public final fun getPeerUserId ()Ljava/lang/String; + public final fun getRoomId ()Ljava/lang/String; + public final fun history (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun isVerified (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun markVerified (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun messages ()Lkotlinx/coroutines/flow/Flow; + public final fun safetyNumber (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun send (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class io/github/androidpoet/supabase/e2ee/EncryptedRoomKt { + public static final fun openEncryptedRoom (Lio/github/androidpoet/supabase/database/DatabaseClient;Lio/github/androidpoet/supabase/realtime/RealtimeClient;Lio/github/androidpoet/supabase/e2ee/KeyDirectory;Lio/github/androidpoet/supabase/e2ee/E2eeKeyPair;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/github/androidpoet/supabase/e2ee/TrustStore;ZLjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun openEncryptedRoom$default (Lio/github/androidpoet/supabase/database/DatabaseClient;Lio/github/androidpoet/supabase/realtime/RealtimeClient;Lio/github/androidpoet/supabase/e2ee/KeyDirectory;Lio/github/androidpoet/supabase/e2ee/E2eeKeyPair;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/github/androidpoet/supabase/e2ee/TrustStore;ZLjava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public final class io/github/androidpoet/supabase/e2ee/FingerprintKt { + public static final fun safetyNumber ([B[BLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class io/github/androidpoet/supabase/e2ee/InMemoryKeyDirectory : io/github/androidpoet/supabase/e2ee/KeyDirectory { + public fun ()V + public fun fetch (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun publish (Ljava/lang/String;[BLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class io/github/androidpoet/supabase/e2ee/InMemoryTrustStore : io/github/androidpoet/supabase/e2ee/TrustStore { + public fun ()V + public fun get (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun put (Ljava/lang/String;Lio/github/androidpoet/supabase/e2ee/TrustEntry;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun remove (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class io/github/androidpoet/supabase/e2ee/KeyDirectory { + public abstract fun fetch (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun publish (Ljava/lang/String;[BLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class io/github/androidpoet/supabase/e2ee/SupabaseKeyDirectory : io/github/androidpoet/supabase/e2ee/KeyDirectory { + public fun (Lio/github/androidpoet/supabase/database/DatabaseClient;Ljava/lang/String;)V + public synthetic fun (Lio/github/androidpoet/supabase/database/DatabaseClient;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun fetch (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun publish (Ljava/lang/String;[BLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class io/github/androidpoet/supabase/e2ee/TrustEntry { + public fun ([BLio/github/androidpoet/supabase/e2ee/TrustLevel;)V + public final fun getLevel ()Lio/github/androidpoet/supabase/e2ee/TrustLevel; + public final fun getPublicKey ()[B + public final fun withLevel (Lio/github/androidpoet/supabase/e2ee/TrustLevel;)Lio/github/androidpoet/supabase/e2ee/TrustEntry; +} + +public final class io/github/androidpoet/supabase/e2ee/TrustLevel : java/lang/Enum { + public static final field UNKNOWN Lio/github/androidpoet/supabase/e2ee/TrustLevel; + public static final field UNVERIFIED Lio/github/androidpoet/supabase/e2ee/TrustLevel; + public static final field VERIFIED Lio/github/androidpoet/supabase/e2ee/TrustLevel; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lio/github/androidpoet/supabase/e2ee/TrustLevel; + public static fun values ()[Lio/github/androidpoet/supabase/e2ee/TrustLevel; +} + +public abstract interface class io/github/androidpoet/supabase/e2ee/TrustStore { + public abstract fun get (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun put (Ljava/lang/String;Lio/github/androidpoet/supabase/e2ee/TrustEntry;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun remove (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + diff --git a/supabase-e2ee/api/supabase-e2ee.klib.api b/supabase-e2ee/api/supabase-e2ee.klib.api index 6959c98b..d543c4c8 100644 --- a/supabase-e2ee/api/supabase-e2ee.klib.api +++ b/supabase-e2ee/api/supabase-e2ee.klib.api @@ -6,6 +6,44 @@ // - Show declarations: true // Library unique name: +final enum class io.github.androidpoet.supabase.e2ee/TrustLevel : kotlin/Enum { // io.github.androidpoet.supabase.e2ee/TrustLevel|null[0] + enum entry UNKNOWN // io.github.androidpoet.supabase.e2ee/TrustLevel.UNKNOWN|null[0] + enum entry UNVERIFIED // io.github.androidpoet.supabase.e2ee/TrustLevel.UNVERIFIED|null[0] + enum entry VERIFIED // io.github.androidpoet.supabase.e2ee/TrustLevel.VERIFIED|null[0] + + final val entries // io.github.androidpoet.supabase.e2ee/TrustLevel.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // io.github.androidpoet.supabase.e2ee/TrustLevel.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): io.github.androidpoet.supabase.e2ee/TrustLevel // io.github.androidpoet.supabase.e2ee/TrustLevel.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // io.github.androidpoet.supabase.e2ee/TrustLevel.values|values#static(){}[0] +} + +abstract interface io.github.androidpoet.supabase.e2ee/KeyDirectory { // io.github.androidpoet.supabase.e2ee/KeyDirectory|null[0] + abstract suspend fun fetch(kotlin/String): io.github.androidpoet.supabase.core.result/SupabaseResult // io.github.androidpoet.supabase.e2ee/KeyDirectory.fetch|fetch(kotlin.String){}[0] + abstract suspend fun publish(kotlin/String, kotlin/ByteArray): io.github.androidpoet.supabase.core.result/SupabaseResult // io.github.androidpoet.supabase.e2ee/KeyDirectory.publish|publish(kotlin.String;kotlin.ByteArray){}[0] +} + +abstract interface io.github.androidpoet.supabase.e2ee/TrustStore { // io.github.androidpoet.supabase.e2ee/TrustStore|null[0] + abstract suspend fun get(kotlin/String): io.github.androidpoet.supabase.e2ee/TrustEntry? // io.github.androidpoet.supabase.e2ee/TrustStore.get|get(kotlin.String){}[0] + abstract suspend fun put(kotlin/String, io.github.androidpoet.supabase.e2ee/TrustEntry) // io.github.androidpoet.supabase.e2ee/TrustStore.put|put(kotlin.String;io.github.androidpoet.supabase.e2ee.TrustEntry){}[0] + abstract suspend fun remove(kotlin/String) // io.github.androidpoet.supabase.e2ee/TrustStore.remove|remove(kotlin.String){}[0] +} + +final class io.github.androidpoet.supabase.e2ee/DecryptedMessage { // io.github.androidpoet.supabase.e2ee/DecryptedMessage|null[0] + constructor (kotlin/String, kotlin/String, kotlin/String?, kotlin/Boolean, kotlin/String?) // io.github.androidpoet.supabase.e2ee/DecryptedMessage.|(kotlin.String;kotlin.String;kotlin.String?;kotlin.Boolean;kotlin.String?){}[0] + + final val createdAt // io.github.androidpoet.supabase.e2ee/DecryptedMessage.createdAt|{}createdAt[0] + final fun (): kotlin/String? // io.github.androidpoet.supabase.e2ee/DecryptedMessage.createdAt.|(){}[0] + final val decryptFailed // io.github.androidpoet.supabase.e2ee/DecryptedMessage.decryptFailed|{}decryptFailed[0] + final fun (): kotlin/Boolean // io.github.androidpoet.supabase.e2ee/DecryptedMessage.decryptFailed.|(){}[0] + final val id // io.github.androidpoet.supabase.e2ee/DecryptedMessage.id|{}id[0] + final fun (): kotlin/String // io.github.androidpoet.supabase.e2ee/DecryptedMessage.id.|(){}[0] + final val plaintext // io.github.androidpoet.supabase.e2ee/DecryptedMessage.plaintext|{}plaintext[0] + final fun (): kotlin/String? // io.github.androidpoet.supabase.e2ee/DecryptedMessage.plaintext.|(){}[0] + final val senderId // io.github.androidpoet.supabase.e2ee/DecryptedMessage.senderId|{}senderId[0] + final fun (): kotlin/String // io.github.androidpoet.supabase.e2ee/DecryptedMessage.senderId.|(){}[0] +} + final class io.github.androidpoet.supabase.e2ee/E2eeKeyPair { // io.github.androidpoet.supabase.e2ee/E2eeKeyPair|null[0] final val publicKey // io.github.androidpoet.supabase.e2ee/E2eeKeyPair.publicKey|{}publicKey[0] final fun (): kotlin/ByteArray // io.github.androidpoet.supabase.e2ee/E2eeKeyPair.publicKey.|(){}[0] @@ -18,10 +56,70 @@ final class io.github.androidpoet.supabase.e2ee/E2eeSession { // io.github.andro final suspend fun encrypt(kotlin/String): io.github.androidpoet.supabase.core.result/SupabaseResult // io.github.androidpoet.supabase.e2ee/E2eeSession.encrypt|encrypt(kotlin.String){}[0] } +final class io.github.androidpoet.supabase.e2ee/EncryptedRoom { // io.github.androidpoet.supabase.e2ee/EncryptedRoom|null[0] + final val myUserId // io.github.androidpoet.supabase.e2ee/EncryptedRoom.myUserId|{}myUserId[0] + final fun (): kotlin/String // io.github.androidpoet.supabase.e2ee/EncryptedRoom.myUserId.|(){}[0] + final val peerUserId // io.github.androidpoet.supabase.e2ee/EncryptedRoom.peerUserId|{}peerUserId[0] + final fun (): kotlin/String // io.github.androidpoet.supabase.e2ee/EncryptedRoom.peerUserId.|(){}[0] + final val roomId // io.github.androidpoet.supabase.e2ee/EncryptedRoom.roomId|{}roomId[0] + final fun (): kotlin/String // io.github.androidpoet.supabase.e2ee/EncryptedRoom.roomId.|(){}[0] + + final fun messages(): kotlinx.coroutines.flow/Flow // io.github.androidpoet.supabase.e2ee/EncryptedRoom.messages|messages(){}[0] + final suspend fun history(): io.github.androidpoet.supabase.core.result/SupabaseResult> // io.github.androidpoet.supabase.e2ee/EncryptedRoom.history|history(){}[0] + final suspend fun isVerified(): kotlin/Boolean // io.github.androidpoet.supabase.e2ee/EncryptedRoom.isVerified|isVerified(){}[0] + final suspend fun markVerified() // io.github.androidpoet.supabase.e2ee/EncryptedRoom.markVerified|markVerified(){}[0] + final suspend fun safetyNumber(): kotlin/String // io.github.androidpoet.supabase.e2ee/EncryptedRoom.safetyNumber|safetyNumber(){}[0] + final suspend fun send(kotlin/String): io.github.androidpoet.supabase.core.result/SupabaseResult // io.github.androidpoet.supabase.e2ee/EncryptedRoom.send|send(kotlin.String){}[0] +} + +final class io.github.androidpoet.supabase.e2ee/InMemoryKeyDirectory : io.github.androidpoet.supabase.e2ee/KeyDirectory { // io.github.androidpoet.supabase.e2ee/InMemoryKeyDirectory|null[0] + constructor () // io.github.androidpoet.supabase.e2ee/InMemoryKeyDirectory.|(){}[0] + + final suspend fun fetch(kotlin/String): io.github.androidpoet.supabase.core.result/SupabaseResult // io.github.androidpoet.supabase.e2ee/InMemoryKeyDirectory.fetch|fetch(kotlin.String){}[0] + final suspend fun publish(kotlin/String, kotlin/ByteArray): io.github.androidpoet.supabase.core.result/SupabaseResult // io.github.androidpoet.supabase.e2ee/InMemoryKeyDirectory.publish|publish(kotlin.String;kotlin.ByteArray){}[0] +} + +final class io.github.androidpoet.supabase.e2ee/InMemoryTrustStore : io.github.androidpoet.supabase.e2ee/TrustStore { // io.github.androidpoet.supabase.e2ee/InMemoryTrustStore|null[0] + constructor () // io.github.androidpoet.supabase.e2ee/InMemoryTrustStore.|(){}[0] + + final suspend fun get(kotlin/String): io.github.androidpoet.supabase.e2ee/TrustEntry? // io.github.androidpoet.supabase.e2ee/InMemoryTrustStore.get|get(kotlin.String){}[0] + final suspend fun put(kotlin/String, io.github.androidpoet.supabase.e2ee/TrustEntry) // io.github.androidpoet.supabase.e2ee/InMemoryTrustStore.put|put(kotlin.String;io.github.androidpoet.supabase.e2ee.TrustEntry){}[0] + final suspend fun remove(kotlin/String) // io.github.androidpoet.supabase.e2ee/InMemoryTrustStore.remove|remove(kotlin.String){}[0] +} + +final class io.github.androidpoet.supabase.e2ee/SupabaseKeyDirectory : io.github.androidpoet.supabase.e2ee/KeyDirectory { // io.github.androidpoet.supabase.e2ee/SupabaseKeyDirectory|null[0] + constructor (io.github.androidpoet.supabase.database/DatabaseClient, kotlin/String = ...) // io.github.androidpoet.supabase.e2ee/SupabaseKeyDirectory.|(io.github.androidpoet.supabase.database.DatabaseClient;kotlin.String){}[0] + + final suspend fun fetch(kotlin/String): io.github.androidpoet.supabase.core.result/SupabaseResult // io.github.androidpoet.supabase.e2ee/SupabaseKeyDirectory.fetch|fetch(kotlin.String){}[0] + final suspend fun publish(kotlin/String, kotlin/ByteArray): io.github.androidpoet.supabase.core.result/SupabaseResult // io.github.androidpoet.supabase.e2ee/SupabaseKeyDirectory.publish|publish(kotlin.String;kotlin.ByteArray){}[0] +} + +final class io.github.androidpoet.supabase.e2ee/TrustEntry { // io.github.androidpoet.supabase.e2ee/TrustEntry|null[0] + constructor (kotlin/ByteArray, io.github.androidpoet.supabase.e2ee/TrustLevel) // io.github.androidpoet.supabase.e2ee/TrustEntry.|(kotlin.ByteArray;io.github.androidpoet.supabase.e2ee.TrustLevel){}[0] + + final val level // io.github.androidpoet.supabase.e2ee/TrustEntry.level|{}level[0] + final fun (): io.github.androidpoet.supabase.e2ee/TrustLevel // io.github.androidpoet.supabase.e2ee/TrustEntry.level.|(){}[0] + final val publicKey // io.github.androidpoet.supabase.e2ee/TrustEntry.publicKey|{}publicKey[0] + final fun (): kotlin/ByteArray // io.github.androidpoet.supabase.e2ee/TrustEntry.publicKey.|(){}[0] + + final fun withLevel(io.github.androidpoet.supabase.e2ee/TrustLevel): io.github.androidpoet.supabase.e2ee/TrustEntry // io.github.androidpoet.supabase.e2ee/TrustEntry.withLevel|withLevel(io.github.androidpoet.supabase.e2ee.TrustLevel){}[0] +} + +final object io.github.androidpoet.supabase.e2ee/E2eeErrorCodes { // io.github.androidpoet.supabase.e2ee/E2eeErrorCodes|null[0] + final const val IDENTITY_CHANGED // io.github.androidpoet.supabase.e2ee/E2eeErrorCodes.IDENTITY_CHANGED|{}IDENTITY_CHANGED[0] + final fun (): kotlin/String // io.github.androidpoet.supabase.e2ee/E2eeErrorCodes.IDENTITY_CHANGED.|(){}[0] + final const val MISSING_KEYS // io.github.androidpoet.supabase.e2ee/E2eeErrorCodes.MISSING_KEYS|{}MISSING_KEYS[0] + final fun (): kotlin/String // io.github.androidpoet.supabase.e2ee/E2eeErrorCodes.MISSING_KEYS.|(){}[0] + final const val UNVERIFIED // io.github.androidpoet.supabase.e2ee/E2eeErrorCodes.UNVERIFIED|{}UNVERIFIED[0] + final fun (): kotlin/String // io.github.androidpoet.supabase.e2ee/E2eeErrorCodes.UNVERIFIED.|(){}[0] +} + final suspend fun (io.github.androidpoet.supabase.e2ee/E2eeKeyPair).io.github.androidpoet.supabase.e2ee/deriveSelfSession(): io.github.androidpoet.supabase.core.result/SupabaseResult // io.github.androidpoet.supabase.e2ee/deriveSelfSession|deriveSelfSession@io.github.androidpoet.supabase.e2ee.E2eeKeyPair(){}[0] final suspend fun (io.github.androidpoet.supabase.e2ee/E2eeKeyPair).io.github.androidpoet.supabase.e2ee/deriveSession(kotlin/ByteArray): io.github.androidpoet.supabase.core.result/SupabaseResult // io.github.androidpoet.supabase.e2ee/deriveSession|deriveSession@io.github.androidpoet.supabase.e2ee.E2eeKeyPair(kotlin.ByteArray){}[0] final suspend fun (io.github.androidpoet.supabase.e2ee/E2eeKeyPair).io.github.androidpoet.supabase.e2ee/exportPrivateKey(): io.github.androidpoet.supabase.core.result/SupabaseResult // io.github.androidpoet.supabase.e2ee/exportPrivateKey|exportPrivateKey@io.github.androidpoet.supabase.e2ee.E2eeKeyPair(){}[0] final suspend fun io.github.androidpoet.supabase.e2ee/generateE2eeKeyPair(): io.github.androidpoet.supabase.core.result/SupabaseResult // io.github.androidpoet.supabase.e2ee/generateE2eeKeyPair|generateE2eeKeyPair(){}[0] final suspend fun io.github.androidpoet.supabase.e2ee/importE2eeKeyPair(kotlin/ByteArray, kotlin/ByteArray): io.github.androidpoet.supabase.core.result/SupabaseResult // io.github.androidpoet.supabase.e2ee/importE2eeKeyPair|importE2eeKeyPair(kotlin.ByteArray;kotlin.ByteArray){}[0] +final suspend fun io.github.androidpoet.supabase.e2ee/openEncryptedRoom(io.github.androidpoet.supabase.database/DatabaseClient, io.github.androidpoet.supabase.realtime/RealtimeClient, io.github.androidpoet.supabase.e2ee/KeyDirectory, io.github.androidpoet.supabase.e2ee/E2eeKeyPair, kotlin/String, kotlin/String, kotlin/String, io.github.androidpoet.supabase.e2ee/TrustStore = ..., kotlin/Boolean = ..., kotlin/String = ...): io.github.androidpoet.supabase.core.result/SupabaseResult // io.github.androidpoet.supabase.e2ee/openEncryptedRoom|openEncryptedRoom(io.github.androidpoet.supabase.database.DatabaseClient;io.github.androidpoet.supabase.realtime.RealtimeClient;io.github.androidpoet.supabase.e2ee.KeyDirectory;io.github.androidpoet.supabase.e2ee.E2eeKeyPair;kotlin.String;kotlin.String;kotlin.String;io.github.androidpoet.supabase.e2ee.TrustStore;kotlin.Boolean;kotlin.String){}[0] +final suspend fun io.github.androidpoet.supabase.e2ee/safetyNumber(kotlin/ByteArray, kotlin/ByteArray): kotlin/String // io.github.androidpoet.supabase.e2ee/safetyNumber|safetyNumber(kotlin.ByteArray;kotlin.ByteArray){}[0] final suspend inline fun <#A: reified kotlin/Any?> (io.github.androidpoet.supabase.e2ee/E2eeSession).io.github.androidpoet.supabase.e2ee/decryptValue(kotlin/ByteArray, kotlinx.serialization.json/Json = ...): io.github.androidpoet.supabase.core.result/SupabaseResult<#A> // io.github.androidpoet.supabase.e2ee/decryptValue|decryptValue@io.github.androidpoet.supabase.e2ee.E2eeSession(kotlin.ByteArray;kotlinx.serialization.json.Json){0§}[0] final suspend inline fun <#A: reified kotlin/Any?> (io.github.androidpoet.supabase.e2ee/E2eeSession).io.github.androidpoet.supabase.e2ee/encryptValue(#A, kotlinx.serialization.json/Json = ...): io.github.androidpoet.supabase.core.result/SupabaseResult // io.github.androidpoet.supabase.e2ee/encryptValue|encryptValue@io.github.androidpoet.supabase.e2ee.E2eeSession(0:0;kotlinx.serialization.json.Json){0§}[0] diff --git a/supabase-e2ee/build.gradle.kts b/supabase-e2ee/build.gradle.kts index 84b2afdc..7d9615b8 100644 --- a/supabase-e2ee/build.gradle.kts +++ b/supabase-e2ee/build.gradle.kts @@ -34,6 +34,12 @@ kotlin { sourceSets { commonMain.dependencies { api(project(":supabase-core")) + // The key directory and EncryptedRoom store keys/ciphertext over the + // database and deliver live messages over realtime, so this opt-in + // module pairs the crypto with both (a Supabase chat app uses them + // anyway). The raw crypto API stays usable without touching either. + api(project(":supabase-database")) + api(project(":supabase-realtime")) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) implementation(libs.cryptography.random) diff --git a/supabase-e2ee/src/commonMain/kotlin/io/github/androidpoet/supabase/e2ee/EncryptedRoom.kt b/supabase-e2ee/src/commonMain/kotlin/io/github/androidpoet/supabase/e2ee/EncryptedRoom.kt new file mode 100644 index 00000000..1e4fcd33 --- /dev/null +++ b/supabase-e2ee/src/commonMain/kotlin/io/github/androidpoet/supabase/e2ee/EncryptedRoom.kt @@ -0,0 +1,241 @@ +@file:OptIn(ExperimentalEncodingApi::class) + +package io.github.androidpoet.supabase.e2ee + +import io.github.androidpoet.supabase.core.models.Column +import io.github.androidpoet.supabase.core.result.SupabaseError +import io.github.androidpoet.supabase.core.result.SupabaseResult +import io.github.androidpoet.supabase.core.result.flatMap +import io.github.androidpoet.supabase.core.result.map +import io.github.androidpoet.supabase.database.DatabaseClient +import io.github.androidpoet.supabase.realtime.RealtimeClient +import io.github.androidpoet.supabase.realtime.models.PostgresChangeEvent +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** Synthetic error codes raised by [EncryptedRoom]. */ +public object E2eeErrorCodes { + /** Strict mode refused to send to an unverified peer. */ + public const val UNVERIFIED: String = "e2ee_unverified" + + /** The peer has not published a public key. */ + public const val MISSING_KEYS: String = "e2ee_missing_keys" + + /** The peer's published key differs from the one previously trusted. */ + public const val IDENTITY_CHANGED: String = "e2ee_identity_changed" +} + +/** A decrypted chat message read back from the encrypted [EncryptedRoom]. */ +public class DecryptedMessage( + /** Server row id. */ + public val id: String, + /** The author's user id. */ + public val senderId: String, + /** The recovered clear text, or `null` if absent / undecryptable. */ + public val plaintext: String?, + /** Whether decryption was attempted and failed. */ + public val decryptFailed: Boolean, + /** Creation timestamp string from the row, if present. */ + public val createdAt: String?, +) + +/** + * A 1:1 end-to-end encrypted room over a Supabase `messages` table. + * + * Bodies are encrypted with a shared AES-256-GCM key (ECDH → HKDF) before they + * leave the device; the server only ever stores ciphertext. Because the shared + * key is symmetric, both peers decrypt the same rows — including their own. + * + * **Verify first.** In the default strict mode, [send] refuses until the peer's + * [safetyNumber] has been confirmed out of band and [markVerified] called — + * this is what prevents a tampered key directory from mounting a + * man-in-the-middle. Open one with [openEncryptedRoom]. + */ +public class EncryptedRoom internal constructor( + private val database: DatabaseClient, + private val realtime: RealtimeClient, + private val session: E2eeSession, + private val myPublicKey: ByteArray, + /** The local user id. */ + public val myUserId: String, + /** The peer's user id. */ + public val peerUserId: String, + private val peerPublicKey: ByteArray, + private val trustStore: TrustStore, + /** The room this instance is scoped to. */ + public val roomId: String, + private val requireVerified: Boolean, + private val messagesTable: String, +) { + /** The safety number to compare out of band with the peer. */ + public suspend fun safetyNumber(): String = + safetyNumber(myPublicKey, peerPublicKey) + + /** Whether the peer's key has been verified. */ + public suspend fun isVerified(): Boolean = + trustStore.get(peerUserId)?.level == TrustLevel.VERIFIED + + /** Marks the peer verified — call only after confirming [safetyNumber]. */ + public suspend fun markVerified() { + val entry = + trustStore.get(peerUserId) + ?: TrustEntry(peerPublicKey, TrustLevel.VERIFIED) + trustStore.put(peerUserId, entry.withLevel(TrustLevel.VERIFIED)) + } + + /** + * Encrypts [text] and inserts it as ciphertext. In strict mode, fails with + * [E2eeErrorCodes.UNVERIFIED] if the peer is not yet verified. + */ + public suspend fun send(text: String): SupabaseResult { + if (requireVerified && !isVerified()) { + return SupabaseResult.Failure( + SupabaseError( + message = "refusing to encrypt to unverified peer $peerUserId", + code = E2eeErrorCodes.UNVERIFIED, + ), + ) + } + return session.encrypt(text).flatMap { ciphertext -> + val body = + buildJsonObject { + put("room_id", roomId) + put("sender_id", myUserId) + put("ciphertext", Base64.encode(ciphertext)) + }.toString() + database.insert(table = messagesTable, body = body).map { } + } + } + + /** Loads and decrypts the existing messages in this room. */ + public suspend fun history(): SupabaseResult> = + database + .select(table = messagesTable, columns = "*") { + where { Column("room_id") eq roomId } + }.map { rowsJson -> + Json + .parseToJsonElement(rowsJson) + .jsonArray + .map { decodeRow(it.jsonObject) } + } + + /** + * A live stream of newly-inserted messages, decrypted on the fly. Collect + * after [history] for the full timeline. + */ + public fun messages(): Flow = + callbackFlow { + realtime.connect() + val subscription = + realtime + .channel("e2ee:$roomId") + .onPostgresChange( + schema = "public", + table = messagesTable, + filter = "room_id=eq.$roomId", + event = PostgresChangeEvent.INSERT, + ) { payload -> + val record = payload["record"]?.jsonObject + if (record != null) trySend(decodeRow(record)) + }.subscribe() + awaitClose { + launch { runCatching { subscription.unsubscribe() } } + } + } + + private suspend fun decodeRow(row: JsonObject): DecryptedMessage { + val id = row["id"]?.jsonPrimitive?.content.orEmpty() + val senderId = row["sender_id"]?.jsonPrimitive?.content.orEmpty() + val createdAt = row["created_at"]?.jsonPrimitive?.content + val ciphertext = + row["ciphertext"]?.jsonPrimitive?.content + ?: return DecryptedMessage(id, senderId, null, false, createdAt) + return when (val decrypted = session.decryptToString(Base64.decode(ciphertext))) { + is SupabaseResult.Success -> + DecryptedMessage(id, senderId, decrypted.value, false, createdAt) + is SupabaseResult.Failure -> + DecryptedMessage(id, senderId, null, true, createdAt) + } + } +} + +/** + * Opens an [EncryptedRoom] for a 1:1 conversation: publishes the local public + * key, fetches the peer's, records it (trust-on-first-use), rejects a changed + * key as [E2eeErrorCodes.IDENTITY_CHANGED], and derives the shared session. + * + * The returned room is **unverified** until you compare [EncryptedRoom.safetyNumber] + * out of band and call [EncryptedRoom.markVerified]; in the default strict mode + * sending is blocked until then. + */ +public suspend fun openEncryptedRoom( + database: DatabaseClient, + realtime: RealtimeClient, + keyDirectory: KeyDirectory, + myKeyPair: E2eeKeyPair, + myUserId: String, + peerUserId: String, + roomId: String, + trustStore: TrustStore = InMemoryTrustStore(), + requireVerified: Boolean = true, + messagesTable: String = "e2ee_messages", +): SupabaseResult { + // Publish own key, then fetch the peer's — chained so a failure short-circuits. + val fetched = + keyDirectory + .publish(myUserId, myKeyPair.publicKey) + .flatMap { keyDirectory.fetch(peerUserId) } + val peerKey = + when (fetched) { + is SupabaseResult.Failure -> return fetched + is SupabaseResult.Success -> + fetched.value + ?: return SupabaseResult.Failure( + SupabaseError( + message = "no published key for $peerUserId", + code = E2eeErrorCodes.MISSING_KEYS, + ), + ) + } + + // Trust on first use; reject a later key change as tampering. + val existing = trustStore.get(peerUserId) + if (existing == null) { + trustStore.put(peerUserId, TrustEntry(peerKey, TrustLevel.UNVERIFIED)) + } else if (!existing.publicKey.contentEquals(peerKey)) { + return SupabaseResult.Failure( + SupabaseError( + message = "identity key for $peerUserId changed; re-verify", + code = E2eeErrorCodes.IDENTITY_CHANGED, + ), + ) + } + + // Derive the session; a derive failure flows through `map` as Failure. + return myKeyPair.deriveSession(peerKey).map { session -> + EncryptedRoom( + database = database, + realtime = realtime, + session = session, + myPublicKey = myKeyPair.publicKey, + myUserId = myUserId, + peerUserId = peerUserId, + peerPublicKey = peerKey, + trustStore = trustStore, + roomId = roomId, + requireVerified = requireVerified, + messagesTable = messagesTable, + ) + } +} diff --git a/supabase-e2ee/src/commonMain/kotlin/io/github/androidpoet/supabase/e2ee/Fingerprint.kt b/supabase-e2ee/src/commonMain/kotlin/io/github/androidpoet/supabase/e2ee/Fingerprint.kt new file mode 100644 index 00000000..f8111f9f --- /dev/null +++ b/supabase-e2ee/src/commonMain/kotlin/io/github/androidpoet/supabase/e2ee/Fingerprint.kt @@ -0,0 +1,61 @@ +package io.github.androidpoet.supabase.e2ee + +import dev.whyoleg.cryptography.CryptographyProvider +import dev.whyoleg.cryptography.algorithms.SHA256 + +private const val SAFETY_NUMBER_ITERATIONS = 5200 +private const val SAFETY_NUMBER_GROUPS = 6 + +/** + * Computes a deterministic, order-independent **safety number** for a pair of + * public keys, so two users can compare it out of band (read it aloud / show a + * QR) and confirm there is no man-in-the-middle. + * + * Because `supabase-e2ee` derives session keys from raw ECDH, the only thing + * standing between you and a server that swaps in its own public key is this + * comparison. Both sides pass the same two keys (in either order) and get the + * **same** number; sort-independence is why it matches on both devices. + * + * The result is [SAFETY_NUMBER_GROUPS] space-separated 5-digit groups, derived + * from [SAFETY_NUMBER_ITERATIONS] SHA-256 iterations (the same hardening idea as + * Signal's fingerprint). + */ +public suspend fun safetyNumber( + localPublicKey: ByteArray, + peerPublicKey: ByteArray, +): String { + val ordered = + if (compareBytes(localPublicKey, peerPublicKey) <= 0) { + localPublicKey + peerPublicKey + } else { + peerPublicKey + localPublicKey + } + val hasher = CryptographyProvider.Default.get(SHA256).hasher() + var digest = hasher.hash(ordered) + repeat(SAFETY_NUMBER_ITERATIONS - 1) { + digest = hasher.hash(digest + ordered) + } + return buildString { + for (group in 0 until SAFETY_NUMBER_GROUPS) { + if (group > 0) append(' ') + append(fiveDigits(digest, group * 5)) + } + } +} + +private fun fiveDigits(hash: ByteArray, offset: Int): String { + var acc = 0L + for (i in 0 until 5) { + acc = (acc shl 8) or (hash[offset + i].toLong() and 0xFF) + } + return (acc % 100_000).toString().padStart(5, '0') +} + +private fun compareBytes(a: ByteArray, b: ByteArray): Int { + val min = minOf(a.size, b.size) + for (i in 0 until min) { + val diff = (a[i].toInt() and 0xFF) - (b[i].toInt() and 0xFF) + if (diff != 0) return diff + } + return a.size - b.size +} diff --git a/supabase-e2ee/src/commonMain/kotlin/io/github/androidpoet/supabase/e2ee/KeyDirectory.kt b/supabase-e2ee/src/commonMain/kotlin/io/github/androidpoet/supabase/e2ee/KeyDirectory.kt new file mode 100644 index 00000000..f3103772 --- /dev/null +++ b/supabase-e2ee/src/commonMain/kotlin/io/github/androidpoet/supabase/e2ee/KeyDirectory.kt @@ -0,0 +1,97 @@ +@file:OptIn(ExperimentalEncodingApi::class) + +package io.github.androidpoet.supabase.e2ee + +import io.github.androidpoet.supabase.core.models.Column +import io.github.androidpoet.supabase.core.result.SupabaseResult +import io.github.androidpoet.supabase.core.result.map +import io.github.androidpoet.supabase.database.DatabaseClient +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Publishes and serves users' **public** E2EE keys, so a sender can fetch a + * recipient's key and derive a shared session. Only public key material is ever + * handled — private keys never leave the device. + * + * The public key alone is not proof of identity: authenticate it with the + * [safetyNumber] before trusting it (see [EncryptedRoom]). + */ +public interface KeyDirectory { + /** Publishes (replaces) [userId]'s public key. */ + public suspend fun publish(userId: String, publicKey: ByteArray): SupabaseResult + + /** Fetches [userId]'s published public key, or `null` if none. */ + public suspend fun fetch(userId: String): SupabaseResult +} + +/** + * A [KeyDirectory] backed by a Supabase `device_keys` table (one row per user, + * the public key stored base64-encoded). RLS should allow anyone to read keys + * (they are public) but only let a user write their own row — see the package + * migration. + */ +public class SupabaseKeyDirectory( + private val database: DatabaseClient, + private val table: String = "device_keys", +) : KeyDirectory { + override suspend fun publish( + userId: String, + publicKey: ByteArray, + ): SupabaseResult { + val body = + buildJsonObject { + put("user_id", userId) + put("public_key", Base64.encode(publicKey)) + }.toString() + return database + .insert( + table = table, + body = body, + upsert = true, + onConflict = "user_id", + ).map { } + } + + override suspend fun fetch(userId: String): SupabaseResult = + database + .select(table = table, columns = "public_key") { + where { Column("user_id") eq userId } + }.map { rowsJson -> + val rows = Json.parseToJsonElement(rowsJson).jsonArray + val key = + rows + .firstOrNull() + ?.jsonObject + ?.get("public_key") + ?.jsonPrimitive + ?.content + key?.let { Base64.decode(it) } + } +} + +/** A non-persistent [KeyDirectory] for tests and local prototyping. */ +public class InMemoryKeyDirectory : KeyDirectory { + private val mutex = Mutex() + private val keys = mutableMapOf() + + override suspend fun publish( + userId: String, + publicKey: ByteArray, + ): SupabaseResult = + mutex.withLock { + keys[userId] = publicKey + SupabaseResult.Success(Unit) + } + + override suspend fun fetch(userId: String): SupabaseResult = + mutex.withLock { SupabaseResult.Success(keys[userId]) } +} diff --git a/supabase-e2ee/src/commonMain/kotlin/io/github/androidpoet/supabase/e2ee/TrustStore.kt b/supabase-e2ee/src/commonMain/kotlin/io/github/androidpoet/supabase/e2ee/TrustStore.kt new file mode 100644 index 00000000..c40136c8 --- /dev/null +++ b/supabase-e2ee/src/commonMain/kotlin/io/github/androidpoet/supabase/e2ee/TrustStore.kt @@ -0,0 +1,67 @@ +package io.github.androidpoet.supabase.e2ee + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** How much a peer's public key is trusted. */ +public enum class TrustLevel { + /** The peer's key has never been seen. */ + UNKNOWN, + + /** + * The key is known (recorded on first contact) but the user has **not** + * confirmed the [safetyNumber] out of band. A network attacker could have + * supplied it; treat messages as not-yet-authenticated. + */ + UNVERIFIED, + + /** The user compared the [safetyNumber] out of band and confirmed it. */ + VERIFIED, +} + +/** A recorded peer identity: the public key bytes and its [TrustLevel]. */ +public class TrustEntry( + /** The peer's public key. */ + public val publicKey: ByteArray, + /** How much [publicKey] is trusted. */ + public val level: TrustLevel, +) { + /** Returns a copy with a different [level]. */ + public fun withLevel(level: TrustLevel): TrustEntry = + TrustEntry(publicKey = publicKey, level = level) +} + +/** + * Persists the per-peer trust ledger: which public key was seen for each user + * and whether the user verified it. + * + * **Bring your own persistence.** Back this with secure storage so verifications + * survive restarts; the default [InMemoryTrustStore] loses them. This module + * never bundles a platform storage dependency. + */ +public interface TrustStore { + /** Returns the entry for [userId], or `null` if the peer is unseen. */ + public suspend fun get(userId: String): TrustEntry? + + /** Stores or replaces the entry for [userId]. */ + public suspend fun put(userId: String, entry: TrustEntry) + + /** Forgets [userId] (used to accept a legitimate key change). */ + public suspend fun remove(userId: String) +} + +/** A non-persistent [TrustStore] for tests and prototyping. */ +public class InMemoryTrustStore : TrustStore { + private val mutex = Mutex() + private val entries = mutableMapOf() + + override suspend fun get(userId: String): TrustEntry? = + mutex.withLock { entries[userId] } + + override suspend fun put(userId: String, entry: TrustEntry): Unit = + mutex.withLock { entries[userId] = entry } + + override suspend fun remove(userId: String) { + mutex.withLock { entries.remove(userId) } + } +} diff --git a/supabase-e2ee/src/commonTest/kotlin/io/github/androidpoet/supabase/e2ee/VerificationTest.kt b/supabase-e2ee/src/commonTest/kotlin/io/github/androidpoet/supabase/e2ee/VerificationTest.kt new file mode 100644 index 00000000..f62439f6 --- /dev/null +++ b/supabase-e2ee/src/commonTest/kotlin/io/github/androidpoet/supabase/e2ee/VerificationTest.kt @@ -0,0 +1,103 @@ +package io.github.androidpoet.supabase.e2ee + +import io.github.androidpoet.supabase.core.result.SupabaseResult +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class VerificationTest { + private fun SupabaseResult.value(): T = (this as SupabaseResult.Success).value + + @Test + fun test_safetyNumber_isIdenticalOnBothSides() = + runTest { + val alice = generateE2eeKeyPair().value() + val bob = generateE2eeKeyPair().value() + + val fromAlice = safetyNumber(alice.publicKey, bob.publicKey) + val fromBob = safetyNumber(bob.publicKey, alice.publicKey) + + assertEquals(fromAlice, fromBob) // order-independent → matches on both + assertEquals(6, fromAlice.split(' ').size) + assertTrue(fromAlice.replace(" ", "").all { it.isDigit() }) + } + + @Test + fun test_safetyNumber_differsForDifferentPeers() = + runTest { + val alice = generateE2eeKeyPair().value() + val bob = generateE2eeKeyPair().value() + val eve = generateE2eeKeyPair().value() + + assertNotEquals( + safetyNumber(alice.publicKey, bob.publicKey), + safetyNumber(alice.publicKey, eve.publicKey), + ) + } + + @Test + fun test_trustStore_recordsAndUpgradesVerification() = + runTest { + val store = InMemoryTrustStore() + val key = byteArrayOf(1, 2, 3) + assertNull(store.get("bob")) + + store.put("bob", TrustEntry(key, TrustLevel.UNVERIFIED)) + assertEquals(TrustLevel.UNVERIFIED, store.get("bob")?.level) + + store.put("bob", store.get("bob")!!.withLevel(TrustLevel.VERIFIED)) + assertEquals(TrustLevel.VERIFIED, store.get("bob")?.level) + + store.remove("bob") + assertNull(store.get("bob")) + } + + @Test + fun test_inMemoryKeyDirectory_publishThenFetch() = + runTest { + val directory = InMemoryKeyDirectory() + val alice = generateE2eeKeyPair().value() + + assertNull(directory.fetch("alice").value()) + directory.publish("alice", alice.publicKey) + + val fetched = directory.fetch("alice").value() + assertTrue(fetched != null && fetched.contentEquals(alice.publicKey)) + } + + @Test + fun test_endToEnd_verifyThenEncryptDecrypt_viaDirectory() = + runTest { + // Mirrors what EncryptedRoom does, minus the DB/realtime IO: publish + // keys, fetch the peer's, gate on verification, then round-trip. + val directory = InMemoryKeyDirectory() + val aliceTrust = InMemoryTrustStore() + val alice = generateE2eeKeyPair().value() + val bob = generateE2eeKeyPair().value() + directory.publish("alice", alice.publicKey) + directory.publish("bob", bob.publicKey) + + val bobKey = directory.fetch("bob").value()!! + aliceTrust.put("bob", TrustEntry(bobKey, TrustLevel.UNVERIFIED)) + + // Strict gate: not verified yet. + assertNotEquals(TrustLevel.VERIFIED, aliceTrust.get("bob")?.level) + + // Compare safety numbers out of band, then verify. + assertEquals( + safetyNumber(alice.publicKey, bobKey), + safetyNumber(bob.publicKey, alice.publicKey), + ) + aliceTrust.put("bob", aliceTrust.get("bob")!!.withLevel(TrustLevel.VERIFIED)) + assertEquals(TrustLevel.VERIFIED, aliceTrust.get("bob")?.level) + + // Now encrypt → decrypt (shared key; both sides derive the same one). + val aliceSession = alice.deriveSession(bobKey).value() + val bobSession = bob.deriveSession(directory.fetch("alice").value()!!).value() + val cipher = aliceSession.encrypt("verified hello 🔐").value() + assertEquals("verified hello 🔐", bobSession.decryptToString(cipher).value()) + } +} diff --git a/supabase/migrations/20260628_add_e2ee_tables.sql b/supabase/migrations/20260628_add_e2ee_tables.sql new file mode 100644 index 00000000..cdf142e4 --- /dev/null +++ b/supabase/migrations/20260628_add_e2ee_tables.sql @@ -0,0 +1,52 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- supabase-e2ee — opt-in end-to-end encryption tables +-- ════════════════════════════════════════════════════════════════════════════ +-- Apply this if you use `supabase-e2ee` (KeyDirectory + EncryptedRoom). It adds: +-- 1. device_keys — each user's PUBLIC E2EE key (base64), and +-- 2. e2ee_messages — per-room ciphertext rows (the server never sees plaintext). +-- Public keys are not proof of identity: clients must compare the safetyNumber() +-- out of band before trusting a key (see EncryptedRoom verification). + +-- ──────────────────────────── public key directory ───────────────────────── +create table if not exists public.device_keys ( + user_id uuid primary key references auth.users (id) on delete cascade, + public_key text not null, -- base64 ECDH P-256 public key + updated_at timestamptz not null default now() +); + +alter table public.device_keys enable row level security; + +-- Public key material: any authenticated user may read it to start a session. +drop policy if exists "device_keys read" on public.device_keys; +create policy "device_keys read" + on public.device_keys for select to authenticated using (true); + +-- A user may only publish / replace their OWN key. +drop policy if exists "device_keys write own" on public.device_keys; +create policy "device_keys write own" + on public.device_keys for all to authenticated + using (user_id = auth.uid()) with check (user_id = auth.uid()); + +-- ──────────────────────────── encrypted messages ─────────────────────────── +create table if not exists public.e2ee_messages ( + id uuid primary key default gen_random_uuid(), + room_id text not null, + sender_id uuid references auth.users (id), + ciphertext text not null, -- base64 AES-256-GCM ciphertext + created_at timestamptz not null default now() +); + +create index if not exists e2ee_messages_room_created_at_idx + on public.e2ee_messages (room_id, created_at desc); + +alter table public.e2ee_messages enable row level security; + +-- Tighten this to your room-membership model in production. The default allows +-- authenticated users to read/write; the bodies are ciphertext regardless. +drop policy if exists "e2ee_messages auth all" on public.e2ee_messages; +create policy "e2ee_messages auth all" + on public.e2ee_messages for all to authenticated + using (true) with check (true); + +-- Realtime delivery of new ciphertext rows. +alter publication supabase_realtime add table public.e2ee_messages;