Skip to content

Instantly share code, notes, and snippets.

@Gargron
Last active May 20, 2023 05:13
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Gargron/ef09c05cd81580b8b9f4597c458bee1b to your computer and use it in GitHub Desktop.
Save Gargron/ef09c05cd81580b8b9f4597c458bee1b to your computer and use it in GitHub Desktop.
Mastodon E2EE implementation guide

Mastodon E2EE implementation guide

You need the crypto OAuth scope. Check if there is a way for you to use Olm for your programming language / platform. Using libsignal should be possible but has not been tested.

Step 1: Setting up the device/app

Use Olm to generate the device keypairs and a set of one-time keys. Decide on what the device will be called publicly (this can be changed any time). Also generate a random device ID, this can be a number or a string as long as it's securely random.

There are two device keypairs: The Curve25519 key that we call the identity key, and the Ed25519 key that we call the fingerprint key, which is used for signing and verifying that other keys belong to the same device.

One-time keys (or "pre-keys") are Curve25519 keys that can be consumed by other devices trying to talk to us. Your device is responsible for generating and uploading them and ensuring that the pool is stocked up. The keys must be signed using your device's fingerprint key.

You must permanently store the device ID and the Olm account data on the device, Olm exposes pickle and from_pickle methods for this.

Olm.init().then(() => {
  const deviceId = generateDeviceId();
  const account = new Olm.Account();
  const displayName = 'My iPhone';

  account.create();
  account.generate_one_time_keys(100);

  const identityKeys = JSON.parse(account.identity_keys());
  const oneTimeKeys  = JSON.parse(account.one_time_keys());

  axios.post('http://localhost:3000/api/v1/crypto/keys/upload', {
    device: {
      device_id: deviceId,
      name: displayName,
      fingerprint_key: identityKeys.ed25519,
      identity_key: identityKeys.curve25519,
    },

    one_time_keys: Object.keys(oneTimeKeys.curve25519).map(key => ({
      key_id: key,
      key: oneTimeKeys.curve25519[key],
      signature: account.sign(oneTimeKeys.curve25519[key]),
    })),
  }).then(() => {
    account.mark_keys_as_published();
  });
});

You should periodically check how many one-time keys are left in the pool, and stock up if it's running low:

axios.get('http://localhost:3000/api/v1/crypto/keys/count').then(res => {
  console.log(`${res.data.one_time_keys} are left in the pool`);
});

Step 2: Initiating a new session

To start an E2EE session with someone, you first retrieve a list of devices that belongs to them (you can get devices for multiple people using the same request):

axios.post('http://localhost:3000/api/v1/crypto/keys/query', {
  id: [456],
});

Then perhaps you might show a choice to the user of which device(s) to start a session with, or start sessions with all of them. Using the retrieved device_id values, you'll now need to claim a one-time key for each device:

axios.post('http://localhost:3000/api/v1/crypto/keys/claim', {
  device: [
    { account_id: 456, device_id: '33456' },
  ],
});

Once you have that, you can create an outbound session, but first you need to check if the one-time key you received really belongs to the device you expect it to belong to, by verifying the signature using the device's fingerprint key:

const session = new Olm.Session();
const util    = new Olm.Utility();

util.ed25519_verify(device.fingerprint_key, one_time_key.key, one_time_key.signature);
session.create_outbound(account, device.identity_key, one_time_key.key);

You'll need to permanently store that session object somewhere, and you'll need to know which device it's for. There's pickle and from_pickle methods for it too.

Now that you have a session, you can send an encrypted message with it! However, a required part of E2EE messages is message franking. It works this way:

For every new message, you generate a brand new HMAC key, include the HMAC key in your message, compute a HMAC signature of the message you want to send, then submit the HMAC signature alongside the resulting encrypted blob:

const key = await crypto.subtle.generateKey({ name: 'HMAC', hash: { name: 'SHA-256' } }, true, ['sign', 'verify']);
const message = ''; // TODO
const hmac = await crypto.subtle.sign('HMAC', key, (new TextEncoder()).encode(message));

const encryptedMessage = session.encrypt(message);

axios.post('http://localhost:3000/api/v1/crypto/deliveries', {
  device: [
    {
      account_id: 456,
      device_id: '33456',
      body: encryptedMessage.body,
      type: encryptedMessage.type,
      hmac: bufferToHex(hmac),
    },
  ],
});

Now it's worth noting that the inside of the encrypted message will have to be standartized, otherwise different apps across the fediverse would not understand each other. You should expect it to take the shape of JSON-LD / ActivityStreams objects just like the public ones.

Step 3: Receiving messages

If you use the streaming API you'll receive an encrypted_message event from the user stream when there's a new message for your device.

If you don't, you can poll instead:

axios.get('http://localhost:3000/api/v1/crypto/encrypted_messages')

When processing a message, you either already have a session for the device it's sent from, or you don't. If you don't, you create it:

const session = new Olm.Session();
session.create_inbound(account, message.body);

Inbound and outbound sessions are interchangeable, that is, if you have one, you don't need to make another, the only difference is how they are initialized.

When you have a session, you can decrypt the message:

const decryptedMessage = session.decrypt(message.type, message.body);

You then need to extract the HMAC key from it, and verify the digest that you received along with the message. If it doesn't verify, throw the message away.

You should clear out messages you've processed from the server:

axios.post('http://localhost:3000/api/v1/crypto/encrypted_messages/clear', {
  up_to_id: '104275469619612983',
});
@connyduck
Copy link

Thx! I think I could already implement a basic version with this guide.

What is definitely missing is examples of Json responses from the server.

You should periodically check how many one-time keys are left in the pool, and stock up if it's running low

This needs be specified more precisly. Every hour? Daily? Weekly? Monthly? Whats low? 0? 10? 100? I guess it would make sense to check everytime after a certain amount of messages have been received? Should we stock higher for accounts with a lot of followers?

I am missing a way to delete a device for a user.

Now it's worth noting that the inside of the encrypted message will have to be standartized

When/where is this going to happen?

The best UI for this is probably a completly separate (from unencrypted direct messages) section with a chat-like interface. What do you have in mind?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment