feat(protocol): add THP pairing cryptography

Functions used by THP pairing workflow
This commit is contained in:
Szymon Lesisz
2024-08-28 12:32:42 +02:00
committed by Szymon Lesisz
parent cbc1ba8381
commit f16730bebb
2 changed files with 307 additions and 0 deletions

View File

@@ -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<typeof getCurve25519KeyPair>;
// 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<string, unknown>) => { 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<typeof aesgcm>;
// 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')}`,
);
}
};

View File

@@ -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]]);
});
});