Skip to content

Telegram Mini App that allows you to generate one-time passwords inside Telegram

License

Notifications You must be signed in to change notification settings

UselessStudio/TeleOTP

Repository files navigation

πŸ” TeleOTP

Deploy static content to Pages Build Telegram bot image Plausible analytics

Telegram Mini App that allows you to generate one-time 2FA passwords inside Telegram.

✨ Features

  • βœ… Universal: TeleOTP implements TOTP (Time-Based One-Time Password Algorithm) which is used by most services.
  • πŸ‘Œ Convenient: Accounts are safely stored in your Telegram cloud storage, so you can access them anywhere you can use Telegram.
  • πŸ”’ Secure: All accounts are encrypted using AES. That means even if your Telegram account is breached, the attacker won't have access to your tokens without the encryption password.
  • πŸ₯° User-friendly: TeleOTP is designed to look like Telegram and follows your color theme.
  • πŸ€— Open: TeleOTP supports account migration to and from Google Authenticator. You can switch the platforms at any time without any hassle!

Table of contents

βš™οΈ Setup guide

πŸ“± Mini App

TeleOTP is made with React, Typescript, and Material UI. Vite frontend tooling is used for rapid development and easy deployment.

Installing the dependencies

To begin working with the project, you should install the dependencies by running this command:

npm install

Configuring the environment

Before starting the server or building the app, make sure that your project directory has a file named .env. It should follow the .env.example file structure. You could also set these variables directly when running the app.

The app uses following environment variables:

  • VITE_BOT_USERNAME - This value contains the bot username. It is used to send export requests to the bot. Example: VITE_BOT_USERNAME=TeleOTPAppBot

Starting the development server

To start the development server with hot reload, run:

npm run dev

After that, the server will be accessible on http://localhost:5173/

Note

If you want the app to be accessible on your local network, you should add --host argument to the command

npm run dev -- --host

Building the app

npm run build

After a successful build, app bundle will be available in ./dist.

πŸ” CI/CD

GitHub Actions is used to automate the deployment of the app to the GitHub Pages. The workflow is defined in the deploy.yml file and ran on every push to main.

πŸ’¬ Bot

TeleOTP uses a helper bot to send user a link to the app and assist with account migration. The bot is written in Python using Python Telegram Bot library.

Starting bot

To start the bot, you have to run the main.py script with environment variables.

python main.py

Environment variables

Note

Make sure that WEBAPP_URL doesn't end with a /! It is added automatically by the bot.

Running in Docker

We recommend running the bot inside the Docker container. The latest image is available at ghcr.io/uselessstudio/teleotp-bot:main.

Example docker-compose.yml file:

services:
  bot:
    image: ghcr.io/uselessstudio/teleotp-bot:main
    restart: unless-stopped
    environment:
      - TG_APP=https://t.me/TeleOTPAppBot/app
      - WEBAPP_URL=https://uselessstudio.github.io/TeleOTP
      - TOKEN=<insert your token>

And running is as simple as:

docker compose up

πŸ” CI/CD

GitHub Actions is used to automate the building of the bot container. The workflow is defined in the bot.yml file and ran on every push to main. After a successful build, the container is published in the GitHub Container Registry.

πŸ’» Structure

πŸ›£οΈ Routing

TeleOTP uses React Router to switch between pages. Routes are specified in the main.tsx file.

  • Route implemented in Root.tsx is responsible for showing "required" screens:
    • PasswordSetup.tsx is a screen which is showed when no password is created. Alternatively, this screen is shown when user clicked a button to change the password. It displays a prompt to create a new password which is used to encrypt the accounts.
    • Decrypt.tsx is a screen which shows a password prompt to decrypt stored accounts. By default, it is shown only once on a new device. Later, the password retrieved from localStorage (if not disabled in the settings).
  • Accounts.tsx is the main screen, which shows the generated password and a list of accounts that user has.
  • NewAccount.tsx is a screen, which prompts user to open the QR-code scanner. Otherwise, user could press a button to enter an account manually, which would redirect them to ManualAccount.tsx
  • ManualAccount.tsx prompts user to enter a secret for an OTP account.
  • CreateAccount.tsx is a final step in the account creation flow. User is redirected here after scanning a QR-code or after manually providing a secret. This screen allows user to change issuer and label and to select an icon with a color for the account.
  • EditAccount.tsx is a screen similar to CreateAccount.tsx which allows user to edit or delete an account.
  • Settings.tsx is a menu screen with a few options. User can delete all accounts, encrypt them, change the password, or set preferences.
  • ExportAccounts.tsx is a page that handles the export logic. This page could be opened only by pressing the keyboard button in the chat. It sends the exported accounts back to the bot.
  • ResetAccounts.tsx is a page that verifies that the user wants to delete all accounts and reset the password. This page can be accessed through the settings, or by typing in the password incorrectly when decrypting.

