mirror of
https://github.com/trezor/trezor-suite.git
synced 2026-03-06 23:39:38 +01:00
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:
committed by
Szymon Lesisz
parent
c883e604c1
commit
b33b491b40
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
16
packages/protocol/src/protocol-thp/constants.ts
Normal file
16
packages/protocol/src/protocol-thp/constants.ts
Normal 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;
|
||||
63
packages/protocol/src/protocol-thp/crypto/crc32.ts
Normal file
63
packages/protocol/src/protocol-thp/crypto/crc32.ts
Normal 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;
|
||||
};
|
||||
173
packages/protocol/src/protocol-thp/decode.ts
Normal file
173
packages/protocol/src/protocol-thp/decode.ts
Normal 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);
|
||||
};
|
||||
172
packages/protocol/src/protocol-thp/encode.ts
Normal file
172
packages/protocol/src/protocol-thp/encode.ts
Normal 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;
|
||||
};
|
||||
@@ -1,4 +1,7 @@
|
||||
export * from './decode';
|
||||
export * from './encode';
|
||||
export * from './messages';
|
||||
export * from './utils';
|
||||
|
||||
export { ThpState } from './ThpState';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,3 +19,7 @@ export type ThpCredentialResponse = {
|
||||
trezor_static_pubkey: string;
|
||||
credential: string;
|
||||
};
|
||||
|
||||
export type ThpProtobufMessageType = {
|
||||
ThpDeviceProperties: ThpDeviceProperties;
|
||||
};
|
||||
|
||||
117
packages/protocol/src/protocol-thp/utils.ts
Normal file
117
packages/protocol/src/protocol-thp/utils.ts
Normal 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);
|
||||
46
packages/protocol/tests/protocol-thp/crc32.test.ts
Normal file
46
packages/protocol/tests/protocol-thp/crc32.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
218
packages/protocol/tests/protocol-thp/protocol-thp.test.ts
Normal file
218
packages/protocol/tests/protocol-thp/protocol-thp.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
30
packages/protocol/tests/protocol-thp/utils.test.ts
Normal file
30
packages/protocol/tests/protocol-thp/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user