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

  1. Introduction
  2. Threat Model
  3. Cryptographic Primitives
  4. Protocol Overview
  5. Authentication
  6. Seed Lifecycle
  7. Keypair Derivation
  8. Space Key Management
  9. Content Encryption
  10. Data Classification
  11. Storage Architecture
  12. Recovery Mechanisms
  13. Agent Considerations
  14. Security Analysis
  15. References

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

  1. Confidentiality at rest. All user-generated content stored in the database must be encrypted. The storage provider must never observe plaintext content.
  2. Minimal key ceremony. Users authenticate once with a passphrase. Subsequent sessions require no passphrase entry.
  3. Symmetric content encryption. All space members share a single symmetric space key for content operations, enabling efficient bulk encryption and decryption.
  4. 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.
  5. Cross-platform portability. The protocol must be implementable on Node.js, iOS, Android, and web platforms using a single cryptographic library.
  6. Misuse resistance. The protocol relies on high-level, audited cryptographic APIs that minimize the surface area for implementation error.
  7. 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:

AdversaryCapabilityMitigated
Storage compromiseRead access to the full database, including backups and logsYes
Database administratorQuery-level access to all tables and functionsYes
Realtime infrastructure compromiseObservation of broadcast payloads in transit through Supabase RealtimeYes
Network observerPassive observation of encrypted traffic between client and serverYes (TLS + E2EE)
Compromised client deviceFull filesystem and memory access on a single devicePartial (see 2.2)
AI model providerObservation of plaintext prompts and responsesNo (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

OperationPrimitiveLibsodium APIParameters
Passphrase-based key derivationArgon2idcrypto_pwhashm=65536 KiB, t=3, p=1
Symmetric authenticated encryptionXSalsa20-Poly1305crypto_secretbox / crypto_secretbox_open24-byte nonce, 32-byte key
Asymmetric key generationCurve25519crypto_box_seed_keypair32-byte seed input
Asymmetric sealed encryptionX25519 + XSalsa20-Poly1305crypto_box_seal / crypto_box_seal_openEphemeral X25519 keypair
Cryptographic randomCSPRNGrandombytes_bufPlatform-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

  1. The user provides their email address to the client application.
  2. The client requests an OTP from Supabase Auth (signInWithOtp).
  3. Supabase sends a 6-digit code to the user’s email.
  4. The user enters the code in the client application.
  5. The client verifies the code with Supabase Auth (verifyOtp).
  6. On success, Supabase returns a JWT session containing the user’s unique identifier.
  7. The JWT session is persisted to secure storage for automatic session resumption.
  8. For new users, the client prompts for a username and stores it on the profiles table.
  9. The client proceeds to seed establishment (Phase 2).

5.2 Session Persistence

The JWT session is cached in platform-appropriate secure storage:

PlatformStorage mechanism
Node.js (CLI)~/.bidi/session.json (mode 0o600)
iOS / macOSKeychain (kSecClassGenericPassword)
AndroidEncryptedSharedPreferences

On subsequent launches, the cached session is restored automatically. The full OTP flow is only required when no valid session exists.

Figure 0: Email OTP Authentication Flow
Figure 0: Email OTP Authentication Flow. The user provides an email address, receives a 6-digit OTP, and verifies it with Supabase Auth. The resulting JWT session is cached for automatic resumption. New users create a username before proceeding to seed establishment (Fig 1). Returning users proceed to seed retrieval (Fig 2).

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:

  1. Fetch the passphrase blob from the encryption_keys table.
  2. Parse the blob to extract the version, salt, nonce, and encrypted seed.
  3. Derive the encryption key from the passphrase and salt using Argon2id.
  4. Decrypt the seed using crypto_secretbox_open with the encryption key and nonce.
  5. 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.

Figure 1: Passphrase Blob Creation
Figure 1: Passphrase Blob Creation. First-time setup. Three random values are generated (seed, salt, nonce). The passphrase and salt are fed to Argon2id to produce an encryption key. The encryption key, nonce, and seed are fed to 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.
Figure 2: Seed Retrieval
Figure 2: Seed Retrieval. Subsequent login. The passphrase blob is fetched from the 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 profiles row 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.

Figure 3: Keypair Derivation
Figure 3: Keypair Derivation. The seed is loaded from secure storage and passed to 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 profiles table).

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:

  1. The inviting member looks up the new user’s public_key from the profiles table.
  2. The inviting member seals the space key: crypto_box_seal(space_key, new_member_public_key).
  3. The wrapped key and membership record are inserted into space_members.
  4. The new member, on next launch, fetches their wrapped_key, derives their private key from their seed, and unseals the space key.
  5. 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:

  1. Generating a new space key.
  2. Re-wrapping the new key to all current members.
  3. Defining epoch boundaries for which key decrypts which content.
  4. 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.

Figure 4: Space and Key Creation
Figure 4: Space and Key Creation. First-time boot for the space owner. The public key (from Fig 3) and a freshly generated space key feed crypto_box_seal, producing a wrapped key stored in space_member_keys on Supabase. The plaintext space key is cached in local secure storage.
Figure 5: User Joining a Space
Figure 5: User Joining a Space. The user's private key (derived via Fig 3) and their wrapped key (fetched from Supabase) feed crypto_box_seal_open, producing the plaintext space key. The space key is cached in local secure storage.
Figure 6: Member Invitation
Figure 6: Member Invitation. An existing member looks up the new user's public key from the 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:

ColumnTypeStored as
events.payloadJSONBJSON string ("base64...")
channels.nametextPlain string (base64...)
agents.nametextPlain 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.