πŸ€– Data and business logic

πŸͺ Hooks

⬅️ Telegram Back Button

import useTelegramBackButton from "./useTelegramBackButton";

useTelegramBackButton(): void

This hook sends a request to telegram to display button to navigate back in history. It is used only once in Root.tsx. This hook automatically shows the button if the current route is not root (/). To navigate back, useNavigate(-1) hook from React Router is used.

πŸ“³ Telegram Haptics

import useTelegramHaptics from "./useTelegramHaptics";

useTelegramHaptics(): {
  impactOccurred: (style: "light" | "medium" | "heavy" | "rigid" | "soft") => void,
  notificationOccurred: (style: "error" | "success" | "warning") => void,
  selectionChanged: () => void,
}

This hook wraps the Telegram HapticFeedback object.

Returns:

  • impactOccurred A method tells that an impact occurred.
    • style
      • light, indicates a collision between small or lightweight UI objects,
      • medium, indicates a collision between medium-sized or medium-weight UI objects,
      • heavy, indicates a collision between large or heavyweight UI objects,
      • rigid, indicates a collision between hard or inflexible UI objects,
      • soft, indicates a collision between soft or flexible UI objects.
  • notificationOccurred A method tells that a task or action has succeeded, failed, or produced a warning.
    • style
      • error, indicates that a task or action has failed,
      • success, indicates that a task or action has completed successfully,
      • warning, indicates that a task or action produced a warning.
  • selectionChanged A method tells that the user has changed a selection.

βœ… Telegram Main Button

import useTelegramMainButton from "./useTelegramMainButton";

useTelegramMainButton(onClick: () => boolean, text: string, disabled: boolean = false): void

Params:

  • onClick is a callback that is executed when user presses the button. If the callback returns true, the button will be hidden.
  • text is a string which contains the text that should be displayed on the button.
  • disabled (default false) is a boolean flag that indicates, whether the button should be disabled or not.

This hook shows a main button and adds the callback as the listener for clicks. The button is automatically hidden if the component using this hook is disposed.

πŸ“· Telegram QR Scanner

import useTelegramQrScanner from "./useTelegramQrScanner";

useTelegramQrScanner(callback: (scanned: string) => void): (text?: string) => void

Params:

  • callback is a function that is executed after a successful scan.
    • argument scanned contains the contents of the QR-code.

Returns:

A function to open the QR-code scanner. Optionally, accepts text string as the argument. The text to be displayed under the 'Scan QR' heading, 0-64 characters.

🎨 Telegram Theme

import useTelegramTheme from "./useTelegramTheme";

useTelegramTheme(): Theme

Creates a Material UI theme from Telegram-provided color palette. This hook automatically listens for theme change events.

Returns:

A Material UI theme, to be used with ThemeProvider:

<ThemeProvider theme={theme}>

πŸ”‘ Account

import useAccount from "./useAccount";

useAccount(accountUri?: string): { code: string, period: number, progress: number }

Params:

  • accountUri is a string which contains a key URI.

Returns:

  • code is the generated code string.
  • period is the token time-to-live duration in seconds.
  • progress is the current token lifespan progress. A number between 0 (fresh) and 1 (expired).

This hook generates the actual 2FA code. Progress is updated every 300ms. If accountUri is not provided or invalid, the code returned is "N/A".

Generation of codes is implemented in the otpauth library.

πŸ‘οΈ Biometrics manager

BiometricsManager is used as an interface to Telegram's WebApp.BiometricManager. It allows to store the encryption key inside secure storage on device, locked by a biometric lock.

To get an instance of BiometricsManager, you should use the useContext hook:

import {BiometricsManagerContext} from "./biometrics";

const biometricsManager = useContext(BiometricsManagerContext);

BiometricsManager is created using BiometricsManagerProvider component:

Important

BiometricsManagerProvider must be used inside the SettingsManagerProvider

  • requestReason is a message when user is prompted to provide necessary permissions
  • authenticateReason is a message shown to the user, when the key is requested
