feat(protocol): add THP encoding/decoding logic

- only one message exchange is supported ThpCreateChannelRequest > ThpCreateChannelResponse
- without cryptography module. will be added in the next commit
This commit is contained in:
Szymon Lesisz
2025-05-21 13:30:29 +02:00
committed by Szymon Lesisz
parent c883e604c1
commit b33b491b40
12 changed files with 1009 additions and 2 deletions

View File

@@ -1,13 +1,25 @@
import { ThpCredentials, ThpDeviceProperties } from './messages';
import { ThpCredentials, ThpDeviceProperties, ThpMessageSyncBit } from './messages';
export type ThpStateSerialized = {
properties?: ThpDeviceProperties;
credentials: ThpCredentials[];
channel: string; // 2 bytes as hex
sendBit: ThpMessageSyncBit; // host synchronization bit
recvBit: ThpMessageSyncBit; // device synchronization bit
sendNonce: number; // host nonce
recvNonce: number; // device nonce
expectedResponses: number[]; // expected responses from the device
};
export class ThpState {
private _properties?: ThpDeviceProperties;
private _pairingCredentials: ThpCredentials[] = [];
private _channel: Buffer = Buffer.alloc(0);
private _sendBit: ThpMessageSyncBit = 0;
private _sendNonce: number = 0;
private _recvBit: ThpMessageSyncBit = 0;
private _recvNonce: number = 1;
private _expectedResponses: number[] = [];
get properties() {
return this._properties;
@@ -29,10 +41,110 @@ export class ThpState {
}
}
get channel() {
return this._channel;
}
setChannel(channel: Buffer) {
this._channel = channel;
}
get sendBit() {
return this._sendBit;
}
get sendNonce() {
return this._sendNonce;
}
get recvBit() {
return this._recvBit;
}
get recvNonce() {
return this._recvNonce;
}
updateSyncBit(type: 'send' | 'recv') {
if (type === 'send') {
this._sendBit = this._sendBit > 0 ? 0 : 1;
} else {
this._recvBit = this._recvBit > 0 ? 0 : 1;
}
}
updateNonce(type: 'send' | 'recv') {
if (type === 'send') {
this._sendNonce += 1;
} else {
this._recvNonce += 1;
}
}
serialize(): ThpStateSerialized {
return {
properties: this._properties,
channel: this.channel.toString('hex'),
sendBit: this.sendBit,
recvBit: this.recvBit,
sendNonce: this.sendNonce,
recvNonce: this.recvNonce,
expectedResponses: this._expectedResponses,
credentials: this._pairingCredentials,
};
}
deserialize(json: ReturnType<(typeof this)['serialize']>) {
// simple fields validation
const error = new Error('ThpState.deserialize invalid state');
if (!json || typeof json !== 'object') {
throw error;
}
if (!Array.isArray(json.expectedResponses)) {
throw error;
}
if (typeof json.channel !== 'string') {
throw error;
}
[
json.sendBit,
json.recvBit,
json.sendNonce,
json.recvNonce,
...json.expectedResponses,
].forEach(nr => {
if (typeof nr !== 'number') {
throw error;
}
});
this._channel = Buffer.from(json.channel, 'hex');
this._expectedResponses = json.expectedResponses;
this._sendBit = json.sendBit;
this._recvBit = json.recvBit;
this._sendNonce = json.sendNonce;
this._recvNonce = json.recvNonce;
}
get expectedResponses() {
return this._expectedResponses;
}
setExpectedResponses(expected: number[]) {
this._expectedResponses = expected;
}
resetState() {
this._channel = Buffer.alloc(0);
this._sendBit = 0;
this._sendNonce = 0;
this._recvBit = 0;
this._recvNonce = 1;
this._expectedResponses = [];
this._pairingCredentials = [];
}
toString() {
return JSON.stringify(this.serialize());
}
}

View File

@@ -0,0 +1,16 @@
export const THP_CREATE_CHANNEL_REQUEST = 0x40;
export const THP_CREATE_CHANNEL_RESPONSE = 0x41;
export const THP_HANDSHAKE_INIT_REQUEST = 0x00;
export const THP_HANDSHAKE_INIT_RESPONSE = 0x01;
export const THP_HANDSHAKE_COMPLETION_REQUEST = 0x02;
export const THP_HANDSHAKE_COMPLETION_RESPONSE = 0x03;
export const THP_ERROR_HEADER_BYTE = 0x42;
export const THP_READ_ACK_HEADER_BYTE = 0x20; // [0x20, 0x30];
export const THP_CONTROL_BYTE_ENCRYPTED = 0x04; // [0x04, 0x14];
export const THP_CONTROL_BYTE_DECRYPTED = 0x05; // [0x05, 0x15];
export const THP_CONTINUATION_PACKET = 0x80;
export const THP_DEFAULT_CHANNEL = Buffer.from([0xff, 0xff]);
export const CRC_LENGTH = 4;
export const TAG_LENGTH = 16;

