Bidi Security Whitepaper
Version: 1.0
Date: 9 April 2026
Authors: Casey Kurtyka <ckurt3@icloud.com>
Status: Pre-Production
Abstract
Bidi is a real-time conversational platform where AI agents and human users communicate through shared spaces. This document specifies Bidi’s end-to-end encryption (E2EE) protocol, which ensures that all user-generated content stored in the platform’s backend infrastructure is encrypted at rest and readable only by authorized space members. The protocol employs a layered cryptographic architecture built entirely on the libsodium library: Argon2id for passphrase-based key derivation, XSalsa20-Poly1305 for symmetric authenticated encryption, and Curve25519-based sealed boxes for asymmetric key distribution. The design prioritizes cross-platform portability, cryptographic misuse resistance, and forward compatibility, while maintaining practical usability for both human users and autonomous AI agents.
Table of Contents
- Introduction
- Threat Model
- Cryptographic Primitives
- Protocol Overview
- Authentication
- Seed Lifecycle
- Keypair Derivation
- Space Key Management
- Content Encryption
- Data Classification
- Storage Architecture
- Recovery Mechanisms
- Agent Considerations
- Security Analysis
- References
- Appendix A: Libsodium Function Reference
- Appendix B: Figure Index
- Appendix C: Cross-Platform Implementation
1. Introduction
Bidi organizes communication into spaces, each containing channels, messages, and events. Messages are containers whose content is stored as JSONB event payloads in a Supabase-hosted PostgreSQL database. The platform supports two classes of participants:
- Human users, who interact through mobile, desktop, and web clients.
- AI agents, which listen for messages via Supabase Realtime, process them through a large language model, and persist responses.
Without encryption, the storage layer (Supabase) has full visibility into all message content, channel names, and agent identifiers. The E2EE protocol described in this document ensures that the storage layer handles only ciphertext, with all encryption and decryption performed client-side.
1.1 Design Goals
- Confidentiality at rest. All user-generated content stored in the database must be encrypted. The storage provider must never observe plaintext content.
- Minimal key ceremony. Users authenticate once with a passphrase. Subsequent sessions require no passphrase entry.
- Symmetric content encryption. All space members share a single symmetric space key for content operations, enabling efficient bulk encryption and decryption.
- Asymmetric key distribution. The space key is distributed to members via public-key cryptography, eliminating the need to share secrets out-of-band after initial setup.
- Cross-platform portability. The protocol must be implementable on Node.js, iOS, Android, and web platforms using a single cryptographic library.
- Misuse resistance. The protocol relies on high-level, audited cryptographic APIs that minimize the surface area for implementation error.
- Forward compatibility. All encrypted artifacts include version identifiers to support future algorithm migration without breaking existing data.
1.2 Scope
This document covers encryption of all user-generated content: data at rest in the Bidi storage layer and ephemeral Realtime broadcast payloads. The protocol operates alongside Supabase Row Level Security (RLS), which provides server-side access control over who can read and write database rows. RLS restricts access to encryption artifacts (passphrase blobs, wrapped keys) to authorized users, but the protocol does not depend on RLS for content confidentiality — even if RLS were bypassed, all stored content remains encrypted.
This document does not address:
- Transport-layer encryption (handled by TLS between clients and Supabase).
- Encryption of data in transit to third-party AI providers (a separate trust boundary).
- RLS policy design (documented separately in the Bidi database migration files).
2. Threat Model
2.1 Adversary Capabilities
The protocol is designed to protect against adversaries with the following capabilities:
| Adversary | Capability | Mitigated |
|---|---|---|
| Storage compromise | Read access to the full database, including backups and logs | Yes |
| Database administrator | Query-level access to all tables and functions | Yes |
| Realtime infrastructure compromise | Observation of broadcast payloads in transit through Supabase Realtime | Yes |
| Network observer | Passive observation of encrypted traffic between client and server | Yes (TLS + E2EE) |
| Compromised client device | Full filesystem and memory access on a single device | Partial (see 2.2) |
| AI model provider | Observation of plaintext prompts and responses | No (see 2.3) |
2.2 Trust Boundaries
Trusted:
- Client devices during active sessions. Space keys reside in secure storage (accessible when device is unlocked, only after first unlock since boot) and are loaded into memory during operation. The seed is also stored in secure storage but behind a biometric gate (Face ID, fingerprint) on mobile platforms, requiring explicit user authentication to access. A compromised device post first unlock since boot exposes the cached space key(s) for spaces the user is a member of. The seed is additionally exposed only if the attacker can bypass the platform’s biometric protection. The user’s passphrase is never stored on the device. On the CLI agent, only the space key is stored locally; the seed is not cached.
- The cryptographic library (libsodium). All primitives are assumed to be correctly implemented and free of backdoors.
Untrusted:
- The storage layer (Supabase). The database, its administrators, its backups, and its logs are treated as potentially hostile. All stored content must be indistinguishable from random data without the space key.
- Observability systems (e.g. Posthog). Never log unencrypted content or secrets.
- Network intermediaries. Although TLS provides transport encryption, the protocol does not rely on it for confidentiality of persisted data.
2.3 Explicit Non-Goals
- Protection from AI model providers. The Bidi agent decrypts incoming messages and passes plaintext to the language model API to generate responses. The model provider observes plaintext during processing. This is an inherent requirement of the agent architecture, not a failure of the encryption protocol. This can be mitigated via the usage of an on-device, locally-hosted, or enterprise model.
- Protection from compromised client devices. If an adversary gains full access to a user’s device after it’s been unlocked at least once since last cold boot, the cached space key is recoverable. The protocol limits blast radius: the adversary obtains access to spaces the user belongs to, but not to the user’s master seed unless they also obtain the passphrase.
- Forward secrecy. The protocol uses a static space key for all content within a space. Compromise of the space key exposes all historical content. Key rotation is discussed in Section 8.4 as a future enhancement.
- Deniability. The protocol does not provide deniable authentication. Sealed boxes are anonymous, but the space membership model provides implicit attribution.
3. Cryptographic Primitives
All cryptographic operations use the libsodium library (NaCl-compatible). No custom cryptographic constructions are employed.
3.1 Primitive Selection
| Operation | Primitive | Libsodium API | Parameters |
|---|---|---|---|
| Passphrase-based key derivation | Argon2id | crypto_pwhash | m=65536 KiB, t=3, p=1 |
| Symmetric authenticated encryption | XSalsa20-Poly1305 | crypto_secretbox / crypto_secretbox_open | 24-byte nonce, 32-byte key |
| Asymmetric key generation | Curve25519 | crypto_box_seed_keypair | 32-byte seed input |
| Asymmetric sealed encryption | X25519 + XSalsa20-Poly1305 | crypto_box_seal / crypto_box_seal_open | Ephemeral X25519 keypair |
| Cryptographic random | CSPRNG | randombytes_buf | Platform-native entropy source |
3.2 Rationale
Argon2id (via crypto_pwhash) was selected for key derivation as the winner of the Password Hashing Competition (2015) and the current OWASP recommendation. It combines data-independent memory access (resistance to side-channel attacks) with data-dependent access (resistance to GPU/ASIC acceleration). The parameters (64 MiB memory, 3 iterations) follow OWASP guidelines for interactive login contexts.
XSalsa20-Poly1305 (via crypto_secretbox) provides authenticated encryption with a 24-byte nonce, making random nonce collision probability negligible (2^-96 per pair at the birthday bound). This eliminates the practical nonce-reuse risks associated with AES-GCM’s 12-byte nonce in high-throughput applications.
Curve25519 sealed boxes (crypto_box_seal) provide anonymous authenticated encryption to a public key using an ephemeral X25519 keypair. This construction requires no sender identity, simplifying the key distribution model: any space member holding the space key can wrap it for a new member using only the recipient’s public key.
libsodium was selected as the sole cryptographic dependency for its cross-platform availability (Node.js via WASM, iOS via Swift-Sodium, Android via Lazysodium), misuse-resistant API design, and extensive audit history.
4. Protocol Overview
The protocol operates in five phases, each building on the previous:
Phase 1: Authentication
Email OTP verification via Supabase Auth → JWT session
Phase 2: Seed Establishment
Passphrase → Argon2id → encryption key → seal/unseal seed
Phase 3: Keypair Derivation
Seed → crypto_box_seed_keypair → public key (stored) + private key (ephemeral)
Phase 4: Space Key Distribution
Random space key → crypto_box_seal to each member's public key
Phase 5: Content Encryption
Space key → crypto_secretbox for all persisted content
Phases 1-3 occur during initial account setup. On subsequent sessions, the cached space key enables Phase 5 directly, bypassing all key ceremony.
5. Authentication
Authentication is handled by Supabase Auth using email-based one-time passwords (OTP). This phase establishes the user’s identity and provides a JWT session for subsequent API access.
5.1 Flow
- The user provides their email address to the client application.
- The client requests an OTP from Supabase Auth (
signInWithOtp). - Supabase sends a 6-digit code to the user’s email.
- The user enters the code in the client application.
- The client verifies the code with Supabase Auth (
verifyOtp). - On success, Supabase returns a JWT session containing the user’s unique identifier.
- The JWT session is persisted to secure storage for automatic session resumption.
- For new users, the client prompts for a username and stores it on the
profilestable. - The client proceeds to seed establishment (Phase 2).
5.2 Session Persistence
The JWT session is cached in platform-appropriate secure storage:
| Platform | Storage mechanism |
|---|---|
| Node.js (CLI) | ~/.bidi/session.json (mode 0o600) |
| iOS / macOS | Keychain (kSecClassGenericPassword) |
| Android | EncryptedSharedPreferences |
On subsequent launches, the cached session is restored automatically. The full OTP flow is only required when no valid session exists.
6. Seed Lifecycle
The seed is a 256-bit (32-byte) cryptographically random value that serves as the user’s master secret. It is used exclusively to derive the user’s asymmetric keypair (Section 7). The seed never directly encrypts content and is not required during normal operation after initial setup.
6.1 Seed Generation
On first-time account setup, the seed is generated using libsodium’s CSPRNG:
seed = randombytes_buf(32)
This produces 256 bits of entropy from the platform’s native secure random source (/dev/urandom on Linux/macOS, BCryptGenRandom on Windows, SecRandomCopyBytes on iOS, SecureRandom on Android).
6.2 Passphrase-Based Seed Protection
The seed is encrypted with a key derived from the user’s passphrase, producing the passphrase blob — a self-contained binary artifact stored in the database.
6.2.1 Key Derivation
salt = randombytes_buf(32)
encryption_key = crypto_pwhash(passphrase, salt)
Argon2id parameters:
- Memory: 65536 KiB (64 MiB)
- Iterations: 3
- Parallelism: 1
- Output length: 32 bytes
The salt is generated randomly per blob and stored alongside the ciphertext. It ensures that two users with identical passphrases produce different encryption keys.
6.2.2 Seed Encryption
nonce = randombytes_buf(24)
encrypted_seed = crypto_secretbox(seed, nonce, encryption_key)
The nonce is generated randomly per encryption and stored alongside the ciphertext.
6.2.3 Blob Format
The passphrase blob is a binary structure encoded as base64 for database storage:
Offset Length Field
0 1 Version (0x01)
1 32 Salt (Argon2id input)
33 24 Nonce (XSalsa20-Poly1305 input)
57 48 Encrypted seed + Poly1305 auth tag
---
Total: 105 bytes (~140 characters base64)
The version byte enables future algorithm migration. Decryption routines dispatch on the version to select the appropriate algorithm and parameters.
6.2.4 Storage
The passphrase blob is stored in the encryption_keys table, accessible only to the owning user via row-level security (RLS) policies. This table is separate from the profiles table to prevent exposure of encrypted key material through profile queries visible to other space members.
encryption_keys
- profile_id (FK → profiles, PK)
- passphrase_blob (text, base64-encoded)
- recovery_blob (text, base64-encoded)
RLS policy: profile_id = auth.uid() for all operations.
6.3 Passphrase Requirements
Passphrases must be 12 or more characters. No composition rules (uppercase, digit, special character) are enforced, consistent with NIST SP 800-63B guidance that composition rules lead to predictable patterns without meaningfully improving entropy. All printable characters including spaces are accepted, encouraging multi-word passphrases.
The passphrase is validated client-side at input time. It is never transmitted to or stored on the server. Argon2id’s memory-hard key derivation (Section 6.2.1) provides the primary defense against brute-force attacks on weak passphrases.
6.4 Seed Retrieval
On subsequent logins (when a passphrase blob exists), the seed is recovered by reversing the encryption process:
- Fetch the passphrase blob from the
encryption_keystable. - Parse the blob to extract the version, salt, nonce, and encrypted seed.
- Derive the encryption key from the passphrase and salt using Argon2id.
- Decrypt the seed using
crypto_secretbox_openwith the encryption key and nonce. - Store the seed in secure storage for the duration of the session.
If the passphrase is incorrect, crypto_secretbox_open fails because the Poly1305 authentication tag does not verify. This produces an explicit error rather than silent decryption to garbage.
crypto_secretbox to produce the encrypted seed. All components are packed into the passphrase blob and stored in the encryption_keys table. The recovery blob is produced in parallel using the recovery code (Section 12) in place of the passphrase.encryption_keys table and unpacked into its components. The salt feeds Argon2id (with the user's passphrase) to reproduce the encryption key. The encryption key, nonce, encrypted seed, and auth tag feed crypto_secretbox_open to recover the plaintext seed. The seed is stored in secure storage.7. Keypair Derivation
The seed is used to derive a Curve25519 keypair via libsodium’s deterministic key generation:
(public_key, private_key) = crypto_box_seed_keypair(seed)
This operation is deterministic: the same seed always produces the same keypair. The keypair serves the following roles:
- Public key: Stored on the user’s
profilesrow in the database, visible to other authenticated users. Used by other space members to seal the space key for this user (Section 8). - Private key: Held in memory only for the duration of operations requiring it (space key unsealing). Never stored to disk or transmitted.
7.1 Public Key Storage
The public key is written to the profiles table on first derivation:
UPDATE profiles SET public_key = $1 WHERE id = auth.uid() AND public_key IS NULL
The IS NULL condition ensures the write occurs only once. Since derivation is deterministic, subsequent derivations produce an identical public key.
7.2 Ephemeral Private Key
The private key exists in memory only when needed — specifically during crypto_box_seal_open operations (Section 8.3). After the space key is obtained, the private key is discarded. It can be re-derived from the seed at any time.
crypto_box_seed_keypair, which deterministically produces a Curve25519 public/private keypair. The public key is stored on the profiles table in Supabase (written once, on first derivation). The private key is held in memory only.8. Space Key Management
Each space has a space key — a 256-bit symmetric key used for all content encryption within that space. The space key is generated once at space creation and distributed to members using asymmetric sealed boxes.
8.1 Space Key Generation
The space key is generated by the agent CLI during first-time setup, when the owner logs in and creates their agent and space. This is part of the agent bootstrap sequence: authentication (Phase 1) → seed establishment (Phase 2) → keypair derivation (Phase 3) → space creation → space key generation. The owner’s public key must be available for self-wrapping before this step.
The space key is 256 bits of cryptographic random, generated locally:
space_key = randombytes_buf(32)
This key is the sole symmetric secret used for all content encryption within the space (Section 9). It is never transmitted in plaintext — it is immediately wrapped to the owner’s public key (Section 8.2) and stored as a sealed box in the space_members table. The plaintext space key is then cached in the owner’s local secure storage for ongoing use.
On subsequent launches, the owner loads the cached space key directly — no unwrapping needed unless the cache is lost (e.g., device change).
8.2 Key Wrapping
The space key is wrapped (encrypted) to each member’s public key using libsodium’s sealed box construction:
wrapped_key = crypto_box_seal(space_key, member_public_key)
crypto_box_seal generates an ephemeral X25519 keypair internally, performs a Diffie-Hellman key agreement with the recipient’s public key, and encrypts the space key using the resulting shared secret. The output includes the ephemeral public key (32 bytes) and the authenticated ciphertext (32 + 16 bytes), totaling 80 bytes.
The wrapping operation requires only:
- The space key (available to any current member).
- The recipient’s public key (readable from the
profilestable).
No private key is needed to seal. This means any existing space member can wrap the space key for a new member, without requiring the space owner or the AI agent to be online.
8.3 Key Unwrapping
A member unwraps their copy of the space key using their private key:
space_key = crypto_box_seal_open(wrapped_key, own_public_key, own_private_key)
This requires the member’s seed to derive the private key (Phase 2 → Phase 3 → unwrap). After unwrapping, the space key is cached in secure storage and the seed and private key are discarded from memory.
8.4 Key Storage
Wrapped keys are stored as a column on the space_members table:
space_members
- space_id (FK → spaces)
- profile_id (FK → profiles)
- role ('owner' | 'member')
- wrapped_key (text, base64-encoded sealed box)
- joined_at
Each member has exactly one wrapped copy. RLS policies ensure members can read all rows for spaces they belong to (necessary for displaying the member list). Other members’ wrapped keys are visible but harmless — each is a sealed box openable only by the intended recipient’s private key. Confidentiality of wrapped keys is enforced by the cryptography, not by access control.
8.5 Member Invitation
Adding a new member to a space:
- The inviting member looks up the new user’s
public_keyfrom theprofilestable. - The inviting member seals the space key:
crypto_box_seal(space_key, new_member_public_key). - The wrapped key and membership record are inserted into
space_members. - The new member, on next launch, fetches their
wrapped_key, derives their private key from their seed, and unseals the space key. - The new member caches the space key in secure storage.
No passphrase sharing is required. No re-encryption of existing content is required. The new member can immediately decrypt all historical content in the space.
8.6 Key Rotation (Future)
The current protocol uses a static space key. Future versions may implement key rotation, which would involve:
- Generating a new space key.
- Re-wrapping the new key to all current members.
- Defining epoch boundaries for which key decrypts which content.
- Revoking access for removed members by not wrapping the new key for them.
Key rotation does not require re-encryption of historical content if epoch-based key selection is implemented.
crypto_box_seal, producing a wrapped key stored in space_member_keys on Supabase. The plaintext space key is cached in local secure storage.crypto_box_seal_open, producing the plaintext space key. The space key is cached in local secure storage.profiles table. The existing member's cached space key and the new user's public key feed crypto_box_seal, producing a wrapped key stored in space_members on Supabase.9. Content Encryption
All user-generated content persisted to the database is encrypted using the space key with crypto_secretbox (XSalsa20-Poly1305).
9.1 Encryption
nonce = randombytes_buf(24)
ciphertext = crypto_secretbox(plaintext, nonce, space_key)
stored_payload = nonce || ciphertext
A fresh random nonce is generated for each encryption operation. The nonce is prepended to the ciphertext for storage. The combined output is base64-encoded for storage in text or JSONB columns.
9.2 Decryption
nonce = stored_payload[0:24]
ciphertext = stored_payload[24:]
plaintext = crypto_secretbox_open(ciphertext, nonce, space_key)
The nonce is extracted from the first 24 bytes of the stored payload. Decryption fails with an explicit error if the space key is incorrect or the ciphertext has been tampered with (Poly1305 authentication failure).
9.3 Unified Encrypted Format
All encrypted values — event payloads, channel names, agent names, and broadcast payloads — use a single binary format, base64-encoded for storage:
base64(version || nonce || ciphertext)
Offset Length Field
0 1 Version (0x01)
1 24 Nonce (XSalsa20-Poly1305 input)
25 variable Ciphertext + Poly1305 auth tag
The version byte enables future algorithm migration. Decryption routines read the first byte to select the appropriate algorithm and parameters.
This format is stored identically across all column types:
| Column | Type | Stored as |
|---|---|---|
events.payload | JSONB | JSON string ("base64...") |
channels.name | text | Plain string (base64...) |
agents.name | text | Plain string (base64...) |
One format, one decoding path. The client base64-decodes, reads the version byte, extracts the nonce, and decrypts with crypto_secretbox_open.
All persisted content is encrypted. There is no plaintext mode.
9.5 Realtime Broadcasts
Supabase Realtime broadcast payloads are encrypted using the same crypto_secretbox mechanism as persisted content. All clients hold the space key in memory during active sessions, making broadcast encryption a single additional symmetric operation per event.
9.5.1 Broadcast Encryption
The sending client encrypts each broadcast payload using the same unified format (Section 9.3):
nonce = randombytes_buf(24)
ciphertext = crypto_secretbox(payload, nonce, space_key)
broadcast_payload = base64(version || nonce || ciphertext)
9.5.2 Broadcast Decryption
Receiving clients decrypt upon receipt:
raw = base64_decode(broadcast_payload)
version = raw[0]
nonce = raw[1:25]
ciphertext = raw[25:]
payload = crypto_secretbox_open(ciphertext, nonce, space_key)
9.5.3 Rationale
Although broadcasts are ephemeral and access-controlled by Supabase channel authentication, encrypting them provides defense-in-depth. A compromise of the Realtime infrastructure or a channel authorization bug would not expose broadcast content. The performance overhead is negligible — XSalsa20-Poly1305 operates at microsecond latency per operation, well within the tolerance of real-time streaming.
crypto_secretbox. The output (nonce + ciphertext) is stored as an encrypted payload in Supabase. This applies to messages, events, channel names, agent names, and any other persisted user-generated content.crypto_secretbox_open, producing the plaintext payload for display.10. Data Classification
| Data | Table | Column | Encrypted | Rationale |
|---|---|---|---|---|
| Message content | events | payload | Yes | Primary user-generated content |
| Channel names | channels | name | Yes | Auto-generated from message content |
| Agent names | agents | name | Yes | User-chosen, may reveal purpose |
| Usernames | profiles | username | No | Cross-space identity; not scoped to a single space key |
| Email addresses | profiles | email | No | Required by Supabase Auth |
| Public keys | profiles | public_key | No | Must be readable by other users for key distribution |
| Event types | events | type | No | Required for server-side query filtering |
| Message metadata | messages | id, role, channel_id, etc. | No | Structural metadata required for queries, joins, and RLS |
| Wrapped keys | space_members | wrapped_key | N/A | Already encrypted (sealed box output) |
| Passphrase blobs | encryption_keys | passphrase_blob | N/A | Already encrypted (secretbox output) |
| Recovery blobs | encryption_keys | recovery_blob | N/A | Already encrypted (secretbox output) |
11. Storage Architecture
11.1 Server-Side (Supabase)
profiles
- id (uuid, PK)
- username (text)
- email (text)
- public_key (text, base64) ← visible to authenticated users
- created_at, updated_at (timestamptz)
encryption_keys
- profile_id (uuid, PK, FK → profiles)
- passphrase_blob (text, base64) ← owner-only access (RLS)
- recovery_blob (text, base64) ← owner-only access (RLS)
agents
- id (uuid, PK)
- owner_id (uuid, FK → profiles)
- name (text) ← encrypted (base64 format)
- model (text)
- last_heartbeat_at (timestamptz)
- created_at, updated_at (timestamptz)
spaces
- id (uuid, PK)
- agent_id (uuid, FK → agents)
- created_at, updated_at (timestamptz)
space_members
- space_id (uuid, FK → spaces)
- profile_id (uuid, FK → profiles)
- role (text: 'owner' | 'member')
- wrapped_key (text, base64) ← sealed box; visible to all members, openable only by recipient
- joined_at (timestamptz)
channels
- id (uuid, PK)
- name (text) ← encrypted (base64 format)
- space_id (uuid, FK → spaces)
- session_id (text)
- created_at (timestamptz)
messages
- id (uuid, PK)
- channel_id (uuid, FK → channels)
- role (text: 'human' | 'agent')
- parent_message_id (uuid, self-FK)
- profile_id (uuid, FK → profiles)
- agent_id (uuid, FK → agents)
- created_at (timestamptz)
events
- id (uuid, PK)
- message_id (uuid, FK → messages)
- type (text) ← plaintext (needed for queries)
- payload (jsonb) ← encrypted (base64 string)
- created_at (timestamptz)
11.2 Client-Side
| Platform | Stored artifacts | Mechanism | Access level |
|---|---|---|---|
| Node.js (CLI) | JWT session, space key | ~/.bidi/ directory, file mode 0o600 | N/A |
| iOS / macOS | JWT session, seed, space key(s) | Keychain (kSecClassGenericPassword) | Seed: kSecAccessControlBiometryAny (biometric required). Space keys: kSecAttrAccessibleAfterFirstUnlock |
| Android | JWT session, seed, space key(s) | Android Keystore / EncryptedSharedPreferences | Seed: setUserAuthenticationRequired(true) (biometric required). Space keys: accessible after first unlock |
On mobile platforms, the seed is persistently cached behind biometric protection (Face ID, fingerprint). Accessing the seed to join a new space requires explicit biometric authentication. Space keys use AfterFirstUnlock access — they are accessible through lock/unlock cycles (enabling push notification decryption while the device is locked) but protected at rest before the first unlock after a reboot. This matches the approach used by Signal, WhatsApp, and other end-to-end encrypted messaging applications.
On the CLI agent, only the space key is stored locally. The seed is not cached — the agent must re-enter the passphrase to derive the keypair when needed (e.g., joining a new space).
11.3 Local Content Cache
Mobile clients may cache message content locally via a KMP-based Room (or other) SQL DB for offline access and performance. Cached content is stored in its encrypted form — the same base64 ciphertext received from Supabase. Decryption occurs at render time using the space key from secure storage.
This provides protection in two scenarios where the space key is unavailable:
- Before first unlock after reboot. The space key is stored with
AfterFirstUnlockaccess and is inaccessible. Encrypted local content remains opaque. - Backup and forensic extraction. If the raw SQLite database file is extracted from a device backup, the content is encrypted and unusable without the space key.
No additional encryption library (e.g., SQLCipher) is required. The ciphertext format is identical to what is stored in Supabase.
11.4 In-Memory Only
The following values exist only in process memory and are never persisted to disk:
- Private key (derived from seed, used for
crypto_box_seal_open) - Encryption key (derived from passphrase via Argon2id, used for
crypto_secretbox_open) - Plaintext content (exists only between decryption and display, or between user input and encryption)
12. Recovery Mechanisms
12.1 Recovery Code
At account creation, a recovery code is generated alongside the passphrase blob:
recovery_bytes = randombytes_buf(24)
recovery_code = base32_encode(recovery_bytes)
The recovery code is formatted as 8 groups of 6 characters for readability:
ABCDEF-GHJKLM-NPQRST-UVWX23-456789-ABCDEF-GHJKLM-NPQRST
The base32 alphabet excludes visually ambiguous characters (I, O, 0, 1), yielding 144 bits of entropy.
12.2 Recovery Blob
The recovery code is used as the passphrase input to produce a second sealed copy of the seed:
recovery_salt = randombytes_buf(32)
recovery_nonce = randombytes_buf(24)
recovery_key = crypto_pwhash(recovery_code, recovery_salt)
encrypted_seed = crypto_secretbox(seed, recovery_nonce, recovery_key)
recovery_blob = [version || recovery_salt || recovery_nonce || encrypted_seed]
The recovery blob has identical structure to the passphrase blob and is stored in the encryption_keys table.
12.3 Recovery Flow
If a user forgets their passphrase:
- The user enters their recovery code.
- The client fetches the recovery blob from
encryption_keys. - The recovery code and the blob’s salt are fed to Argon2id to derive the recovery encryption key.
- The seed is decrypted using
crypto_secretbox_open. - The user is prompted to choose a new passphrase.
- A new passphrase blob is generated and stored, replacing the old one.
- The recovery blob is unchanged (the recovery code still works).
12.4 Recovery Code Handling
The recovery code is displayed exactly once during account creation. It is never stored in the database, on the client device, or in any log. The user is responsible for recording it securely (e.g., in a password manager or physical safe).
12.5 Space Key Recovery
If a user loses their cached space key (e.g., device loss), recovery follows the normal key retrieval path:
- Authenticate (Fig 0).
- Enter passphrase or recovery code to retrieve the seed (Fig 1/Fig 2).
- Derive keypair (Fig 3).
- Fetch wrapped key from
space_membersand unseal (Fig 5). - Cache the space key in secure storage.
No other members need to take any action. The wrapped key in space_members persists across device changes.
13. Agent Considerations
The Bidi AI agent is a process that authenticates as the space owner and participates in the space like any other member. This section addresses agent-specific security considerations.
13.1 Agent Identity
The agent authenticates using the same email OTP flow as human users. It shares the owner’s identity, keypair, and space membership. From the encryption protocol’s perspective, the agent is indistinguishable from the owner using a different device.
13.2 Space Key Access
The agent caches the space key locally, just like any client. On first boot, the owner enters their passphrase to unlock the seed, derive the keypair, and unseal the space key. On subsequent boots, the cached space key is loaded directly.
13.3 AI Tool Access Risks
The agent may have tool access (file reading, code execution) as part of its AI capabilities. This introduces a risk: a malicious or manipulated prompt could instruct the AI to read the cached space key from disk.
Mitigation strategies:
- Scope limitation. Restrict the AI agent’s tool access to exclude paths containing key material. In the future, we’ll explore virtualization and other process isolation mitigations.
- Proportional risk. The cached space key grants access equivalent to being a space member — a privilege the agent already holds. Exposure of the space key does not compromise individual users’ seeds or passphrases.
- Minimal local footprint. The agent caches only the space key on disk. The seed is never cached on the agent — it must be re-derived from the passphrase when needed (e.g., joining a new space). The private key exists only in memory during key ceremonies and is discarded immediately afterward. Even if the agent reads the cached space key, it cannot derive the owner’s keypair or access other spaces.
13.4 Plaintext Exposure to AI Providers
The agent decrypts incoming messages, passes plaintext to the language model API, encrypts the response, and stores the ciphertext. During this process, the AI provider observes the plaintext. This is a fundamental requirement of the agent architecture and is documented as an explicit non-goal of the encryption protocol (Section 2.3).
14. Security Analysis
14.1 Passphrase Brute-Force Resistance
Argon2id with 64 MiB memory and 3 iterations makes offline brute-force attacks computationally expensive. Each evaluation requires 64 MiB of memory, severely limiting GPU parallelism — a GPU with 24 GB VRAM can run approximately 375 evaluations concurrently at ~1 second each.
The following table estimates exhaustive search cost and time for a well-funded attacker operating 100 GPUs (~37,500 evaluations/sec):
| Passphrase type | Entropy | Search space | Time to exhaust | Cost to exhaust |
|---|---|---|---|---|
| 3-word Diceware | ~39 bits | ~5.5 × 10^11 | ~170 days | ~$5.5 billion |
| 4-word Diceware | ~52 bits | ~4.5 × 10^15 | ~3,800 years | ~$45 trillion |
| 12-char random mixed | ~72 bits | ~4.7 × 10^21 | ~4 billion years | ~$4.7 × 10^19 |
These are exhaustive-search upper bounds; an average-case attack succeeds in half the time. The passphrase blob is additionally protected by RLS, limiting its exposure to the owning user. An attacker must first compromise the database or the user’s session to obtain the blob before offline brute-force can begin.
14.2 Nonce Reuse
XSalsa20-Poly1305 uses a 24-byte (192-bit) nonce. With random nonce generation, the probability of a collision after n encryptions is approximately n^2 / 2^192. Even at 10^12 encryptions, the collision probability is approximately 10^-33, making accidental nonce reuse a negligible risk.
14.3 Space Key Compromise
If the space key is compromised in isolation, the adversary gains the ability to decrypt all content in that space — past, present, and future. They cannot, however, insert, modify, or delete rows in the database; those operations require a valid authenticated session, which is enforced by Supabase RLS independently of encryption. Existing messages are immutable in the database schema.
If the adversary also compromises an authenticated session (e.g., a stolen JWT), they can additionally write new encrypted content that appears legitimate to other members, up to the capabilities of that session’s RLS permissions.
The blast radius of a space key compromise is limited to a single space. Other spaces used by the same members are unaffected (different space keys). Individual users’ seeds and passphrases remain secure.
14.4 Device Compromise
If a mobile device is compromised, the adversary obtains:
- The cached space key(s) for spaces the user belongs to (accessible after first unlock; this enables push notification decryption but means space keys are available to a compromised app while the device is on).
- The JWT session (enabling API access until the session is revoked).
- The user’s seed, only if the attacker can bypass the platform’s biometric protection (Face ID, fingerprint). The seed is stored behind a biometric gate and cannot be read silently by a compromised app.
If the CLI agent machine is compromised, the adversary obtains:
- The cached space key(s) (stored as files with 0o600 permissions).
- The JWT session.
- The seed is not stored on the CLI agent.
In all cases, the adversary does not obtain:
- The user’s passphrase (exists only in the user’s memory).
- Access to other users’ accounts or spaces.
- The ability to derive the keypair without the seed (space keys alone do not expose the keypair).
14.5 Encryption Enforcement
All persisted content must be encrypted. There is no plaintext fallback or mixed-mode operation. All encrypted values use the unified format (base64(version || nonce || ciphertext)). Clients that fail to base64-decode or find an unrecognized version byte should treat it as an error.
14.6 Version Migration
The version byte (first byte of every encrypted value) provides a mechanism for future algorithm migration. A new version would:
- Introduce a new encryption scheme under a new version number.
- Decrypt existing content using the version-appropriate algorithm.
- Encrypt new content using the latest version.
- Optionally re-encrypt historical content during a migration window.
15. References
- libsodium. D. J. Bernstein et al. “The sodium cryptographic library.” https://doc.libsodium.org/
- Argon2. A. Biryukov, D. Dinu, D. Khovratovich. “Argon2: the memory-hard function for password hashing and other applications.” Password Hashing Competition, 2015.
- XSalsa20. D. J. Bernstein. “Extending the Salsa20 nonce.” Workshop Record of Symmetric Key Encryption Workshop, 2011.
- Poly1305. D. J. Bernstein. “The Poly1305-AES message-authentication code.” Fast Software Encryption, 2005.
- Curve25519. D. J. Bernstein. “Curve25519: new Diffie-Hellman speed records.” Public Key Cryptography, 2006.
- OWASP Password Storage Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
- Supabase Auth. https://supabase.com/docs/guides/auth
- Supabase Row Level Security. https://supabase.com/docs/guides/auth/row-level-security
Appendix A: Libsodium Function Reference
| Function | Input | Output | Used In |
|---|---|---|---|
randombytes_buf(n) | Length | n random bytes | Seed, salt, nonce, space key, recovery code generation |
crypto_pwhash(pass, salt) | Passphrase, salt | 32-byte derived key | Fig 1, Fig 2 |
crypto_secretbox(msg, nonce, key) | Plaintext, nonce, key | Ciphertext + auth tag | Fig 1, Fig 7 |
crypto_secretbox_open(ct, nonce, key) | Ciphertext, nonce, key | Plaintext | Fig 2, Fig 8 |
crypto_box_seed_keypair(seed) | 32-byte seed | Public key + private key | Fig 3 |
crypto_box_seal(msg, pk) | Plaintext, recipient public key | Sealed box | Fig 4, Fig 6 |
crypto_box_seal_open(ct, pk, sk) | Sealed box, public key, private key | Plaintext | Fig 5 |
Appendix B: Figure Index
| Figure | Title | Description |
|---|---|---|
| Fig 0 | Email OTP Authentication | User authentication via Supabase OTP |
| Fig 1 | Passphrase Blob Creation | Seed generation and encryption with passphrase |
| Fig 2 | Seed Retrieval | Decryption of seed from passphrase blob |
| Fig 3 | Keypair Derivation | Deterministic generation of Curve25519 keypair from seed |
| Fig 4 | Space and Key Creation | Space key generation and self-wrapping |
| Fig 5 | User Joining a Space | Unsealing wrapped space key with private key |
| Fig 6 | Member Invitation | Sealing space key to new member’s public key |
| Fig 7 | Writing Payloads | Symmetric encryption of content with space key |
| Fig 8 | Reading Payloads | Symmetric decryption of content with space key |
Appendix C: Cross-Platform Implementation
The protocol is designed for implementation across all Bidi client platforms using libsodium. All platforms use identical algorithms, parameters, and data formats. Ciphertext produced on any platform is decryptable on any other platform with the correct key. No platform-specific encoding or parameter differences exist.
| Platform | Library | Distribution |
|---|---|---|
| Node.js | libsodium-wrappers | npm (WASM, zero native dependencies) |
| iOS / macOS | Swift-Sodium | Swift Package Manager |
| Android | Lazysodium | Gradle / Maven Central |
| Web | libsodium.js | npm (WASM) |