import {BiometricsManagerProvider} from "./biometrics";

<BiometricsManagerProvider requestReason="Allow access to biometrics to be able to decrypt your accounts"
                           authenticateReason="Authenticate to decrypt your accounts">
  ... rest of the app code ...
</BiometricsManagerProvider>

isAvailable

isAvailable: boolean;

Boolean flag indicating whether biometric storage is available on the current device.

isSaved

isSaved: boolean;

Boolean flag indicating whether the encryption key is saved inside the storage. This flag is stored using the SettingsManager.

Note

Behaviour of this flag is different to WebApp.BiometricManager.isBiometricTokenSaved, as in the current implementation isBiometricTokenSaved returns true even if a token is empty.

updateToken

updateToken(token: string): void;

This method saves the key inside secure storage. It may ask the user for necessary permissions. To delete the stored key, pass empty string to the token parameter.

getToken

getToken(callback: (token?: string) => void): void;

This method requests a token from the storage. If a request is successful, the token is passed in the token parameter inside a callback. In case of a failure, callback is called with empty token.

βš™οΈ Settings manager

SettingsManager is used to provide the app with user's preferences. Currently, SettingsManager stores settings in the localStorage, so the state is NOT persistent between devices, even on the same Telegram account.

shouldKeepUnlocked

shouldKeepUnlocked: boolean;

This flag indicates whether the app should stay in the unlocked state between restarts.

setKeepUnlocked

setKeepUnlocked(keep: boolean): void;

This method changes the value of the shouldKeepUnlocked flag.

lastSelectedAccount

lastSelectedAccount: string | null;

This value contains the id of the account that was previously selected. If this value is missing in the storage, returns null.

Note

The id returned in this method is NOT checked to be a valid account id.

setLastSelectedAccount

setLastSelectedAccount(id: string): void;

This method updates the last selected account value in the storage.

πŸ” Encryption manager

EncryptionManager is used to handle everything related to encryption. It is responsible for unlocking the storage, checking passwords, and encrypting/decrypting data. Currently, AES-128 encryption and PBKDF2 key derive function are implemented.

The actual encryption methods used are implemented in the CryptoJS library.

User's password is not stored anywhere outside the device itself. Instead, Key Checksum Value is used to verify the validity of the entered key. After a successful password entry, the derived key is stored in the localStorage (if not disabled in settings).

To get an instance of EncryptionManager, you should use the useContext hook:

import {EncryptionManagerContext} from "./encryption";

const encryptionManager = useContext(EncryptionManagerContext);

EncryptionManager is created using EncryptionManagerProvider component:

Important

EncryptionManagerProvider must be used inside the SettingsManagerProvider

import {EncryptionManagerProvider} from "./encryption";

<EncryptionManagerProvider>
  ... rest of the app code ...
</EncryptionManagerProvide>

storageChecked

storageChecked: boolean;

This is a boolean indicating if KCV and password salt were read from the storage. The app should wait for this value to be true to try to unlock the EncryptionManager or use encrypt/decrypt methods.

passwordCreated

passwordCreated: boolean | null;

This flag indicates that the password exists, and it's salt and KCV are in the storage. Its value is null when EncryptionManager haven't checked the storage yet.

createPassword

createPassword(password: string): void;

This method is used to create a new password or change the existing one. Password should be provided in the plaintext form. If this method is called with the EncryptionManager being unlocked, the previous key is stored in the oldKey variable.

removePassword

removePassword(): void;

This method removes the salt and KCV from the storage. After it is called, passwordCreated would become false.

isLocked

isLocked: boolean;

This flag indicates whether EncryptionManager is locked or not. If it is equal to true, the user should unlock the storage using the unlock method

unlock

unlock(password: string): boolean;

This method takes in the plaintext password from the user, verifies the validity using KCV, and stores the key locally in case of success. After the successful execution of this method, isLocked would change to false.

Returns: a boolean indicating whether the provided password is correct.

lock

lock(): void;

This method removes the stored key from localStorage After the execution of this method, isLocked would change to true.

oldKey

oldKey: crypto.lib.WordArray | null;

This variable contains the previous password's key. It is used to indicate that the password was changed to re-encrypt the accounts with the correct new key.

encrypt

encrypt(data: string): string | null;

This method encrypts the data string with the stored key and returns the corresponding ciphertext. This method will return null if the EncryptionManager is locked.