View File

@@ -0,0 +1,63 @@
// inspired by
// https://github.com/brianloveswords/buffer-crc32/blob/master/index.js
// optimized by
// https://stackoverflow.com/a/18639975
// we don't want to have dependency in @trezor/protocol package + our implementation is simpler and faster
const getCrcTable = () =>
new Int32Array([
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535,
0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd,
0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d,
0x6ddde4eb, 0xf4d4b551, 0x83d385c7, 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec,
0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4,
0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c,
0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac,
0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f,
0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, 0x2f6f7c87, 0x58684c11, 0xc1611dab,
0xb6662d3d, 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f,
0x9fbfe4a5, 0xe8b8d433, 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb,
0x086d3d2d, 0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e,
0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea,
0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, 0x4db26158, 0x3ab551ce,
0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a,
0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9,
0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409,
0xce61e49f, 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81,
0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, 0xead54739,
0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8,
0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, 0xf00f9344, 0x8708a3d2, 0x1e01f268,
0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0,
0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8,
0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b,
0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef,
0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, 0xcc0c7795, 0xbb0b4703,
0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7,
0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a,
0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae,
0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242,
0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777, 0x88085ae6,
0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45,
0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d,
0x3e6e77db, 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5,
0x47b2cf7f, 0x30b5ffe9, 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605,
0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94,
0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d,
]);
export const crc32 = (buf: Buffer): Buffer => {
if (!Buffer.isBuffer(buf)) {
throw new Error('Invalid crc input');
}
const table = getCrcTable();
let crc = -1;
for (let i = 0; i < buf.length; i++) {
crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
}
const buffer = Buffer.alloc(4);
buffer.writeInt32BE(crc ^ -1, 0);
return buffer;
};

View File

