Skip to content

Commit

Permalink
Add OTP support (#131)
Browse files Browse the repository at this point in the history
* Add first classes for otp saving and generation

* Typo

* OTP saving complete

* Better OTP saving

* OTP filling

* Add recovery codes to prompt

* Eslint
  • Loading branch information
C0ntroller authored Jan 18, 2024
1 parent bde0e56 commit 755db8b
Show file tree
Hide file tree
Showing 7 changed files with 207 additions and 9 deletions.
29 changes: 29 additions & 0 deletions src/background.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 30 additions & 4 deletions src/contentScripts/login/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<boolean> {
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
Expand Down
33 changes: 29 additions & 4 deletions src/contentScripts/login/idp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,16 @@ const cookieSettings: CookieSettings = {
async additionalFunctionsPostCheck (): Promise<void> {
this.confirmData()
this.outdatedRequest()
this.selectOTPType()
}

confirmData () {
// Check if this is the consense page
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 () {
Expand All @@ -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<boolean | HTMLElement | Element | null> {
return document.querySelector('.content p font[color="red"]')
}

async loginFieldsAvailable (): Promise<boolean | LoginFields> {
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> {
Expand Down
24 changes: 24 additions & 0 deletions src/contentScripts/other/otpSnatcher.ts
Original file line number Diff line number Diff line change
@@ -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?')
}
5 changes: 5 additions & 0 deletions src/manifest.chrome.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 7 additions & 1 deletion src/manifest.firefox.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"*://*/"
],
"background": {
"page": "background.html"
"scripts": ["background.js"],
"type": "module"
},
"content_scripts": [
{
Expand Down Expand Up @@ -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": {
Expand Down
83 changes: 83 additions & 0 deletions src/modules/otp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { getUserData } from './credentials'

// Get the TOTP code for the user
export async function getTOTP (platform: string = 'zih'): Promise<string|undefined> {
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<string|undefined> {
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<string> {
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<Uint8Array> {
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
}

0 comments on commit 755db8b

Please sign in to comment.