TeleOTP
TeleOTP copied to clipboard
Telegram Mini App that allows you to generate one-time passwords inside Telegram
🔐 TeleOTP
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
- 🔐 TeleOTP
- ✨ Features
- Table of contents
- ⚙️ Setup guide
- 📱 Mini App
- Installing the dependencies
- Configuring the environment
- Starting the development server
- Building the app
- 🔁 CI/CD
- 💬 Bot
- Starting bot
- Environment variables
- Running in Docker
- 🔁 CI/CD
- 📱 Mini App
- 💻 Structure
- 🛣️ Routing
- 🤖 Data and business logic
- 🪝 Hooks
- ⬅️ Telegram Back Button
- 📳 Telegram Haptics
- ✅ Telegram Main Button
- 📷 Telegram QR Scanner
- 🎨 Telegram Theme
- 🔑 Account
- 👁️ Biometrics manager
- isAvailable
- isSaved
- updateToken
- getToken
- ⚙️ Settings manager
- shouldKeepUnlocked
- setKeepUnlocked
- lastSelectedAccount
- setLastSelectedAccount
- 🔐 Encryption manager
- storageChecked
- passwordCreated
- createPassword
- removePassword
- isLocked
- unlock
- lock
- oldKey
- encrypt
- decrypt
- 💾 Storage manager
- ready
- accounts
- saveAccount
- saveAccounts
- removeAccount
- clearStorage
- ✈️ Migration
- Caveats
- 🤗 Icons and colors
- ➕ Adding custom icons
- 🪝 Hooks
- 📋 TODOs
- 👋 Acknowledgements
- 🖌️ Content
- 📚 Libraries used
⚙️ 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
--hostargument 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
TOKEN- Telegram bot token provided by @BotFatherTG_APP- A link to the Mini App in Telegram (e.g. https://t.me/TeleOTPAppBot/app)WEBAPP_URL- Deployed Mini App URL (e.g. https://uselessstudio.github.io/TeleOTP)
[!NOTE] Make sure that
WEBAPP_URLdoesn'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.tsxis responsible for showing "required" screens:PasswordSetup.tsxis 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.tsxis 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 fromlocalStorage(if not disabled in the settings).
Accounts.tsxis the main screen, which shows the generated password and a list of accounts that user has.NewAccount.tsxis 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 toManualAccount.tsxManualAccount.tsxprompts user to enter a secret for an OTP account.CreateAccount.tsxis 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.tsxis a screen similar toCreateAccount.tsxwhich allows user to edit or delete an account.Settings.tsxis a menu screen with a few options. User can delete all accounts, encrypt them, change the password, or set preferences.ExportAccounts.tsxis 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.tsxis 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:
impactOccurredA method tells that an impact occurred.stylelight, 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.
notificationOccurredA 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.
selectionChangedA 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:
onClickis a callback that is executed when user presses the button. If the callback returns true, the button will be hidden.textis a string which contains the text that should be displayed on the button.disabled(defaultfalse) 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:
callbackis a function that is executed after a successful scan.- argument
scannedcontains the contents of the QR-code.
- argument
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:
accountUriis a string which contains a key URI.
Returns:
codeis the generated code string.periodis the token time-to-live duration in seconds.progressis 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
requestReasonis a message when user is prompted to provide necessary permissionsauthenticateReasonis 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 implementationisBiometricTokenSavedreturnstrueeven 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:
- Use the Material UI icons.
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
- Emoji animations from Telegram stickers.
- Duck stickers
- Brand icons from Simple Icons
📚 Libraries used
- @twa-dev/types - Typescript types for Telegram Mini App SDK
- OTPAuth - generating TOTP codes
- nanoid - generating unique ids for accounts
- lottie-react - rendering lottie animations
- copy-text-to-clipboard - copying codes to the clipboard
TeleOTP is made for Telegram Mini App Contest.
Designed by @lunnaholy, implemented by @LowderPlay with ❤️