@@ -0,0 +1,173 @@
import { ThpState } from './ThpState';
import {
CRC_LENGTH,
THP_CREATE_CHANNEL_RESPONSE,
THP_ERROR_HEADER_BYTE,
THP_READ_ACK_HEADER_BYTE,
} from './constants';
import { TransportProtocolDecode } from '../types';
import { crc32 } from './crypto/crc32';
import { ThpError, ThpMessageResponse } from './messages';
import { clearControlBit, readThpHeader } from './utils';
type ThpMessage = ReturnType<TransportProtocolDecode> & {
magic: number;
thpState: ThpState;
};
// @trezor/protobuf decodeMessage without direct reference to protobuf root
type ProtobufDecoder = (
messageType: string | number,
payload: Buffer,
) => {
type: string;
message: Record<string, unknown>;
};
type MessageV2 = ReturnType<TransportProtocolDecode>;
// TODO: link-to-public-docs
// https://www.notion.so/satoshilabs/THP-Specification-2-0-18fdc5260606806ab573d0a7cba1897a
// example: 41ffff0020639ba57ff4e0c2343c830a0454335731180220002802280328042801c0171551
// [magic | channel | len* | nonce | protobuf: messageType + message | crc ]
// [41 | ffff | 0020 | 639ba57ff4e0c234 | 3c830a0454335731180220002802280328042801 | c0171551]
// *len includes nonce+protobuf+crc
const createChannelResponse = (
{ payload }: ThpMessage,
protobufDecoder: ProtobufDecoder,
): ThpMessageResponse => {
const nonce = payload.subarray(0, 8);
const channel = payload.subarray(8, 10);
const props = payload.subarray(10, payload.length - CRC_LENGTH);
const properties = protobufDecoder('ThpDeviceProperties', props).message;
// TODO: add-crypto
// const handshakeHash = handleCreateChannelResponse(props);
return {
type: 'ThpCreateChannelResponse',
message: {
nonce,
channel,
properties,
// TODO: add-crypto
// handshakeHash,
},
} as any;
};
const decodeReadAck = (): ThpMessageResponse => ({
type: 'ThpAck',
message: {},
});
// TODO: link-to-public-docs
// https://www.notion.so/satoshilabs/THP-Specification-2-0-18fdc5260606806ab573d0a7cba1897a
// example: 42122200050270303cfa
// [magic | channel | len* | error | crc ]
// [42 | 1222 | 0005 | 02 | 70303cfa]
// *len includes error+crc
const decodeThpError = (payload: Buffer): ThpMessageResponse => {
const [errorType] = payload;
const error = (() => {
switch (errorType) {
case 0x01:
return 'ThpTransportBusy';
case 0x02:
return 'ThpUnallocatedChannel';
case 0x03:
return 'ThpDecryptionFailed';
case 0x04:
return 'ThpInvalidData';
case 0x05:
return 'ThpDeviceLocked';
default:
return 'ThpUnknownError';
}
})();
const message: ThpError = {
code: error,
message: error ?? `Unknown ThpError ${errorType}`,
};
return {
type: 'ThpError',
message,
};
};
const validateCrc = (decodedMessage: ReturnType<TransportProtocolDecode>) => {
// payload length without crc
const payloadLen = decodedMessage.length - CRC_LENGTH;
const length = Buffer.alloc(2);
length.writeUInt16BE(decodedMessage.length);
// build crc locally
const expectedCrc = crc32(
Buffer.concat([
decodedMessage.header,
length,
decodedMessage.payload.subarray(0, payloadLen),
]),
);
// get crc from the message
const crc = decodedMessage.payload.subarray(payloadLen, decodedMessage.length);
// compare both crc
if (expectedCrc.compare(crc) !== 0) {
throw new Error(
`Invalid CRC. expected: ${expectedCrc.toString('hex')} received: ${crc.toString('hex')}`,
);
}
};
// Decode protocol-v2 message from thp send process: ThpAck or ThpError
export const decodeSendAck = (decodedMessage: MessageV2) => {
validateCrc(decodedMessage);
const header = readThpHeader(decodedMessage.header);
const magic = clearControlBit(header.magic);
if (magic === THP_ERROR_HEADER_BYTE) {
return decodeThpError(decodedMessage.payload);
}
if (magic === THP_READ_ACK_HEADER_BYTE) {
return decodeReadAck();
}
throw new Error('Unexpected send response: ' + magic);
};
// Decode protocol-v2 message from thp receive process
export const decode = (
decodedMessage: MessageV2,
protobufDecoder: ProtobufDecoder,
thpState?: ThpState,
): ThpMessageResponse => {
if (!thpState) {
throw new Error('Cannot decode THP message without ThpState');
}
validateCrc(decodedMessage);
const header = readThpHeader(decodedMessage.header);
const message: ThpMessage = {
...decodedMessage,
...header,
thpState,
};
const magic = clearControlBit(message.magic);
if (magic === THP_ERROR_HEADER_BYTE) {
return decodeThpError(message.payload);
}
if (magic === THP_READ_ACK_HEADER_BYTE) {
return decodeReadAck();
}
if (magic === THP_CREATE_CHANNEL_RESPONSE) {
return createChannelResponse(message, protobufDecoder);
}
throw new Error('Unknown message type: ' + magic);
};

View File

