From f52b83bc19fbd23b71847aea77152dbe0c5799b7 Mon Sep 17 00:00:00 2001 From: Fabian Haas Date: Mon, 25 Mar 2024 16:49:47 +0100 Subject: [PATCH] add login request handler test --- src/index.ts | 8 +- src/instagram.ts | 58 +++++---- test/instagram.test.ts | 263 +++++++++++++++++++++++++++++------------ 3 files changed, 230 insertions(+), 99 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4edfd4f..a1d3757 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,18 @@ import * as prompt from '@inquirer/prompts'; -import {login} from "./instagram"; +import {encryptPassword, fetchVerification, login} from "./instagram"; async function authenticate() { + const verification = await fetchVerification() + while (true) { try { const user = await prompt.input({message: "Instagram username, phone number, or email: "}) const password = await prompt.password({message: "Password: "}) - return await login({user, password}) + const encryptedPassword = await encryptPassword({password, key: verification.key}) + + return await login({user, password: encryptedPassword, verification}) } catch (e) { console.error((e as Error).message) } diff --git a/src/instagram.ts b/src/instagram.ts index c77da68..6b93caf 100644 --- a/src/instagram.ts +++ b/src/instagram.ts @@ -4,7 +4,23 @@ import sealBox from "tweetnacl-sealedbox-js"; const crypto = globalThis.crypto const encoder = new TextEncoder() -export async function fetchVerification(): Promise<{ csrf: string, keyVersion: number, keyId: number, publicKey: string }> { +export interface InstagramEncryptionKey { + public: string, + id: number, + version: number +} + +export interface VerificationData { + csrf: string + key: InstagramEncryptionKey +} + +export interface EncryptedPassword { + timestamp: number, + cipher: string +} + +export async function fetchVerification(): Promise { const response = await fetch("https://www.instagram.com/api/v1/web/data/shared_data/", { headers: { "Sec-Fetch-Site": "same-origin" @@ -24,27 +40,28 @@ export async function fetchVerification(): Promise<{ csrf: string, keyVersion: n return { csrf: data.config.csrf_token, - keyId: parseInt(data.encryption.key_id, 10), - publicKey: data.encryption.public_key, - keyVersion: parseInt(data.encryption.version, 10), + key: { + id: parseInt(data.encryption.key_id, 10), + public: data.encryption.public_key, + version: parseInt(data.encryption.version, 10), + } } } -export async function encryptPassword({time, password, keyId, publicKey, providedKey}: { +export async function encryptPassword({time, password, key, providedKey}: { time?: Date | undefined, providedKey?: CryptoKey | undefined, password: string, - keyId: number, - publicKey: string, -}) { + key: InstagramEncryptionKey +}): Promise { const passwordBuffer = encoder.encode(password) const timeString = ((time ?? new Date()).getTime() / 1000).toFixed(0) - if (publicKey.length !== 64) throw new Error("Wrong public key hex.") - const keyBuffer = new Uint8Array(hexToArrayBuffer(publicKey)) + if (key.public.length !== 64) throw new Error("Wrong public key hex.") + const keyBuffer = new Uint8Array(hexToArrayBuffer(key.public)) const target = new Uint8Array(100 + passwordBuffer.length) - target.set([1, keyId]) + target.set([1, key.id]) const algorithmName = "AES-GCM" const rawKeys = providedKey ?? await crypto.subtle.generateKey({ @@ -74,18 +91,19 @@ export async function encryptPassword({time, password, keyId, publicKey, provide const converted = [] target.forEach(element => converted.push(String.fromCharCode(element))) - return {time: parseInt(timeString, 10), encryptedPassword: btoa(converted.join(''))} + return {timestamp: parseInt(timeString, 10), cipher: btoa(converted.join(''))} } -export async function login({user, password}: { user: string, password: string }): Promise { - const verification = await fetchVerification() - const {time, encryptedPassword} = await encryptPassword({...verification, password}) - +export async function login({user, password, verification}: { + user: string, + password: EncryptedPassword, + verification: VerificationData +}): Promise { const data = new FormData() data.set("username", user) data.set( "enc_password", - `#PWD_INSTAGRAM_BROWSER:${verification.keyVersion}:${time}:${encryptedPassword}` + `#PWD_INSTAGRAM_BROWSER:${verification.key.version}:${password.timestamp}:${password.cipher}` ) const response = await fetch("https://www.instagram.com/api/v1/web/accounts/login/ajax/", { @@ -97,9 +115,6 @@ export async function login({user, password}: { user: string, password: string } } }) - const identifier = "sessionid=" - const identify = (cookie: string) => cookie.startsWith(identifier) - if (!response.ok) { if (response.headers.get("Content-Type").startsWith("application/json;")) { throw new Error((await response.json()).message ?? "Login attempted failed.") @@ -112,6 +127,9 @@ export async function login({user, password}: { user: string, password: string } throw new Error("Authentication failed.") } + const identifier = "sessionid=" + const identify = (cookie: string) => cookie.startsWith(identifier) + return response.headers .getSetCookie().find(identify) .split(";").find(identify) diff --git a/test/instagram.test.ts b/test/instagram.test.ts index 810b056..0a532b6 100644 --- a/test/instagram.test.ts +++ b/test/instagram.test.ts @@ -1,15 +1,17 @@ import {beforeEach, describe, expect, test, jest} from "@jest/globals"; -import {encryptPassword, fetchVerification} from "../src/instagram"; - -interface InstagramEncryptionKey { - public: string, - id: number, -} +import { + EncryptedPassword, + encryptPassword, + fetchVerification, + InstagramEncryptionKey, + login, + VerificationData +} from "../src/instagram"; interface PasswordEncryption { password: string, encryptionKey: InstagramEncryptionKey, - providedKey: Uint8Array, + symmetricKey: Uint8Array, time: Date, expected: { end: string @@ -21,78 +23,81 @@ interface PasswordEncryption { const instagramKey87: InstagramEncryptionKey = { public: "578e8e819de302cc5b6215db3a4ec84ba8630a1000d7434562a6d15d5530b571", id: 165, + version: 9 } -const encryptionTestCases: PasswordEncryption[] = [ - { - password: "12345678", - encryptionKey: instagramKey87, - providedKey: new Uint8Array([ - 19, 168, 95, 134, 127, 20, 177, 171, 173, 63, 50, 209, 62, 47, 70, 86, 172, 99, 7, 217, 105, 78, 224, 116, - 97, 168, 255, 104, 110, 142, 39, 135, - ]), - time: new Date(1711324598 * 1000), - expected: { - end: "OlSw0xMJ1WF+NGfWt53DNQCdSXrCKESL", - start: "AaVQA", - length: 144 - } - }, - { - password: "abcdef", - encryptionKey: instagramKey87, - providedKey: new Uint8Array([ - 43, 37, 50, 175, 12, 231, 99, 252, 209, 88, 153, 187, 95, 111, 192, 117, 68, 88, 250, 17, 87, 78, 82, 172, - 175, 8, 29, 206, 197, 153, 38, 18, - ]), - time: new Date(1711324912 * 1000), - expected: { - start: "AaVQA", - end: "f0xKaa/DTViB9x3JJ2Ynj0G+hMXbDA==", - length: 144 - } - }, - { - password: "a-vary-long-password-with-$*+@-to-make-sure-it-won't-fail", - encryptionKey: instagramKey87, - time: new Date(1711326293 * 1000), - providedKey: new Uint8Array([ - 78, 254, 81, 197, 106, 5, 68, 95, 239, 197, 9, 173, 62, 13, 168, 119, 237, 110, 29, 197, 133, 84, 163, 61, - 105, 236, 57, 206, 96, 244, 52, 62, - ]), - expected: { - start: "AaVQA", - end: "8bMu0OFgsFELZ+vKlSBEj8oYDWNUNpZZzBhawzQB+j/Y/KmJw4ck/IsOIlWnGqjKZnVYiAwQDtvpVgE1ZtKZLsHTgNafSIl4fw==", - length: 212 +describe("Password encryption", () => { + const encryptionTestCases: PasswordEncryption[] = [ + { + password: "12345678", + encryptionKey: instagramKey87, + symmetricKey: new Uint8Array([ + 19, 168, 95, 134, 127, 20, 177, 171, 173, 63, 50, 209, 62, 47, 70, 86, 172, 99, 7, 217, 105, 78, 224, + 116, 97, 168, 255, 104, 110, 142, 39, 135, + ]), + time: new Date(1711324598 * 1000), + expected: { + end: "OlSw0xMJ1WF+NGfWt53DNQCdSXrCKESL", + start: "AaVQA", + length: 144 + } + }, + { + password: "abcdef", + encryptionKey: instagramKey87, + symmetricKey: new Uint8Array([ + 43, 37, 50, 175, 12, 231, 99, 252, 209, 88, 153, 187, 95, 111, 192, 117, 68, 88, 250, 17, 87, 78, 82, + 172, 175, 8, 29, 206, 197, 153, 38, 18, + ]), + time: new Date(1711324912 * 1000), + expected: { + start: "AaVQA", + end: "f0xKaa/DTViB9x3JJ2Ynj0G+hMXbDA==", + length: 144 + } + }, + { + password: "a-vary-long-password-with-$*+@-to-make-sure-it-won't-fail", + encryptionKey: instagramKey87, + time: new Date(1711326293 * 1000), + symmetricKey: new Uint8Array([ + 78, 254, 81, 197, 106, 5, 68, 95, 239, 197, 9, 173, 62, 13, 168, 119, 237, 110, 29, 197, 133, 84, 163, + 61, 105, 236, 57, 206, 96, 244, 52, 62, + ]), + expected: { + start: "AaVQA", + end: "8bMu0OFgsFELZ+vKlSBEj8oYDWNUNpZZzBhawzQB+j/Y/KmJw4ck/IsOIlWnGqjKZnVYiAwQDtvpVgE1ZtKZLsHTgNafSIl4fw==", + length: 212 + } } - } -] + ] -describe("Password encryption", () => { describe("Matches Instagram's web app", () => { - test.each(encryptionTestCases)("$password", async ({password, time, expected, providedKey, encryptionKey}) => { + test.each(encryptionTestCases)("$password", async ({password, time, expected, symmetricKey, encryptionKey}) => { const cryptoKeys = await crypto.subtle.importKey( "raw", - providedKey, + symmetricKey, "AES-GCM", true, ['encrypt', 'decrypt'] ) - const {encryptedPassword} = await encryptPassword({ + const {cipher} = await encryptPassword({ password, time, providedKey: cryptoKeys, - keyId: encryptionKey.id, - publicKey: encryptionKey.public + key: { + id: encryptionKey.id, + public: encryptionKey.public + } as InstagramEncryptionKey }) // Only the surrounding bits and the length of the encrypted password match every time, // the remaining characters are random and therefore cannot be checked. // I might be missing something here though - expect(encryptedPassword.length).toStrictEqual(expected.length) - expect(encryptedPassword.substring(0, expected.start.length)).toStrictEqual(expected.start) - expect(encryptedPassword.substring(expected.length - expected.end.length)).toStrictEqual(expected.end) + expect(cipher.length).toStrictEqual(expected.length) + expect(cipher.substring(0, expected.start.length)).toStrictEqual(expected.start) + expect(cipher.substring(expected.length - expected.end.length)).toStrictEqual(expected.end) }) }) @@ -103,8 +108,10 @@ describe("Password encryption", () => { const encryptionConfig = { password, time, - keyId: instagramKey87.id, - publicKey: instagramKey87.public + key: { + id: instagramKey87.id, + public: instagramKey87.public, + } as InstagramEncryptionKey } const [first, second] = await Promise.all([ @@ -116,18 +123,18 @@ describe("Password encryption", () => { }) }) -const sharedData = { - encryption: { - key_id: "87", - public_key: "8dd9aad29d9a614c338cff479f850d3ec57c525c33b3f702ab65e9e057fc087e", - version: "9" - }, - config: { - csrf_token: "KdiF63JpmmBdeXp2Bs2LT7t8vlwWXXXX", +describe("Verification data", () => { + const sharedData = { + encryption: { + key_id: "87", + public_key: "8dd9aad29d9a614c338cff479f850d3ec57c525c33b3f702ab65e9e057fc087e", + version: "9" + }, + config: { + csrf_token: "KdiF63JpmmBdeXp2Bs2LT7t8vlwWXXXX", + } } -} -describe("Verification data", () => { beforeEach(() => { jest.spyOn(global, "fetch").mockImplementation(() => Promise.resolve({ ok: true, @@ -141,17 +148,119 @@ describe("Verification data", () => { }) test("Fetches public key", async () => { - const {publicKey} = await fetchVerification() - expect(publicKey).toStrictEqual(sharedData.encryption.public_key) + const {key} = await fetchVerification() + expect(key.public).toStrictEqual(sharedData.encryption.public_key) }) test("Fetches key id", async () => { - const {keyId} = await fetchVerification() - expect(keyId).toStrictEqual(parseInt(sharedData.encryption.key_id, 10)) + const {key} = await fetchVerification() + expect(key.id).toStrictEqual(parseInt(sharedData.encryption.key_id, 10)) }) test("Fetches key version", async () => { - const {keyVersion} = await fetchVerification() - expect(keyVersion).toStrictEqual(parseInt(sharedData.encryption.version, 10)) + const {key} = await fetchVerification() + expect(key.version).toStrictEqual(parseInt(sharedData.encryption.version, 10)) + }) +}) + +describe("Login request handler", () => { + const encryptedPassword: EncryptedPassword = { + cipher: btoa("cipher-text-as-base64"), + timestamp: 1234567890 + } + + const verification: VerificationData = { + key: instagramKey87, + csrf: "random-csrf-value" + } + + test("Returns session id on success", async () => { + const sessionId = "a-super-secret-session-id" + + const headers = new Headers() + headers.set( + "set-cookie", + `sessionid=${sessionId}; Domain=.instagram.com; expires=Tue, 25-Mar-2025 12:23:08 GMT; HttpOnly; Max-Age=31536000; Path=/; Secure` + ) + + jest.spyOn(global, "fetch").mockImplementation(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({authenticated: true}), + headers + } as Response)) + + const result = await login({ + user: "user", + password: encryptedPassword, + verification + }) + + expect(result).toStrictEqual(sessionId) + }) + + describe("Throws exception on failed login", () => { + test.each([undefined, "Received error description"])("Message: %s", async (message) => { + const headers = new Headers() + headers.set("Content-Type", "application/json; charset=utf-8") + + jest.spyOn(global, "fetch").mockImplementation(() => Promise.resolve({ + ok: false, + json: () => Promise.resolve({authenticated: false, message}), + headers + } as Response)) + + try { + await login({ + user: "user", + password: encryptedPassword, + verification + }) + } catch (e) { + expect.assertions(1) + expect(e.message).toStrictEqual(message ?? expect.any(String)) + } + }) + }) + + test("Throws if not authenticated", async () => { + jest.spyOn(global, "fetch").mockImplementation(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({authenticated: false}), + } as Response)) + + try { + await login({ + user: "user", + password: encryptedPassword, + verification + }) + } catch (e) { + expect.assertions(1) + expect(e.message).toStrictEqual(expect.any(String)) + } + }) + + test("Throws exception on failed request", async () => { + const message = "Error message" + + const headers = new Headers() + headers.set("Content-Type", "text/plain; charset=utf-8") + + jest.spyOn(global, "fetch").mockImplementation(() => Promise.resolve({ + ok: false, + text: () => Promise.resolve(message), + headers + } as Response)) + + try { + await login({ + user: "user", + password: encryptedPassword, + verification + }) + } catch (e) { + expect.assertions(1) + expect(e.message).toStrictEqual(message) + } }) })