Vup Chat: Bringing E2E Encryption to Bluesky

Vup Chat: Bringing E2E Encryption to Bluesky

Vup Chat is an experimental chat app that allows you to message other Bluesky users without sacrificing privacy.


The goal of this project was to create a chat app that enables ATProto (the base layer that runs Bluesky) users to chat securely. E2EE chats have been a requested feature for nearly three years but have yet to be developed. While they are planned, they remain unimplemented. Meanwhile, the current approach prioritizes rapidly developing a centralized chat system as a stop-gap before transitioning to a more robust solution, like integrating Matrix. Yes, privacy is important, but relying on Bluesky directly for core features is sub-optimal for a network intended to be open. With Bluesky gaining significant traction over the past year, developing a privacy-first chat app presents a strong opportunity for growth — especially if it could be refined for the mass market.

Backend

The core of the backend is built around the S5 Streams architecture, enabling
high-performance data routing in a highly decentralized manner. About a year ago, Redsolver showcased a stream-based chat that used MLS (Message Layer Security) for encryption, and I was impressed. In the demo, two users — each on a separate S5 node — could communicate with sub-second latency. While it was only a proof-of-concept, it inspired me to pursue this project. Since the core S5 libraries are written in Dart, the backend was also developed in Dart for seamless integration.

Messenger Core

This section of the codebase had to handle two main streams of information, sorting and storing them accordingly:

Bluesky Messages

Messages from Bluesky were retrieved by polling the respective API endpoint. The system checked for new messages and inserted them into the database if they hadn’t been seen before. However, this polling method had significant drawbacks:

  • There was no way to subscribe to a real-time stream of updates, requiring frequent polling.
  • While there was theoretically an option to request a diff of new messages since the last poll, this feature didn’t work in the Dart SDK.
  • Polling scaled poorly — if a user had messaged 30 people and polled every 10 seconds (which is relatively infrequent), it would result in over 10,000 API calls per hour, far exceeding reasonable limits and causing excessive battery drain.

Optimizations like setting a maximum number of total calls per hour and prioritizing active chats could help, but ultimately, this lack of scalability made resource utilization and performance a challenge.

S5 Streams Messages

Receiving messages via S5 Streams was significantly more efficient. Instead of polling, S5 Streams operated on an interrupt-based system, where new messages triggered a function to handle them. This approach was:

  • Faster – Messages arrived in real-time.
  • More performant – No unnecessary API calls.
  • Straightforward – Only authentication required additional effort.

Since ATProto doesn’t easily allow profiles to exchange information beyond direct messaging, content records were used to indicate whether a user supported encrypted chats. To signal encryption support, the client sets app.vup.chat.mlskeys with the necessary keys, showing its willingness to accept a chat room invite.

The authentication process worked as follows:

  1. Client A posts their public encryption keys as a content record to ATProto.
  2. Client B detects that Client A accepts encrypted chats and creates a private chat room.
  3. Client B generates an “invite token” and sends it via the unencrypted ATProto chat channel.
  4. Client A receives the token and joins the encrypted chat room.
  5. All message channels are encrypted by default unless manually disabled or unauthenticated. The invite token is currently unencrypted, but this poses minimal risk since invite links are user-specific and non-reusable. A channel can only be compromised if the token is intercepted before Client A joins. If Vup Chat exits beta, encrypting invite tokens would be a necessary security upgrade.

Database

When choosing a database, there are many options, from native solutions like Hive to Mongo-based Realm, or SQL-based options like sqflite or Drift. Ultimately, I chose Drift because it’s built on SQLite, which is known for its exceptional performance, reliability, and status as the industry standard embedded database.

Drift serves as a wrapper for SQLite, making it easy to define and query data. One standout feature is its ability to subscribe to a stream for a query. For example, you can create a stream based on a chat room, and the UI can automatically update when a new message is added to the database. This feature enables efficient separation of backend logic and UI updates.
Drift Table

Drift Table