@@ -0,0 +1,172 @@
import { ThpState } from './ThpState';
import {
CRC_LENGTH,
TAG_LENGTH,
THP_CONTROL_BYTE_ENCRYPTED,
THP_CREATE_CHANNEL_REQUEST,
THP_DEFAULT_CHANNEL,
THP_READ_ACK_HEADER_BYTE,
} from './constants';
import { crc32 } from './crypto/crc32';
import { addAckBit, addSequenceBit, getControlBit, isThpMessageName } from './utils';
// @trezor/protobuf encodeMessage without direct reference to protobuf root
type ProtobufEncoder = (
messageName: string,
messageData: Record<string, unknown>,
) => {
messageType: number;
message: Buffer;
};
// utility for **RequestPayload inputs/params
const getBytesFromField = (data: Record<string, unknown>, fieldName: string) => {
const value = data[fieldName];
if (typeof value === 'string') {
return Buffer.from(value, 'hex');
}
if (Buffer.isBuffer(value)) {
return value;
}
};
const createChannelRequestPayload = (data: Record<string, unknown>) => {
const nonce = getBytesFromField(data, 'nonce');
if (!nonce) {
throw new Error('Missing nonce field');
}
return nonce;
};
export const encodePayload = (name: string, data: Record<string, unknown>, _thpState: ThpState) => {
if (name === 'ThpCreateChannelRequest') {
return createChannelRequestPayload(data);
}
return Buffer.alloc(0);
};
// TODO: link-to-public-docs
// https://www.notion.so/satoshilabs/THP-Specification-2-0-18fdc5260606806ab573d0a7cba1897a
// example: 40ffff000c639ba57ff4e0c2348189a406
// [magic | channel | len* | nonce | crc ]
// [40 | ffff | 000c | 639ba57ff4e0c234 | 8189a406]
// *len includes nonce+crc
const createChannelRequest = (data: Buffer, channel: Buffer) => {
const length = Buffer.alloc(2);
length.writeUInt16BE(data.length + CRC_LENGTH); // 8 nonce + 4 crc
const magic = Buffer.from([THP_CREATE_CHANNEL_REQUEST]);
const message = Buffer.concat([magic, channel, length, data]);
const crc = crc32(message);
return Buffer.concat([message, crc]);
};
const encodeThpMessage = (
messageType: string,
data: Buffer,
channel: Buffer,
_thpState: ThpState,
) => {
if (messageType === 'ThpCreateChannelRequest') {
return createChannelRequest(data, channel);
}
throw new Error(`Unknown ThpMessage type ${messageType}`);
};
// TODO: link-to-public-docs
// https://www.notion.so/satoshilabs/THP-Specification-2-0-18fdc5260606806ab573d0a7cba1897a
export const encodeProtobufMessage = (
messageType: number,
data: Buffer,
channel: Buffer,
thpState?: ThpState,
) => {
if (!thpState) {
throw new Error('ThpState missing');
}
const length = Buffer.alloc(2);
length.writeUInt16BE(1 + 2 + data.length + TAG_LENGTH + CRC_LENGTH); // 1 session_id + 2 messageType + protobuf len + 16 tag + 4 crc
const magic = addSequenceBit(THP_CONTROL_BYTE_ENCRYPTED, thpState.sendBit);
const header = Buffer.concat([magic, channel]);
const messageTypeBytes = Buffer.alloc(2);
messageTypeBytes.writeUInt16BE(messageType);
// TODO: add-crypto
// const cipheredMessage = cipherMessage(
// thpState.handshakeCredentials.hostKey,
// thpState.sendNonce,
// Buffer.alloc(0),
// Buffer.concat([thpState.sessionId, messageTypeBytes, data]),
// );
const cipheredMessage = Buffer.concat([Buffer.alloc(0), messageTypeBytes, data]);
const message = Buffer.concat([header, length, cipheredMessage]);
const crc = crc32(message);
return Buffer.concat([message, crc]);
};
// TODO: link-to-public-docs
// https://www.notion.so/satoshilabs/THP-Specification-2-0-18fdc5260606806ab573d0a7cba1897a
// example: 2012340004d9fcce58
// [magic | channel | len | crc ]
// [20 | 1234 | 0004 | d9fcce58]
const encodeReadAck = (channel: Buffer, syncBit: number) => {
const length = Buffer.alloc(2);
length.writeUInt16BE(CRC_LENGTH);
const magic = addAckBit(THP_READ_ACK_HEADER_BYTE, syncBit);
const message = Buffer.concat([magic, channel, length]);
const crc = crc32(message);
return Buffer.concat([message, crc]);
};
export const encodeAck = (bytesOrState: Buffer | ThpState) => {
if (Buffer.isBuffer(bytesOrState)) {
// 1 byte
const magic = bytesOrState.readUInt8();
// sequence bit
const recvBit = getControlBit(magic);
// 2 bytes channel id
const channel = bytesOrState.subarray(1, 3);
return encodeReadAck(channel, recvBit);
}
const { channel, recvBit } = bytesOrState;
return encodeReadAck(channel, recvBit);
};
// Encode protocol-v2 message
export const encode = (options: {
messageName: string;
data: Record<string, unknown>;
thpState?: ThpState;
protobufEncoder: ProtobufEncoder;
header?: Buffer;
}) => {
if (!options.thpState) {
throw new Error('ThpState missing');
}
const channel = options.thpState.channel || THP_DEFAULT_CHANNEL;
const { messageName, data, protobufEncoder, thpState } = options;
let result: Buffer;
if (isThpMessageName(messageName)) {
const payload = encodePayload(messageName, data, thpState);
result = encodeThpMessage(messageName, payload, channel, options.thpState);
} else {
const { messageType, message } = protobufEncoder(messageName, data);
result = encodeProtobufMessage(messageType, message, channel, thpState);
}
return result;
};

