mirror of
https://github.com/trezor/trezor-suite.git
synced 2026-03-06 23:39:38 +01:00
feat(connect): implement THP pairing
This commit is contained in:
committed by
Szymon Lesisz
parent
2d26b5c928
commit
1a7e054e21
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
135
packages/connect/src/device/thp/handshake.ts
Normal file
135
packages/connect/src/device/thp/handshake.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
322
packages/connect/src/device/thp/pairing.ts
Normal file
322
packages/connect/src/device/thp/pairing.ts
Normal 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);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user