decrypt

decrypt(data: string): string | null;

This method decrypts the data string with the stored key and returns the corresponding plaintext. This method will return null if the EncryptionManager is locked.

πŸ’Ύ Storage manager

StorageManager is responsible for saving and restoring accounts from Telegram's CloudStorage. StorageManager encrypts the accounts by calling encrypt/decrypt methods on the EncryptionManager.

Telegram CloudStorage is limited to 1024 keys. A few of these keys are used to store the data for the decryption and more could be used later for other features. Each account is stored as the different CloudStorage item, so the limit of the accounts is around 1000. We do not limit the amount of the accounts that user has because this number is somewhat impractical in real-world use.

Account object is defined as:

import {Color, Icon} from "./globals";

interface Account {
  id: string;
  label: string;
  issuer?: string;
  uri: string;
  color: Color;
  icon: Icon;
}

To get an instance of StorageManager, you should use the useContext hook:

import {StorageManagerContext} from "./storage";

const encryptionManager = useContext(StorageManagerContext);

StorageManager is created using StorageManagerProvider component:

Important

StorageManagerProvider must be used inside the EncryptionManagerProvider

import {StorageManagerProvider} from "./encryption";

<StorageManagerProvider>
  ... rest of the app code ...
</StorageManagerProvider>

ready

ready: boolean;

This is a boolean flag indicating if the StorageManager had loaded and decrypted the accounts. If this flag is false, UI should display a loading indicator.

accounts

accounts: Record<string, Account>;

This is an object containing every account currently in the storage. The key is a string id of the account. This object is updated every time a new account is saved/removed.

saveAccount

saveAccount(account: Account): void;

Note

If you need to save multiple accounts, use saveAccounts

This method saves the provided account in the CloudStorage. If the account with the same id exists, it is overridden.

saveAccounts

saveAccount(accounts: Account[]): void;

This method is should be used if needed to save multiple accounts. The only difference in this method and running saveAccount in a loop is that saveAccounts doesn't update the state for each account.

removeAccount

removeAccount(id: string): void;

This method removes the account with provided id from the CloudStorage.

clearStorage

clearStorage(): void;

This method clears the entirety of the CloudStorage. It removes accounts and password salt with KCV. After this method is executed, the removePassword method on EncryptionManager is executed to ensure that the account was deleted.

✈️ Migration

TeleOTP implements the otpauth-migration URI standard. During the migration, accounts are serialized using Protocol Buffers and sent to the bot via sendData method. Bot then generates the QR-code and a link, that can be used for migrating to another instance of TeleOTP.

Caveats

The length of the data that can be sent using sendData is limited to 4096 bytes. So the quantity of the accounts that could be migrated from TeleOTP is limited. Although, this number theoretically is pretty big, it is still smaller than the amount of the accounts that can be stored in CloudStorage (around 1000).

πŸ€— Icons and colors

All the icons and colors for accounts are defined in the globals.tsx file. Available icons are exported in the icons const, colors are available in the colors const. Icon and Color types are provided for checking the validity of the corresponding item.

βž• Adding custom icons

You have two options regarding adding a custom icon:

Adding a Material UI icon is as simple as creating a new entry in the icons object. The key should uniquely identify the icon. The value should be an SvgIconComponent imported from @mui/icons-material.

import StarIcon from '@mui/icons-material/Star';

export const icons: Record<string, SvgIconComponent> = {
    "star": StarIcon,
    ...
} as const;
  • Provide a custom SVG icon.

First of all, the icon should be added to the src/assets/icons folder. We recommend getting the icons at https://simpleicons.org/. Then, the SvgIconComponent should be created using the createSvgIcon function, adding the icon are the same steps as with the Material UI icons:

// Make sure to add `?react` to the icon path.
// This makes the imported icon a React Component.
import Discord from "./assets/icons/discord.svg?react"; 

const DiscordIcon = createSvgIcon(<Discord/>, "Discord");

export const icons: Record<string, SvgIconComponent> = {
  "discord": DiscordIcon,
  ...
} as const;

πŸ“‹ TODOs

  • Implement counter-based HOTP
  • Add more icons
  • Add localization

πŸ‘‹ Acknowledgements

πŸ–ŒοΈ Content

πŸ“š Libraries used

TeleOTP is made for Telegram Mini App Contest.

Designed by @lunnaholy, implemented by @LowderPlay with ❀️