View File

@@ -1,4 +1,7 @@
export * from './decode';
export * from './encode';
export * from './messages';
export * from './utils';
export { ThpState } from './ThpState';

View File

@@ -1,3 +1,56 @@
import type { ThpCredentialResponse } from './protobufTypes';
// protobuf messages handled by the THP layer of Trezor firmware
// not defined in the firmware proto files.
// created and maintained manually
import type {
ThpCredentialResponse,
ThpDeviceProperties,
ThpProtobufMessageType,
} from './protobufTypes';
export type ThpError = {
code:
| 'ThpTransportBusy'
| 'ThpUnallocatedChannel'
| 'ThpDecryptionFailed'
| 'ThpInvalidData'
| 'ThpDeviceLocked'
| 'ThpUnknownError';
message: string;
};
export type ThpAck = Record<string, unknown>;
export type ThpCreateChannelRequest = {
nonce: Buffer;
};
export type ThpCreateChannelResponse = {
nonce: Buffer;
channel: Buffer;
properties: ThpDeviceProperties;
handshakeHash: Buffer;
};
export type ThpMessageType = ThpProtobufMessageType & {
ThpError: ThpError;
ThpAck: ThpAck;
ThpCreateChannelRequest: ThpCreateChannelRequest;
ThpCreateChannelResponse: ThpCreateChannelResponse;
};
export type ThpCredentials = ThpCredentialResponse & { autoconnect?: boolean };
export type ThpMessageSyncBit = 0 | 1;
// same as @trezor/protobuf Messages
export type ThpMessageKey = keyof ThpMessageType;
export type ThpMessagePayload<T extends ThpMessageKey = ThpMessageKey> = ThpMessageType[T];
export type ThpMessageResponse<T extends ThpMessageKey = ThpMessageKey> = T extends any
? {
type: T;
message: ThpMessagePayload<T>;
}
: never;

View File

@@ -19,3 +19,7 @@ export type ThpCredentialResponse = {
trezor_static_pubkey: string;
credential: string;
};
export type ThpProtobufMessageType = {
ThpDeviceProperties: ThpDeviceProperties;
};

View File

@@ -0,0 +1,117 @@
import type { ThpState } from './ThpState';
import {
THP_CONTINUATION_PACKET,
THP_CREATE_CHANNEL_REQUEST,
THP_CREATE_CHANNEL_RESPONSE,
THP_ERROR_HEADER_BYTE,
} from './constants';
import type { ThpMessageSyncBit } from './messages';
export const addAckBit = (magic: number, ackBit: number) => {
const result = Buffer.alloc(1);
result.writeInt8(magic | (ackBit << 3));
return result;
};
export const addSequenceBit = (magic: number, seqBit: number) => {
const result = Buffer.alloc(1);
result.writeInt8(magic | (seqBit << 4));
return result;
};
// clear 4th (ack) and 5th (sequence) bit
export const clearControlBit = (magic: number) => magic & ~(1 << 3) & ~(1 << 4);
export const getControlBit = (magic: number): ThpMessageSyncBit => {
const ackBit = (magic & (1 << 3)) === 0 ? 0 : 1;
const sequenceBit = (magic & (1 << 4)) === 0 ? 0 : 1;
return ackBit || sequenceBit;
};
// transform protocol-v2 message header to ThpHeader object
export const readThpHeader = (bytes: Buffer) => {
// 1 byte
const magic = bytes.readUInt8();
// sequence bit
const controlBit = getControlBit(magic);
// 2 bytes channel id
const channel = bytes.subarray(1, 3);
return {
magic,
controlBit,
channel,
};
};
// check if ThpAck is send/expected by Trezor
// Trezor doesn't send ThpAck after ThpCreateChannelRequest
// Trezor doesn't expect ThpAck ThpCreateChannelResponse
export const isAckExpected = (bytesOrMagic: Buffer | number[]) => {
const isCreateChannelMessage = (magic: number) =>
[THP_CREATE_CHANNEL_REQUEST, THP_CREATE_CHANNEL_RESPONSE].includes(magic);
if (Array.isArray(bytesOrMagic)) {
return !bytesOrMagic.find(n => isCreateChannelMessage(n));
}
return !isCreateChannelMessage(bytesOrMagic.readUInt8());
};
// get expected responses from decoded request data
export const getExpectedResponses = (bytes: Buffer) => {
const header = readThpHeader(bytes);
const magic = clearControlBit(header.magic);
if (magic === THP_CREATE_CHANNEL_REQUEST) {
return [THP_CREATE_CHANNEL_RESPONSE];
}
return [];
};
export const isExpectedResponse = (bytes: Buffer, state: ThpState) => {
if (bytes.length < 3) {
// ignore messages with minimum info
return false;
}
const header = readThpHeader(bytes);
if (header.channel.compare(state.channel) !== 0) {
// ignore messages from different channels
return false;
}
const magic = clearControlBit(header.magic);
if (magic === THP_ERROR_HEADER_BYTE) {
// ThpError is always expected
return true;
}
const { expectedResponses } = state;
for (let i = 0; i < expectedResponses.length; i++) {
if (magic === expectedResponses[i]) {
// continuation packet is not masked by controlBit
if (magic !== THP_CONTINUATION_PACKET && header.controlBit !== state?.recvBit) {
console.warn('Unexpected control bit');
return false;
}
return true;
}
}
return false;
};
export const isThpMessageName = (name: string) =>
[
'ThpCreateChannelRequest',
'ThpHandshakeInitRequest',
'ThpHandshakeCompletionRequest',
'ThpReadAck',
].includes(name);

