DEV Community

Cover image for Taking Mastodon security to the next level - part 1: Encrypt your toots
Dimitri Merejkowsky for Tanker

Posted on • Updated on

Taking Mastodon security to the next level - part 1: Encrypt your toots

What is this about?

My name is Dimitri Merejkowsky and I’ve been working at Tanker since June 2016. We’re a software company whose goal is to make end-to-end encryption simple. (More details on our website).

I’ve been an enthusiastic user of Mastodon since April 2017. One thing that always bugs me is that Mastodon administrators have access to everything about their users, as we'll see in a minute.

A few weeks ago, I decided to tackle this issue and try to encrypt Mastodon's direct messages with Tanker.

And that's how this series of articles was born. They’re written as something in between a tutorial and a story. You can use it to follow in my footsteps or to just enjoy the ride and have a good read: we'll discover what it actually means to implement Tanker in an existing solution and learn a few things about Mastodon’s internals. If you're curious, you can also jump to the end result on GitHub.

But first, let's go back to the problem that triggered the whole thing.

Introduction - What's wrong with Mastodon's direct messages?

Let's assume there is a Mastodon instance running with 3 users: Alice, Bob, and Eve.

First, Alice decides to send a direct message to Bob. She doesn't want her, or Bob’s, followers to see it, so she selects "Direct" visibility in the drop-down menu before sending her message:

Alice sends a DM to Bob

Once the message is sent, she can see it the Direct messages column:

Alice views message to Bob

Bob, on the other hand, gets a notification and Alice's message appears in his column:

Bob views message from Alice

Finally, Eve does not get any notification, and if she tries to access the message directly using the permalink, she gets a 404 error:

Eve does not see Alice's message

At first glance, it looks as if the feature is working - only Bob can see Alice's message.

But, alas, the Mastodon admins can still read it because they have access to the database:

# select text from statuses order by id desc;
 @bob hello!
Enter fullscreen mode Exit fullscreen mode

The aim of this series

In this series of articles, I would like to invite you to follow the steps I took to implement end-to-end encryption for direct messages on Mastodon. Note that I'm using Debian 10; your mileage may differ if you’re using a different distribution or another operating system.

When we're done, here's what we'll have:

Nothing will change from Alice's point of view when composing the direct message.

Bob will still see Alice's message, but this time there will be a lock to signify it’s encrypted:

Bob sees encrypted message

And the admins will no longer be able to read all the messages.

# select encrypted, text from statuses order by id desc;
encrypted | text
 t        | A4qYtb2RBWs4vTvF8Z4fpEYy402IvfMZQqBckhOaC7DLHzw
 f        | @bob hello!
Enter fullscreen mode Exit fullscreen mode

Sounds interesting? Let's dive in!

Getting started

We are going to make some changes in Mastodon's source code, so let's clone it and make sure we can run an instance on our development machine.

git clone git://
cd mastodon
# install all required libraries:
cat Aptfile | sudo apt install -y
# Install correct ruby version with rvm
rvm install ruby-2.6.1
# Install all ruby dependencies
bundle install
# Install all Javascript dependencies
# Run all processes with foreman
foreman start -f
Enter fullscreen mode Exit fullscreen mode

Now we can open the http://localhost:3000 URL in a browser and sign up our first user.

The "vanilla" Mastodon is running as expected, so we can start changing the code and see what happens :)

Calling encrypt() the naive way

In the API section of the Tanker documentation, we notice there’s an encrypt() function in a Tanker object. We also see a bit of code that tells us how to instantiate Tanker:

const config = { appId: 'your-app-id' };
const tanker = new Tanker(config);
Enter fullscreen mode Exit fullscreen mode

We need an App ID, so let's create an application in the Tanker Dashboard and patch the front-end code directly, without thinking too much about the implications.

