diff --git a/packages/protocol/src/protocol-thp/crypto/pairing.ts b/packages/protocol/src/protocol-thp/crypto/pairing.ts new file mode 100644 index 0000000000..5a5a56c250 --- /dev/null +++ b/packages/protocol/src/protocol-thp/crypto/pairing.ts @@ -0,0 +1,269 @@ +import { createHash, randomBytes } from 'crypto'; + +import { aesgcm } from './aesgcm'; +import { curve25519, elligator2, getCurve25519KeyPair } from './curve25519'; +import { bigEndianBytesToBigInt, getIvFromNonce, hashOfTwo, hkdf, sha256 } from './tools'; +import { ThpState } from '../ThpState'; +import { + ThpCredentialResponse, + ThpHandshakeCredentials, + ThpHandshakeInitResponse, + ThpPairingMethod, +} from '../messages'; + +const getProtocolName = () => + Buffer.concat([Buffer.from('Noise_XX_25519_AESGCM_SHA256'), Buffer.alloc(4).fill(0)]); + +export const getHandshakeHash = (deviceProperties: Buffer) => + // 1. Set h = SHA-256(protocol_name || device_properties). + hashOfTwo(getProtocolName(), deviceProperties); + +// 10. Search credentials for a pairs (trezor_static_pubkey, credential) such that trezor_masked_static_pubkey == X25519(SHA-256(trezor_static_pubkey || trezor_ephemeral_pubkey), trezor_static_pubkey). +export const findKnownPairingCredentials = ( + knownCredentials: ThpCredentialResponse[], + trezorMaskedStaticPubkey: Buffer, + trezorEphemeralPubkey: Buffer, +) => + knownCredentials.filter(c => { + try { + const trezorStaticPubkey = Buffer.from(c.trezor_static_pubkey, 'hex'); + // X25519(SHA-256(trezor_static_pubkey || trezor_ephemeral_pubkey), trezor_static_pubkey). + const h = hashOfTwo(trezorStaticPubkey, trezorEphemeralPubkey); + + return trezorMaskedStaticPubkey.compare(curve25519(h, trezorStaticPubkey)) === 0; + } catch { + // silent + } + }); + +export const getTrezorState = (credentials: ThpHandshakeCredentials, payload: Buffer) => { + // 2. Set trezor_state, success = AES-GCM-DECRYPT(key=key_response, IV=0^96, ad=empty_string, plaintext=trezor_state). Assert that success is True. + const aes = aesgcm(credentials.trezorKey, Buffer.alloc(12)); + aes.auth(Buffer.alloc(0)); + const trezorState = aes.decrypt(payload.subarray(0, 1), payload.subarray(1, 17)); + + return trezorState.readUint8() as 0 | 1; +}; + +type Curve25519KeyPair = ReturnType; + +// State HH1 +// TODO: link-to-public-docs +// https://www.notion.so/satoshilabs/THP-Specification-2-0-18fdc5260606806ab573d0a7cba1897a#193dc526060681b4b871e6b761107fba +export const handleHandshakeInit = ({ + handshakeInitResponse, + thpState, + knownCredentials, + hostStaticKeys, + hostEphemeralKeys, + protobufEncoder, +}: { + handshakeInitResponse: ThpHandshakeInitResponse; + thpState: ThpState; + knownCredentials: ThpCredentialResponse[]; + hostEphemeralKeys: Curve25519KeyPair; + hostStaticKeys: Curve25519KeyPair; + protobufEncoder: (name: string, data: Record) => { message: Buffer }; +}) => { + if (!thpState.handshakeCredentials) { + throw new Error('ThpStateMissing'); + } + + const { trezorEphemeralPubkey, trezorEncryptedStaticPubkey, tag } = handshakeInitResponse; + const { sendNonce, recvNonce } = thpState; + const { handshakeHash } = thpState.handshakeCredentials; + const iv0 = getIvFromNonce(sendNonce); // should be 0 + const iv1 = getIvFromNonce(recvNonce); // should be 1 + + let h: Buffer, point: Buffer, aes: ReturnType; + + // 1. Set h = SHA-256(protocol_name || device_properties). + // h = hash_of_two(PROTOCOL_NAME, deviceProperties); // moved to getHandshakeHash + h = handshakeHash; + // 2. Set h = SHA-256(h || host_ephemeral_pubkey). + h = hashOfTwo(h, hostEphemeralKeys.publicKey); + // 3. Set h = SHA-256(h). + h = hashOfTwo(h, Buffer.alloc(0)); + // 4. Set h = SHA-256(h || trezor_ephemeral_pubkey). + h = hashOfTwo(h, trezorEphemeralPubkey); + // 5. Set ck, k = HKDF(protocol_name, X25519(host_ephemeral_privkey, trezor_ephemeral_pubkey)). + point = curve25519(hostEphemeralKeys.privateKey, trezorEphemeralPubkey); + let [ck, k] = hkdf(getProtocolName(), point); + + // 6. Set trezor_masked_static_pubkey, success = AES-GCM-DECRYPT(key=k, IV=0^96 (bits, 12 bytes), ad=h, plaintext=encrypted_trezor_static_pubkey). Assert that success is True. + aes = aesgcm(k, iv0); + aes.auth(h); + const trezorStaticPubkey = trezorEncryptedStaticPubkey.subarray(0, 32); + const trezorStaticPubkeyTag = trezorEncryptedStaticPubkey.subarray(32, 32 + 16); + const trezorMaskedStaticPubkey = aes.decrypt(trezorStaticPubkey, trezorStaticPubkeyTag); + // 7. Set h = SHA-256(h || encrypted_trezor_static_pubkey) + h = hashOfTwo(h, trezorEncryptedStaticPubkey); + // 8. Set ck, k = HKDF(ck, X25519(host_ephemeral_privkey, trezor_masked_static_pubkey)) + point = curve25519(hostEphemeralKeys.privateKey, trezorMaskedStaticPubkey); + [ck, k] = hkdf(ck, point); + + // 9. Set tag_of_empty_string, success = AES-GCM-DECRYPT(key=k, IV=0^96 (bits, 12 bytes), ad=h, plaintext=empty_string). Assert that success is True. + aes = aesgcm(k, iv0); + aes.auth(h); + aes.decrypt(Buffer.alloc(0), tag); + // 10. Set h = SHA-256(h || tag) + h = hashOfTwo(h, tag); + + // 11. Search credentials for a pairs + const allCredentials = findKnownPairingCredentials( + knownCredentials, + trezorMaskedStaticPubkey, + trezorEphemeralPubkey, + ); + // and use first from the list (could be undefined) + const credentials: ThpCredentialResponse | undefined = allCredentials[0]; + + // 11.1 If found set (temp_host_static_privkey, temp_host_static_pubkey) = (host_static_privkey, host_static_pubkey). + // 11.2 If not found set (temp_host_static_privkey, temp_host_static_pubkey) = (X25519(0, B), 0). + const hostTempKeys = credentials + ? hostStaticKeys + : getCurve25519KeyPair(Buffer.alloc(32).fill(0)); + + // 12. Set encrypted_host_static_pubkey = AES-GCM-ENCRYPT(key=k, IV=0^95 || 1, ad=h, plaintext=temp_host_static_pubkey). + aes = aesgcm(k, iv1); + aes.auth(h); + const hostEncryptedStaticPubkey = Buffer.concat([ + aes.encrypt(hostTempKeys.publicKey), + aes.finish(), + ]); + // 13. Set h = SHA-256(h || encrypted_host_static_pubkey). + h = hashOfTwo(h, hostEncryptedStaticPubkey); + // 14. Set ck, k = HKDF(ck, X25519(temp_host_static_privkey, trezor_ephemeral_pubkey)). + point = curve25519(hostTempKeys.privateKey, trezorEphemeralPubkey); + [ck, k] = hkdf(ck, point); + // 15. Set payload_binary = PROTOBUF-ENCODE(type=HandshakeCompletionReqNoisePayload, host_pairing_credential). + const { message } = protobufEncoder('ThpHandshakeCompletionReqNoisePayload', { + host_pairing_credential: credentials?.credential, + }); + // 16. Set *encrypted_payload* = AES-GCM-ENCRYPT(*key*=*k*, *IV*=*0^96*, *ad*=*h*, *plaintext*=*payload_binary*). + aes = aesgcm(k, iv0); + aes.auth(h); + const encryptedPayload = Buffer.concat([aes.encrypt(message), aes.finish()]); + h = hashOfTwo(h, encryptedPayload); + + // HH2 and HH3 + // 1. Set key_request, key_response = HKDF(ck, empty_string). + const [hostKey, trezorKey] = hkdf(ck, Buffer.alloc(0)); + + return { + trezorMaskedStaticPubkey, + trezorEncryptedStaticPubkey, + hostEncryptedStaticPubkey, + hostKey, + trezorKey, + handshakeHash: h, + credentials, + allCredentials, + encryptedPayload, + }; +}; + +export const getCpaceHostKeys = (code: Buffer, handshakeHash: Buffer) => { + // TODO: link-to-public-docs + // https://www.notion.so/satoshilabs/Pairing-phase-996b0e879fff4ebd9460ae27376fce76 + // If the user enters code, take the following actions: + // 2. Compute *pregenerator* as the first 32 bytes of SHA-512(*prefix* || *code* - 6 bytes || *padding || h*), where *prefix* is the byte-string 0x08 || 0x43 || 0x50 || 0x61 || 0x63 || 0x65 || 0x32 || 0x35 || 0x35 || 0x06 and *padding* is the byte-string 0x50 || 0x00 ^ 80 || 0x20. + // 3. Set *generator =* ELLIGATOR2(*pregenerator*). + // 4. Generate a random 32-byte *cpace_host_private_key.* + // 5. Set *cpace_host_public_key* = X25519(*cpace_host_private_key*, *generator*). + // 6. Send the message CodeEntryCpaceHost(*cpace_host_public_key*) to the host. + + const shaCtx = createHash('sha512'); + shaCtx.update(Buffer.from([0x08, 0x43, 0x50, 0x61, 0x63, 0x65, 0x32, 0x35, 0x35, 0x06])); + shaCtx.update(code); + shaCtx.update( + Buffer.concat([Buffer.from([0x6f]), Buffer.alloc(111).fill(0), Buffer.from([0x20])]), + ); + shaCtx.update(handshakeHash); + shaCtx.update(Buffer.from([0x00])); + const sha = shaCtx.digest().subarray(0, 32); + + const generator = elligator2(sha); + + const privateKey = randomBytes(32); + const publicKey = curve25519(privateKey, generator); + + return { privateKey, publicKey }; +}; + +export const getSharedSecret = (publicKey: Buffer, privateKey: Buffer) => { + // 1. Set *shared_secret* = X25519(*cpace_host_private_key*, *cpace_trezor_public_key*). + // 2. Set *tag* = SHA-256(*shared_secret*). + + const sharedSecret = curve25519(privateKey, publicKey); + + return sha256(Buffer.from(sharedSecret)); +}; + +export const validateCodeEntryTag = ( + credentials: ThpHandshakeCredentials, + value: string, + secret: string, +) => { + // 1. Assert that handshake commitment = SHA-256(secret) + const sha = createHash('sha256').update(Buffer.from(secret, 'hex')).digest(); + const { handshakeHash, handshakeCommitment, codeEntryChallenge } = credentials; + if (sha.compare(handshakeCommitment) !== 0) { + throw new Error( + `HP5: commitment mismatch ${handshakeCommitment.toString('hex')} != ${sha.toString('hex')}`, + ); + } + + // 2. Assert that value = SHA-256(ThpPairingMethod.CodeEntry || h || secret || challenge) % 1000000 + const shaCtx = createHash('sha256'); + shaCtx.update(Buffer.from([ThpPairingMethod.CodeEntry])); + shaCtx.update(handshakeHash); + shaCtx.update(Buffer.from(secret, 'hex')); + shaCtx.update(codeEntryChallenge); + + const calculatedValue = bigEndianBytesToBigInt(shaCtx.digest()) % 1000000n; + if (calculatedValue !== BigInt(value)) { + throw new Error(`HP5: code mismatch ${value} != ${calculatedValue.toString()}`); + } +}; + +export const validateQrCodeTag = ( + { handshakeHash }: ThpHandshakeCredentials, + value: string, + secret: string, // ThpQrCodeSecret.secret +) => { + // Assert that value = SHA-256(ThpPairingMethod.QrCode || h || secret) + const shaCtx = createHash('sha256'); + shaCtx.update(Buffer.from([ThpPairingMethod.QrCode])); + shaCtx.update(handshakeHash); + shaCtx.update(Buffer.from(secret, 'hex')); + + const calculatedValue = shaCtx.digest().subarray(0, 16); + const expectedValue = Buffer.from(value, 'hex').subarray(0, 16); + if (calculatedValue.compare(expectedValue) !== 0) { + throw new Error( + `HP6: code mismatch ${calculatedValue.toString('hex')} != ${expectedValue.toString('hex')}`, + ); + } +}; + +// validate ThpNfcTagTrezor +export const validateNfcTag = ( + { handshakeHash }: ThpHandshakeCredentials, + value: string, // ThpNfcTagTrezor.tag + secret: Buffer, // ThpState.nfcSecret +) => { + // Assert that value = SHA-256(ThpPairingMethod.NFC || h || secret) + const shaCtx = createHash('sha256'); + shaCtx.update(Buffer.from([ThpPairingMethod.NFC])); + shaCtx.update(handshakeHash); + shaCtx.update(secret); + + const calculatedValue = shaCtx.digest().subarray(0, 16); + const expectedValue = Buffer.from(value, 'hex').subarray(0, 16); + if (calculatedValue.compare(expectedValue) !== 0) { + throw new Error( + `HP7: code mismatch ${calculatedValue.toString('hex')} != ${expectedValue.toString('hex')}`, + ); + } +}; diff --git a/packages/protocol/tests/protocol-thp/pairing.test.ts b/packages/protocol/tests/protocol-thp/pairing.test.ts new file mode 100644 index 0000000000..ad306eccfc --- /dev/null +++ b/packages/protocol/tests/protocol-thp/pairing.test.ts @@ -0,0 +1,38 @@ +import { findKnownPairingCredentials } from '../../src/protocol-thp/crypto/pairing'; + +describe('pairing', () => { + it('findKnownPairingCredentials', () => { + const knownCredentials = [ + { + trezor_static_pubkey: + '1317c99c16fce04935782ed250cf0cacb12216f739cea55257258a2ff9440763', + credential: + '0a0f0a0d5472657a6f72436f6e6e6563741220f69918996c0afa1045b3625d06e7e816b0c4c4bd3902dfd4cad068b3f2425ec8', + }, + { + trezor_static_pubkey: + '2bcdbc9fd7949c3f37aa80a53801f52ec554facfe76118030926294250fd6838', + credential: + '0a110a0d5472657a6f72436f6e6e65637410011220b97509ef252b07dcc70071c9d13dd70746d8a9fb671765049ca74e58b9058d6b', + }, + ]; + + const trezorMaskedStaticPubkey = Buffer.from( + 'be8d024bbcd5ac116e041035fcc6243ce1d77d7075e351c87aa0831fbf46ce66', + 'hex', + ); + + const trezorEphemeralPubkey = Buffer.from( + 'f817f577f24d7ec08c1ea397df2da916e0ee81423961763dc45395e18fc02121', + 'hex', + ); + + const credentials = findKnownPairingCredentials( + knownCredentials, + trezorMaskedStaticPubkey, + trezorEphemeralPubkey, + ); + + expect(credentials).toEqual([knownCredentials[1]]); + }); +});