View File

@@ -0,0 +1,46 @@
// fixtures: https://github.com/brianloveswords/buffer-crc32/blob/master/tests/crc.test.js
import { crc32 } from '../../src/protocol-thp/crypto/crc32';
describe('crc32', () => {
it('simple', () => {
const input = Buffer.from('hey sup bros');
const expected = Buffer.from([0x47, 0xfa, 0x55, 0x70]);
expect(crc32(input)).toEqual(expected);
});
it('more complex', () => {
const input = Buffer.from([0x00, 0x00, 0x00]);
const expected = Buffer.from([0xff, 0x41, 0xd9, 0x12]);
expect(crc32(input)).toEqual(expected);
});
it('extreme', () => {
const input = Buffer.from('शीर्षक');
const expected = Buffer.from([0x17, 0xb8, 0xaf, 0xf1]);
expect(crc32(input)).toEqual(expected);
});
it('another simple one', () => {
const input = Buffer.from('IEND');
const expected = Buffer.from([0xae, 0x42, 0x60, 0x82]);
expect(crc32(input)).toEqual(expected);
});
it('slightly more complex', () => {
const input = Buffer.from([0x00, 0x00, 0x00]);
const expected = Buffer.from([0xff, 0x41, 0xd9, 0x12]);
expect(crc32(input)).toEqual(expected);
});
it('complex crc32 gets calculated like a champ', () => {
const input = Buffer.from('शीर्षक');
const expected = Buffer.from([0x17, 0xb8, 0xaf, 0xf1]);
expect(crc32(input)).toEqual(expected);
});
it('crc32 throws on bad input', () => {
// @ts-expect-error
expect(() => crc32({})).toThrow('Invalid crc input');
});
});

View File

