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.
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`);
});
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.
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',
});
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.
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.
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?