Figure 7: Writing Payloads
Figure 7: Writing Payloads. The plaintext payload, the space key (from secure storage), and a freshly generated nonce feed 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.
Figure 8: Reading Payloads
Figure 8: Reading Payloads. The encrypted payload is fetched from Supabase and split into nonce and ciphertext. These, along with the space key (from secure storage), feed crypto_secretbox_open, producing the plaintext payload for display.

10. Data Classification

DataTableColumnEncryptedRationale
Message contenteventspayloadYesPrimary user-generated content
Channel nameschannelsnameYesAuto-generated from message content
Agent namesagentsnameYesUser-chosen, may reveal purpose
UsernamesprofilesusernameNoCross-space identity; not scoped to a single space key
Email addressesprofilesemailNoRequired by Supabase Auth
Public keysprofilespublic_keyNoMust be readable by other users for key distribution
Event typeseventstypeNoRequired for server-side query filtering
Message metadatamessagesid, role, channel_id, etc.NoStructural metadata required for queries, joins, and RLS
Wrapped keysspace_memberswrapped_keyN/AAlready encrypted (sealed box output)
Passphrase blobsencryption_keyspassphrase_blobN/AAlready encrypted (secretbox output)
Recovery blobsencryption_keysrecovery_blobN/AAlready 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

PlatformStored artifactsMechanismAccess level
Node.js (CLI)JWT session, space key~/.bidi/ directory, file mode 0o600N/A
iOS / macOSJWT session, seed, space key(s)Keychain (kSecClassGenericPassword)Seed: kSecAccessControlBiometryAny (biometric required). Space keys: kSecAttrAccessibleAfterFirstUnlock
AndroidJWT session, seed, space key(s)Android Keystore / EncryptedSharedPreferencesSeed: 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 AfterFirstUnlock access 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:

  1. The user enters their recovery code.
  2. The client fetches the recovery blob from encryption_keys.
  3. The recovery code and the blob’s salt are fed to Argon2id to derive the recovery encryption key.
  4. The seed is decrypted using crypto_secretbox_open.
  5. The user is prompted to choose a new passphrase.
  6. A new passphrase blob is generated and stored, replacing the old one.
  7. 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:

  1. Authenticate (Fig 0).
  2. Enter passphrase or recovery code to retrieve the seed (Fig 1/Fig 2).
  3. Derive keypair (Fig 3).
  4. Fetch wrapped key from space_members and unseal (Fig 5).
  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:

  1. 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.
  2. 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.
  3. 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 typeEntropySearch spaceTime to exhaustCost 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:

  1. Introduce a new encryption scheme under a new version number.
  2. Decrypt existing content using the version-appropriate algorithm.
  3. Encrypt new content using the latest version.
  4. Optionally re-encrypt historical content during a migration window.

15. References

  1. libsodium. D. J. Bernstein et al. “The sodium cryptographic library.” https://doc.libsodium.org/
  2. Argon2. A. Biryukov, D. Dinu, D. Khovratovich. “Argon2: the memory-hard function for password hashing and other applications.” Password Hashing Competition, 2015.
  3. XSalsa20. D. J. Bernstein. “Extending the Salsa20 nonce.” Workshop Record of Symmetric Key Encryption Workshop, 2011.
  4. Poly1305. D. J. Bernstein. “The Poly1305-AES message-authentication code.” Fast Software Encryption, 2005.
  5. Curve25519. D. J. Bernstein. “Curve25519: new Diffie-Hellman speed records.” Public Key Cryptography, 2006.
  6. OWASP Password Storage Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
  7. Supabase Auth. https://supabase.com/docs/guides/auth
  8. Supabase Row Level Security. https://supabase.com/docs/guides/auth/row-level-security

Appendix A: Libsodium Function Reference

FunctionInputOutputUsed In
randombytes_buf(n)Lengthn random bytesSeed, salt, nonce, space key, recovery code generation
crypto_pwhash(pass, salt)Passphrase, salt32-byte derived keyFig 1, Fig 2
crypto_secretbox(msg, nonce, key)Plaintext, nonce, keyCiphertext + auth tagFig 1, Fig 7
crypto_secretbox_open(ct, nonce, key)Ciphertext, nonce, keyPlaintextFig 2, Fig 8
crypto_box_seed_keypair(seed)32-byte seedPublic key + private keyFig 3
crypto_box_seal(msg, pk)Plaintext, recipient public keySealed boxFig 4, Fig 6
crypto_box_seal_open(ct, pk, sk)Sealed box, public key, private keyPlaintextFig 5

Appendix B: Figure Index

FigureTitleDescription
Fig 0Email OTP AuthenticationUser authentication via Supabase OTP
Fig 1Passphrase Blob CreationSeed generation and encryption with passphrase
Fig 2Seed RetrievalDecryption of seed from passphrase blob
Fig 3Keypair DerivationDeterministic generation of Curve25519 keypair from seed
Fig 4Space and Key CreationSpace key generation and self-wrapping
Fig 5User Joining a SpaceUnsealing wrapped space key with private key
Fig 6Member InvitationSealing space key to new member’s public key
Fig 7Writing PayloadsSymmetric encryption of content with space key
Fig 8Reading PayloadsSymmetric 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.

PlatformLibraryDistribution
Node.jslibsodium-wrappersnpm (WASM, zero native dependencies)
iOS / macOSSwift-SodiumSwift Package Manager
AndroidLazysodiumGradle / Maven Central
Weblibsodium.jsnpm (WASM)