Skip to content

Commit dfa37d4

Browse files
authored
fix(apple-tv): wrap malformed pair-verify TLV8 responses in PairingError (#303)
1 parent 4e792ae commit dfa37d4

2 files changed

Lines changed: 134 additions & 4 deletions

File tree

src/lib/apple-tv/pairing-protocol/pair-verification-protocol.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ const log = getLogger('PairVerificationProtocol');
5252
*
5353
* Security Properties:
5454
* - Perfect Forward Secrecy: Each session uses unique ephemeral keys
55-
* - Mutual Authentication: Both client and device prove their identities
55+
* - One-Way Authentication: Only the client proves its identity; the accessory's
56+
* identity is not verified during Pair-Verify (the accessory's long-term public
57+
* key is exposed in Pair-Setup M6 but is not currently persisted in the pair record)
5658
* - Replay Protection: Ephemeral keys prevent replay attacks
5759
*
5860
* References:
@@ -122,7 +124,7 @@ export class PairVerificationProtocol {
122124
throw new PairingError('No pairing data in STATE=2 response', 'STATE_2_NO_DATA');
123125
}
124126

125-
const tlvData = decodeTLV8ToDict(Buffer.from(pairingData, 'base64'));
127+
const tlvData = this.parseTlvOrThrow(Buffer.from(pairingData, 'base64'), 'STATE=2 response');
126128

127129
if (tlvData[PairingDataComponentType.ERROR]) {
128130
const errorCode = tlvData[PairingDataComponentType.ERROR] as Buffer;
@@ -136,11 +138,19 @@ export class PairVerificationProtocol {
136138
throw new PairingError('No device public key in STATE=2', 'STATE_2_NO_PUBLIC_KEY');
137139
}
138140

139-
log.debug(' - STATE=2: Receive devices X25519 public key + encrypted data');
141+
log.debug(' - STATE=2: Receive devices X25519 public key');
140142

141143
return devicePublicKey;
142144
}
143145

146+
private parseTlvOrThrow(payload: Buffer, context: string): Record<number, Buffer> {
147+
try {
148+
return decodeTLV8ToDict(payload);
149+
} catch (error) {
150+
throw new PairingError(`Failed to parse TLV8 ${context}`, 'TLV8_PARSE_ERROR', error);
151+
}
152+
}
153+
144154
private computeSharedSecret(privateKey: KeyObject, devicePublicKey: Buffer): Buffer {
145155
return performX25519DiffieHellman(privateKey, devicePublicKey);
146156
}
@@ -162,7 +172,7 @@ export class PairVerificationProtocol {
162172
return;
163173
}
164174

165-
const state4TLV = decodeTLV8ToDict(Buffer.from(state4Data, 'base64'));
175+
const state4TLV = this.parseTlvOrThrow(Buffer.from(state4Data, 'base64'), 'STATE=4 response');
166176

167177
if (state4TLV[PairingDataComponentType.ERROR]) {
168178
const errorCode = state4TLV[PairingDataComponentType.ERROR] as Buffer;
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import assert from 'node:assert/strict';
2+
import {beforeEach, describe, it} from 'node:test';
3+
4+
import {PairingDataComponentType} from '../../../src/lib/apple-tv/constants.js';
5+
import {
6+
generateEd25519KeyPair,
7+
generateX25519KeyPair,
8+
hkdf,
9+
performX25519DiffieHellman,
10+
type X25519KeyPair,
11+
} from '../../../src/lib/apple-tv/encryption/index.js';
12+
import {PairingError} from '../../../src/lib/apple-tv/errors.js';
13+
import type {NetworkClientInterface} from '../../../src/lib/apple-tv/network/types.js';
14+
import {PAIR_VERIFY_STATES} from '../../../src/lib/apple-tv/pairing-protocol/constants.js';
15+
import {PairVerificationProtocol} from '../../../src/lib/apple-tv/pairing-protocol/pair-verification-protocol.js';
16+
import type {PairingRequest} from '../../../src/lib/apple-tv/pairing-protocol/types.js';
17+
import type {PairRecord} from '../../../src/lib/apple-tv/storage/types.js';
18+
import {decodeTLV8ToDict, encodeTLV8} from '../../../src/lib/apple-tv/tlv/index.js';
19+
import type {PairingKeys} from '../../../src/lib/apple-tv/types.js';
20+
21+
interface PairingDataResponse {
22+
message: {plain: {_0: {event: {_0: {pairingData: {_0: {data: string}}}}}}};
23+
}
24+
25+
const DEVICE_ID = 'AA:BB:CC:DD:EE:FF';
26+
27+
function wrapStateResponse(tlv: Buffer): PairingDataResponse {
28+
return {message: {plain: {_0: {event: {_0: {pairingData: {_0: {data: tlv.toString('base64')}}}}}}}};
29+
}
30+
31+
function extractTlv(packet: PairingRequest): Partial<Record<number, Buffer>> {
32+
const payload = packet.message.plain._0;
33+
assert.ok('event' in payload, 'sent packet is missing pairing event payload');
34+
const data = payload.event._0.pairingData?._0.data;
35+
assert.ok(data, 'sent packet is missing pairing data');
36+
return decodeTLV8ToDict(Buffer.from(data, 'base64'));
37+
}
38+
39+
function buildState2(): {deviceKeys: X25519KeyPair; response: PairingDataResponse} {
40+
const deviceKeys = generateX25519KeyPair();
41+
const response = wrapStateResponse(
42+
encodeTLV8([
43+
{type: PairingDataComponentType.STATE, data: Buffer.from([PAIR_VERIFY_STATES.STATE_02])},
44+
{type: PairingDataComponentType.PUBLIC_KEY, data: deviceKeys.publicKey},
45+
]),
46+
);
47+
return {deviceKeys, response};
48+
}
49+
50+
function buildState4(): PairingDataResponse {
51+
return wrapStateResponse(
52+
encodeTLV8([{type: PairingDataComponentType.STATE, data: Buffer.from([PAIR_VERIFY_STATES.STATE_04])}]),
53+
);
54+
}
55+
56+
function createTransport(respond: (sentPackets: PairingRequest[]) => PairingDataResponse): NetworkClientInterface {
57+
const sentPackets: PairingRequest[] = [];
58+
return {
59+
connect: async () => {},
60+
sendPacket: async (data: PairingRequest) => {
61+
sentPackets.push(data);
62+
},
63+
receiveResponse: async () => respond(sentPackets),
64+
disconnect: () => {},
65+
};
66+
}
67+
68+
describe('Apple TV - PairVerificationProtocol', function () {
69+
let hostKeys: PairingKeys;
70+
let pairRecord: PairRecord;
71+
72+
beforeEach(function () {
73+
hostKeys = generateEd25519KeyPair();
74+
pairRecord = {
75+
publicKey: hostKeys.publicKey,
76+
privateKey: hostKeys.privateKey,
77+
remoteUnlockHostKey: '',
78+
};
79+
});
80+
81+
it('should complete verification and derive session keys from the ephemeral exchange', async function () {
82+
let deviceKeys: X25519KeyPair | undefined;
83+
let clientPublicKey: Buffer | undefined;
84+
const transport = createTransport((sent) => {
85+
if (sent.length === 1) {
86+
clientPublicKey = extractTlv(sent[0])[PairingDataComponentType.PUBLIC_KEY];
87+
const state2 = buildState2();
88+
deviceKeys = state2.deviceKeys;
89+
return state2.response;
90+
}
91+
return buildState4();
92+
});
93+
const protocol = new PairVerificationProtocol(transport);
94+
95+
const keys = await protocol.verify(pairRecord, DEVICE_ID);
96+
97+
assert.ok(deviceKeys);
98+
assert.ok(clientPublicKey);
99+
const sharedSecret = performX25519DiffieHellman(deviceKeys.privateKey, clientPublicKey);
100+
const expectedServerKey = hkdf({
101+
ikm: sharedSecret,
102+
salt: null,
103+
info: Buffer.from('ServerEncrypt-main'),
104+
length: 32,
105+
});
106+
assert.ok(keys.clientEncryptionKey.length === 32);
107+
assert.ok(keys.serverEncryptionKey.equals(expectedServerKey));
108+
});
109+
110+
it('should translate malformed STATE=2 TLV8 payloads into TLV8_PARSE_ERROR', async function () {
111+
const transport = createTransport(() => wrapStateResponse(Buffer.from([0x06])));
112+
const protocol = new PairVerificationProtocol(transport);
113+
114+
await assert.rejects(protocol.verify(pairRecord, DEVICE_ID), (err: unknown) => {
115+
assert.ok(err instanceof PairingError);
116+
assert.strictEqual(err.code, 'TLV8_PARSE_ERROR');
117+
return true;
118+
});
119+
});
120+
});

0 commit comments

Comments
 (0)