From 4220fcdf299a3501286e1019b8f49b2196ceb767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20=C5=BBydek?= Date: Tue, 23 Jan 2024 17:07:09 +0800 Subject: [PATCH] feat: add method for decoding sealed results --- example/sealedResults.js | 26 ++++++ package.json | 3 +- readme.md | 5 ++ src/index.ts | 1 + src/sealedResults.ts | 69 +++++++++++++++ tests/unit-tests/sealedResults.ts | 136 ++++++++++++++++++++++++++++++ 6 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 example/sealedResults.js create mode 100644 src/sealedResults.ts create mode 100644 tests/unit-tests/sealedResults.ts diff --git a/example/sealedResults.js b/example/sealedResults.js new file mode 100644 index 0000000..8ae2b91 --- /dev/null +++ b/example/sealedResults.js @@ -0,0 +1,26 @@ +const { unsealEventsResponse } = require('@fingerprintjs/fingerprintjs-pro-server-api'); + +async function main() { + const sealedData = process.env.BASE64_SEALED_RESULT; + const decryptionKey = process.env.BASE64_KEY; + + if (!sealedData || !decryptionKey) { + console.error('Please set BASE64_KEY and BASE64_SEALED_RESULT environment variables'); + process.exit(1); + } + + try { + const unsealedData = await unsealEventsResponse(Buffer.from(sealedData, 'base64'), [ + { + key: Buffer.from(decryptionKey, 'base64'), + algorithm: 'aes-256-gcm', + }, + ]); + console.log(JSON.stringify(unsealedData, null, 2)); + } catch (e) { + console.error(e); + process.exit(1); + } +} + +main(); diff --git a/package.json b/package.json index a695d79..584a495 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "test:coverage": "jest --coverage", "test:dts": "tsc --noEmit --isolatedModules dist/index.d.ts", "generateTypes": "yarn openapi-typescript resources/fingerprint-server-api.yaml --output ./src/generatedApiTypes.ts -c .prettierrc.json", - "release": "semantic-release" + "release": "semantic-release", + "docs": "typedoc --out docs src" }, "keywords": [], "author": "FingerprintJS, Inc (https://fingerprint.com)", diff --git a/readme.md b/readme.md index c824942..0a2cb9a 100644 --- a/readme.md +++ b/readme.md @@ -145,6 +145,11 @@ client }); ``` +## Sealed results + +This SDK provides utility methods for decoding sealed results. +To learn more, refer to example located in [example/sealedResults.js](./example/sealedResults.js). + ## API Reference ### `constructor({region: Region, apiKey: string})` diff --git a/src/index.ts b/src/index.ts index caa476d..edd6537 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from './urlUtils'; export * from './serverApiClient'; export * from './types'; +export * from './sealedResults'; diff --git a/src/sealedResults.ts b/src/sealedResults.ts new file mode 100644 index 0000000..fcc0836 --- /dev/null +++ b/src/sealedResults.ts @@ -0,0 +1,69 @@ +import { createDecipheriv } from 'crypto'; +import { inflateRaw } from 'zlib'; +import { promisify } from 'util'; +import { EventResponse } from './types'; + +const asyncInflateRaw = promisify(inflateRaw); + +export enum DecryptionAlgorithm { + Aes256Gcm = 'aes-256-gcm', +} + +export interface DecryptionKey { + key: Buffer; + algorithm: DecryptionAlgorithm; +} + +const SEALED_HEADER = Buffer.from([0x9e, 0x85, 0xdc, 0xed]); + +export async function unsealEventsResponse(sealedData: Buffer, decryptionKeys: DecryptionKey[]) { + const unsealed = await unseal(sealedData, decryptionKeys); + const json = JSON.parse(unsealed) as EventResponse; + + if (!json.products) { + throw new Error('Sealed data is not valid events response'); + } + + return json; +} + +export async function unseal(sealedData: Buffer, decryptionKeys: DecryptionKey[]) { + if ( + sealedData.subarray(0, SEALED_HEADER.length).toString('hex') !== SEALED_HEADER.toString('hex') + ) { + throw new Error('Invalid sealed data header'); + } + + for (const decryptionKey of decryptionKeys) { + switch (decryptionKey.algorithm) { + case DecryptionAlgorithm.Aes256Gcm: + try { + return await unsealAes256Gcm(sealedData, decryptionKey.key); + } catch { + continue; + } + + default: + throw new Error(`Unsupported decryption algorithm: ${decryptionKey.algorithm}`); + } + } + + throw new Error('Unable to decrypt sealed data'); +} + +async function unsealAes256Gcm(sealedData: Buffer, decryptionKey: Buffer) { + const nonceLength = 12; + const nonce = sealedData.subarray(SEALED_HEADER.length, SEALED_HEADER.length + nonceLength); + + const authTagLength = 16; + const authTag = sealedData.subarray(-authTagLength); + + const ciphertext = sealedData.subarray(SEALED_HEADER.length + nonceLength, -authTagLength); + + const decipher = createDecipheriv('aes-256-gcm', decryptionKey, nonce).setAuthTag(authTag); + const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + + const payload = await asyncInflateRaw(compressed); + + return payload.toString(); +} diff --git a/tests/unit-tests/sealedResults.ts b/tests/unit-tests/sealedResults.ts new file mode 100644 index 0000000..4deadee --- /dev/null +++ b/tests/unit-tests/sealedResults.ts @@ -0,0 +1,136 @@ +import { DecryptionAlgorithm, unsealEventsResponse } from '../../src'; + +describe('Unseal event response', () => { + const sealedData = Buffer.from( + 'noXc7SXO+mqeAGrvBMgObi/S0fXTpP3zupk8qFqsO/1zdtWCD169iLA3VkkZh9ICHpZ0oWRzqG0M9/TnCeKFohgBLqDp6O0zEfXOv6i5q++aucItznQdLwrKLP+O0blfb4dWVI8/aSbd4ELAZuJJxj9bCoVZ1vk+ShbUXCRZTD30OIEAr3eiG9aw00y1UZIqMgX6CkFlU9L9OnKLsNsyomPIaRHTmgVTI5kNhrnVNyNsnzt9rY7fUD52DQxJILVPrUJ1Q+qW7VyNslzGYBPG0DyYlKbRAomKJDQIkdj/Uwa6bhSTq4XYNVvbk5AJ/dGwvsVdOnkMT2Ipd67KwbKfw5bqQj/cw6bj8Cp2FD4Dy4Ud4daBpPRsCyxBM2jOjVz1B/lAyrOp8BweXOXYugwdPyEn38MBZ5oL4D38jIwR/QiVnMHpERh93jtgwh9Abza6i4/zZaDAbPhtZLXSM5ztdctv8bAb63CppLU541Kf4OaLO3QLvfLRXK2n8bwEwzVAqQ22dyzt6/vPiRbZ5akh8JB6QFXG0QJF9DejsIspKF3JvOKjG2edmC9o+GfL3hwDBiihYXCGY9lElZICAdt+7rZm5UxMx7STrVKy81xcvfaIp1BwGh/HyMsJnkE8IczzRFpLlHGYuNDxdLoBjiifrmHvOCUDcV8UvhSV+UAZtAVejdNGo5G/bz0NF21HUO4pVRPu6RqZIs/aX4hlm6iO/0Ru00ct8pfadUIgRcephTuFC2fHyZxNBC6NApRtLSNLfzYTTo/uSjgcu6rLWiNo5G7yfrM45RXjalFEFzk75Z/fu9lCJJa5uLFgDNKlU+IaFjArfXJCll3apbZp4/LNKiU35ZlB7ZmjDTrji1wLep8iRVVEGht/DW00MTok7Zn7Fv+MlxgWmbZB3BuezwTmXb/fNw==', + 'base64' + ); + const validKey = Buffer.from('p2PA7MGy5tx56cnyJaFZMr96BCFwZeHjZV2EqMvTq53=', 'base64'); + const invalidKey = Buffer.from('a2PA7MGy5tx56cnyJaFZMr96BCFwZeHjZV2EqMvTq53=', 'base64'); + + it('unseals sealed data using aes256gcm', async () => { + const result = await unsealEventsResponse(sealedData, [ + { + key: invalidKey, + algorithm: DecryptionAlgorithm.Aes256Gcm, + }, + { + key: validKey, + algorithm: DecryptionAlgorithm.Aes256Gcm, + }, + ]); + + expect(result).toBeTruthy(); + expect(result).toMatchInlineSnapshot(` + Object { + "products": Object { + "botd": Object { + "data": Object { + "bot": Object { + "result": "notDetected", + }, + "ip": "::1", + "meta": Object { + "foo": "bar", + }, + "requestId": "1703067132750.Z5hutJ", + "time": "2023-12-20T10:12:13.894Z", + "url": "http://localhost:8080/", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15", + }, + }, + "identification": Object { + "data": Object { + "browserDetails": Object { + "browserFullVersion": "17.3", + "browserMajorVersion": "17", + "browserName": "Safari", + "device": "Other", + "os": "Mac OS X", + "osVersion": "10.15.7", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15", + }, + "confidence": Object { + "score": 1, + }, + "firstSeenAt": Object { + "global": "2023-12-15T12:13:55.103Z", + "subscription": "2023-12-15T12:13:55.103Z", + }, + "incognito": false, + "ip": "::1", + "ipLocation": Object { + "accuracyRadius": 1000, + "city": Object { + "name": "Stockholm", + }, + "continent": Object { + "code": "EU", + "name": "Europe", + }, + "country": Object { + "code": "SE", + "name": "Sweden", + }, + "latitude": 59.3241, + "longitude": 18.0517, + "postalCode": "100 05", + "subdivisions": Array [ + Object { + "isoCode": "AB", + "name": "Stockholm County", + }, + ], + "timezone": "Europe/Stockholm", + }, + "lastSeenAt": Object { + "global": "2023-12-19T11:39:51.52Z", + "subscription": "2023-12-19T11:39:51.52Z", + }, + "requestId": "1703067132750.Z5hutJ", + "tag": Object { + "foo": "bar", + }, + "time": "2023-12-20T10:12:16Z", + "timestamp": 1703067136286, + "url": "http://localhost:8080/", + "visitorFound": true, + "visitorId": "2ZEDCZEfOfXjEmMuE3tq", + }, + }, + }, + } + `); + }); + + it('throws error if header is not correct', async () => { + const invalidData = Buffer.from( + 'xzXc7SXO+mqeAGrvBMgObi/S0fXTpP3zupk8qFqsO/1zdtWCD169iLA3VkkZh9ICHpZ0oWRzqG0M9/TnCeKFohgBLqDp6O0zEfXOv6i5q++aucItznQdLwrKLP+O0blfb4dWVI8/aSbd4ELAZuJJxj9bCoVZ1vk+ShbUXCRZTD30OIEAr3eiG9aw00y1UZIqMgX6CkFlU9L9OnKLsNsyomPIaRHTmgVTI5kNhrnVNyNsnzt9rY7fUD52DQxJILVPrUJ1Q+qW7VyNslzGYBPG0DyYlKbRAomKJDQIkdj/Uwa6bhSTq4XYNVvbk5AJ/dGwvsVdOnkMT2Ipd67KwbKfw5bqQj/cw6bj8Cp2FD4Dy4Ud4daBpPRsCyxBM2jOjVz1B/lAyrOp8BweXOXYugwdPyEn38MBZ5oL4D38jIwR/QiVnMHpERh93jtgwh9Abza6i4/zZaDAbPhtZLXSM5ztdctv8bAb63CppLU541Kf4OaLO3QLvfLRXK2n8bwEwzVAqQ22dyzt6/vPiRbZ5akh8JB6QFXG0QJF9DejsIspKF3JvOKjG2edmC9o+GfL3hwDBiihYXCGY9lElZICAdt+7rZm5UxMx7STrVKy81xcvfaIp1BwGh/HyMsJnkE8IczzRFpLlHGYuNDxdLoBjiifrmHvOCUDcV8UvhSV+UAZtAVejdNGo5G/bz0NF21HUO4pVRPu6RqZIs/aX4hlm6iO/0Ru00ct8pfadUIgRcephTuFC2fHyZxNBC6NApRtLSNLfzYTTo/uSjgcu6rLWiNo5G7yfrM45RXjalFEFzk75Z/fu9lCJJa5uLFgDNKlU+IaFjArfXJCll3apbZp4/LNKiU35ZlB7ZmjDTrji1wLep8iRVVEGht/DW00MTok7Zn7Fv+MlxgWmbZB3BuezwTmXb/fNw==', + 'base64' + ); + + await expect( + unsealEventsResponse(invalidData, [ + { + key: invalidKey, + algorithm: DecryptionAlgorithm.Aes256Gcm, + }, + { + key: validKey, + algorithm: DecryptionAlgorithm.Aes256Gcm, + }, + ]) + ).rejects.toThrowError('Invalid sealed data header'); + }); + + it('throws error if all decryption keys are invalid', async () => { + await expect( + unsealEventsResponse(sealedData, [ + { + key: invalidKey, + algorithm: DecryptionAlgorithm.Aes256Gcm, + }, + ]) + ).rejects.toThrowError('Unable to decrypt sealed data'); + }); +});