class Senders extends Table 
    { TextColumn get did => text()();
    TextColumn get displayName => text()();
    TextColumn get handle => text().withDefault(const Constant(""))(); 
    BlobColumn get avatar => blob().nullable()();
    TextColumn get avatarUrl => text().nullable()(); 
    TextColumn get description => text().nullable()
    ();
    TextColumn get pubkey => text().nullable()(); // pubkey for signing shite

    @override
    Set<Column> get primaryKey => {did};
}

Creates the "Senders" table, which stores local profiles containing the user's DID, profile picture, and other important information.

Drift Query

Stream<ChatRoom> watchChatRoom(String chatID) 
    { final query = select(chatRooms)..where((t) => t.id.equals(chatID));
    return query.watchSingle();
}

Checks for changes in a chat room. For example, if the user changes the chat room's name, this function will notify the UI to update.

With these examples provided, Drift resulted in the best choice, and everything ran smoothly without any issues encountered.

Frontend

The decision to use Flutter was influenced by the backend being written in Dart, as it had the most mature S5 library available. My goal was to create a cross-platform app with a single codebase. As the sole developer, I needed a framework that could handle as much of the heavy lifting as possible, making Flutter the best choice.

Experience using Flutter

Using Flutter for the web wasn’t a great experience. Before diving into the issues, it’s important to note that many of the problems I faced were less about Flutter itself and more about developing web apps in general. Web browsers were not designed to run web apps, and most of the challenges stemmed from that, not specifically from Flutter. I managed to get the web version working, but it was a painful process. Here are the key issues I encountered:

Limitations

Localstore

The 5 MB per origin restriction made it impractical for Vup Chat, forcing me to keep the database and cached data within that limit — an unrealistic constraint for a web app. Most web apps solve this by offloading storage and processing to a server, but Vup Chat has no server; instead, an S5 node manages database backups and chat routing. This is especially problematic for images, as S5-generated links aren’t static, requiring app-level caching, which doesn’t work well in a web browser.

CORS Policies

Bluesky’s strict CORS policies complicate working with profile images hosted on their platform. While they support embedding images, they don’t permit direct downloads, making it challenging to load images as raw bytes into memory. As a result, image caching must occur at the browser level, leading to slower image loading in the web version. I also had to use the now depreciated Flutter HTML rendering engine because Canvaskit, being OpenGL-based, doesn’t support HTML image embeds.

HTML Rendering Engine

Due to CORS issues, I couldn’t use the default Canvaskit or the new Skwasm rendering engine. The alternative was far from ideal — it lacked performance optimization and only

supported single-threading. This meant that any intensive background tasks would freeze the UI until they were completed, severely affecting the app’s performance.

Half-Baked libraries

A recurring issue in Flutter development was encountering incomplete libraries. A good example of this was working with notifications. You’d expect Flutter to have a simple notification library that works across all platforms, but that wasn’t the case. Here are some libraries I explored:

  • awesome_notifications: Great for mobile, but it only works on mobile.
  • flutter_local_notification: The library I ended up using, but it’s complex, requiring platform-specific configurations and lacking clear documentation. Setting up message threads with replies on Android was particularly unclear.
  • platform_local_notifications: This one looked promising but failed out of the box.
  • push: Requires Firebase on Android, which was a dealbreaker for me. This highlights a broader issue where Google seems to expect developers to rely on Firebase for certain features, leaving third-party libraries only partially complete.

Conclusion & future outlook

After working on Vup Chat for three months, I can confidently say it’s not ready yet. Building a user-facing chat app as a solo developer in such a short time is ambitious, but the effort wasn’t in vain. It served as a solid proof-of-concept and laid the foundation for future projects — the messaging tech works well. For example, my upcoming grant, Luogo, a privacy-focused group location-sharing app. With a more focused scope, I plan to have Luogo in the hands of users by August.

In addition, I developed a set of libraries to simplify interactions with S5, which were intended for integration into Vup Chat but were delayed due to time constraints. These libraries — cached_s5_image, cached_s5_video, and cached_s5_audio — will be valuable in future projects, enabling easy rendering of Flutter widgets with just an S5 CID. Though not yet implemented, these tools will support future developments like Luogo.

All relevant code is in the GitHub repository

That's all from me for now! Feel free to reach out on Discord (@covalent1) or on Matrix — I’d love to chat!