Support for translating Desktop sessions to libsignal-client sessions
This commit is contained in:
parent
44dfd28017
commit
c73e35b1b6
7 changed files with 1513 additions and 73 deletions
409
ts/util/sessionTranslation.ts
Normal file
409
ts/util/sessionTranslation.ts
Normal file
|
@ -0,0 +1,409 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { get, isFinite, isInteger, isString } from 'lodash';
|
||||
import { HKDF } from 'libsignal-client';
|
||||
|
||||
import { signal } from '../protobuf/compiled';
|
||||
import {
|
||||
bytesFromString,
|
||||
fromEncodedBinaryToArrayBuffer,
|
||||
typedArrayToArrayBuffer,
|
||||
} from '../Crypto';
|
||||
|
||||
const { RecordStructure, SessionStructure } = signal.proto.storage;
|
||||
const { Chain } = SessionStructure;
|
||||
|
||||
type KeyPairType = {
|
||||
privKey?: string;
|
||||
pubKey?: string;
|
||||
};
|
||||
|
||||
type OldRatchetType = {
|
||||
added?: number;
|
||||
ephemeralKey?: string;
|
||||
};
|
||||
|
||||
type SessionType = {
|
||||
registrationId?: number;
|
||||
currentRatchet?: {
|
||||
rootKey?: string;
|
||||
lastRemoteEphemeralKey?: string;
|
||||
previousCounter?: number;
|
||||
ephemeralKeyPair?: KeyPairType;
|
||||
};
|
||||
indexInfo?: {
|
||||
remoteIdentityKey?: string;
|
||||
closed?: number;
|
||||
baseKey?: string;
|
||||
baseKeyType?: number;
|
||||
};
|
||||
pendingPreKey?: {
|
||||
baseKey?: string;
|
||||
signedPreKeyId?: number;
|
||||
// The first two are required; this one is optional
|
||||
preKeyId?: number;
|
||||
};
|
||||
oldRatchetList?: Array<OldRatchetType>;
|
||||
|
||||
// Note: ChainTypes are stored here, keyed by their baseKey. Typescript
|
||||
/// doesn't allow that kind of combination definition (known keys and
|
||||
// indexer), so we force session to `any` below whenever we access it like
|
||||
// `session[baseKey]`.
|
||||
};
|
||||
|
||||
type MessageKeyGroup = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
type ChainType = {
|
||||
messageKeys?: MessageKeyGroup;
|
||||
chainKey?: {
|
||||
counter?: number;
|
||||
key?: string;
|
||||
};
|
||||
chainType: number;
|
||||
};
|
||||
|
||||
type SessionListType = {
|
||||
[key: string]: SessionType;
|
||||
};
|
||||
|
||||
type SessionRecordType = {
|
||||
sessions?: SessionListType;
|
||||
version?: 'v1';
|
||||
};
|
||||
|
||||
export type LocalUserDataType = {
|
||||
identityKeyPublic: ArrayBuffer;
|
||||
registrationId: number;
|
||||
};
|
||||
|
||||
export function sessionStructureToArrayBuffer(
|
||||
recordStructure: signal.proto.storage.RecordStructure
|
||||
): ArrayBuffer {
|
||||
return typedArrayToArrayBuffer(
|
||||
signal.proto.storage.RecordStructure.encode(recordStructure).finish()
|
||||
);
|
||||
}
|
||||
|
||||
export function sessionRecordToProtobuf(
|
||||
record: SessionRecordType,
|
||||
ourData: LocalUserDataType
|
||||
): signal.proto.storage.RecordStructure {
|
||||
const proto = new RecordStructure();
|
||||
|
||||
proto.previousSessions = [];
|
||||
|
||||
const sessionGroup = record.sessions || {};
|
||||
const sessions = Object.values(sessionGroup);
|
||||
|
||||
const first = sessions.find(session => {
|
||||
return session?.indexInfo?.closed === -1;
|
||||
});
|
||||
|
||||
if (first) {
|
||||
proto.currentSession = toProtobufSession(first, ourData);
|
||||
}
|
||||
|
||||
sessions.sort((left, right) => {
|
||||
// Descending - we want recently-closed sessions to be first
|
||||
return (right?.indexInfo?.closed || 0) - (left?.indexInfo?.closed || 0);
|
||||
});
|
||||
const onlyClosed = sessions.filter(
|
||||
session => session?.indexInfo?.closed !== -1
|
||||
);
|
||||
|
||||
if (onlyClosed.length < sessions.length - 1) {
|
||||
throw new Error('toProtobuf: More than one open session!');
|
||||
}
|
||||
|
||||
proto.previousSessions = [];
|
||||
onlyClosed.forEach(session => {
|
||||
proto.previousSessions.push(toProtobufSession(session, ourData));
|
||||
});
|
||||
|
||||
if (!proto.currentSession && proto.previousSessions.length === 0) {
|
||||
throw new Error('toProtobuf: Record had no sessions!');
|
||||
}
|
||||
|
||||
return proto;
|
||||
}
|
||||
|
||||
function toProtobufSession(
|
||||
session: SessionType,
|
||||
ourData: LocalUserDataType
|
||||
): signal.proto.storage.SessionStructure {
|
||||
const proto = new SessionStructure();
|
||||
|
||||
// Core Fields
|
||||
|
||||
proto.aliceBaseKey = binaryToUint8Array(session, 'indexInfo.baseKey', 33);
|
||||
proto.localIdentityPublic = new Uint8Array(ourData.identityKeyPublic);
|
||||
proto.localRegistrationId = ourData.registrationId;
|
||||
|
||||
proto.previousCounter = getInteger(session, 'currentRatchet.previousCounter');
|
||||
proto.remoteIdentityPublic = binaryToUint8Array(
|
||||
session,
|
||||
'indexInfo.remoteIdentityKey',
|
||||
33
|
||||
);
|
||||
proto.remoteRegistrationId = getInteger(session, 'registrationId');
|
||||
proto.rootKey = binaryToUint8Array(session, 'currentRatchet.rootKey', 32);
|
||||
proto.sessionVersion = 1;
|
||||
|
||||
// Note: currently unused
|
||||
// proto.needsRefresh = null;
|
||||
|
||||
// Pending PreKey
|
||||
|
||||
if (session.pendingPreKey) {
|
||||
proto.pendingPreKey = new signal.proto.storage.SessionStructure.PendingPreKey();
|
||||
proto.pendingPreKey.baseKey = binaryToUint8Array(
|
||||
session,
|
||||
'pendingPreKey.baseKey',
|
||||
33
|
||||
);
|
||||
proto.pendingPreKey.signedPreKeyId = getInteger(
|
||||
session,
|
||||
'pendingPreKey.signedKeyId'
|
||||
);
|
||||
|
||||
if (session.pendingPreKey.preKeyId !== undefined) {
|
||||
proto.pendingPreKey.preKeyId = getInteger(
|
||||
session,
|
||||
'pendingPreKey.preKeyId'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sender Chain
|
||||
|
||||
const senderBaseKey = session.currentRatchet?.ephemeralKeyPair?.pubKey;
|
||||
if (!senderBaseKey) {
|
||||
throw new Error('toProtobufSession: No sender base key!');
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const senderChain = (session as any)[senderBaseKey] as ChainType | undefined;
|
||||
if (!senderChain) {
|
||||
throw new Error(
|
||||
'toProtobufSession: No matching chain found with senderBaseKey!'
|
||||
);
|
||||
}
|
||||
|
||||
if (senderChain.chainType !== 1) {
|
||||
throw new Error(
|
||||
`toProtobufSession: Expected sender chain type for senderChain, got ${senderChain.chainType}`
|
||||
);
|
||||
}
|
||||
|
||||
const protoSenderChain = toProtobufChain(senderChain);
|
||||
|
||||
protoSenderChain.senderRatchetKey = binaryToUint8Array(
|
||||
session,
|
||||
'currentRatchet.ephemeralKeyPair.pubKey',
|
||||
33
|
||||
);
|
||||
protoSenderChain.senderRatchetKeyPrivate = binaryToUint8Array(
|
||||
session,
|
||||
'currentRatchet.ephemeralKeyPair.privKey',
|
||||
32
|
||||
);
|
||||
|
||||
proto.senderChain = protoSenderChain;
|
||||
|
||||
// First Receiver Chain
|
||||
|
||||
proto.receiverChains = [];
|
||||
|
||||
const firstReceiverChainBaseKey =
|
||||
session.currentRatchet?.lastRemoteEphemeralKey;
|
||||
if (!firstReceiverChainBaseKey) {
|
||||
throw new Error('toProtobufSession: No receiver base key!');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const firstReceiverChain = (session as any)[firstReceiverChainBaseKey] as
|
||||
| ChainType
|
||||
| undefined;
|
||||
|
||||
// If the session was just initialized, then there will be no receiver chain
|
||||
if (firstReceiverChain) {
|
||||
const protoFirstReceiverChain = toProtobufChain(firstReceiverChain);
|
||||
|
||||
if (firstReceiverChain.chainType !== 2) {
|
||||
throw new Error(
|
||||
`toProtobufSession: Expected receiver chain type for firstReceiverChain, got ${firstReceiverChain.chainType}`
|
||||
);
|
||||
}
|
||||
|
||||
protoFirstReceiverChain.senderRatchetKey = binaryToUint8Array(
|
||||
session,
|
||||
'currentRatchet.lastRemoteEphemeralKey',
|
||||
33
|
||||
);
|
||||
|
||||
proto.receiverChains.push(protoFirstReceiverChain);
|
||||
}
|
||||
|
||||
// Old Receiver Chains
|
||||
|
||||
const oldChains = (session.oldRatchetList || [])
|
||||
.slice(0)
|
||||
.sort((left, right) => (right.added || 0) - (left.added || 0));
|
||||
oldChains.forEach(oldRatchet => {
|
||||
const baseKey = oldRatchet.ephemeralKey;
|
||||
if (!baseKey) {
|
||||
throw new Error('toProtobufSession: No base key for old receiver chain!');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const chain = (session as any)[baseKey] as ChainType | undefined;
|
||||
if (!chain) {
|
||||
throw new Error(
|
||||
'toProtobufSession: No chain for old receiver chain base key!'
|
||||
);
|
||||
}
|
||||
|
||||
if (chain.chainType !== 2) {
|
||||
throw new Error(
|
||||
`toProtobufSession: Expected receiver chain type, got ${chain.chainType}`
|
||||
);
|
||||
}
|
||||
|
||||
const protoChain = toProtobufChain(chain);
|
||||
|
||||
protoChain.senderRatchetKey = binaryToUint8Array(
|
||||
oldRatchet,
|
||||
'ephemeralKey',
|
||||
33
|
||||
);
|
||||
|
||||
proto.receiverChains.push(protoChain);
|
||||
});
|
||||
|
||||
return proto;
|
||||
}
|
||||
|
||||
function toProtobufChain(
|
||||
chain: ChainType
|
||||
): signal.proto.storage.SessionStructure.Chain {
|
||||
const proto = new Chain();
|
||||
|
||||
const protoChainKey = new Chain.ChainKey();
|
||||
protoChainKey.index = getInteger(chain, 'chainKey.counter');
|
||||
if (chain.chainKey?.key !== undefined) {
|
||||
protoChainKey.key = binaryToUint8Array(chain, 'chainKey.key', 32);
|
||||
}
|
||||
proto.chainKey = protoChainKey;
|
||||
|
||||
const messageKeys = Object.entries(chain.messageKeys || {});
|
||||
proto.messageKeys = messageKeys.map(entry => {
|
||||
const protoMessageKey = new SessionStructure.Chain.MessageKey();
|
||||
protoMessageKey.index = getInteger(entry, '0');
|
||||
const key = binaryToUint8Array(entry, '1', 32);
|
||||
|
||||
const { cipherKey, macKey, iv } = translateMessageKey(key);
|
||||
|
||||
protoMessageKey.cipherKey = new Uint8Array(cipherKey);
|
||||
protoMessageKey.macKey = new Uint8Array(macKey);
|
||||
protoMessageKey.iv = new Uint8Array(iv);
|
||||
|
||||
return protoMessageKey;
|
||||
});
|
||||
|
||||
return proto;
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
|
||||
const WHISPER_MESSAGE_KEYS = 'WhisperMessageKeys';
|
||||
|
||||
function deriveSecrets(
|
||||
input: ArrayBuffer,
|
||||
salt: ArrayBuffer,
|
||||
info: ArrayBuffer
|
||||
): Array<ArrayBuffer> {
|
||||
const hkdf = HKDF.new(3);
|
||||
const output = hkdf.deriveSecrets(
|
||||
3 * 32,
|
||||
Buffer.from(input),
|
||||
Buffer.from(info),
|
||||
Buffer.from(salt)
|
||||
);
|
||||
return [
|
||||
typedArrayToArrayBuffer(output.slice(0, 32)),
|
||||
typedArrayToArrayBuffer(output.slice(32, 64)),
|
||||
typedArrayToArrayBuffer(output.slice(64, 96)),
|
||||
];
|
||||
}
|
||||
|
||||
function translateMessageKey(key: Uint8Array) {
|
||||
const input = key.buffer;
|
||||
const salt = new ArrayBuffer(32);
|
||||
const info = bytesFromString(WHISPER_MESSAGE_KEYS);
|
||||
|
||||
const [cipherKey, macKey, ivContainer] = deriveSecrets(input, salt, info);
|
||||
|
||||
return {
|
||||
cipherKey,
|
||||
macKey,
|
||||
iv: ivContainer.slice(0, 16),
|
||||
};
|
||||
}
|
||||
|
||||
function binaryToUint8Array(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
object: any,
|
||||
path: string,
|
||||
length: number
|
||||
): Uint8Array {
|
||||
const target = get(object, path);
|
||||
if (target === null || target === undefined) {
|
||||
throw new Error(`binaryToUint8Array: Falsey path ${path}`);
|
||||
}
|
||||
|
||||
if (!isString(target)) {
|
||||
throw new Error(`binaryToUint8Array: String not found at path ${path}`);
|
||||
}
|
||||
|
||||
const buffer = fromEncodedBinaryToArrayBuffer(target);
|
||||
if (length && buffer.byteLength !== length) {
|
||||
throw new Error(
|
||||
`binaryToUint8Array: Got unexpected length ${buffer.byteLength} instead of ${length} at path ${path}`
|
||||
);
|
||||
}
|
||||
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function getInteger(object: any, path: string): number {
|
||||
const target = get(object, path);
|
||||
if (target === null || target === undefined) {
|
||||
throw new Error(`getInteger: Falsey path ${path}`);
|
||||
}
|
||||
|
||||
if (isString(target)) {
|
||||
const result = parseInt(target, 10);
|
||||
if (!isFinite(result)) {
|
||||
throw new Error(
|
||||
`getInteger: Value could not be parsed as number at ${path}: {target}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!isInteger(result)) {
|
||||
throw new Error(
|
||||
`getInteger: Parsed value not an integer at ${path}: {target}`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!isInteger(target)) {
|
||||
throw new Error(`getInteger: Value not an integer at ${path}: {target}`);
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue