feat(connect): implement THP pairing

This commit is contained in:
Szymon Lesisz
2024-04-15 13:55:55 +02:00
committed by Szymon Lesisz
parent 2d26b5c928
commit 1a7e054e21
6 changed files with 484 additions and 7 deletions

View File

@@ -45,6 +45,7 @@ export const ERROR_CODES = {
Device_MissingCapabilityBtcOnly: 'Device is missing capability (BTC only)', // thrown by methods which require specific capability when using BTC only firmware
Device_ThpPairingTagInvalid: 'Pairing tag mismatch', // thrown by RECEIVE_THP_PAIRING_TAG handler
Device_ThpStateMissing: 'ThpState missing', // thrown by thp related actions
Device_ThpPairingMethodsException: 'No common pairing methods', // device doesn't support requested pairing method(s)
Failure_ActionCancelled: 'Action canceled by user',
Failure_FirmwareError: 'Firmware installation failed',

View File

@@ -499,6 +499,9 @@ export class Device extends TypedEmitter<DeviceEvents> {
if (this.protocol.name === 'v2') {
const withInteraction = !!fn;
await getThpChannel(this, withInteraction);
if (this.getThpState()?.isAutoconnectPaired || withInteraction) {
await this.getFeatures();
}
} else if (fn) {
await this.initialize(!!options.useCardanoDerivation);
} else {

View File

@@ -0,0 +1,135 @@
import { randomBytes } from 'crypto';
import { encodeMessage } from '@trezor/protobuf';
import { ThpPairingMethod, thp as protocolThp } from '@trezor/protocol';
import { thpCall } from './thpCall';
import { ERRORS } from '../../constants';
import { DataManager } from '../../data/DataManager';
import type { Device } from '../Device';
// intersection of device acceptable methods and host acceptable methods
const enumFromString = (dm: ThpPairingMethod | keyof typeof ThpPairingMethod) =>
typeof dm === 'string' ? protocolThp.ThpPairingMethod[dm] : dm;
const getPairingMethods = (
deviceMethods?: (ThpPairingMethod | keyof typeof ThpPairingMethod)[],
settingsMethods?: (ThpPairingMethod | keyof typeof ThpPairingMethod)[],
) =>
deviceMethods?.flatMap(dm => {
const value = enumFromString(dm);
const isRequested =
settingsMethods && settingsMethods.find(sm => value === enumFromString(sm));
return isRequested ? value : [];
});
// State HH0
// TODO: link-to-public-docs
// https://www.notion.so/satoshilabs/THP-Specification-2-1-203dc5260606804192aecaa58fb961ca
export const createThpChannel = async (device: Device) => {
const thpState = device.getThpState();
if (!thpState) {
throw ERRORS.TypedError('Device_ThpStateMissing');
}
// set default channel and create random nonce
thpState.setChannel(protocolThp.constants.THP_DEFAULT_CHANNEL);
const nonce = randomBytes(8);
const createChannel = await thpCall(device, 'ThpCreateChannelRequest', { nonce });
const { properties, ...resp } = createChannel.message;
// TODO: link-to-public-docs nonce validation is not mentioned by the docs
if (nonce.compare(resp.nonce) !== 0) {
throw new Error(
'Nonce not meet' + nonce.toString('hex') + ' ' + resp.nonce.toString('hex'),
);
}
// find common pairing methods
const settings = DataManager.getSettings('thp');
const pairingMethods = getPairingMethods(properties.pairing_methods, settings?.pairingMethods);
if (!pairingMethods?.length) {
throw ERRORS.TypedError('Device_ThpPairingMethodsException');
}
// update properties, channel and handshake credentials
thpState.setThpProperties(properties);
thpState.setChannel(resp.channel);
thpState.updateHandshakeCredentials({
pairingMethods,
handshakeHash: resp.handshakeHash,
});
// ready for transition to state HH1 -> thpHandshake
};
// State HH1 and HH2
// TODO: link-to-public-docs
// https://www.notion.so/satoshilabs/THP-Specification-2-1-203dc5260606804192aecaa58fb961ca
export const thpHandshake = async (device: Device) => {
const thpState = device.getThpState();
if (!thpState?.handshakeCredentials) {
throw ERRORS.TypedError('Device_ThpStateMissing');
}
const settings = DataManager.getSettings('thp');
// get staticKey from settings or create new random
const staticKey = settings?.staticKey
? Buffer.from(settings.staticKey, 'hex')
: randomBytes(32);
const hostStaticKeys = protocolThp.getCurve25519KeyPair(staticKey);
// sort credentials by autoconnect field
const knownCredentials = (settings?.knownCredentials || []).sort(cre =>
cre.autoconnect ? -1 : 1,
);
// 1. Generate a new ephemeral X25519 key pair (host_ephemeral_privkey, host_ephemeral_pubkey).
const hostEphemeralKeys = protocolThp.getCurve25519KeyPair(randomBytes(32));
// 2. Send the message HandshakeInitiationReq(host_ephemeral_pubkey) to the host.
const handshakeInit = await thpCall(device, 'ThpHandshakeInitRequest', {
key: hostEphemeralKeys.publicKey,
});
const { trezorEncryptedStaticPubkey } = handshakeInit.message;
// cryptography steps from HH1 to HH2
const handshakeCredentials = protocolThp.handleHandshakeInit({
handshakeInitResponse: handshakeInit.message,
thpState,
hostStaticKeys,
hostEphemeralKeys,
knownCredentials,
protobufEncoder: (name, data) => encodeMessage(device.transport.getMessages(), name, data),
});
// update thpState
const { hostKey, trezorKey, hostEncryptedStaticPubkey } = handshakeCredentials;
thpState.updateHandshakeCredentials({
trezorEncryptedStaticPubkey,
hostEncryptedStaticPubkey,
handshakeHash: handshakeCredentials.handshakeHash,
trezorKey,
hostKey,
staticKey,
hostStaticPublicKey: hostStaticKeys.publicKey,
});
thpState.setPairingCredentials(handshakeCredentials.allCredentials);
const handshakeCompletion = await thpCall(device, 'ThpHandshakeCompletionRequest', {
hostPubkey: hostEncryptedStaticPubkey,
encryptedPayload: handshakeCredentials.encryptedPayload,
});
thpState.setIsPaired(!!handshakeCompletion.message.state);
thpState.setPhase('pairing');
if (thpState.isPaired && thpState.isAutoconnectPaired) {
// finish pairing. device is ready to communicate without further interaction
await thpCall(device, 'ThpEndRequest', {});
thpState.setPhase('paired');
}
};

View File

@@ -1,9 +1,25 @@
import { ERRORS } from '../../constants';
import type { Device } from '../Device';
import { createThpChannel, thpHandshake } from './handshake';
import { thpPairing } from './pairing';
export const getThpChannel = async (_device: Device, _withInteraction?: boolean) => {
// implementation...
await new Promise((_, reject) => {
reject(ERRORS.TypedError('Device_ThpStateMissing'));
});
export { abortThpWorkflow } from './thpCall';
export { getThpCredentials } from './pairing';
export const getThpChannel = async (device: Device, withInteraction?: boolean) => {
const thpState = device.getThpState();
try {
if (thpState?.phase === 'handshake') {
await createThpChannel(device);
await thpHandshake(device);
}
if (thpState?.phase === 'pairing' && withInteraction) {
// start pairing with UI interaction
await thpPairing(device);
}
} catch (error) {
thpState?.resetState();
throw error;
}
};

View File

@@ -0,0 +1,322 @@
import { createHash, randomBytes } from 'crypto';
import { thp as protocolThp } from '@trezor/protocol';
import { createDeferred } from '@trezor/utils';
import { ERRORS } from '../../constants';
import { DataManager } from '../../data/DataManager';
import { DEVICE, UiResponseThpPairingTag } from '../../events';
import type { Device } from '../Device';
import { abortThpWorkflow, thpCall } from './thpCall';
const processQrCodeTag = async (device: Device, value: string) => {
const thpState = device.getThpState();
if (!thpState?.handshakeCredentials) {
throw ERRORS.TypedError('Device_ThpStateMissing');
}
const tagSha = createHash('sha256')
.update(thpState.handshakeCredentials.handshakeHash)
.update(Buffer.from(value, 'hex'))
.digest('hex');
const qrCodeSecret = await thpCall(device, 'ThpQrCodeTag', {
tag: tagSha,
});
protocolThp.validateQrCodeTag(
thpState.handshakeCredentials,
value,
qrCodeSecret.message.secret,
);
return qrCodeSecret;
};
const processNfcTag = async (device: Device, value: string) => {
const thpState = device.getThpState();
if (!thpState?.handshakeCredentials) {
throw ERRORS.TypedError('Device_ThpStateMissing');
}
if (!thpState?.nfcSecret) {
throw new Error('missing nfcSecret');
}
const tagSha = createHash('sha256')
.update(Buffer.from([protocolThp.ThpPairingMethod.NFC]))
.update(thpState.handshakeCredentials.handshakeHash)
.update(Buffer.from(value, 'hex'))
.digest('hex');
const nfcTagTrezor = await thpCall(device, 'ThpNfcTagHost', {
tag: tagSha,
});
protocolThp.validateNfcTag(
thpState.handshakeCredentials,
nfcTagTrezor.message.tag,
thpState.nfcSecret,
);
return nfcTagTrezor;
};
const processCodeEntry = async (device: Device, value: string) => {
if (value.length !== 6) {
throw ERRORS.TypedError('Device_ThpPairingTagInvalid');
}
const codeValue = Buffer.from(value, 'ascii');
const thpState = device.getThpState();
if (!thpState?.handshakeCredentials) {
throw ERRORS.TypedError('Device_ThpStateMissing');
}
const hostKeys = protocolThp.getCpaceHostKeys(
codeValue,
thpState.handshakeCredentials.handshakeHash,
);
const tag = protocolThp
.getSharedSecret(thpState.handshakeCredentials.trezorCpacePublicKey, hostKeys.privateKey)
.toString('hex');
const codeEntrySecret = await thpCall(device, 'ThpCodeEntryCpaceHostTag', {
tag,
cpace_host_public_key: hostKeys.publicKey.toString('hex'),
});
protocolThp.validateCodeEntryTag(
thpState.handshakeCredentials,
value,
codeEntrySecret.message.secret,
);
return codeEntrySecret;
};
const processThpPairingResponse = (device: Device, payload: UiResponseThpPairingTag['payload']) => {
if ('selectedMethod' in payload) {
// change pairing method
device.getThpState()?.setPairingMethod(payload.selectedMethod);
return thpCall(device, 'ThpSelectMethod', {
selected_pairing_method: payload.selectedMethod,
});
}
if (payload.source === 'qr-code') {
return processQrCodeTag(device, payload.tag);
}
if (payload.source === 'nfc') {
return processNfcTag(device, payload.tag);
}
if (payload.source === 'code-entry') {
return processCodeEntry(device, payload.tag);
}
throw new Error(`Unknown THP pairing source ${payload.source}`);
};
const waitForPairingCancel = (device: Device) => {
const readAbort = new AbortController();
device.getThpState()?.setExpectedResponses([0x04]); // expect Cancel
const readCancel = device.getCurrentSession().receive({
signal: readAbort.signal,
});
return {
readAbort,
readCancel,
};
};
const waitForPairingTag = async (device: Device) => {
const thpState = device.getThpState();
if (!thpState?.handshakeCredentials) {
throw ERRORS.TypedError('Device_ThpStateMissing');
}
const dfd = createDeferred<UiResponseThpPairingTag['payload'] | { error: string }>();
// start listening for the Cancel message from Trezor
const { readAbort, readCancel } = waitForPairingCancel(device);
readCancel
.then(readResult => {
if (readResult.success) {
let error: string;
if (readResult.payload.type === 'Failure' && readResult.payload.message.message) {
error = readResult.payload.message.message;
} else {
error = `Pairing tag cancelled (${readResult.payload.type})`;
}
dfd.resolve({ error });
}
})
.catch(() => {
// silent
});
// start listening for the UI response
const payload = {
availableMethods: thpState.handshakeCredentials.pairingMethods,
selectedMethod: thpState.pairingMethod,
nfcData: thpState.nfcData?.toString('hex'),
};
device.prompt('thp_pairing', { payload }).then(response => {
if (response.success) {
dfd.resolve(response.payload);
} else {
abortThpWorkflow(device).then(() => {
dfd.resolve({ error: response.error.message });
});
}
});
const pairingResponse = await dfd.promise;
// wait for readCancel to finish reading
readAbort.abort();
await readCancel;
if ('error' in pairingResponse) {
throw new Error(pairingResponse.error);
}
// node-bridge + usb: abort received on client side of http request resolves faster than server. result with "device call in progress"
await new Promise(resolve => setTimeout(resolve, 500));
return processThpPairingResponse(device, pairingResponse).catch(e => {
// catch pairing tag mismatch
if (e.code === 'Failure_FirmwareError') {
// 'Unexpected Code Entry Tag'
throw ERRORS.TypedError('Device_ThpPairingTagInvalid', e.message);
}
throw ERRORS.TypedError(e.code, e.message);
});
};
export const getThpCredentials = async (device: Device, autoconnect = false) => {
const thpState = device.getThpState();
if (!thpState?.handshakeCredentials) {
throw ERRORS.TypedError('Device_ThpStateMissing');
}
const credentials = await thpCall(device, 'ThpCredentialRequest', {
autoconnect,
host_static_public_key: thpState.handshakeCredentials.hostStaticPublicKey.toString('hex'),
credential: thpState.pairingCredentials[0]?.credential,
});
return { ...credentials.message, autoconnect };
};
export const thpPairingEnd = (device: Device) => {
device.getThpState()?.setPhase('paired');
return thpCall(device, 'ThpEndRequest', {});
};
// State HH2/HH3 -> HP0 -> HP1 -> HP2 -> HP3 -> HP4
// Workflow will require user interaction
// TODO: link-to-public-docs
// https://www.notion.so/satoshilabs/THP-Specification-2-1-203dc5260606804192aecaa58fb961ca
export const thpPairing = async (device: Device) => {
const thpState = device.getThpState();
if (!thpState?.handshakeCredentials) {
throw ERRORS.TypedError('Device_ThpStateMissing');
}
// State HH2 and HH3 combined
// if thpState.isPaired then transition to HC0 (credentials) otherwise transition to HP0 (pairing)
if (thpState.isPaired && thpState.pairingMethod !== protocolThp.ThpPairingMethod.SkipPairing) {
// State HC0
if (!thpState.isAutoconnectPaired) {
// device is paired but credentials may not be persistent
// get new credentials to enforce ButtonRequest.thp_connection_request flow if necessary
await getThpCredentials(device, false);
}
// State HC1 -> HC2 pairing complete
return thpPairingEnd(device);
}
// use first pairing method from the list
const [selected_pairing_method] = thpState.handshakeCredentials.pairingMethods;
thpState.setPairingMethod(selected_pairing_method);
// State HP0
// ThpPairingRequest will trigger ButtonRequest.thp_pairing_request flow
const settings = DataManager.getSettings('thp');
await thpCall(device, 'ThpPairingRequest', {
host_name: settings?.hostName || 'Unknown hostName',
app_name: settings?.appName || 'Unknown appName',
});
// State HP1
const selectMethod = await thpCall(device, 'ThpSelectMethod', { selected_pairing_method });
// selected_pairing_method === ThpPairingMethod.SkipPairing
if (selectMethod.type === 'ThpEndResponse') {
thpState.setIsPaired(true);
device.getThpState()?.setPhase('paired');
return;
}
// State HP2
if (selectMethod.type === 'ThpCodeEntryCommitment') {
// store handshakeCommitment and validate later in `processCodeEntry`
const codeEntryChallenge = randomBytes(32);
const handshakeCommitment = Buffer.from(selectMethod.message.commitment, 'hex');
thpState.updateHandshakeCredentials({
handshakeCommitment,
codeEntryChallenge,
});
// State HP3a
const codeEntryCpace = await thpCall(device, 'ThpCodeEntryChallenge', {
challenge: codeEntryChallenge.toString('hex'),
});
thpState.updateHandshakeCredentials({
trezorCpacePublicKey: Buffer.from(
codeEntryCpace.message.cpace_trezor_public_key,
'hex',
),
});
// State HP4 -> HP5
await waitForPairingTag(device);
}
if (selectMethod.type === 'ThpPairingPreparationsFinished') {
if (thpState.pairingMethod === protocolThp.ThpPairingMethod.NFC) {
// generate random secret and store it
thpState.setNfcSecret(randomBytes(16));
}
// State HP6 and HP7
await waitForPairingTag(device);
}
// State HC0
// generate new credentials and send
const credentials = await getThpCredentials(device, false);
device.emit(DEVICE.THP_CREDENTIALS_CHANGED, {
credentials,
staticKey: thpState.handshakeCredentials.staticKey.toString('hex'),
});
const settings1 = DataManager.getSettings('thp');
if (settings1) {
settings1.knownCredentials?.push(credentials);
settings1.staticKey = thpState.handshakeCredentials.staticKey.toString('hex');
}
thpState.setPairingCredentials([credentials]);
thpState.setIsPaired(true);
await thpPairingEnd(device);
};

View File

@@ -26,7 +26,7 @@ export const findKnownPairingCredentials = (
) =>
knownCredentials.filter(c => {
try {
const trezorStaticPubkey = Buffer.from(c.trezor_static_pubkey, 'hex');
const trezorStaticPubkey = Buffer.from(c.trezor_static_public_key, 'hex');
// X25519(SHA-256(trezor_static_pubkey || trezor_ephemeral_pubkey), trezor_static_pubkey).
const h = hashOfTwo(trezorStaticPubkey, trezorEphemeralPubkey);