Add 2FA TOTP code editor to browser extension (#1391)

This commit is contained in:
Leendert de Borst
2025-11-24 10:12:04 +01:00
committed by Leendert de Borst
parent dccbda7515
commit a92bbef41a
6 changed files with 474 additions and 10 deletions

View File

@@ -0,0 +1,317 @@
import * as OTPAuth from 'otpauth';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { TotpCode } from '@/utils/dist/shared/models/vault';
type TotpFormData = {
name: string;
secretKey: string;
}
type TotpEditorState = {
isAddFormVisible: boolean;
formData: TotpFormData;
}
type TotpEditorProps = {
totpCodes: TotpCode[];
onTotpCodesChange: (totpCodes: TotpCode[]) => void;
originalTotpCodeIds: string[];
isAddFormVisible: boolean;
formData: TotpFormData;
onStateChange: (state: TotpEditorState) => void;
}
/**
* Component for editing TOTP codes for a credential.
*/
const TotpEditor: React.FC<TotpEditorProps> = ({
totpCodes,
onTotpCodesChange,
originalTotpCodeIds,
isAddFormVisible,
formData,
onStateChange
}) => {
const { t } = useTranslation();
const [formError, setFormError] = useState<string | null>(null);
/**
* Sanitizes the secret key by extracting it from a TOTP URI if needed
*/
const sanitizeSecretKey = (secretKeyInput: string, nameInput: string): { secretKey: string, name: string } => {
let secretKey = secretKeyInput.trim();
let name = nameInput.trim();
// Check if it's a TOTP URI
if (secretKey.toLowerCase().startsWith('otpauth://totp/')) {
try {
const uri = OTPAuth.URI.parse(secretKey);
if (uri instanceof OTPAuth.TOTP) {
secretKey = uri.secret.base32;
// If name is empty, use the label from the URI
if (!name && uri.label) {
name = uri.label;
}
}
} catch {
throw new Error(t('totp.errors.invalidSecretKey'));
}
}
// Remove spaces from the secret key
secretKey = secretKey.replace(/\s/g, '');
// Validate the secret key format (base32)
if (!/^[A-Z2-7]+=*$/i.test(secretKey)) {
throw new Error(t('totp.errors.invalidSecretKey'));
}
return { secretKey, name: name || 'Authenticator' };
};
/**
* Shows the add form
*/
const showAddForm = (): void => {
onStateChange({
isAddFormVisible: true,
formData: { name: '', secretKey: '' }
});
setFormError(null);
};
/**
* Hides the add form
*/
const hideAddForm = (): void => {
onStateChange({
isAddFormVisible: false,
formData: { name: '', secretKey: '' }
});
setFormError(null);
};
/**
* Updates form data
*/
const updateFormData = (updates: Partial<TotpFormData>): void => {
onStateChange({
isAddFormVisible,
formData: { ...formData, ...updates }
});
};
/**
* Handles adding a new TOTP code
*/
const handleAddTotpCode = (e?: React.MouseEvent | React.KeyboardEvent): void => {
e?.preventDefault();
setFormError(null);
// Validate required fields
if (!formData.secretKey) {
setFormError(t('credentials.validation.required'));
return;
}
try {
// Sanitize the secret key
const { secretKey, name } = sanitizeSecretKey(formData.secretKey, formData.name);
// Create new TOTP code
const newTotpCode: TotpCode = {
Id: crypto.randomUUID().toUpperCase(),
Name: name,
SecretKey: secretKey,
CredentialId: '' // Will be set when saving the credential
};
// Add to the list
const updatedTotpCodes = [...totpCodes, newTotpCode];
onTotpCodesChange(updatedTotpCodes);
// Hide the form
hideAddForm();
} catch (error) {
if (error instanceof Error) {
setFormError(error.message);
} else {
setFormError(t('common.errors.unknownErrorTryAgain'));
}
}
};
/**
* Initiates the delete process for a TOTP code
*/
const deleteTotpCode = (totpToDelete: TotpCode): void => {
// Check if this TOTP code was part of the original set
const wasOriginal = originalTotpCodeIds.includes(totpToDelete.Id);
let updatedTotpCodes: TotpCode[];
if (wasOriginal) {
// Mark as deleted (soft delete for syncing)
updatedTotpCodes = totpCodes.map(tc =>
tc.Id === totpToDelete.Id
? { ...tc, IsDeleted: true }
: tc
);
} else {
// Hard delete (remove from array)
updatedTotpCodes = totpCodes.filter(tc => tc.Id !== totpToDelete.Id);
}
onTotpCodesChange(updatedTotpCodes);
};
// Filter out deleted TOTP codes for display
const activeTotpCodes = totpCodes.filter(tc => !tc.IsDeleted);
const hasActiveTotpCodes = activeTotpCodes.length > 0;
return (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('common.twoFactorAuthentication')}
</h2>
{hasActiveTotpCodes && !isAddFormVisible && (
<button
type="button"
onClick={showAddForm}
className="w-8 h-8 flex items-center justify-center text-primary-700 hover:text-white border border-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg dark:border-primary-500 dark:text-primary-500 dark:hover:text-white dark:hover:bg-primary-600 dark:focus:ring-primary-800"
title={t('totp.addCode')}
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
)}
</div>
{!hasActiveTotpCodes && !isAddFormVisible && (
<button
type="button"
onClick={showAddForm}
className="w-full py-1.5 px-4 flex items-center justify-center gap-2 text-primary-700 hover:text-white border border-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg dark:border-primary-500 dark:text-primary-500 dark:hover:text-white dark:hover:bg-primary-600 dark:focus:ring-primary-800"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span>{t('totp.addCode')}</span>
</button>
)}
{isAddFormVisible && (
<div className="p-4 mb-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div className="flex justify-between items-center mb-4">
<h4 className="text-lg font-medium text-gray-900 dark:text-white">
{t('totp.addCode')}
</h4>
<button
type="button"
onClick={hideAddForm}
className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
</button>
</div>
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">
{t('totp.instructions')}
</p>
{formError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-900/20 dark:border-red-800">
<p className="text-sm text-red-800 dark:text-red-200">{formError}</p>
</div>
)}
<div className="mb-4">
<label htmlFor="totp-name" className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
{t('totp.nameOptional')}
</label>
<input
id="totp-name"
type="text"
value={formData.name}
onChange={(e) => updateFormData({ name: e.target.value })}
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
/>
</div>
<div className="mb-4">
<label htmlFor="totp-secret" className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
{t('totp.secretKey')}
</label>
<input
id="totp-secret"
type="text"
value={formData.secretKey}
onChange={(e) => updateFormData({ secretKey: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTotpCode(e);
}
}}
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"
/>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={(e) => handleAddTotpCode(e)}
className="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
{t('common.save')}
</button>
</div>
</div>
)}
{hasActiveTotpCodes && (
<div className="grid grid-cols-1 gap-4 mt-4">
{activeTotpCodes.map(totpCode => (
<div
key={totpCode.Id}
className="p-2 ps-3 pe-3 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600"
>
<div className="flex justify-between items-center gap-2">
<div className="flex items-center flex-1">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">
{totpCode.Name}
</h4>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-col items-end">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t('totp.saveToViewCode')}
</div>
</div>
<button
type="button"
onClick={() => deleteTotpCode(totpCode)}
className="text-red-600 hover:text-red-800 dark:text-red-500 dark:hover:text-red-400"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd"></path>
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default TotpEditor;

View File

@@ -9,6 +9,7 @@ import { sendMessage } from 'webext-bridge/popup';
import * as Yup from 'yup';
import AttachmentUploader from '@/entrypoints/popup/components/Credentials/Details/AttachmentUploader';
import TotpEditor from '@/entrypoints/popup/components/Credentials/Details/TotpEditor';
import Modal from '@/entrypoints/popup/components/Dialogs/Modal';
import EmailDomainField from '@/entrypoints/popup/components/Forms/EmailDomainField';
import { FormInput } from '@/entrypoints/popup/components/Forms/FormInput';
@@ -25,7 +26,7 @@ import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
import { SKIP_FORM_RESTORE_KEY } from '@/utils/Constants';
import { IdentityHelperUtils, CreateIdentityGenerator, CreateUsernameEmailGenerator, Identity, Gender, convertAgeRangeToBirthdateOptions } from '@/utils/dist/shared/identity-generator';
import type { Attachment, Credential } from '@/utils/dist/shared/models/vault';
import type { Attachment, Credential, TotpCode } from '@/utils/dist/shared/models/vault';
import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator';
import { ServiceDetectionUtility } from '@/utils/serviceDetection/ServiceDetectionUtility';
@@ -38,6 +39,13 @@ type PersistedFormData = {
credentialId: string | null;
mode: CredentialMode;
formValues: Omit<Credential, 'Logo'> & { Logo?: string | null };
totpEditorState?: {
isAddFormVisible: boolean;
formData: {
name: string;
secretKey: string;
};
};
}
/**
@@ -92,6 +100,15 @@ const CredentialAddEdit: React.FC = () => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [originalAttachmentIds, setOriginalAttachmentIds] = useState<string[]>([]);
const [totpCodes, setTotpCodes] = useState<TotpCode[]>([]);
const [originalTotpCodeIds, setOriginalTotpCodeIds] = useState<string[]>([]);
const [totpEditorState, setTotpEditorState] = useState<{
isAddFormVisible: boolean;
formData: { name: string; secretKey: string };
}>({
isAddFormVisible: false,
formData: { name: '', secretKey: '' }
});
const [passkeyMarkedForDeletion, setPasskeyMarkedForDeletion] = useState(false);
const webApi = useWebApi();
@@ -141,19 +158,20 @@ const CredentialAddEdit: React.FC = () => {
formValues: {
...formValues,
Logo: null // Don't persist the Logo field as it can't be user modified in the UI.
}
},
totpEditorState
};
await sendMessage('PERSIST_FORM_VALUES', JSON.stringify(persistedData), 'background');
}, [watch, id, mode, localLoading]);
}, [watch, id, mode, localLoading, totpEditorState]);
/**
* Watch for mode changes and persist form values
* Watch for mode and totpEditorState changes and persist form values
*/
useEffect(() => {
if (!localLoading) {
void persistFormValues();
}
}, [mode, persistFormValues, localLoading]);
}, [mode, totpEditorState, persistFormValues, localLoading]);
// Watch for form changes and persist them
useEffect(() => {
@@ -200,6 +218,11 @@ const CredentialAddEdit: React.FC = () => {
Object.entries(persistedDataObject.formValues).forEach(([key, value]) => {
setValue(key as keyof Credential, value as Credential[keyof Credential]);
});
// Restore TOTP editor state if it exists
if (persistedDataObject.totpEditorState) {
setTotpEditorState(persistedDataObject.totpEditorState);
}
} else {
console.error('Persisted values do not match current page');
}
@@ -330,6 +353,11 @@ const CredentialAddEdit: React.FC = () => {
setAttachments(credentialAttachments);
setOriginalAttachmentIds(credentialAttachments.map(a => a.Id));
// Load TOTP codes for this credential
const credentialTotpCodes = dbContext.sqliteClient.getTotpCodesForCredential(id);
setTotpCodes(credentialTotpCodes);
setOriginalTotpCodeIds(credentialTotpCodes.map(tc => tc.Id));
setMode('manual');
setIsInitialLoading(false);
@@ -571,14 +599,14 @@ const CredentialAddEdit: React.FC = () => {
setLocalLoading(false);
if (isEditMode) {
await dbContext.sqliteClient!.updateCredentialById(data, originalAttachmentIds, attachments);
await dbContext.sqliteClient!.updateCredentialById(data, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes);
// Delete passkeys if marked for deletion
if (passkeyMarkedForDeletion) {
await dbContext.sqliteClient!.deletePasskeysByCredentialId(data.Id);
}
} else {
const credentialId = await dbContext.sqliteClient!.createCredential(data, attachments);
const credentialId = await dbContext.sqliteClient!.createCredential(data, attachments, totpCodes);
data.Id = credentialId.toString();
}
}, {
@@ -597,7 +625,7 @@ const CredentialAddEdit: React.FC = () => {
}
},
});
}, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias, webApi, clearPersistedValues, originalAttachmentIds, attachments, passkeyMarkedForDeletion]);
}, [isEditMode, dbContext.sqliteClient, executeVaultMutation, navigate, mode, watch, generateRandomAlias, webApi, clearPersistedValues, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes, passkeyMarkedForDeletion]);
// Set header buttons on mount and clear on unmount
useEffect((): (() => void) => {
@@ -974,6 +1002,15 @@ const CredentialAddEdit: React.FC = () => {
</div>
</div>
<TotpEditor
totpCodes={totpCodes}
onTotpCodesChange={setTotpCodes}
originalTotpCodeIds={originalTotpCodeIds}
isAddFormVisible={totpEditorState.isAddFormVisible}
formData={totpEditorState.formData}
onStateChange={setTotpEditorState}
/>
<AttachmentUploader
attachments={attachments}
onAttachmentsChange={setAttachments}

View File

@@ -57,6 +57,7 @@
"next": "Next",
"use": "Use",
"delete": "Delete",
"save": "Save",
"or": "Or",
"close": "Close",
"copied": "Copied!",
@@ -240,6 +241,16 @@
"enterFullEmail": "Enter full email address",
"enterEmailPrefix": "Enter email prefix"
},
"totp": {
"addCode": "Add 2FA Code",
"instructions": "Enter the secret key shown by the website where you want to add two-factor authentication.",
"nameOptional": "Name (optional)",
"secretKey": "Secret Key",
"saveToViewCode": "Save to view code",
"errors": {
"invalidSecretKey": "Invalid secret key format."
}
},
"emails": {
"title": "Emails",
"deleteEmailTitle": "Delete Email",

View File

@@ -506,7 +506,7 @@ export class SqliteClient {
* @param attachments The attachments to insert
* @returns The ID of the created credential
*/
public async createCredential(credential: Credential, attachments: Attachment[]): Promise<string> {
public async createCredential(credential: Credential, attachments: Attachment[], totpCodes: TotpCode[] = []): Promise<string> {
if (!this.db) {
throw new Error('Database not initialized');
}
@@ -617,6 +617,30 @@ export class SqliteClient {
}
}
// 6. Insert TOTP codes
if (totpCodes) {
for (const totpCode of totpCodes) {
// Skip deleted codes
if (totpCode.IsDeleted) {
continue;
}
const totpCodeQuery = `
INSERT INTO TotpCodes (Id, Name, SecretKey, CredentialId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`;
this.executeUpdate(totpCodeQuery, [
totpCode.Id || crypto.randomUUID().toUpperCase(),
totpCode.Name,
totpCode.SecretKey,
credentialId,
currentDateTime,
currentDateTime,
0
]);
}
}
await this.commitTransaction();
return credentialId;
@@ -857,7 +881,7 @@ export class SqliteClient {
* @param attachments The attachments to update
* @returns The number of rows modified
*/
public async updateCredentialById(credential: Credential, originalAttachmentIds: string[], attachments: Attachment[]): Promise<number> {
public async updateCredentialById(credential: Credential, originalAttachmentIds: string[], attachments: Attachment[], originalTotpCodeIds: string[] = [], totpCodes: TotpCode[] = []): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
@@ -1038,6 +1062,76 @@ export class SqliteClient {
}
}
// 6. Handle TOTP codes
if (totpCodes) {
// Get current TOTP code IDs (excluding deleted ones)
const currentTotpCodeIds = totpCodes
.filter(tc => !tc.IsDeleted)
.map(tc => tc.Id);
// Mark TOTP codes as deleted that were removed
const totpCodesToDelete = originalTotpCodeIds.filter(id => !currentTotpCodeIds.includes(id));
for (const totpCodeId of totpCodesToDelete) {
const deleteQuery = `
UPDATE TotpCodes
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = ?`;
this.executeUpdate(deleteQuery, [currentDateTime, totpCodeId]);
}
// Handle TOTP codes marked for deletion in the array
const markedForDeletion = totpCodes.filter(tc => tc.IsDeleted && originalTotpCodeIds.includes(tc.Id));
for (const totpCode of markedForDeletion) {
const deleteQuery = `
UPDATE TotpCodes
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = ?`;
this.executeUpdate(deleteQuery, [currentDateTime, totpCode.Id]);
}
// Process each TOTP code
for (const totpCode of totpCodes) {
// Skip deleted codes
if (totpCode.IsDeleted) {
continue;
}
const isExistingTotpCode = originalTotpCodeIds.includes(totpCode.Id);
if (!isExistingTotpCode) {
// Insert new TOTP code
const insertQuery = `
INSERT INTO TotpCodes (Id, Name, SecretKey, CredentialId, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?)`;
this.executeUpdate(insertQuery, [
totpCode.Id || crypto.randomUUID().toUpperCase(),
totpCode.Name,
totpCode.SecretKey,
credential.Id,
currentDateTime,
currentDateTime,
0
]);
} else {
// Update existing TOTP code
const updateQuery = `
UPDATE TotpCodes
SET Name = ?,
SecretKey = ?,
UpdatedAt = ?
WHERE Id = ?`;
this.executeUpdate(updateQuery, [
totpCode.Name,
totpCode.SecretKey,
currentDateTime,
totpCode.Id
]);
}
}
}
await this.commitTransaction();
return 1;

View File

@@ -50,6 +50,8 @@ type TotpCode = {
SecretKey: string;
/** The credential ID this TOTP code belongs to */
CredentialId: string;
/** Whether the TOTP code has been deleted (soft delete) */
IsDeleted?: boolean;
};
/**

View File

@@ -13,4 +13,7 @@ export type TotpCode = {
/** The credential ID this TOTP code belongs to */
CredentialId: string;
/** Whether the TOTP code has been deleted (soft delete) */
IsDeleted?: boolean;
}