You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-desktop/ts/session/medium_group/ratchet.ts

295 lines
7.9 KiB
TypeScript

import { PubKey } from '../types';
import * as Data from '../../../js/modules/data';
import { saveSenderKeysInner } from './index';
import { StringUtils } from '../utils';
import { MediumGroupRequestKeysMessage } from '../messages/outgoing';
import { getMessageQueue } from '..';
const toHex = (buffer: ArrayBuffer) => StringUtils.decode(buffer, 'hex');
const fromHex = (hex: string) => StringUtils.encode(hex, 'hex');
const jobQueue: { [key: string]: Promise<any> } = {};
async function queueJobForNumber(number: string, runJob: any) {
// tslint:disable-next-line no-promise-as-boolean
const runPrevious = jobQueue[number] || Promise.resolve();
const runCurrent = runPrevious.then(runJob, runJob);
jobQueue[number] = runCurrent;
// tslint:disable-next-line no-floating-promises
runCurrent
.then(() => {
if (jobQueue[number] === runCurrent) {
// tslint:disable-next-line no-dynamic-delete
delete jobQueue[number];
}
})
.catch((e: any) => {
window.log.error('queueJobForNumber() Caught error', e);
});
return runCurrent;
}
// This is different from the other ratchet type!
interface Ratchet {
chainKey: any;
keyIdx: number;
messageKeys: any;
}
// TODO: change the signature to return "NO KEY" instead of throwing
async function loadChainKey(
groupId: string,
senderIdentity: string
): Promise<Ratchet | null> {
const senderKeyEntry = await Data.getSenderKeys(groupId, senderIdentity);
if (!senderKeyEntry) {
// TODO: we should try to request the key from the sender in this case
return null;
}
const { chainKeyHex, idx: keyIdx, messageKeys } = senderKeyEntry.ratchet;
if (!chainKeyHex) {
throw Error('Chain key not found');
}
// TODO: This could fail if the data is not hex, handle
// this case
const chainKey = fromHex(chainKeyHex);
return { chainKey, keyIdx, messageKeys };
}
export async function getChainKey(
groupId: string,
senderIdentity: string
): Promise<{ chainKey: Uint8Array; keyIdx: number } | null> {
const maybeKey = await loadChainKey(groupId, senderIdentity);
if (!maybeKey) {
return null;
} else {
const { chainKey, keyIdx } = maybeKey;
return { chainKey, keyIdx };
}
}
export async function encryptWithSenderKey(
plaintext: Uint8Array,
groupId: string,
ourIdentity: string
) {
// We only want to serialize jobs with the same pair (groupId, ourIdentity)
const id = groupId + ourIdentity;
return queueJobForNumber(id, () =>
encryptWithSenderKeyInner(plaintext, groupId, ourIdentity)
);
}
async function encryptWithSenderKeyInner(
plaintext: Uint8Array,
groupId: string,
ourIdentity: string
) {
const { messageKey, keyIdx } = await stepRatchetOnce(groupId, ourIdentity);
const ciphertext = await window.libloki.crypto.EncryptGCM(
messageKey,
plaintext
);
return { ciphertext, keyIdx };
}
async function hmacSHA256(keybuf: any, data: any) {
// NOTE: importKey returns a 'PromiseLike'
// tslint:disable-next-line await-promise
const key = await crypto.subtle.importKey(
'raw',
keybuf,
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['sign']
);
return crypto.subtle.sign({ name: 'HMAC', hash: 'SHA-256' }, key, data);
}
async function stepRatchet(ratchet: Ratchet) {
const { chainKey, keyIdx, messageKeys } = ratchet;
const byteArray = new Uint8Array(1);
byteArray[0] = 1;
const messageKey = await hmacSHA256(chainKey, byteArray.buffer);
byteArray[0] = 2;
const nextChainKey = await hmacSHA256(chainKey, byteArray.buffer);
const nextKeyIdx = keyIdx + 1;
return { nextChainKey, messageKey, nextKeyIdx, messageKeys };
}
async function stepRatchetOnce(
groupId: string,
senderIdentity: string
): Promise<{ messageKey: any; keyIdx: any }> {
const ratchet = await loadChainKey(groupId, senderIdentity);
if (!ratchet) {
window.log.error(
`Could not find ratchet for groupId ${groupId} sender: ${senderIdentity}`
);
throw {};
}
const { nextChainKey, messageKey, nextKeyIdx } = await stepRatchet(ratchet);
// Don't need to remember message keys for a sending ratchet
const messageKeys = {};
const nextChainKeyHex = toHex(nextChainKey);
await saveSenderKeysInner(
groupId,
PubKey.cast(senderIdentity),
nextChainKeyHex,
nextKeyIdx,
messageKeys
);
return { messageKey, keyIdx: nextKeyIdx };
}
// Advance the ratchet until idx
async function advanceRatchet(
groupId: string,
senderIdentity: string,
idx: number
) {
const { log } = window;
const ratchet = await loadChainKey(groupId, senderIdentity);
if (!ratchet) {
log.error(
`Could not find ratchet for groupId ${groupId} sender: ${senderIdentity}`
);
throw new window.textsecure.SenderKeyMissing(senderIdentity);
}
// Normally keyIdx will be 1 behind, in which case we stepRatchet one time only
if (idx < ratchet.keyIdx) {
// If the request is for some old index, retrieve the key generated earlier and
// remove it from the database (there is no need to advance the ratchet)
const messageKey = ratchet.messageKeys[idx];
if (messageKey) {
// tslint:disable-next-line no-dynamic-delete
delete ratchet.messageKeys[idx];
// TODO: just pass in the ratchet?
// tslint:disable-next-line no-shadowed-variable
const chainKeyHex = toHex(ratchet.chainKey);
await saveSenderKeysInner(
groupId,
PubKey.cast(senderIdentity),
chainKeyHex,
ratchet.keyIdx,
ratchet.messageKeys
);
return fromHex(messageKey);
}
log.error('[idx] not found key for idx: ', idx);
// I probably want a better error handling than this
return null;
} else if (idx === ratchet.keyIdx) {
log.error(
`advanceRatchet() called with idx:${idx}, current ratchetIdx:${ratchet.keyIdx}. We already burnt that keyIdx before.`
);
return null;
}
const { messageKeys } = ratchet;
let curMessageKey;
// tslint:disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line no-await-in-loop
const { nextKeyIdx, nextChainKey, messageKey } = await stepRatchet(ratchet);
ratchet.chainKey = nextChainKey;
ratchet.keyIdx = nextKeyIdx;
if (nextKeyIdx === idx) {
curMessageKey = messageKey;
break;
} else if (nextKeyIdx > idx) {
log.error(
`Could not decrypt for an older ratchet step: (${nextKeyIdx})nextKeyIdx > (${idx})idx`
);
throw new Error(`Cannot revert ratchet for group ${groupId}!`);
} else {
// Store keys for skipped nextKeyIdx, we might need them to decrypt
// messages that arrive out-of-order
messageKeys[nextKeyIdx] = toHex(messageKey);
}
}
const chainKeyHex = toHex(ratchet.chainKey);
await saveSenderKeysInner(
groupId,
PubKey.cast(senderIdentity),
chainKeyHex,
idx,
messageKeys
);
return curMessageKey;
}
export async function decryptWithSenderKey(
ciphertext: Uint8Array,
curKeyIdx: number,
groupId: string,
senderIdentity: string
) {
// We only want to serialize jobs with the same pair (groupId, senderIdentity)
const id = groupId + senderIdentity;
return queueJobForNumber(id, () =>
decryptWithSenderKeyInner(ciphertext, curKeyIdx, groupId, senderIdentity)
);
}
async function decryptWithSenderKeyInner(
ciphertext: Uint8Array,
curKeyIdx: number,
groupId: string,
senderIdentity: string
) {
const messageKey = await advanceRatchet(groupId, senderIdentity, curKeyIdx);
if (!messageKey) {
return null;
}
try {
const plaintext = await window.libloki.crypto.DecryptGCM(
messageKey,
ciphertext
);
return plaintext;
} catch (e) {
window.log.error('Got error during DecryptGCM()', e);
if (e instanceof DOMException) {
window.log.error(
'Got DOMException during DecryptGCM(). Rethrowing as SenderKeyMissing '
);
throw new window.textsecure.SenderKeyMissing(senderIdentity);
}
}
}