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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
75 changes: 75 additions & 0 deletions supabase-e2ee/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
84 changes: 84 additions & 0 deletions supabase-e2ee/api/android/supabase-e2ee.api
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
public final class io/github/androidpoet/supabase/e2ee/DecryptedMessage {
public fun <init> (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
}
Expand All @@ -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 <init> ()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 <init> ()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 <init> (Lio/github/androidpoet/supabase/database/DatabaseClient;Ljava/lang/String;)V
public synthetic fun <init> (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 <init> ([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;
}

84 changes: 84 additions & 0 deletions supabase-e2ee/api/jvm/supabase-e2ee.api
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
public final class io/github/androidpoet/supabase/e2ee/DecryptedMessage {
public fun <init> (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
}
Expand All @@ -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 <init> ()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 <init> ()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 <init> (Lio/github/androidpoet/supabase/database/DatabaseClient;Ljava/lang/String;)V
public synthetic fun <init> (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 <init> ([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;
}

Loading
Loading