// In app/javascript/mastodon/actions/compose.js
export function submitCompose(routerHistory) {
  const config = { appId: 'our-app-id' };
  const tanker = new Tanker(config);
  let clearText = getState().getIn(['compose', 'text'], '');
  const encryptedData = await tanker.encrypt(clearText);
Enter fullscreen mode Exit fullscreen mode

But then we get:

PreconditionFailed: Expected status READY but got STOPPED trying to encrypt.
Enter fullscreen mode Exit fullscreen mode

After digging in the documentation, it turns out we need to start a session first.

If you’re wondering, here's why: Tanker implements an end-to-end protocol and thus encryption occurs on the users' devices. To that end, it uses an Encrypted Local Storage (containing some private keys, among other things) which can be accessed only when a Tanker session has been started.

The doc also says we need to verify users’ identities before starting a Tanker session, and that Tanker identities must be generated and stored on the application server - in our case, the Ruby on Rails code from the Mastodon project.

That means that we can’t do everything client-side in Javascript; we also need to modify the server as well as figuring out how these two communicate with each other.

Getting to know the architecture

The Mastodon development guide contains an overview of the Mastodon architecture. Here are the relevant parts:

To understand how the Ruby and the Javascript codes cooperate we can look at the HTML source of the page:

<!DOCTYPE html>
<!-- .. -->
<script id=”initial-state”, type=”application/json”>
    "access_token": "....",
    "email": "",
    "me": "2"
    // ...
Enter fullscreen mode Exit fullscreen mode

That page is generated by Rails. The React app parses this HTML, extracts its initial state from the <script> element, and starts from there.
Note that the initial state contains a JSON object under the meta key.
The meta object contains (among other things):

  • An access token for the WebSocket server
  • The email of the current user
  • The ID of the current user in the database (under the me key)

So, here's the plan:

  • We'll generate a Tanker identity server-side
  • Put it inside the initial state
  • Fetch it from the initial state and start a Tanker session

Generating Tanker Identities

First, add the Tanker App Id and secret into the .env file:

(The Tanker app secret must not be checked in along with the rest of the source code):

TANKER_APP_ID = <the-app-id>
TANKER_APP_SECRET = <the-ap-secret>
Enter fullscreen mode Exit fullscreen mode

Then we create a new file named app/lib/tanker_identity.rb containing this code:

module TankerIdentity
  def self.create(user_id)
    Tanker::Identity.create_identity(ENV["TANKER_APP_ID"], ENV["TANKER_APP_SECRET"], user_id.to_s)
Enter fullscreen mode Exit fullscreen mode

We adapt the User model:

# app/models/users.rb
class User < ApplicationRecord

  after_create :set_tanker_identity

  def set_tanker_identity
    self.tanker_identity = TankerIdentity.create_identity(
    self.update_attribute :tanker_identity, self.tanker_identity

Enter fullscreen mode Exit fullscreen mode

We write a migration and then migrate the DB:

# db/migrate/20190909112533_add_tanker_identities_to_users.rb
class AddTankerIdentitiesToUsers<ActiveRecord::Migration[5.2]
  def change
    add_column :users, :tanker_identity, :string
Enter fullscreen mode Exit fullscreen mode
$ rails db:setup
Enter fullscreen mode Exit fullscreen mode

Finally, we write a new test for the AppSignUpService and run the tests:

# spec/services/app_sign_up_service_spec.rb
it 'creates a user with a Tanker identity' do
  access_token =, good_params)
  user = User.find_by(id: access_token.resource_owner_id)
Enter fullscreen mode Exit fullscreen mode
$ rspec
Finished in 3 minutes 49.4 seconds (files took 8.56 seconds to load)
2417 examples, 0 failure
Enter fullscreen mode Exit fullscreen mode

They pass! We now have Tanker identities generated server-side. Let's use them to start a Tanker session.

Starting a Tanker session

When starting a Tanker session you need to verify the identity of the user. This involves sending an email and entering an 8-digit code - that's how you can be sure that you’re sharing encrypted data with the correct user.

As a shortcut, Tanker provides a @tanker/verfication-ui package containing a ready-to-use UI to handle identity verification using emails.

It's used like this:

const config = { appId: "app id" };
const tanker = new Tanker(config);
const verificationUI = new VerificationUI({ tanker });
await verificationUI.start(email, identity);
Enter fullscreen mode Exit fullscreen mode

We need the app ID, the Tanker identity and the email to start a Tanker session, so let's make sure they appear in the aforementioned <script> element:

# app/helpers/application_helper.rb
def render_initial_state
  state_params = {
    # ...

  if user_signed_in?
    state_params[:tanker_identity] = current_account.user.tanker_identity
    # ...
Enter fullscreen mode Exit fullscreen mode
# app/presenters/initial_state_presenter.rb
class InitialStatePresenter < ActiveModelSerializers::Model
  attributes :settings, :push_subscription, :token,
             # ...
             :tanker_identity, :email, :tanker_app_id
Enter fullscreen mode Exit fullscreen mode
# app/serializers/initial_state_serializer.rb
require_relative "../../lib/tanker"

class InitialStateSerializer < ActiveModel::Serializer
  attributes :meta, :compose, :accounts,

  # ...

  store[:tanker_identity] = object.current_account.user.tanker_identity
  store[:email]           =
  store[:tanker_app_id]   = TANKER_APP_ID
Enter fullscreen mode Exit fullscreen mode

Then, we fetch our values from the initial_state.js file:

// app/javascript/mastodon/initial_state.js
export const tankerIdentity = getMeta('tanker_identity');
export const email = getMeta('email');
export const tankerAppId = getMeta('tanker_app_id');
Enter fullscreen mode Exit fullscreen mode

Creating a Tanker service

The challenge now becomes: how and when do we call verificationUI.start(), knowing that it will display a big pop-up and hide the rest of the UI?

After a bit of thinking, we decide to wrap calls to tanker.encrypt(), tanker.decrypt() and verificationUI.starte() in a TankerService class.

The TankerService class will be responsible for ensuring the tanker session is started right before data is encrypted or decrypted:

// app/javascript/mastodon/tanker/index.js
import { fromBase64, toBase64, Tanker } from '@tanker/client-browser';
import { VerificationUI } from '@tanker/verification-ui';

export default class TankerService {

  constructor({ email, tankerIdentity, tankerAppId }) { = email;
    this.tankerIdentity = tankerIdentity;
    this.tanker = new Tanker({ appId: tankerAppId });
    this.verificationUI = new VerificationUI(this.tanker);

  encrypt = async (clearText) => {
    await this.lazyStart();

    const encryptedData = await this.tanker.encrypt(clearText);
    const encryptedText = toBase64(encryptedData);
    return encryptedText;

  decrypt = async (encryptedText) => {
    await this.lazyStart();

    const encryptedData = fromBase64(encryptedText);
    const clearText = await this.tanker.decrypt(encryptedData);
    return clearText;

  stop = async() => {
    await this.tanker.stop();

  lazyStart = async () => {
    if (this.tanker.status !== Tanker.statuses.STOPPED) {

    if (!this.startPromise) {
      this.startPromise = this.verificationUI.start(, this.tankerIdentity);

    try {
      await this.startPromise;
      delete this.startPromise;
    } catch(e) {
      delete this.startPromise;
      throw e;


Enter fullscreen mode Exit fullscreen mode

Next we configure Redux thunk middleware to take the TankerService as
extra argument:

// app/javascript/mastodon/store/configureStore.js
import thunkMiddleWare from 'redux-thunk';
import {
} from '../initial_state';
import TankerService from '../tanker';

const tankerService = new TankerService({ email, tankerIdentity, tankerAppId });

const thunk = thunkMiddleWare.withExtraArgument({ tankerService });

export default function configureStore() {
  return createStore(appReducer, compose(applyMiddleware(
    // ...
Enter fullscreen mode Exit fullscreen mode

After this change, the thunk middleware allows us to access the TankerService instance from any Redux action.

So, now we can adapt the submitCompose action properly:

// app/javascript/mastodon/actions/compose.js
export function submitCompose(routerHistory) {
  return async function (dispatch, getState, { tankerService }) {
    let visibility = getState().getIn(['compose', 'privacy']);

    const shouldEncrypt = (visibility === 'direct');

    if (shouldEncrypt) {
      const encryptedText = await tankerService.encrypt(status);
      console.log('about to send encrypted text', encryptedText);


    api(getState).post('/api/v1/statuses', {
      // ...,
Enter fullscreen mode Exit fullscreen mode

When we're done, we get those pop-ups showing us that the verification process worked:

Identity verification prompt

Identity verified

And some logs indicating the status was indeed encrypted

Starting verification UI ...
Verification UI started
About to send  encrypted text: AxMXSEhEnboU732MUc4tqvOmJECocd+fy/lprQfpYGSggJ28
Enter fullscreen mode Exit fullscreen mode

That's all for Part 1. We can now create and verify cryptographic identities of all users in our local instance, use them to start a Tanker session, and encrypt our direct messages.

But how will the server actually handle those encrypted messages?

Stay tuned for part 2!

Follow Tanker on or on twitter to be notified when the next part is published - and feel free to ask questions in the comments section below.

Top comments (0)