@@ -0,0 +1,218 @@
import {
ThpState,
decode,
decodeSendAck,
encode,
encodeAck,
getExpectedResponses,
isAckExpected,
isExpectedResponse,
} from '../../src/protocol-thp';
import { decode as decodeV2 } from '../../src/protocol-v2';
const protobufEncoder = (..._args: any[]) => ({
messageType: 1,
message: Buffer.alloc(1),
});
const protobufDecoder = (messageName: string | number, messageData: Buffer) => ({
type: messageName.toString(),
message: {
mockProtobufData: messageData,
},
});
const thpState = new ThpState();
describe('protocol-thp', () => {
beforeEach(() => {
thpState.resetState();
});
it('encode ThpCreateChannelRequest, decode ThpCreateChannelResponse', () => {
thpState.setChannel(Buffer.from('ffff', 'hex'));
const nonce = Buffer.from('639ba57ff4e0c234', 'hex');
const encoded = encode({
messageName: 'ThpCreateChannelRequest',
data: { nonce },
protobufEncoder,
thpState,
});
expect(encoded.toString('hex')).toEqual('40ffff000c639ba57ff4e0c2348189a406');
const response = Buffer.from(
'41ffff0020639ba57ff4e0c2343c830a0454335731180220002802280328042801c0171551',
'hex',
);
const decoded = decode(decodeV2(response), protobufDecoder, thpState);
expect(decoded.type).toEqual('ThpCreateChannelResponse');
expect(decoded.message).toMatchObject({
channel: Buffer.from('3c83', 'hex'),
nonce,
// properties asserted below
// TODO: add-crypto
// handshakeHash
});
// @ts-expect-error
const protobuf = decoded.message.properties.mockProtobufData.toString('hex');
expect(protobuf).toEqual('0a0454335731180220002802280328042801');
});
it('encode/decode ThpAck', () => {
thpState.setChannel(Buffer.from('1234', 'hex'));
const encodeAsBytes1 = encodeAck(Buffer.from('201234', 'hex')); // ackByte: 0
expect(encodeAsBytes1.toString('hex')).toEqual('2012340004d9fcce58');
const encodeAsBytes2 = encodeAck(Buffer.from('281234', 'hex')); // ackByte: 1
expect(encodeAsBytes2.toString('hex')).toEqual('2812340004e98c8599');
const encodeAsState1 = encodeAck(thpState); // ackByte: 0
expect(encodeAsState1.toString('hex')).toEqual('2012340004d9fcce58');
thpState.updateSyncBit('recv');
const encodeAsState2 = encodeAck(thpState); // ackByte: 1
expect(encodeAsState2.toString('hex')).toEqual('2812340004e98c8599');
expect(decode(decodeV2(encodeAsBytes1), protobufDecoder, thpState).type).toBe('ThpAck');
expect(decode(decodeV2(encodeAsBytes2), protobufDecoder, thpState).type).toBe('ThpAck');
expect(decode(decodeV2(encodeAsState1), protobufDecoder, thpState).type).toBe('ThpAck');
expect(decode(decodeV2(encodeAsState2), protobufDecoder, thpState).type).toBe('ThpAck');
});
it('decode ThpError', () => {
thpState.setChannel(Buffer.from('1222', 'hex'));
const data = Buffer.from('42122200050270303cfa', 'hex');
const thpError = decode(decodeV2(data), protobufDecoder, thpState);
expect(thpError.type).toBe('ThpError');
expect(thpError.message).toMatchObject({
code: 'ThpUnallocatedChannel',
});
});
it('decodeSendAck', () => {
const thpError2 = decodeSendAck(decodeV2(Buffer.from('42122200050270303cfa', 'hex')));
expect(thpError2.type).toBe('ThpError');
const ack = decodeSendAck(decodeV2(Buffer.from('2812340004e98c8599', 'hex')));
expect(ack.type).toBe('ThpAck');
expect(() => decodeSendAck(decodeV2(Buffer.from('40ffff0004f9215951', 'hex')))).toThrow(
'Unexpected send response',
);
expect(() => decodeSendAck(decodeV2(Buffer.from('40ffff000499999999', 'hex')))).toThrow(
'Invalid CRC',
);
});
it('ThpState serialize/deserialize', () => {
const state1 = new ThpState();
state1.setChannel(Buffer.from('4321', 'hex'));
state1.updateSyncBit('send');
state1.updateNonce('recv');
state1.setExpectedResponses([1, 2, 3, 4]);
const serializedState = state1.serialize();
const state2 = new ThpState();
state2.deserialize(serializedState);
expect(state1.channel).toEqual(state2.channel);
expect(state1.sendBit).toEqual(state2.sendBit);
expect(state1.recvNonce).toEqual(state2.recvNonce);
expect(state1.expectedResponses).toEqual(state2.expectedResponses);
expect(state1.toString()).toMatch('{"channel":"4321"');
const s = serializedState;
const e = 'invalid state';
// @ts-expect-error
expect(() => state2.deserialize(null)).toThrow(e);
// @ts-expect-error
expect(() => state2.deserialize({})).toThrow(e);
// @ts-expect-error
expect(() => state2.deserialize({ ...s, expectedResponses: [null] })).toThrow(e);
// @ts-expect-error
expect(() => state2.deserialize({ ...s, sendBit: null })).toThrow(e);
// @ts-expect-error
expect(() => state2.deserialize({ ...s, recvBit: null })).toThrow(e);
// @ts-expect-error
expect(() => state2.deserialize({ ...s, channel: 11 })).toThrow(e);
});
it('ThpState update sync bit & nonce', () => {
const state = new ThpState();
state.updateSyncBit('send'); // set initial to 1
// rotate few times
for (let i = 0; i < 7; i++) {
// send process completed
state.updateSyncBit('send');
state.updateNonce('send');
// receive process completed
state.updateSyncBit('recv');
state.updateNonce('recv');
}
expect(state.serialize()).toMatchObject({
sendBit: 0,
recvBit: 1,
sendNonce: 7,
recvNonce: 8,
});
});
it('isAckExpected', () => {
thpState.setChannel(Buffer.from('1234', 'hex'));
// ThpCreateChannelRequest => ThpAck not expected
let msg = Buffer.from('40ffff000ceb7c85d5604bf4d7ad7bc634', 'hex');
expect(isAckExpected(msg)).toBe(false);
// ThpHandshakeInitRequest => ThpAck expected
msg = Buffer.from(
'001234002407070707070707070707070707070707a0a1a2a3a4a5a6a7a8a9b0b1b2b3b4b5d47b551c',
'hex',
);
expect(isAckExpected(msg)).toBe(true);
});
it('getExpectedResponses', () => {
thpState.setChannel(Buffer.from('1234', 'hex'));
// ThpCreateChannelRequest
expect(
getExpectedResponses(Buffer.from('40ffff000ceb7c85d5604bf4d7ad7bc634', 'hex')),
).toEqual([65]); // ThpCreateChannelResponse should start with 41ffff...
// unknown thp message type 33...
expect(getExpectedResponses(Buffer.from('33123400000', 'hex'))).toEqual([]);
});
it('isExpectedResponse', () => {
thpState.setChannel(Buffer.from('1234', 'hex'));
thpState.setExpectedResponses([0x20]); // expect ThpAck
const consoleSpy = jest.fn();
jest.spyOn(console, 'warn').mockImplementation(consoleSpy);
expect(isExpectedResponse(Buffer.from('2012340004d9fcce58', 'hex'), thpState)).toBe(true); // ThpAck
expect(isExpectedResponse(Buffer.from('42123400050270303cfa', 'hex'), thpState)).toBe(true); // ThpError
expect(isExpectedResponse(Buffer.from('4012340000', 'hex'), thpState)).toBe(false); // something else
expect(isExpectedResponse(Buffer.from('4043210000', 'hex'), thpState)).toBe(false); // something else on different channel
expect(isExpectedResponse(Buffer.from('20', 'hex'), thpState)).toBe(false); // message to short
expect(isExpectedResponse(Buffer.from('2812340004e98c8599', 'hex'), thpState)).toBe(false); // ThpAck with unexpected control bit
expect(consoleSpy).toHaveBeenCalledTimes(1); // check unexpected control bit warning
thpState.setExpectedResponses([0x04, 0x80]); // expect encrypted message and continuation packet
thpState.setChannel(Buffer.from('485a', 'hex'));
expect(isExpectedResponse(Buffer.from('04485a0000', 'hex'), thpState)).toBe(true);
expect(isExpectedResponse(Buffer.from('80485a0000', 'hex'), thpState)).toBe(true);
expect(isExpectedResponse(Buffer.from('14485a0000', 'hex'), thpState)).toBe(false); // decrypted with unexpected control bit
expect(consoleSpy).toHaveBeenCalledTimes(2); // check unexpected control bit warning
});
});

View File

@@ -0,0 +1,30 @@
import {
addAckBit,
addSequenceBit,
clearControlBit,
getControlBit,
} from '../../src/protocol-thp/utils';
describe('controlBit', () => {
it('ackBit', () => {
expect(addAckBit(0x20, 0).readUint8()).toEqual(0x20);
expect(addAckBit(0x20, 1).readUint8()).toEqual(0x28);
expect(getControlBit(0x20)).toEqual(0);
expect(getControlBit(0x28)).toEqual(1);
expect(clearControlBit(0x20)).toEqual(0x20);
expect(clearControlBit(0x28)).toEqual(0x20);
});
it('sequenceBit', () => {
expect(addSequenceBit(0x03, 0).readUint8()).toEqual(0x03);
expect(addSequenceBit(0x03, 1).readUint8()).toEqual(0x13);
expect(getControlBit(0x03)).toEqual(0);
expect(getControlBit(0x13)).toEqual(1);
expect(clearControlBit(0x03)).toEqual(0x03);
expect(clearControlBit(0x13)).toEqual(0x03);
});
});