diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 565801ad..56cd741c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Steps to contribute: 2. Checkout develop branch `git checkout develop` 3. Create new feature branch from develop `git checkout -b ` 4. Install dependencies `npm ci` - you need node package manager npm installed -5. Run `npm useChrome` or `npm useFF` to select the browser you are developing for - this will copy the corresponding manifest.json +5. Run `npm run useChrome` or `npm run useFF` to select the browser you are developing for - this will copy the corresponding manifest.json 6. Run `npm run dev` while developing. This is will compile sass and ts files and watch for changes in your working tree. 7. Load the ./build directory as an unpacked extension in your browser 8. Run tests locally before committing code `npm run test` diff --git a/src/background.ts b/src/background.ts index 309e495a..0a56a447 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,5 +1,6 @@ 'use strict' import * as credentials from './modules/credentials' +import * as otp from './modules/otp' import * as owaFetch from './modules/owaFetch' import * as opalInline from './modules/opalInline' import { isFirefox } from './modules/firefoxCheck' @@ -225,6 +226,34 @@ chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { // Asynchronous response credentials.deleteUserData(request.platform).then(sendResponse) // Response can probably be ignored return true // required for async sendResponse + case 'get_totp': + // Asynchronous response + otp.getTOTP(request.platform).then(sendResponse) + return true // required for async sendResponse + case 'get_iotp': + // Asynchronous response + if (!request.indexes) return sendResponse(undefined) + otp.getIOTP(request.platform, ...request.indexes).then(sendResponse) + return true // required for async sendResponse + case 'set_otp': + // Asynchronous response + switch (request.otpType) { + case 'totp': + if (!request.secret) return sendResponse(false) + credentials.setUserData({ user: 'totp', pass: request.secret }, (request.platform ?? 'zih') + '-totp').then(() => { + credentials.deleteUserData((request.platform ?? 'zih') + '-iotp').then(() => sendResponse(true)) + }) + return true // required for async sendResponse + + case 'iotp': + if (!request.secret) return sendResponse(false) + credentials.setUserData({ user: 'iotp', pass: request.secret }, (request.platform ?? 'zih') + '-iotp').then(() => { + credentials.deleteUserData((request.platform ?? 'zih') + '-totp').then(() => sendResponse(true)) + }) + return true // required for async sendResponse + + default: return sendResponse(false) + } /* OWA */ case 'enable_owa_fetch': owaFetch.enableOWAFetch().then(sendResponse) diff --git a/src/contentScripts/login/common.ts b/src/contentScripts/login/common.ts index 515203cf..416b3764 100644 --- a/src/contentScripts/login/common.ts +++ b/src/contentScripts/login/common.ts @@ -17,10 +17,18 @@ export interface CookieSettings { usesIdp?: boolean; } +interface OTPSettings { + input: HTMLInputElement | null; + submitButton?: HTMLElement | null; + type: 'totp' | 'iotp'; + indexes?: number[]; +} + export interface LoginFields { usernameField: HTMLInputElement; passwordField: HTMLInputElement; submitButton?: HTMLElement; + otpSettings?: OTPSettings; } // This is the default lifetime for the logout cookie in minutes. @@ -147,15 +155,33 @@ export abstract class Login { const avail = await this.loginFieldsAvailable().catch(() => { }) if (typeof avail === 'boolean' && !avail) return - if (typeof avail === 'object') { - if (!avail.usernameField || !avail.passwordField) return - else loginFields = avail - } + if (typeof avail === 'object') loginFields = avail + + // Fill the otp + // If we clicked the submit button, we can return here + if (loginFields?.otpSettings && (await this.fillOtp(loginFields.otpSettings))) return await this.onLogin() await this.login(userData, loginFields) } + async fillOtp (otpSettings: OTPSettings): Promise { + if (!otpSettings.input) return false + + let otp: string | undefined + if (otpSettings.type === 'totp') { + otp = await chrome.runtime.sendMessage({ cmd: 'get_totp', platform: this.platform }) + } else if (otpSettings.type === 'iotp') { + otp = await chrome.runtime.sendMessage({ cmd: 'get_iotp', platform: this.platform, indexes: otpSettings.indexes }) + } + + if (!otp || otp.length === 0) return false + + this.fakeInput(otpSettings.input, otp) + otpSettings.submitButton?.click() + return !!otpSettings.submitButton + } + fakeInput (input: HTMLInputElement, value: string) { // Inspired by how the Bitwarden extension does it // https://github.com/bitwarden/clients/blob/master/apps/browser/src/content/autofill.js#L346 diff --git a/src/contentScripts/login/idp.ts b/src/contentScripts/login/idp.ts index 12e4969a..ccb4071e 100644 --- a/src/contentScripts/login/idp.ts +++ b/src/contentScripts/login/idp.ts @@ -22,6 +22,7 @@ const cookieSettings: CookieSettings = { async additionalFunctionsPostCheck (): Promise { this.confirmData() this.outdatedRequest() + this.selectOTPType() } confirmData () { @@ -29,8 +30,8 @@ const cookieSettings: CookieSettings = { if (!document.getElementById('generalConsentDiv')) return // Click the button - const button = document.querySelector('input[type="submit"][name="_eventId_proceed"]') - if (button) (button as HTMLInputElement).click() + const button = document.querySelector('input[type="submit"][name="_eventId_proceed"]') as HTMLInputElement | null + button?.click() } outdatedRequest () { @@ -40,16 +41,40 @@ const cookieSettings: CookieSettings = { // We don't know where the user tried to login, so we can't jsut redirect to Opal/etc } + selectOTPType () { + if (!document.getElementById('fudis_selected_token_ids_input')) return + + const button = document.querySelector('button[type="submit"][name="_eventId_proceed"]') as HTMLButtonElement | null + button?.click() + } + async findCredentialsError (): Promise { return document.querySelector('.content p font[color="red"]') } async loginFieldsAvailable (): Promise { - return { + const fields: LoginFields = { usernameField: document.getElementById('username') as HTMLInputElement, passwordField: document.getElementById('password') as HTMLInputElement, - submitButton: document.querySelector('button[name="_eventId_proceed"][value="Login"]') as HTMLButtonElement + submitButton: document.querySelector('button[name="_eventId_proceed"][type="submit"]') as HTMLButtonElement } + + const otpInput = document.getElementById('fudis_otp_input') as HTMLInputElement | null + if (otpInput) { + const indexesText = otpInput.parentElement?.parentElement?.querySelector('td:first-of-type')?.textContent?.trim() + // find number & number | remove whole match | to numbers | to zero based (first index is 0) + const indexes = indexesText?.match(/(\d+) & (\d+)/)?.slice(1, 3).map((x) => Number.parseInt(x, 10) - 1) + + fields.otpSettings = { + input: otpInput, + submitButton: document.querySelector('button[name="_eventId_proceed"][type="submit"]') as HTMLButtonElement | null, + type: indexesText?.toLocaleLowerCase().includes('totp') ? 'totp' : 'iotp', + indexes: indexes && indexes.length > 0 ? indexes : undefined + } + console.log(fields) + } + + return fields } async findLogoutButtons (): Promise<(HTMLElement|Element|null)[] | NodeList | null> { diff --git a/src/contentScripts/other/otpSnatcher.ts b/src/contentScripts/other/otpSnatcher.ts new file mode 100644 index 00000000..ce0e8923 --- /dev/null +++ b/src/contentScripts/other/otpSnatcher.ts @@ -0,0 +1,24 @@ +const qrAvailable = !!document.getElementById('qr-code') +const seedLink = document.querySelector('#seed-link a[href^="otpauth://totp/"]') + +const indexedAvailable = document.getElementById('indexed-secret') + +if (qrAvailable && seedLink && showWarning()) { + const seed = seedLink.getAttribute('href') + if (seed) { + const secret = new URL(seed).searchParams.get('secret') + chrome.runtime.sendMessage({ cmd: 'set_otp', otpType: 'totp', secret, platform: 'zih' }) + } +} else if (!!indexedAvailable && showWarning()) { + const cols = Array.from(indexedAvailable.querySelectorAll('tr:nth-of-type(2) td')) + // Maybe the ZIH will change the number of chars in the future + // Update it here! + if (cols.length === 25) { + const secret = cols.map((col) => (col as HTMLTableCellElement).innerText).reduce((acc, cur) => acc + cur, '') + chrome.runtime.sendMessage({ cmd: 'set_otp', otpType: 'iotp', secret, platform: 'zih' }) + } +} + +function showWarning (): boolean { + return confirm('TUfast kann diesen 2-Faktor-Code für dich speichern und automatisch an den entsprechenden Stellen einf\u00fcgen. Dies geht jedoch gegen den Sinn eines zweiten Faktors und ist noch in Entwicklung.\n\nSPEICHERE DIR DEN CODE UND DIE RECOVERY CODES AUF JEDEN FALL AUCH AN EINER ANDEREN STELLE!\n\nSoll TUfast für dich die 2-Faktor-Authentifizierung \u00fcbernehmen?') +} diff --git a/src/freshContent/popup/popup.html b/src/freshContent/popup/popup.html index e48b38db..8ae49594 100644 --- a/src/freshContent/popup/popup.html +++ b/src/freshContent/popup/popup.html @@ -42,44 +42,44 @@ - + - - - + - + - + - + - + - + - + - + - - @@ -91,15 +91,15 @@ style="flex: 1" id="nautos"> - - - @@ -127,8 +127,6 @@ - - diff --git a/src/manifest.chrome.json b/src/manifest.chrome.json index a95ee964..a296ff46 100644 --- a/src/manifest.chrome.json +++ b/src/manifest.chrome.json @@ -203,6 +203,11 @@ "js": ["contentScripts/forward/searchEngines/startpage.js"], "run_at": "document_idle", "matches": ["https://www.startpage.com/*"] + }, + { + "js": ["contentScripts/other/otpSnatcher.js"], + "run_at": "document_idle", + "matches": ["https://selfservice.tu-dresden.de/services/idm/token/create"] } ], "icons": { diff --git a/src/manifest.firefox.json b/src/manifest.firefox.json index 0f8ee1e5..6ce79c0a 100644 --- a/src/manifest.firefox.json +++ b/src/manifest.firefox.json @@ -17,7 +17,8 @@ "*://*/" ], "background": { - "page": "background.html" + "scripts": ["background.js"], + "type": "module" }, "content_scripts": [ { @@ -202,6 +203,11 @@ "js": ["contentScripts/forward/searchEngines/startpage.js"], "run_at": "document_idle", "matches": ["https://www.startpage.com/*"] + }, + { + "js": ["contentScripts/other/otpSnatcher.js"], + "run_at": "document_idle", + "matches": ["https://selfservice.tu-dresden.de/services/idm/token/create"] } ], "icons": { diff --git a/src/modules/otp.ts b/src/modules/otp.ts new file mode 100644 index 00000000..f5e4c0be --- /dev/null +++ b/src/modules/otp.ts @@ -0,0 +1,83 @@ +import { getUserData } from './credentials' + +// Get the TOTP code for the user +export async function getTOTP (platform: string = 'zih'): Promise { + const userData = await getUserData(platform + '-totp') + try { + if (!userData || !userData.pass) return undefined + return await generateTOTP(userData.pass ?? '') + } catch { + return undefined + } +} + +export async function getIOTP (platform: string = 'zih', ...indexes): Promise { + const userData = await getUserData(platform + '-iotp') + if (!userData || !userData.pass) return undefined + + let result = '' + for (const index of indexes) { + const char = userData.pass[index] + if (!char) return undefined + result += char + } + return result +} + +// Generate a TOTP code from a seed URI +// Returns a string as it can be prefixed with 0s +async function generateTOTP (secret: string): Promise { + if (!secret) { + throw new Error('No secret found in URI') + } + + // Counter is the current time in seconds divided by the interval + // Interval is 30 for the TUD + const counter = Math.floor((Date.now() / 1000) / 30) + + const key = await b32ToUInt8Arr(secret) + const value = new ArrayBuffer(8) + const view = new DataView(value) + view.setUint32(4, counter, false) + + const cryptoKey = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign', 'verify']) + const signed = await crypto.subtle.sign({ name: 'HMAC', hash: 'SHA-1' }, cryptoKey, value) + + // Truncate the result + const signature = new Uint8Array(signed) + const offset = signature[signature.length - 1] & 0xf + const code = (signature[offset + 0] & 0x7f) << 24 | (signature[offset + 1] & 0xff) << 16 | (signature[offset + 2] & 0xff) << 8 | (signature[offset + 3] & 0xff) + // because 6 digits v + return (code % Math.pow(10, 6)).toString().padStart(6, '0') +} + +async function b32ToUInt8Arr (base32: string): Promise { + base32 = base32.replace(/=+$/, '').toLocaleUpperCase() + + /** + * How this works: + * - Convert each base32 character to a value. + * - Each value is 5bits long (because of 32 available chars). + * - We want bytes, but thats 8bits. + * - So get all the 5bit values, and concat them into a string. + * - Then get chunks of 8 from the string and parse the numeric value. + */ + + const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' + let bits = '' + + for (let i = 0; i < base32.length; i++) { + const val = base32Chars.indexOf(base32.charAt(i)) + if (val === -1) throw new Error('Invalid character in secret') + bits += val.toString(2).padStart(5, '0') + } + + const result = new Uint8Array(bits.length / 8) + + for (let i = 0; i + 8 <= bits.length; i += 8) { + const chunk = bits.substring(i, i + 8) + result[i / 8] = Number.parseInt(chunk, 2) + } + + return result +}