This commit is contained in:
Janek Bevendorff 2026-03-09 02:23:31 +01:00 committed by GitHub
commit ca8a56d19d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1223 additions and 690 deletions

View file

@ -17,9 +17,11 @@
import argparse import argparse
from collections import defaultdict
import ctypes import ctypes
from datetime import datetime from datetime import datetime
import hashlib import hashlib
import json
import logging import logging
import lzma import lzma
import os import os
@ -33,7 +35,8 @@ import subprocess
import sys import sys
import tarfile import tarfile
import tempfile import tempfile
from urllib.request import urlretrieve from urllib import request
from xml import sax
########################################################################################### ###########################################################################################
@ -447,6 +450,7 @@ class Check(Command):
if checkout: if checkout:
_git_checkout(git_ref, cwd=src_dir) _git_checkout(git_ref, cwd=src_dir)
logger.debug('Attempting to find "%s" version string in source files...', version) logger.debug('Attempting to find "%s" version string in source files...', version)
cls.check_version_in_vcpkg_manifest(version, src_dir)
cls.check_version_in_cmake(version, src_dir) cls.check_version_in_cmake(version, src_dir)
cls.check_changelog(version, src_dir) cls.check_changelog(version, src_dir)
cls.check_app_stream_info(version, src_dir) cls.check_app_stream_info(version, src_dir)
@ -460,32 +464,32 @@ class Check(Command):
raise Error(f'Source directory "{src_dir}" does not exist!') raise Error(f'Source directory "{src_dir}" does not exist!')
@staticmethod @staticmethod
def check_git_repository(cwd): def check_git_repository(cwd=None):
if _run(['git', 'rev-parse', '--is-inside-work-tree'], check=False, cwd=cwd).returncode != 0: if _run(['git', 'rev-parse', '--is-inside-work-tree'], check=False, cwd=cwd).returncode != 0:
raise Error('Not a valid Git repository: %s', cwd) raise Error('Not a valid Git repository: %s', cwd)
@staticmethod @staticmethod
def check_release_exists(tag_name, cwd): def check_release_exists(tag_name, cwd=None):
if not _run(['git', 'tag', '--list', tag_name], check=False, cwd=cwd).stdout: if not _run(['git', 'tag', '--list', tag_name], check=False, cwd=cwd).stdout:
raise Error('Release tag does not exists: %s', tag_name) raise Error('Release tag does not exists: %s', tag_name)
@staticmethod @staticmethod
def check_release_does_not_exist(tag_name, cwd): def check_release_does_not_exist(tag_name, cwd=None):
if _run(['git', 'tag', '--list', tag_name], check=False, cwd=cwd).stdout: if _run(['git', 'tag', '--list', tag_name], check=False, cwd=cwd).stdout:
raise Error('Release tag already exists: %s', tag_name) raise Error('Release tag already exists: %s', tag_name)
@staticmethod @staticmethod
def check_working_tree_clean(cwd): def check_working_tree_clean(cwd=None):
if not _git_working_dir_clean(cwd=cwd): if not _git_working_dir_clean(cwd=cwd):
raise Error('Current working tree is not clean! Please commit or unstage any changes.') raise Error('Current working tree is not clean! Please commit or unstage any changes.')
@staticmethod @staticmethod
def check_branch_exists(branch, cwd): def check_branch_exists(branch, cwd=None):
if _run(['git', 'rev-parse', branch], check=False, cwd=cwd).returncode != 0: if _run(['git', 'rev-parse', branch], check=False, cwd=cwd).returncode != 0:
raise Error(f'Branch or tag "{branch}" does not exist!') raise Error(f'Branch or tag "{branch}" does not exist!')
@staticmethod @staticmethod
def check_version_in_cmake(version, cwd): def check_version_in_cmake(version, cwd=None):
cmakelists = Path('CMakeLists.txt') cmakelists = Path('CMakeLists.txt')
if cwd: if cwd:
cmakelists = Path(cwd) / cmakelists cmakelists = Path(cwd) / cmakelists
@ -500,7 +504,17 @@ class Check(Command):
raise Error(f'Version number in {cmakelists} not updated! Expected: %s, found: %s.', version, cmake_version) raise Error(f'Version number in {cmakelists} not updated! Expected: %s, found: %s.', version, cmake_version)
@staticmethod @staticmethod
def check_changelog(version, cwd): def check_version_in_vcpkg_manifest(version, cwd=None):
manifest = Path('vcpkg.json')
if cwd:
manifest = Path(cwd) / manifest
manifest_json = json.load(manifest.open('r'))
if version != manifest_json['version-string']:
raise Error(f'Version number in {manifest} not updated! Expected: %s, found: %s.',
version, manifest_json['version-string'])
@staticmethod
def check_changelog(version, cwd=None):
changelog = Path('CHANGELOG.md') changelog = Path('CHANGELOG.md')
if cwd: if cwd:
changelog = Path(cwd) / changelog changelog = Path(cwd) / changelog
@ -511,12 +525,21 @@ class Check(Command):
raise Error(f'{changelog} has not been updated to the "%s" release.', version) raise Error(f'{changelog} has not been updated to the "%s" release.', version)
@staticmethod @staticmethod
def check_app_stream_info(version, cwd): def check_app_stream_info(version, cwd=None):
appstream = Path('share/linux/org.keepassxc.KeePassXC.appdata.xml') appstream = Path('share/linux/org.keepassxc.KeePassXC.appdata.xml')
if cwd: if cwd:
appstream = Path(cwd) / appstream appstream = Path(cwd) / appstream
if not appstream.is_file(): if not appstream.is_file():
raise Error('File not found: %s', appstream) raise Error('File not found: %s', appstream)
try:
parser = sax.make_parser()
parser.setContentHandler(sax.handler.ContentHandler())
parser.parse(appstream)
except sax.SAXParseException as e:
raise Error(f'{appstream} is not well-formed. Error: %s at line %s, column %s',
e.getMessage(), e.getLineNumber(), e.getColumnNumber())
regex = re.compile(rf'^\s*<release version="{version}" date=".+?">') regex = re.compile(rf'^\s*<release version="{version}" date=".+?">')
with appstream.open('r', encoding='utf-8') as f: with appstream.open('r', encoding='utf-8') as f:
for line in f: for line in f:
@ -572,8 +595,7 @@ class Tag(Command):
# Update translations # Update translations
if not skip_translations: if not skip_translations:
i18n = I18N(self._arg_parser) i18n = I18N(self._arg_parser)
i18n.run_tx_pull(src_dir, i18n.derive_resource_name(tx_resource, cwd=src_dir), tx_min_perc, i18n.run_tx_pull(src_dir, tx_resource, tx_min_perc, commit=True, yes=yes)
commit=True, yes=yes)
changelog = re.search(rf'^## ({major}\.{minor}\.{patch} \(.*?\)\n\n+.+?)\n\n+## ', changelog = re.search(rf'^## ({major}\.{minor}\.{patch} \(.*?\)\n\n+.+?)\n\n+## ',
(Path(src_dir) / 'CHANGELOG.md').read_text("UTF-8"), re.MULTILINE | re.DOTALL) (Path(src_dir) / 'CHANGELOG.md').read_text("UTF-8"), re.MULTILINE | re.DOTALL)
@ -808,7 +830,7 @@ class Build(Command):
if _run(['which', toolname], cwd=None, check=False, **(docker_args or {})).returncode != 0: if _run(['which', toolname], cwd=None, check=False, **(docker_args or {})).returncode != 0:
logger.info(f'Downloading {toolname}...') logger.info(f'Downloading {toolname}...')
outfile = bin_dir / toolname outfile = bin_dir / toolname
urlretrieve(url, outfile) request.urlretrieve(url, outfile)
outfile.chmod(outfile.stat().st_mode | stat.S_IEXEC) outfile.chmod(outfile.stat().st_mode | stat.S_IEXEC)
def build_linux(self, version, src_dir, output_dir, *, install_prefix, parallelism, cmake_opts, use_system_deps, def build_linux(self, version, src_dir, output_dir, *, install_prefix, parallelism, cmake_opts, use_system_deps,
@ -1048,7 +1070,7 @@ class GPGSign(Command):
class I18N(Command): class I18N(Command):
"""Update translation files and pull from or push to Transifex.""" """Update translation files and pull from or push to Transifex."""
TRANSIFEX_RESOURCE = 'keepassxc.share-translations-keepassxc-en-ts--{}' TRANSIFEX_RESOURCE = 'share-translations-keepassxc-en-ts--{}'
TRANSIFEX_PULL_PERC = 60 TRANSIFEX_PULL_PERC = 60
@classmethod @classmethod
@ -1070,6 +1092,15 @@ class I18N(Command):
pull.add_argument('-y', '--yes', help='Don\'t ask before pulling translations.', action='store_true') pull.add_argument('-y', '--yes', help='Don\'t ask before pulling translations.', action='store_true')
pull.add_argument('tx_args', help='Additional arguments to pass to tx subcommand.', nargs=argparse.REMAINDER) pull.add_argument('tx_args', help='Additional arguments to pass to tx subcommand.', nargs=argparse.REMAINDER)
list_translators = subparsers.add_parser('tx-list-translators',
help='Print a HTML-formatted list of translation contributors.')
list_translators.add_argument('-o', '--org', help='Transifex org name.', default='keepassxc')
list_translators.add_argument('-p', '--project', help='Transifex project name.', default='keepassxc')
list_translators.add_argument('-r', '--resource', help='Transifex resource name.',
choices=['master', 'develop'])
list_translators.add_argument('-b', '--member-blacklist', nargs='+', help='Transifex users to ignore',
default=['phoerious', 'droidmonkey'])
lupdate = subparsers.add_parser('lupdate', help='Update source translation file from C++ sources.') lupdate = subparsers.add_parser('lupdate', help='Update source translation file from C++ sources.')
lupdate.add_argument('-d', '--build-dir', help='Build directory for looking up lupdate binary.') lupdate.add_argument('-d', '--build-dir', help='Build directory for looking up lupdate binary.')
lupdate.add_argument('-c', '--commit', help='Commit changes.', action='store_true') lupdate.add_argument('-c', '--commit', help='Commit changes.', action='store_true')
@ -1112,12 +1143,15 @@ class I18N(Command):
self.check_transifex_cmd_exists() self.check_transifex_cmd_exists()
self.check_transifex_config_exists(src_dir) self.check_transifex_config_exists(src_dir)
kwargs['resource'] = self.derive_resource_name(kwargs['resource'], cwd=src_dir) if 'tx_args' in kwargs:
kwargs['tx_args'] = kwargs['tx_args'][1:] kwargs['tx_args'] = kwargs['tx_args'][1:]
if subcmd == 'tx-push': if subcmd == 'tx-push':
self.run_tx_push(src_dir, **kwargs) self.run_tx_push(src_dir, **kwargs)
elif subcmd == 'tx-pull': elif subcmd == 'tx-pull':
self.run_tx_pull(src_dir, **kwargs) self.run_tx_pull(src_dir, **kwargs)
elif subcmd == 'tx-list-translators':
self.run_tx_list_translators(src_dir, kwargs['org'], kwargs['project'], kwargs['resource'],
kwargs['member_blacklist'])
elif subcmd == 'lupdate': elif subcmd == 'lupdate':
kwargs['lupdate_args'] = kwargs['lupdate_args'][1:] kwargs['lupdate_args'] = kwargs['lupdate_args'][1:]
@ -1137,6 +1171,7 @@ class I18N(Command):
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
def run_tx_push(self, src_dir, resource, yes, tx_args): def run_tx_push(self, src_dir, resource, yes, tx_args):
resource = 'keepassxc.' + self.derive_resource_name(resource, cwd=src_dir)
sys.stderr.write('\nAbout to push the ' + fmt.bold('"en"') + sys.stderr.write('\nAbout to push the ' + fmt.bold('"en"') +
' source file from the current branch to Transifex:\n') ' source file from the current branch to Transifex:\n')
sys.stderr.write(f' {fmt.bold(_git_get_branch(cwd=src_dir))}' sys.stderr.write(f' {fmt.bold(_git_get_branch(cwd=src_dir))}'
@ -1151,6 +1186,7 @@ class I18N(Command):
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
def run_tx_pull(self, src_dir, resource, min_perc, commit=False, yes=False, tx_args=None): def run_tx_pull(self, src_dir, resource, min_perc, commit=False, yes=False, tx_args=None):
resource = 'keepassxc.' + self.derive_resource_name(resource, cwd=src_dir)
sys.stderr.write('\nAbout to pull translations for ' + fmt.bold(f'"{resource}"') + '.\n') sys.stderr.write('\nAbout to pull translations for ' + fmt.bold(f'"{resource}"') + '.\n')
if not yes and not _yes_no_prompt('Continue?'): if not yes and not _yes_no_prompt('Continue?'):
logger.error('Pull aborted.') logger.error('Pull aborted.')
@ -1164,6 +1200,71 @@ class I18N(Command):
if commit: if commit:
_git_commit_files(files, 'Update translations.', cwd=src_dir) _git_commit_files(files, 'Update translations.', cwd=src_dir)
# noinspection PyMethodMayBeStatic
def run_tx_list_translators(self, src_dir, org, project, resource, member_blacklist):
txrc = Path.home() / '.transifexrc'
if not txrc.exists():
raise Error('No Transifex config found. Run tx init first.')
org = f'o:{org}'
project = f'{org}:p:{project}'
resource = f'{project}:r:{self.derive_resource_name(resource, cwd=src_dir)}'
token = [l for l in open(txrc, 'r') if l.startswith('token')][0].split('=', 1)[1].strip()
member_blacklist = [f'u:{m}' for m in member_blacklist]
def get_url(url):
req = request.Request(url)
req.add_header('Content-Type', 'application/vnd.api+json')
req.add_header('Authorization', f'Bearer {token}')
with request.urlopen(req) as resp:
return json.load(resp)
logger.info('Fetching languages...',)
languages_json = get_url(f'https://rest.api.transifex.com/projects/{project}/languages')
languages = {}
for lang in languages_json['data']:
languages[lang['id']] = lang['attributes']['name']
logger.info('Fetching language stats...')
language_stats_json = get_url('https://rest.api.transifex.com/resource_language_stats?'
f'filter[project]={project}&filter[resource]={resource}')
for s in language_stats_json['data']:
completion = s['attributes']['translated_strings'] / s['attributes']['total_strings']
if completion < .6:
languages.pop(s['relationships']['language']['data']['id'])
logger.info('Fetching language members...')
members_json = get_url(f'https://rest.api.transifex.com/team_memberships?filter[organization]={org}')
members = defaultdict(set)
for member in members_json['data']:
print('.', end='', file=sys.stderr)
sys.stderr.flush()
if member['relationships']['user']['data']['id'] in member_blacklist:
continue
lid = member['relationships']['language']['data']['id']
if lid not in languages:
continue
user = get_url(member['relationships']['user']['links']['related'])['data']['attributes']['username']
members[lid].add(user)
print(file=sys.stderr, flush=True)
print('<ul>')
for lang in sorted(languages, key=lambda x: languages[x]):
if not members[lang]:
continue
lines = [f' <li><strong>{languages[lang]}:</strong> ']
for i, m in enumerate(sorted(members[lang], key=lambda x: x.lower())):
if len(lines[-1]) + len(m) >= 120:
lines.append(' ')
lines[-1] += m
if i < len(members[lang]) - 1:
lines[-1] += ', '
lines[-1] += '</li>'
print('\n'.join(lines))
print('</ul>')
logger.info('Done. Please add the list to the About dialog and commit the changes.')
def run_lupdate(self, src_dir, build_dir=None, commit=False, lupdate_args=None): def run_lupdate(self, src_dir, build_dir=None, commit=False, lupdate_args=None):
path = _get_bin_path(build_dir) path = _get_bin_path(build_dir)
self.check_lupdate_exists(path) self.check_lupdate_exists(path)

View file

@ -115,6 +115,8 @@ namespace
{"f14", Qt::Key_F14}, {"f14", Qt::Key_F14},
{"f15", Qt::Key_F15}, {"f15", Qt::Key_F15},
{"f16", Qt::Key_F16}}; {"f16", Qt::Key_F16}};
constexpr int s_minWaitDelay = 100; // 100 ms
constexpr int s_maxWaitDelay = 10000; // 10 seconds
} // namespace } // namespace
AutoType* AutoType::m_instance = nullptr; AutoType* AutoType::m_instance = nullptr;
@ -312,6 +314,9 @@ void AutoType::executeAutoTypeActions(const Entry* entry,
// Restore executor mode // Restore executor mode
m_executor->mode = mode; m_executor->mode = mode;
// Initial Auto-Type delay to allow window to come to foreground
Tools::wait(qBound(s_minWaitDelay, config()->get(Config::AutoTypeStartDelay).toInt(), s_maxWaitDelay));
// Grab the current active window after everything settles // Grab the current active window after everything settles
if (window == 0) { if (window == 0) {
window = m_plugin->activeWindow(); window = m_plugin->activeWindow();
@ -543,16 +548,16 @@ AutoType::parseSequence(const QString& entrySequence, const Entry* entry, QStrin
} }
const int maxTypeDelay = 500; const int maxTypeDelay = 500;
const int maxWaitDelay = 10000;
const int maxRepetition = 100; const int maxRepetition = 100;
int currentTypingDelay = qBound(0, config()->get(Config::AutoTypeDelay).toInt(), maxTypeDelay); int currentTypingDelay = qBound(0, config()->get(Config::AutoTypeDelay).toInt(), maxTypeDelay);
int cumulativeDelay = qBound(0, config()->get(Config::AutoTypeStartDelay).toInt(), maxWaitDelay); // Take into account the initial delay which is added before any actions are performed
int cumulativeDelay = qBound(s_minWaitDelay, config()->get(Config::AutoTypeStartDelay).toInt(), s_maxWaitDelay);
// Initial actions include start delay and initial inter-key delay // Initial actions include start delay and initial inter-key delay
QList<QSharedPointer<AutoTypeAction>> actions; QList<QSharedPointer<AutoTypeAction>> actions;
actions << QSharedPointer<AutoTypeBegin>::create(); actions << QSharedPointer<AutoTypeBegin>::create();
actions << QSharedPointer<AutoTypeDelay>::create(currentTypingDelay, true); actions << QSharedPointer<AutoTypeDelay>::create(currentTypingDelay, true);
actions << QSharedPointer<AutoTypeDelay>::create(cumulativeDelay);
// Replace escaped braces with a template for easier regex // Replace escaped braces with a template for easier regex
QString sequence = entrySequence; QString sequence = entrySequence;
@ -631,12 +636,12 @@ AutoType::parseSequence(const QString& entrySequence, const Entry* entry, QStrin
actions << QSharedPointer<AutoTypeDelay>::create(qBound(0, delay, maxTypeDelay), true); actions << QSharedPointer<AutoTypeDelay>::create(qBound(0, delay, maxTypeDelay), true);
} else if (placeholder == "delay") { } else if (placeholder == "delay") {
// Mid typing delay (wait), repeat represents the desired delay in milliseconds // Mid typing delay (wait), repeat represents the desired delay in milliseconds
if (repeat > maxWaitDelay) { if (repeat > s_maxWaitDelay) {
error = tr("Very long delay detected, max is %1: %2").arg(maxWaitDelay).arg(fullPlaceholder); error = tr("Very long delay detected, max is %1: %2").arg(s_maxWaitDelay).arg(fullPlaceholder);
return {}; return {};
} }
cumulativeDelay += repeat; cumulativeDelay += repeat;
actions << QSharedPointer<AutoTypeDelay>::create(qBound(0, repeat, maxWaitDelay)); actions << QSharedPointer<AutoTypeDelay>::create(qBound(0, repeat, s_maxWaitDelay));
} else if (placeholder == "clearfield") { } else if (placeholder == "clearfield") {
// Platform-specific field clearing // Platform-specific field clearing
actions << QSharedPointer<AutoTypeClearField>::create(); actions << QSharedPointer<AutoTypeClearField>::create();

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org> * Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2013 Francois Ferrand * Copyright (C) 2013 Francois Ferrand
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
@ -53,55 +53,72 @@ void BrowserAccessControlDialog::setEntries(const QList<Entry*>& entriesToConfir
QUrl url(urlString); QUrl url(urlString);
m_ui->siteLabel->setText(m_ui->siteLabel->text().arg( m_ui->siteLabel->setText(m_ui->siteLabel->text().arg(
url.toDisplayString(QUrl::RemoveUserInfo | QUrl::RemovePath | QUrl::RemoveQuery | QUrl::RemoveFragment))); url.toDisplayString(QUrl::RemoveUserInfo | QUrl::RemovePath | QUrl::RemoveQuery | QUrl::RemoveFragment)));
m_ui->siteLabel->setToolTip(urlString);
m_ui->rememberDecisionCheckBox->setVisible(!httpAuth); m_ui->rememberDecisionCheckBox->setVisible(!httpAuth);
m_ui->rememberDecisionCheckBox->setChecked(false); m_ui->rememberDecisionCheckBox->setChecked(false);
m_ui->itemsTable->setRowCount(entriesToConfirm.count()); m_ui->itemsTable->setRowCount(entriesToConfirm.count());
m_ui->itemsTable->setColumnCount(2); m_ui->itemsTable->setColumnCount(3);
int row = 0; int row = 0;
for (const auto& entry : entriesToConfirm) { for (const auto& entry : entriesToConfirm) {
addEntryToList(entry, row); addEntryToList(entry, row);
++row; ++row;
} }
m_ui->itemsTable->resizeColumnsToContents();
m_ui->itemsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); m_ui->itemsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch);
m_ui->itemsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);
m_ui->itemsTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::ResizeToContents);
m_ui->itemsTable->selectAll(); m_ui->itemsTable->selectAll();
m_ui->allowButton->setFocus(); m_ui->allowButton->setFocus();
} }
void BrowserAccessControlDialog::addEntryToList(Entry* entry, int row) void BrowserAccessControlDialog::addEntryToList(const Entry* entry, int row)
{ {
auto item = new QTableWidgetItem(); const auto titleItem = new QTableWidgetItem();
item->setText(entry->resolveMultiplePlaceholders(entry->title()) + " - " const auto entryTitle = entry->resolveMultiplePlaceholders(entry->title());
+ entry->resolveMultiplePlaceholders(entry->username())); const auto entryUrl = entry->resolveMultiplePlaceholders(entry->url());
item->setData(Qt::UserRole, row); titleItem->setText(entryTitle);
item->setFlags(item->flags() | Qt::ItemIsSelectable); titleItem->setToolTip(entryUrl);
m_ui->itemsTable->setItem(row, 0, item); titleItem->setData(Qt::UserRole, row);
titleItem->setFlags(titleItem->flags() | Qt::ItemIsSelectable);
m_ui->itemsTable->setItem(row, 0, titleItem);
const auto usernameItem = new QTableWidgetItem();
const auto entryUsername = entry->resolveMultiplePlaceholders(entry->username());
usernameItem->setText(entryUsername);
usernameItem->setData(Qt::UserRole, row);
m_ui->itemsTable->setItem(row, 1, usernameItem);
auto disableButton = new QPushButton(); auto disableButton = new QPushButton();
disableButton->setIcon(icons()->icon("entry-delete")); disableButton->setIcon(icons()->icon("entry-delete"));
disableButton->setToolTip(tr("Disable for this site")); disableButton->setToolTip(tr("Disable for this site"));
connect(disableButton, &QAbstractButton::pressed, [&, item, disableButton] { connect(disableButton, &QAbstractButton::pressed, [&, titleItem, usernameItem, disableButton] {
auto font = item->font(); auto font = titleItem->font();
if (item->flags() == Qt::NoItemFlags) { if (titleItem->flags() == Qt::NoItemFlags) {
item->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); titleItem->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable);
item->setSelected(true); usernameItem->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable);
titleItem->setSelected(true);
usernameItem->setSelected(true);
font.setStrikeOut(false); font.setStrikeOut(false);
item->setFont(font); titleItem->setFont(font);
usernameItem->setFont(font);
disableButton->setIcon(icons()->icon("entry-delete")); disableButton->setIcon(icons()->icon("entry-delete"));
disableButton->setToolTip(tr("Disable for this site")); disableButton->setToolTip(tr("Disable for this site"));
m_ui->rememberDecisionCheckBox->setEnabled(true); m_ui->rememberDecisionCheckBox->setEnabled(true);
} else { } else {
item->setFlags(Qt::NoItemFlags); titleItem->setFlags(Qt::NoItemFlags);
item->setSelected(false); usernameItem->setFlags(Qt::NoItemFlags);
titleItem->setSelected(false);
usernameItem->setSelected(false);
font.setStrikeOut(true); font.setStrikeOut(true);
item->setFont(font); titleItem->setFont(font);
usernameItem->setFont(font);
disableButton->setIcon(icons()->icon("entry-restore")); disableButton->setIcon(icons()->icon("entry-restore"));
disableButton->setToolTip(tr("Undo")); disableButton->setToolTip(tr("Undo"));
@ -112,7 +129,7 @@ void BrowserAccessControlDialog::addEntryToList(Entry* entry, int row)
} }
}); });
m_ui->itemsTable->setCellWidget(row, 1, disableButton); m_ui->itemsTable->setCellWidget(row, 2, disableButton);
} }
bool BrowserAccessControlDialog::remember() const bool BrowserAccessControlDialog::remember() const

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org> * Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2013 Francois Ferrand * Copyright (C) 2013 Francois Ferrand
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
@ -55,7 +55,7 @@ private slots:
void selectionChanged(); void selectionChanged();
private: private:
void addEntryToList(Entry* entry, int row); void addEntryToList(const Entry* entry, int row);
bool areAllDisabled() const; bool areAllDisabled() const;
QList<QTableWidgetItem*> getAllItems() const; QList<QTableWidgetItem*> getAllItems() const;

View file

@ -26,7 +26,7 @@
<string>%1 is requesting access to the following entries:</string> <string>%1 is requesting access to the following entries:</string>
</property> </property>
<property name="alignment"> <property name="alignment">
<set>Qt::AlignCenter</set> <set>Qt::AlignLeft</set>
</property> </property>
</widget> </widget>
</item> </item>

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org> * Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -79,7 +79,7 @@ PublicKeyCredential BrowserPasskeys::buildRegisterPublicKeyCredential(const QJso
// Credential private key // Credential private key
const auto alg = getAlgorithmFromPublicKey(credentialCreationOptions); const auto alg = getAlgorithmFromPublicKey(credentialCreationOptions);
const auto privateKey = buildCredentialPrivateKey(alg, testingVariables.first, testingVariables.second); const auto privateKey = buildCredentialPrivateKey(alg, testingVariables);
if (privateKey.cborEncodedPublicKey.isEmpty() && privateKey.privateKeyPem.isEmpty()) { if (privateKey.cborEncodedPublicKey.isEmpty() && privateKey.privateKeyPem.isEmpty()) {
// Key creation failed // Key creation failed
return {}; return {};
@ -103,6 +103,9 @@ PublicKeyCredential BrowserPasskeys::buildRegisterPublicKeyCredential(const QJso
// Additions for extension side functions // Additions for extension side functions
responseObject["authenticatorData"] = browserMessageBuilder()->getBase64FromArray(authenticatorData); responseObject["authenticatorData"] = browserMessageBuilder()->getBase64FromArray(authenticatorData);
// PublicKey
responseObject["publicKey"] = browserMessageBuilder()->getBase64FromArray(privateKey.spkiPublicKey);
responseObject["publicKeyAlgorithm"] = alg; responseObject["publicKeyAlgorithm"] = alg;
// PublicKeyCredential // PublicKeyCredential
@ -122,14 +125,16 @@ PublicKeyCredential BrowserPasskeys::buildRegisterPublicKeyCredential(const QJso
QJsonObject BrowserPasskeys::buildGetPublicKeyCredential(const QJsonObject& assertionOptions, QJsonObject BrowserPasskeys::buildGetPublicKeyCredential(const QJsonObject& assertionOptions,
const QString& credentialId, const QString& credentialId,
const QString& userHandle, const QString& userHandle,
const QString& privateKeyPem) const QString& privateKeyPem,
const bool beFlag,
const bool bsFlag)
{ {
if (!passkeyUtils()->checkCredentialAssertionOptions(assertionOptions)) { if (!passkeyUtils()->checkCredentialAssertionOptions(assertionOptions)) {
return {}; return {};
} }
const auto authenticatorData = const auto authenticatorData = buildAuthenticatorData(
buildAuthenticatorData(assertionOptions["rpId"].toString(), assertionOptions["extensions"].toString()); assertionOptions["rpId"].toString(), assertionOptions["extensions"].toString(), beFlag, bsFlag);
const auto clientDataJson = assertionOptions["clientDataJson"].toString(); const auto clientDataJson = assertionOptions["clientDataJson"].toString();
const auto clientDataArray = clientDataJson.toUtf8(); const auto clientDataArray = clientDataJson.toUtf8();
@ -168,8 +173,12 @@ QByteArray BrowserPasskeys::buildAttestationObject(const QJsonObject& credential
result.append(rpIdHash); result.append(rpIdHash);
// Use default flags // Use default flags
const auto flags = setFlagsFromJson(QJsonObject( const auto flags = setFlagsFromJson(QJsonObject({{"ED", !extensions.isEmpty()},
{{"ED", !extensions.isEmpty()}, {"AT", true}, {"BS", false}, {"BE", false}, {"UV", true}, {"UP", true}})); {"AT", true},
{"BS", DEFAULT_BS_FLAG},
{"BE", DEFAULT_BE_FLAG},
{"UV", true},
{"UP", true}}));
result.append(flags); result.append(flags);
// Signature counter (not supported, always 0 // Signature counter (not supported, always 0
@ -201,7 +210,10 @@ QByteArray BrowserPasskeys::buildAttestationObject(const QJsonObject& credential
} }
// Build a short version of the attestation object for webauthn.get // Build a short version of the attestation object for webauthn.get
QByteArray BrowserPasskeys::buildAuthenticatorData(const QString& rpId, const QString& extensions) QByteArray BrowserPasskeys::buildAuthenticatorData(const QString& rpId,
const QString& extensions,
const bool beFlag,
const bool bsFlag)
{ {
QByteArray result; QByteArray result;
@ -209,7 +221,7 @@ QByteArray BrowserPasskeys::buildAuthenticatorData(const QString& rpId, const QS
result.append(rpIdHash); result.append(rpIdHash);
const auto flags = setFlagsFromJson(QJsonObject( const auto flags = setFlagsFromJson(QJsonObject(
{{"ED", !extensions.isEmpty()}, {"AT", false}, {"BS", false}, {"BE", false}, {"UV", true}, {"UP", true}})); {{"ED", !extensions.isEmpty()}, {"AT", false}, {"BS", bsFlag}, {"BE", beFlag}, {"UV", true}, {"UP", true}}));
result.append(flags); result.append(flags);
// Signature counter (not supported, always 0 // Signature counter (not supported, always 0
@ -224,8 +236,7 @@ QByteArray BrowserPasskeys::buildAuthenticatorData(const QString& rpId, const QS
} }
// See: https://w3c.github.io/webauthn/#sctn-encoded-credPubKey-examples // See: https://w3c.github.io/webauthn/#sctn-encoded-credPubKey-examples
AttestationKeyPair AttestationKeyPair BrowserPasskeys::buildCredentialPrivateKey(int alg, const TestingVariables& testingVariables)
BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFirst, const QString& predefinedSecond)
{ {
// Only support -7, P256 (EC), -8 (EdDSA) and -257 (RSA) for now // Only support -7, P256 (EC), -8 (EdDSA) and -257 (RSA) for now
if (alg != WebAuthnAlgorithms::ES256 && alg != WebAuthnAlgorithms::RS256 && alg != WebAuthnAlgorithms::EDDSA) { if (alg != WebAuthnAlgorithms::ES256 && alg != WebAuthnAlgorithms::RS256 && alg != WebAuthnAlgorithms::EDDSA) {
@ -234,21 +245,31 @@ BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFir
QByteArray firstPart; QByteArray firstPart;
QByteArray secondPart; QByteArray secondPart;
QByteArray spki;
QByteArray pem; QByteArray pem;
if (!predefinedFirst.isEmpty() && !predefinedSecond.isEmpty()) { if (!testingVariables.first.isEmpty() && !testingVariables.second.isEmpty()) {
firstPart = browserMessageBuilder()->getArrayFromBase64(predefinedFirst); firstPart = browserMessageBuilder()->getArrayFromBase64(testingVariables.first);
secondPart = browserMessageBuilder()->getArrayFromBase64(predefinedSecond); secondPart = browserMessageBuilder()->getArrayFromBase64(testingVariables.second);
} else { } else {
if (alg == WebAuthnAlgorithms::ES256) { if (alg == WebAuthnAlgorithms::ES256) {
try { try {
Botan::ECDSA_PrivateKey privateKey(*randomGen()->getRng(), Botan::EC_Group("secp256r1")); // Use predefined data if found (only for testing private key creation)
const auto keyData = !testingVariables.data.isEmpty()
? Botan::BigInt(testingVariables.data.toStdString())
: Botan::BigInt(0);
Botan::ECDSA_PrivateKey privateKey(*randomGen()->getRng(), Botan::EC_Group("secp256r1"), keyData);
const auto& publicPoint = privateKey.public_point(); const auto& publicPoint = privateKey.public_point();
auto x = publicPoint.get_affine_x(); auto x = publicPoint.get_affine_x();
auto y = publicPoint.get_affine_y(); auto y = publicPoint.get_affine_y();
firstPart = bigIntToQByteArray(x); firstPart = bigIntToQByteArray(x);
secondPart = bigIntToQByteArray(y); secondPart = bigIntToQByteArray(y);
auto publicKey =
Botan::ECDSA_PublicKey(privateKey.algorithm_identifier(), privateKey.public_key_bits());
auto publicKeySpki = publicKey.subject_public_key();
spki = browserMessageBuilder()->getQByteArray(publicKeySpki.data(), publicKeySpki.size());
auto privateKeyPem = Botan::PKCS8::PEM_encode(privateKey); auto privateKeyPem = Botan::PKCS8::PEM_encode(privateKey);
pem = QByteArray::fromStdString(privateKeyPem); pem = QByteArray::fromStdString(privateKeyPem);
} catch (std::exception& e) { } catch (std::exception& e) {
@ -263,6 +284,10 @@ BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFir
firstPart = bigIntToQByteArray(modulus); firstPart = bigIntToQByteArray(modulus);
secondPart = bigIntToQByteArray(exponent); secondPart = bigIntToQByteArray(exponent);
auto publicKey = Botan::RSA_PublicKey(privateKey.algorithm_identifier(), privateKey.public_key_bits());
auto publicKeySpki = publicKey.subject_public_key();
spki = browserMessageBuilder()->getQByteArray(publicKeySpki.data(), publicKeySpki.size());
auto privateKeyPem = Botan::PKCS8::PEM_encode(privateKey); auto privateKeyPem = Botan::PKCS8::PEM_encode(privateKey);
pem = QByteArray::fromStdString(privateKeyPem); pem = QByteArray::fromStdString(privateKeyPem);
} catch (std::exception& e) { } catch (std::exception& e) {
@ -271,17 +296,22 @@ BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFir
} }
} else if (alg == WebAuthnAlgorithms::EDDSA) { } else if (alg == WebAuthnAlgorithms::EDDSA) {
try { try {
Botan::Ed25519_PrivateKey key(*randomGen()->getRng()); Botan::Ed25519_PrivateKey privateKey(*randomGen()->getRng());
auto publicKey = key.get_public_key(); auto publicKeyBits = privateKey.get_public_key();
#ifdef WITH_XC_BOTAN3 #ifdef WITH_XC_BOTAN3
auto privateKey = key.raw_private_key_bits(); auto privateKeyBits = privateKey.raw_private_key_bits();
#else #else
auto privateKey = key.get_private_key(); auto privateKeyBits = privateKey.get_private_key();
#endif #endif
firstPart = browserMessageBuilder()->getQByteArray(publicKey.data(), publicKey.size()); firstPart = browserMessageBuilder()->getQByteArray(publicKeyBits.data(), publicKeyBits.size());
secondPart = browserMessageBuilder()->getQByteArray(privateKey.data(), privateKey.size()); secondPart = browserMessageBuilder()->getQByteArray(privateKeyBits.data(), privateKeyBits.size());
auto privateKeyPem = Botan::PKCS8::PEM_encode(key); auto publicKey =
Botan::Ed25519_PublicKey(privateKey.algorithm_identifier(), privateKey.public_key_bits());
auto publicKeySpki = publicKey.subject_public_key();
spki = browserMessageBuilder()->getQByteArray(publicKeySpki.data(), publicKeySpki.size());
auto privateKeyPem = Botan::PKCS8::PEM_encode(privateKey);
pem = QByteArray::fromStdString(privateKeyPem); pem = QByteArray::fromStdString(privateKeyPem);
} catch (std::exception& e) { } catch (std::exception& e) {
qWarning("BrowserWebAuthn::buildCredentialPrivateKey: Could not create EdDSA private key: %s", qWarning("BrowserWebAuthn::buildCredentialPrivateKey: Could not create EdDSA private key: %s",
@ -299,6 +329,7 @@ BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFir
AttestationKeyPair attestationKeyPair; AttestationKeyPair attestationKeyPair;
attestationKeyPair.cborEncodedPublicKey = result; attestationKeyPair.cborEncodedPublicKey = result;
attestationKeyPair.privateKeyPem = pem; attestationKeyPair.privateKeyPem = pem;
attestationKeyPair.spkiPublicKey = spki;
return attestationKeyPair; return attestationKeyPair;
} }

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org> * Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -25,6 +25,8 @@
#include <botan/asn1_obj.h> #include <botan/asn1_obj.h>
#include <botan/bigint.h> #include <botan/bigint.h>
#define DEFAULT_BE_FLAG true
#define DEFAULT_BS_FLAG true
#define ID_BYTES 32 #define ID_BYTES 32
#define HASH_BYTES 32 #define HASH_BYTES 32
#define RSA_BITS 2048 #define RSA_BITS 2048
@ -61,6 +63,7 @@ struct AttestationKeyPair
{ {
QByteArray cborEncodedPublicKey; QByteArray cborEncodedPublicKey;
QByteArray privateKeyPem; QByteArray privateKeyPem;
QByteArray spkiPublicKey;
}; };
// Predefined variables used for testing the class // Predefined variables used for testing the class
@ -69,6 +72,7 @@ struct TestingVariables
QString credentialId; QString credentialId;
QString first; QString first;
QString second; QString second;
QString data;
}; };
class BrowserPasskeys : public QObject class BrowserPasskeys : public QObject
@ -81,11 +85,13 @@ public:
static BrowserPasskeys* instance(); static BrowserPasskeys* instance();
PublicKeyCredential buildRegisterPublicKeyCredential(const QJsonObject& credentialCreationOptions, PublicKeyCredential buildRegisterPublicKeyCredential(const QJsonObject& credentialCreationOptions,
const TestingVariables& predefinedVariables = {}); const TestingVariables& testingVariables = {});
QJsonObject buildGetPublicKeyCredential(const QJsonObject& assertionOptions, QJsonObject buildGetPublicKeyCredential(const QJsonObject& assertionOptions,
const QString& credentialId, const QString& credentialId,
const QString& userHandle, const QString& userHandle,
const QString& privateKeyPem); const QString& privateKeyPem,
const bool beFlag = DEFAULT_BE_FLAG,
const bool bsFlag = DEFAULT_BE_FLAG);
static const QString AAGUID; static const QString AAGUID;
@ -110,11 +116,12 @@ private:
const QString& extensions, const QString& extensions,
const QString& credentialId, const QString& credentialId,
const QByteArray& cborEncodedPublicKey, const QByteArray& cborEncodedPublicKey,
const TestingVariables& predefinedVariables = {}); const TestingVariables& testingVariables = {});
QByteArray buildAuthenticatorData(const QString& rpId, const QString& extensions); QByteArray buildAuthenticatorData(const QString& rpId,
AttestationKeyPair buildCredentialPrivateKey(int alg, const QString& extensions,
const QString& predefinedFirst = QString(), const bool beFlag = DEFAULT_BE_FLAG,
const QString& predefinedSecond = QString()); const bool bsFlag = DEFAULT_BE_FLAG);
AttestationKeyPair buildCredentialPrivateKey(int alg, const TestingVariables& testingVariables = {});
QByteArray QByteArray
buildSignature(const QByteArray& authenticatorData, const QByteArray& clientData, const QString& privateKeyPem); buildSignature(const QByteArray& authenticatorData, const QByteArray& clientData, const QString& privateKeyPem);
QJsonObject parseAuthData(const QByteArray& authData) const; QJsonObject parseAuthData(const QByteArray& authData) const;

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org> * Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2017 Sami Vänttinen <sami.vanttinen@protonmail.com> * Copyright (C) 2017 Sami Vänttinen <sami.vanttinen@protonmail.com>
* Copyright (C) 2013 Francois Ferrand * Copyright (C) 2013 Francois Ferrand
* *
@ -774,8 +774,16 @@ QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject&
const auto credentialId = passkeyUtils()->getCredentialIdFromEntry(selectedEntry); const auto credentialId = passkeyUtils()->getCredentialIdFromEntry(selectedEntry);
const auto userHandle = selectedEntry->attributes()->value(EntryAttributes::KPEX_PASSKEY_USER_HANDLE); const auto userHandle = selectedEntry->attributes()->value(EntryAttributes::KPEX_PASSKEY_USER_HANDLE);
auto publicKeyCredential = // Get BE and BS flags if present
browserPasskeys()->buildGetPublicKeyCredential(assertionOptions, credentialId, userHandle, privateKeyPem); const auto beFlag = selectedEntry->attributes()->hasKey(EntryAttributes::KPEX_PASSKEY_FLAG_BE)
? selectedEntry->attributes()->value(EntryAttributes::KPEX_PASSKEY_FLAG_BE) == TRUE_STR
: DEFAULT_BE_FLAG;
const auto bsFlag = selectedEntry->attributes()->hasKey(EntryAttributes::KPEX_PASSKEY_FLAG_BS)
? selectedEntry->attributes()->value(EntryAttributes::KPEX_PASSKEY_FLAG_BS) == TRUE_STR
: DEFAULT_BS_FLAG;
auto publicKeyCredential = browserPasskeys()->buildGetPublicKeyCredential(
assertionOptions, credentialId, userHandle, privateKeyPem, beFlag, bsFlag);
if (publicKeyCredential.isEmpty()) { if (publicKeyCredential.isEmpty()) {
return getPasskeyError(ERROR_PASSKEYS_UNKNOWN_ERROR); return getPasskeyError(ERROR_PASSKEYS_UNKNOWN_ERROR);
} }
@ -855,6 +863,8 @@ void BrowserService::addPasskeyToEntry(Entry* entry,
entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_PEM, privateKey, true); entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_PEM, privateKey, true);
entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_RELYING_PARTY, rpId); entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_RELYING_PARTY, rpId);
entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_USER_HANDLE, userHandle, true); entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_USER_HANDLE, userHandle, true);
entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_FLAG_BE, TRUE_STR);
entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_FLAG_BS, TRUE_STR);
entry->addTag(tr("Passkey")); entry->addTag(tr("Passkey"));
entry->endUpdate(); entry->endUpdate();
@ -1012,8 +1022,8 @@ QList<Entry*> BrowserService::searchEntries(const QSharedPointer<Database>& db,
} }
for (const auto& group : rootGroup->groupsRecursive(true)) { for (const auto& group : rootGroup->groupsRecursive(true)) {
if (group->isRecycled() const auto groupOptionHideEntry = group->resolveCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY);
|| group->resolveCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY) == Group::Enable) { if (group->isRecycled() || groupOptionHideEntry == Group::Enable) {
continue; continue;
} }
@ -1028,7 +1038,8 @@ QList<Entry*> BrowserService::searchEntries(const QSharedPointer<Database>& db,
for (auto* entry : group->entries()) { for (auto* entry : group->entries()) {
if (entry->isRecycled() if (entry->isRecycled()
|| (entry->customData()->contains(BrowserService::OPTION_HIDE_ENTRY) || (groupOptionHideEntry == Group::Inherit
&& entry->customData()->contains(BrowserService::OPTION_HIDE_ENTRY)
&& entry->customData()->value(BrowserService::OPTION_HIDE_ENTRY) == TRUE_STR)) { && entry->customData()->value(BrowserService::OPTION_HIDE_ENTRY) == TRUE_STR)) {
continue; continue;
} }

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org> * Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -149,7 +149,6 @@ void CustomData::copyDataFrom(const CustomData* other)
m_data = other->m_data; m_data = other->m_data;
updateLastModified();
emit reset(); emit reset();
emitModified(); emitModified();
} }

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org> * Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2012 Felix Geyer <debfx@fobos.de> * Copyright (C) 2012 Felix Geyer <debfx@fobos.de>
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
@ -46,6 +46,8 @@ const QString EntryAttributes::KPEX_PASSKEY_RELYING_PARTY = QStringLiteral("KPEX
const QString EntryAttributes::KPEX_PASSKEY_USER_HANDLE = QStringLiteral("KPEX_PASSKEY_USER_HANDLE"); const QString EntryAttributes::KPEX_PASSKEY_USER_HANDLE = QStringLiteral("KPEX_PASSKEY_USER_HANDLE");
const QString EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_START = QStringLiteral("-----BEGIN PRIVATE KEY-----"); const QString EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_START = QStringLiteral("-----BEGIN PRIVATE KEY-----");
const QString EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_END = QStringLiteral("-----END PRIVATE KEY-----"); const QString EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_END = QStringLiteral("-----END PRIVATE KEY-----");
const QString EntryAttributes::KPEX_PASSKEY_FLAG_BE = QStringLiteral("KPEX_PASSKEY_FLAG_BE");
const QString EntryAttributes::KPEX_PASSKEY_FLAG_BS = QStringLiteral("KPEX_PASSKEY_FLAG_BS");
// For compatibility with StrongBox // For compatibility with StrongBox
const QString EntryAttributes::KPEX_PASSKEY_GENERATED_USER_ID = QStringLiteral("KPEX_PASSKEY_GENERATED_USER_ID"); const QString EntryAttributes::KPEX_PASSKEY_GENERATED_USER_ID = QStringLiteral("KPEX_PASSKEY_GENERATED_USER_ID");

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org> * Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2012 Felix Geyer <debfx@fobos.de> * Copyright (C) 2012 Felix Geyer <debfx@fobos.de>
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
@ -75,6 +75,8 @@ public:
static const QString KPEX_PASSKEY_USER_HANDLE; static const QString KPEX_PASSKEY_USER_HANDLE;
static const QString KPEX_PASSKEY_PRIVATE_KEY_START; static const QString KPEX_PASSKEY_PRIVATE_KEY_START;
static const QString KPEX_PASSKEY_PRIVATE_KEY_END; static const QString KPEX_PASSKEY_PRIVATE_KEY_END;
static const QString KPEX_PASSKEY_FLAG_BE;
static const QString KPEX_PASSKEY_FLAG_BS;
static bool isDefaultAttribute(const QString& key); static bool isDefaultAttribute(const QString& key);
static bool isPasskeyAttribute(const QString& key); static bool isPasskeyAttribute(const QString& key);

View file

@ -261,6 +261,48 @@ namespace
return entry.take(); return entry.take();
} }
Group* createGroup(Group* rootGroup, const QString& folderName)
{
Group* currentParentGroup = rootGroup;
Group* result = nullptr;
const auto groups = folderName.split("/", Qt::SkipEmptyParts);
// Returns the group name based on depth
const auto getGroupName = [&](const int depth) {
QString groupName;
for (int i = 0; i < depth + 1; ++i) {
groupName.append((i == 0 ? "" : "/") + groups[i]);
}
return groupName;
};
// Create new group(s) always when the path is not found
for (int i = 0; i < groups.length(); ++i) {
const auto groupName = getGroupName(i);
const auto tempGroup = rootGroup->findGroupByPath(groupName);
if (!tempGroup) {
const auto newGroup = new Group();
newGroup->setName(groups[i]);
newGroup->setUuid(QUuid::createUuid());
newGroup->setParent(currentParentGroup);
currentParentGroup = newGroup;
if (groupName == folderName) {
result = newGroup;
}
continue;
}
if (groupName == folderName) {
result = tempGroup;
}
currentParentGroup = tempGroup;
}
return result;
}
void writeVaultToDatabase(const QJsonObject& vault, QSharedPointer<Database> db) void writeVaultToDatabase(const QJsonObject& vault, QSharedPointer<Database> db)
{ {
auto folderField = QString("folders"); auto folderField = QString("folders");
@ -277,12 +319,12 @@ namespace
// Create groups from folders and store a temporary map of id -> uuid // Create groups from folders and store a temporary map of id -> uuid
QMap<QString, Group*> folderMap; QMap<QString, Group*> folderMap;
for (const auto& folder : vault.value(folderField).toArray()) { for (const auto& folder : vault.value(folderField).toArray()) {
auto group = new Group(); const auto folderId = folder.toObject().value("id").toString();
group->setUuid(QUuid::createUuid()); const auto folderName = folder.toObject().value("name").toString();
group->setName(folder.toObject().value("name").toString());
group->setParent(db->rootGroup());
folderMap.insert(folder.toObject().value("id").toString(), group); if (const auto group = createGroup(db->rootGroup(), folderName)) {
folderMap.insert(folderId, group);
}
} }
QString folderId; QString folderId;

View file

@ -153,8 +153,6 @@ void Application::bootstrap(const QString& uiLanguage)
{ {
Bootstrap::bootstrap(uiLanguage); Bootstrap::bootstrap(uiLanguage);
applyFontSize();
osUtils->registerNativeEventFilter(); osUtils->registerNativeEventFilter();
MessageBox::initializeButtonDefs(); MessageBox::initializeButtonDefs();
@ -200,6 +198,7 @@ void Application::applyTheme()
stylesheetFile.close(); stylesheetFile.close();
} }
} }
applyFontSize();
} }
void Application::applyFontSize() void Application::applyFontSize()

View file

@ -392,6 +392,9 @@ void EntryPreviewWidget::updateEntryGeneralTab()
m_ui->entryNotesTextEdit->setFont(Font::defaultFont()); m_ui->entryNotesTextEdit->setFont(Font::defaultFont());
} }
m_ui->entryNotesTextEdit->setTabStopDistance(
QFontMetrics(m_ui->entryNotesTextEdit->font()).horizontalAdvance(QString(4, ' ')));
m_ui->entryUrlLabel->setRawText(m_currentEntry->displayUrl().toHtmlEscaped()); m_ui->entryUrlLabel->setRawText(m_currentEntry->displayUrl().toHtmlEscaped());
const QString url = m_currentEntry->url(); const QString url = m_currentEntry->url();
if (!url.isEmpty()) { if (!url.isEmpty()) {

View file

@ -137,7 +137,6 @@
<widget class="QLabel" name="entryTotpLabel"> <widget class="QLabel" name="entryTotpLabel">
<property name="font"> <property name="font">
<font> <font>
<pointsize>10</pointsize>
<bold>true</bold> <bold>true</bold>
</font> </font>
</property> </property>
@ -434,9 +433,6 @@
<property name="readOnly"> <property name="readOnly">
<bool>true</bool> <bool>true</bool>
</property> </property>
<property name="tabStopDistance">
<double>10.000000000000000</double>
</property>
<property name="blendIn" stdset="0"> <property name="blendIn" stdset="0">
<bool>true</bool> <bool>true</bool>
</property> </property>

View file

@ -2031,7 +2031,6 @@ void MainWindow::initViewMenu()
restartApp(tr("You must restart the application to apply this setting. Would you like to restart now?")); restartApp(tr("You must restart the application to apply this setting. Would you like to restart now?"));
} else { } else {
kpxcApp->applyTheme(); kpxcApp->applyTheme();
kpxcApp->applyFontSize();
} }
}); });

View file

@ -351,25 +351,31 @@ void EditEntryWidget::updateBrowser()
return; return;
} }
auto changeValue = [&](const QString& option, const bool newValue) {
// If value is false and no customData exists, make no edits
if (!m_customData->hasKey(option) && !newValue) {
return;
}
// If customData exists, set the value
m_customData->set(option, (newValue ? TRUE_STR : FALSE_STR));
};
// Only update the custom data if no group level settings are used (checkbox is enabled) // Only update the custom data if no group level settings are used (checkbox is enabled)
if (m_browserUi->hideEntryCheckbox->isEnabled()) { if (m_browserUi->hideEntryCheckbox->isEnabled()) {
auto hide = m_browserUi->hideEntryCheckbox->isChecked(); changeValue(BrowserService::OPTION_HIDE_ENTRY, m_browserUi->hideEntryCheckbox->isChecked());
m_customData->set(BrowserService::OPTION_HIDE_ENTRY, (hide ? TRUE_STR : FALSE_STR));
} }
if (m_browserUi->skipAutoSubmitCheckbox->isEnabled()) { if (m_browserUi->skipAutoSubmitCheckbox->isEnabled()) {
auto skip = m_browserUi->skipAutoSubmitCheckbox->isChecked(); changeValue(BrowserService::OPTION_SKIP_AUTO_SUBMIT, m_browserUi->skipAutoSubmitCheckbox->isChecked());
m_customData->set(BrowserService::OPTION_SKIP_AUTO_SUBMIT, (skip ? TRUE_STR : FALSE_STR));
} }
if (m_browserUi->onlyHttpAuthCheckbox->isEnabled()) { if (m_browserUi->onlyHttpAuthCheckbox->isEnabled()) {
auto onlyHttpAuth = m_browserUi->onlyHttpAuthCheckbox->isChecked(); changeValue(BrowserService::OPTION_ONLY_HTTP_AUTH, m_browserUi->onlyHttpAuthCheckbox->isChecked());
m_customData->set(BrowserService::OPTION_ONLY_HTTP_AUTH, (onlyHttpAuth ? TRUE_STR : FALSE_STR));
} }
if (m_browserUi->notHttpAuthCheckbox->isEnabled()) { if (m_browserUi->notHttpAuthCheckbox->isEnabled()) {
auto notHttpAuth = m_browserUi->notHttpAuthCheckbox->isChecked(); changeValue(BrowserService::OPTION_NOT_HTTP_AUTH, m_browserUi->notHttpAuthCheckbox->isChecked());
m_customData->set(BrowserService::OPTION_NOT_HTTP_AUTH, (notHttpAuth ? TRUE_STR : FALSE_STR));
} }
} }
@ -943,6 +949,9 @@ void EditEntryWidget::setForms(Entry* entry, bool restore)
m_mainUi->notesEdit->setFont(Font::defaultFont()); m_mainUi->notesEdit->setFont(Font::defaultFont());
} }
m_mainUi->notesEdit->setTabStopDistance(
QFontMetrics(m_mainUi->notesEdit->font()).horizontalAdvance(QString(4, ' ')));
m_advancedUi->attachmentsWidget->setReadOnly(m_history); m_advancedUi->attachmentsWidget->setReadOnly(m_history);
m_advancedUi->addAttributeButton->setEnabled(!m_history); m_advancedUi->addAttributeButton->setEnabled(!m_history);
m_advancedUi->editAttributeButton->setEnabled(false); m_advancedUi->editAttributeButton->setEnabled(false);
@ -1037,55 +1046,26 @@ void EditEntryWidget::setForms(Entry* entry, bool restore)
setupBrowser(); setupBrowser();
} }
auto hideEntriesCheckBoxEnabled = true;
auto skipAutoSubmitCheckBoxEnabled = true;
auto onlyHttpAuthCheckBoxEnabled = true;
auto notHttpAuthCheckBoxEnabled = true;
auto hideEntries = false;
auto skipAutoSubmit = false;
auto onlyHttpAuth = false;
auto notHttpAuth = false;
const auto group = m_entry->group(); const auto group = m_entry->group();
if (group) { m_browserUi->messageWidget->showMessage(
hideEntries = group->resolveCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY) == Group::Enable; tr("Some Browser Integration settings are overridden by group settings."), MessageWidget::Information);
skipAutoSubmit = group->resolveCustomDataTriState(BrowserService::OPTION_SKIP_AUTO_SUBMIT) == Group::Enable; m_browserUi->messageWidget->setVisible(false);
onlyHttpAuth = group->resolveCustomDataTriState(BrowserService::OPTION_ONLY_HTTP_AUTH) == Group::Enable;
notHttpAuth = group->resolveCustomDataTriState(BrowserService::OPTION_NOT_HTTP_AUTH) == Group::Enable;
hideEntriesCheckBoxEnabled = auto updateCheckBoxValue = [&](QCheckBox* checkBox, const QString& option) {
group->resolveCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY) == Group::Inherit; const auto optionEnabledInGroup = group ? group->resolveBrowserOptionEnabled(option) : false;
skipAutoSubmitCheckBoxEnabled = const auto optionInherited = group ? group->resolveCustomDataTriState(option) == Group::Inherit : true;
group->resolveCustomDataTriState(BrowserService::OPTION_SKIP_AUTO_SUBMIT) == Group::Inherit;
onlyHttpAuthCheckBoxEnabled =
group->resolveCustomDataTriState(BrowserService::OPTION_ONLY_HTTP_AUTH) == Group::Inherit;
notHttpAuthCheckBoxEnabled =
group->resolveCustomDataTriState(BrowserService::OPTION_NOT_HTTP_AUTH) == Group::Inherit;
}
// Show information about group level settings if (!optionInherited) {
if (!hideEntriesCheckBoxEnabled || !skipAutoSubmitCheckBoxEnabled || !onlyHttpAuthCheckBoxEnabled m_browserUi->messageWidget->setVisible(true);
|| !notHttpAuthCheckBoxEnabled) { }
m_browserUi->messageWidget->showMessage(
tr("Some Browser Integration settings are overridden by group settings."), MessageWidget::Information);
m_browserUi->messageWidget->setVisible(true);
}
// Disable checkboxes based on group level settings updateBrowserIntegrationCheckbox(checkBox, optionInherited, optionEnabledInGroup, option);
updateBrowserIntegrationCheckbox( };
m_browserUi->hideEntryCheckbox, hideEntriesCheckBoxEnabled, hideEntries, BrowserService::OPTION_HIDE_ENTRY);
updateBrowserIntegrationCheckbox(m_browserUi->skipAutoSubmitCheckbox, updateCheckBoxValue(m_browserUi->hideEntryCheckbox, BrowserService::OPTION_HIDE_ENTRY);
skipAutoSubmitCheckBoxEnabled, updateCheckBoxValue(m_browserUi->skipAutoSubmitCheckbox, BrowserService::OPTION_SKIP_AUTO_SUBMIT);
skipAutoSubmit, updateCheckBoxValue(m_browserUi->onlyHttpAuthCheckbox, BrowserService::OPTION_ONLY_HTTP_AUTH);
BrowserService::OPTION_SKIP_AUTO_SUBMIT); updateCheckBoxValue(m_browserUi->notHttpAuthCheckbox, BrowserService::OPTION_NOT_HTTP_AUTH);
updateBrowserIntegrationCheckbox(m_browserUi->onlyHttpAuthCheckbox,
onlyHttpAuthCheckBoxEnabled,
onlyHttpAuth,
BrowserService::OPTION_ONLY_HTTP_AUTH);
updateBrowserIntegrationCheckbox(m_browserUi->notHttpAuthCheckbox,
notHttpAuthCheckBoxEnabled,
notHttpAuth,
BrowserService::OPTION_NOT_HTTP_AUTH);
m_browserUi->addURLButton->setEnabled(!m_history); m_browserUi->addURLButton->setEnabled(!m_history);
m_browserUi->removeURLButton->setEnabled(false); m_browserUi->removeURLButton->setEnabled(false);
@ -1534,7 +1514,7 @@ void EditEntryWidget::updateAutoTypeEnabled()
m_autoTypeUi->inheritSequenceButton->setEnabled(!m_history && autoTypeEnabled); m_autoTypeUi->inheritSequenceButton->setEnabled(!m_history && autoTypeEnabled);
m_autoTypeUi->customSequenceButton->setEnabled(!m_history && autoTypeEnabled); m_autoTypeUi->customSequenceButton->setEnabled(!m_history && autoTypeEnabled);
m_autoTypeUi->sequenceEdit->setEnabled(autoTypeEnabled && m_autoTypeUi->customSequenceButton->isChecked()); m_autoTypeUi->sequenceEdit->setEnabled(autoTypeEnabled && m_autoTypeUi->customSequenceButton->isChecked());
m_autoTypeUi->openHelpButton->setEnabled(autoTypeEnabled && m_autoTypeUi->customSequenceButton->isChecked()); m_autoTypeUi->openHelpButton->setEnabled(autoTypeEnabled);
m_autoTypeUi->assocView->setEnabled(autoTypeEnabled); m_autoTypeUi->assocView->setEnabled(autoTypeEnabled);
m_autoTypeUi->assocAddButton->setEnabled(!m_history); m_autoTypeUi->assocAddButton->setEnabled(!m_history);

View file

@ -90,9 +90,6 @@
</item> </item>
<item> <item>
<widget class="QToolButton" name="openHelpButton"> <widget class="QToolButton" name="openHelpButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="toolTip"> <property name="toolTip">
<string>Open Auto-Type help webpage</string> <string>Open Auto-Type help webpage</string>
</property> </property>

View file

@ -1,386 +1,383 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0"> <ui version="4.0">
<class>EditEntryWidgetMain</class> <class>EditEntryWidgetMain</class>
<widget class="QScrollArea" name="EditEntryWidgetMain"> <widget class="QScrollArea" name="EditEntryWidgetMain">
<property name="geometry"> <property name="geometry">
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>400</width> <width>400</width>
<height>523</height> <height>523</height>
</rect> </rect>
</property> </property>
<property name="windowTitle"> <property name="windowTitle">
<string>Edit Entry</string> <string>Edit Entry</string>
</property> </property>
<property name="frameShape"> <property name="frameShape">
<enum>QFrame::NoFrame</enum> <enum>QFrame::NoFrame</enum>
</property> </property>
<property name="frameShadow"> <property name="frameShadow">
<enum>QFrame::Plain</enum> <enum>QFrame::Plain</enum>
</property> </property>
<property name="horizontalScrollBarPolicy"> <property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum> <enum>Qt::ScrollBarAlwaysOff</enum>
</property> </property>
<property name="sizeAdjustPolicy"> <property name="sizeAdjustPolicy">
<enum>QAbstractScrollArea::AdjustToContents</enum> <enum>QAbstractScrollArea::AdjustToContents</enum>
</property> </property>
<property name="widgetResizable"> <property name="widgetResizable">
<bool>true</bool> <bool>true</bool>
</property> </property>
<widget class="QWidget" name="container"> <widget class="QWidget" name="container">
<property name="geometry"> <property name="geometry">
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>400</width> <width>400</width>
<height>523</height> <height>523</height>
</rect> </rect>
</property> </property>
<layout class="QGridLayout" name="gridLayout" columnstretch="0,1"> <layout class="QGridLayout" name="gridLayout" columnstretch="0,1">
<property name="leftMargin"> <property name="leftMargin">
<number>0</number> <number>0</number>
</property> </property>
<property name="topMargin"> <property name="topMargin">
<number>0</number> <number>0</number>
</property> </property>
<property name="rightMargin"> <property name="rightMargin">
<number>0</number> <number>0</number>
</property> </property>
<property name="bottomMargin"> <property name="bottomMargin">
<number>0</number> <number>0</number>
</property> </property>
<property name="horizontalSpacing"> <property name="horizontalSpacing">
<number>10</number> <number>10</number>
</property> </property>
<property name="verticalSpacing"> <property name="verticalSpacing">
<number>8</number> <number>8</number>
</property> </property>
<item row="0" column="1"> <item row="0" column="1">
<widget class="QLineEdit" name="titleEdit"> <widget class="QLineEdit" name="titleEdit">
<property name="accessibleName"> <property name="accessibleName">
<string>Title field</string> <string>Title field</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="1" column="0"> <item row="1" column="0">
<widget class="QLabel" name="usernameLabel"> <widget class="QLabel" name="usernameLabel">
<property name="text"> <property name="text">
<string>&amp;Username:</string> <string>&amp;Username:</string>
</property> </property>
<property name="alignment"> <property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property> </property>
<property name="buddy"> <property name="buddy">
<cstring>usernameComboBox</cstring> <cstring>usernameComboBox</cstring>
</property> </property>
</widget> </widget>
</item> </item>
<item row="2" column="1"> <item row="2" column="1">
<widget class="PasswordWidget" name="passwordEdit" native="true"> <widget class="PasswordWidget" name="passwordEdit" native="true">
<property name="focusPolicy"> <property name="focusPolicy">
<enum>Qt::StrongFocus</enum> <enum>Qt::StrongFocus</enum>
</property> </property>
<property name="accessibleName"> <property name="accessibleName">
<string>Password field</string> <string>Password field</string>
</property> </property>
</widget> </widget>
</item> </item>
<item row="8" column="1"> <item row="8" column="1">
<layout class="QVBoxLayout" name="verticalLayout_2"> <layout class="QVBoxLayout" name="verticalLayout_2">
<item> <item>
<widget class="QPlainTextEdit" name="notesEdit"> <widget class="QPlainTextEdit" name="notesEdit">
<property name="sizePolicy"> <property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding"> <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch> <horstretch>0</horstretch>
<verstretch>1</verstretch> <verstretch>1</verstretch>
</sizepolicy> </sizepolicy>
</property> </property>
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>0</width> <width>0</width>
<height>100</height> <height>100</height>
</size> </size>
</property> </property>
<property name="accessibleName"> <property name="accessibleName">
<string>Notes field</string> <string>Notes field</string>
</property> </property>
<property name="tabStopDistance"> </widget>
<double>10.000000000000000</double> </item>
</property> </layout>
</widget> </item>
</item> <item row="0" column="0">
</layout> <widget class="QLabel" name="titleLabel">
</item> <property name="text">
<item row="0" column="0"> <string>&amp;Title:</string>
<widget class="QLabel" name="titleLabel"> </property>
<property name="text"> <property name="alignment">
<string>&amp;Title:</string> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property> </property>
<property name="alignment"> <property name="buddy">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> <cstring>titleEdit</cstring>
</property> </property>
<property name="buddy"> </widget>
<cstring>titleEdit</cstring> </item>
</property> <item row="2" column="0">
</widget> <widget class="QLabel" name="passwordLabel">
</item> <property name="text">
<item row="2" column="0"> <string>&amp;Password:</string>
<widget class="QLabel" name="passwordLabel"> </property>
<property name="text"> <property name="alignment">
<string>&amp;Password:</string> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property> </property>
<property name="alignment"> <property name="buddy">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> <cstring>passwordEdit</cstring>
</property> </property>
<property name="buddy"> </widget>
<cstring>passwordEdit</cstring> </item>
</property> <item row="1" column="1">
</widget> <widget class="QComboBox" name="usernameComboBox">
</item> <property name="focusPolicy">
<item row="1" column="1"> <enum>Qt::StrongFocus</enum>
<widget class="QComboBox" name="usernameComboBox"> </property>
<property name="focusPolicy"> <property name="accessibleName">
<enum>Qt::StrongFocus</enum> <string>Username field</string>
</property> </property>
<property name="accessibleName"> </widget>
<string>Username field</string> </item>
</property> <item row="5" column="1">
</widget> <widget class="TagsEdit" name="tagsList" native="true">
</item> <property name="focusPolicy">
<item row="5" column="1"> <enum>Qt::StrongFocus</enum>
<widget class="TagsEdit" name="tagsList" native="true"> </property>
<property name="focusPolicy"> <property name="accessibleName">
<enum>Qt::StrongFocus</enum> <string>Tags list</string>
</property> </property>
<property name="accessibleName"> </widget>
<string>Tags list</string> </item>
</property> <item row="7" column="1">
</widget> <layout class="QHBoxLayout" name="horizontalLayout_2" stretch="0,1,0">
</item> <property name="spacing">
<item row="7" column="1"> <number>8</number>
<layout class="QHBoxLayout" name="horizontalLayout_2" stretch="0,1,0"> </property>
<property name="spacing"> <item>
<number>8</number> <widget class="QCheckBox" name="expireCheck">
</property> <property name="toolTip">
<item> <string>Toggle expiration</string>
<widget class="QCheckBox" name="expireCheck"> </property>
<property name="toolTip"> <property name="accessibleName">
<string>Toggle expiration</string> <string>Toggle expiration</string>
</property> </property>
<property name="accessibleName"> <property name="text">
<string>Toggle expiration</string> <string/>
</property> </property>
<property name="text"> </widget>
<string/> </item>
</property> <item>
</widget> <widget class="QDateTimeEdit" name="expireDatePicker">
</item> <property name="enabled">
<item> <bool>false</bool>
<widget class="QDateTimeEdit" name="expireDatePicker"> </property>
<property name="enabled"> <property name="accessibleName">
<bool>false</bool> <string>Expiration field</string>
</property> </property>
<property name="accessibleName"> <property name="calendarPopup">
<string>Expiration field</string> <bool>true</bool>
</property> </property>
<property name="calendarPopup"> </widget>
<bool>true</bool> </item>
</property> <item>
</widget> <widget class="QPushButton" name="expirePresets">
</item> <property name="sizePolicy">
<item> <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<widget class="QPushButton" name="expirePresets"> <horstretch>0</horstretch>
<property name="sizePolicy"> <verstretch>0</verstretch>
<sizepolicy hsizetype="Fixed" vsizetype="Fixed"> </sizepolicy>
<horstretch>0</horstretch> </property>
<verstretch>0</verstretch> <property name="toolTip">
</sizepolicy> <string>Expiration Presets</string>
</property> </property>
<property name="toolTip"> <property name="accessibleName">
<string>Expiration Presets</string> <string>Expiration presets</string>
</property> </property>
<property name="accessibleName"> <property name="text">
<string>Expiration presets</string> <string>Presets</string>
</property> </property>
<property name="text"> </widget>
<string>Presets</string> </item>
</property> </layout>
</widget> </item>
</item> <item row="3" column="0">
</layout> <widget class="QLabel" name="urlLabel">
</item> <property name="text">
<item row="3" column="0"> <string>UR&amp;L:</string>
<widget class="QLabel" name="urlLabel"> </property>
<property name="text"> <property name="alignment">
<string>UR&amp;L:</string> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property> </property>
<property name="alignment"> <property name="buddy">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> <cstring>urlEdit</cstring>
</property> </property>
<property name="buddy"> </widget>
<cstring>urlEdit</cstring> </item>
</property> <item row="3" column="1">
</widget> <layout class="QHBoxLayout" name="horizontalLayout_6">
</item> <property name="spacing">
<item row="3" column="1"> <number>8</number>
<layout class="QHBoxLayout" name="horizontalLayout_6"> </property>
<property name="spacing"> <item>
<number>8</number> <widget class="URLEdit" name="urlEdit">
</property> <property name="accessibleName">
<item> <string>Url field</string>
<widget class="URLEdit" name="urlEdit"> </property>
<property name="accessibleName"> <property name="placeholderText">
<string>Url field</string> <string notr="true">https://example.com</string>
</property> </property>
<property name="placeholderText"> </widget>
<string notr="true">https://example.com</string> </item>
</property> <item>
</widget> <widget class="QToolButton" name="fetchFaviconButton">
</item> <property name="toolTip">
<item> <string>Download favicon for URL</string>
<widget class="QToolButton" name="fetchFaviconButton"> </property>
<property name="toolTip"> <property name="accessibleName">
<string>Download favicon for URL</string> <string>Download favicon for URL</string>
</property> </property>
<property name="accessibleName"> </widget>
<string>Download favicon for URL</string> </item>
</property> </layout>
</widget> </item>
</item> <item row="8" column="0">
</layout> <layout class="QVBoxLayout" name="verticalLayout">
</item> <item>
<item row="8" column="0"> <widget class="QLabel" name="notesLabel">
<layout class="QVBoxLayout" name="verticalLayout"> <property name="text">
<item> <string>&amp;Notes:</string>
<widget class="QLabel" name="notesLabel"> </property>
<property name="text"> <property name="alignment">
<string>&amp;Notes:</string> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property> </property>
<property name="alignment"> <property name="buddy">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> <cstring>notesEdit</cstring>
</property> </property>
<property name="buddy"> </widget>
<cstring>notesEdit</cstring> </item>
</property> <item>
</widget> <layout class="QHBoxLayout" name="horizontalLayout_3">
</item> <property name="topMargin">
<item> <number>6</number>
<layout class="QHBoxLayout" name="horizontalLayout_3"> </property>
<property name="topMargin"> <item>
<number>6</number> <spacer name="horizontalSpacer">
</property> <property name="orientation">
<item> <enum>Qt::Horizontal</enum>
<spacer name="horizontalSpacer"> </property>
<property name="orientation"> <property name="sizeHint" stdset="0">
<enum>Qt::Horizontal</enum> <size>
</property> <width>5</width>
<property name="sizeHint" stdset="0"> <height>20</height>
<size> </size>
<width>5</width> </property>
<height>20</height> </spacer>
</size> </item>
</property> <item>
</spacer> <widget class="QToolButton" name="revealNotesButton">
</item> <property name="toolTip">
<item> <string>Toggle notes visibility</string>
<widget class="QToolButton" name="revealNotesButton"> </property>
<property name="toolTip"> <property name="accessibleName">
<string>Toggle notes visibility</string> <string>Toggle notes visibility</string>
</property> </property>
<property name="accessibleName"> <property name="iconSize">
<string>Toggle notes visibility</string> <size>
</property> <width>14</width>
<property name="iconSize"> <height>14</height>
<size> </size>
<width>14</width> </property>
<height>14</height> <property name="checkable">
</size> <bool>true</bool>
</property> </property>
<property name="checkable"> </widget>
<bool>true</bool> </item>
</property> </layout>
</widget> </item>
</item> <item>
</layout> <spacer name="verticalSpacer">
</item> <property name="orientation">
<item> <enum>Qt::Vertical</enum>
<spacer name="verticalSpacer"> </property>
<property name="orientation"> <property name="sizeHint" stdset="0">
<enum>Qt::Vertical</enum> <size>
</property> <width>20</width>
<property name="sizeHint" stdset="0"> <height>40</height>
<size> </size>
<width>20</width> </property>
<height>40</height> </spacer>
</size> </item>
</property> </layout>
</spacer> </item>
</item> <item row="5" column="0">
</layout> <widget class="QLabel" name="tagsLabel">
</item> <property name="text">
<item row="5" column="0"> <string>T&amp;ags:</string>
<widget class="QLabel" name="tagsLabel"> </property>
<property name="text"> <property name="alignment">
<string>T&amp;ags:</string> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property> </property>
<property name="alignment"> <property name="buddy">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> <cstring>tagsList</cstring>
</property> </property>
<property name="buddy"> </widget>
<cstring>tagsList</cstring> </item>
</property> <item row="7" column="0">
</widget> <widget class="QLabel" name="expireLabel">
</item> <property name="text">
<item row="7" column="0"> <string>&amp;Expires:</string>
<widget class="QLabel" name="expireLabel"> </property>
<property name="text"> <property name="alignment">
<string>&amp;Expires:</string> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property> </property>
<property name="alignment"> <property name="buddy">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> <cstring>expireCheck</cstring>
</property> </property>
<property name="buddy"> </widget>
<cstring>expireCheck</cstring> </item>
</property> </layout>
</widget> </widget>
</item> </widget>
</layout> <customwidgets>
</widget> <customwidget>
</widget> <class>TagsEdit</class>
<customwidgets> <extends>QWidget</extends>
<customwidget> <header>gui/tag/TagsEdit.h</header>
<class>TagsEdit</class> <container>1</container>
<extends>QWidget</extends> </customwidget>
<header>gui/tag/TagsEdit.h</header> <customwidget>
<container>1</container> <class>URLEdit</class>
</customwidget> <extends>QLineEdit</extends>
<customwidget> <header>gui/URLEdit.h</header>
<class>URLEdit</class> <container>1</container>
<extends>QLineEdit</extends> </customwidget>
<header>gui/URLEdit.h</header> <customwidget>
<container>1</container> <class>PasswordWidget</class>
</customwidget> <extends>QWidget</extends>
<customwidget> <header>gui/PasswordWidget.h</header>
<class>PasswordWidget</class> <container>1</container>
<extends>QWidget</extends> </customwidget>
<header>gui/PasswordWidget.h</header> </customwidgets>
<container>1</container> <tabstops>
</customwidget> <tabstop>titleEdit</tabstop>
</customwidgets> <tabstop>usernameComboBox</tabstop>
<tabstops> <tabstop>passwordEdit</tabstop>
<tabstop>titleEdit</tabstop> <tabstop>urlEdit</tabstop>
<tabstop>usernameComboBox</tabstop> <tabstop>fetchFaviconButton</tabstop>
<tabstop>passwordEdit</tabstop> <tabstop>tagsList</tabstop>
<tabstop>urlEdit</tabstop> <tabstop>expireCheck</tabstop>
<tabstop>fetchFaviconButton</tabstop> <tabstop>expireDatePicker</tabstop>
<tabstop>tagsList</tabstop> <tabstop>expirePresets</tabstop>
<tabstop>expireCheck</tabstop> <tabstop>revealNotesButton</tabstop>
<tabstop>expireDatePicker</tabstop> <tabstop>notesEdit</tabstop>
<tabstop>expirePresets</tabstop> </tabstops>
<tabstop>revealNotesButton</tabstop> <resources/>
<tabstop>notesEdit</tabstop> <connections/>
</tabstops> </ui>
<resources/>
<connections/>
</ui>

View file

@ -27,6 +27,7 @@
#include <QLineEdit> #include <QLineEdit>
#include <QMenu> #include <QMenu>
#include <QMimeData> #include <QMimeData>
#include <QRegExp>
#include <QStandardPaths> #include <QStandardPaths>
#include <QTemporaryFile> #include <QTemporaryFile>
@ -368,8 +369,9 @@ void EntryAttachmentsWidget::saveSelectedAttachments()
QStringList errors; QStringList errors;
for (const QModelIndex& index : indexes) { for (const QModelIndex& index : indexes) {
const QString filename = m_attachmentsModel->keyByIndex(index); QString attachmentKey = m_attachmentsModel->keyByIndex(index);
const QString attachmentPath = saveDir.absoluteFilePath(filename); const QString fileNameSanitized = attachmentKey.replace(QRegExp("[/\\\\]"), "");
const QString attachmentPath = saveDir.absoluteFilePath(fileNameSanitized);
if (QFileInfo::exists(attachmentPath)) { if (QFileInfo::exists(attachmentPath)) {
@ -382,7 +384,7 @@ void EntryAttachmentsWidget::saveSelectedAttachments()
tr("Are you sure you want to overwrite the existing file \"%1\" with the attachment?")); tr("Are you sure you want to overwrite the existing file \"%1\" with the attachment?"));
auto result = MessageBox::question( auto result = MessageBox::question(
this, tr("Confirm overwrite"), questionText.arg(filename), buttons, MessageBox::Cancel); this, tr("Confirm overwrite"), questionText.arg(fileNameSanitized), buttons, MessageBox::Cancel);
if (result == MessageBox::Skip) { if (result == MessageBox::Skip) {
continue; continue;
@ -392,11 +394,11 @@ void EntryAttachmentsWidget::saveSelectedAttachments()
} }
QFile file(attachmentPath); QFile file(attachmentPath);
const QByteArray attachmentData = m_entryAttachments->value(filename); const QByteArray attachmentData = m_entryAttachments->value(attachmentKey);
const bool saveOk = file.open(QIODevice::WriteOnly) && file.setPermissions(QFile::ReadUser | QFile::WriteUser) const bool saveOk = file.open(QIODevice::WriteOnly) && file.setPermissions(QFile::ReadUser | QFile::WriteUser)
&& file.write(attachmentData) == attachmentData.size(); && file.write(attachmentData) == attachmentData.size();
if (!saveOk) { if (!saveOk) {
errors.append(QString("%1 - %2").arg(filename, file.errorString())); errors.append(QString("%1 - %2").arg(fileNameSanitized, file.errorString()));
} }
} }

View file

@ -56,4 +56,7 @@ void TextAttachmentsEditWidget::updateUi()
{ {
m_ui->attachmentsTextEdit->setPlainText(m_attachment.data); m_ui->attachmentsTextEdit->setPlainText(m_attachment.data);
m_ui->attachmentsTextEdit->setReadOnly(m_mode == attachments::OpenMode::ReadOnly); m_ui->attachmentsTextEdit->setReadOnly(m_mode == attachments::OpenMode::ReadOnly);
m_ui->attachmentsTextEdit->setTabStopDistance(
QFontMetrics(m_ui->attachmentsTextEdit->font()).horizontalAdvance(QString(4, ' ')));
} }

View file

@ -57,11 +57,7 @@
</layout> </layout>
</item> </item>
<item> <item>
<widget class="QTextEdit" name="attachmentsTextEdit"> <widget class="QTextEdit" name="attachmentsTextEdit"/>
<property name="tabStopDistance">
<double>10.000000000000000</double>
</property>
</widget>
</item> </item>
</layout> </layout>
</widget> </widget>

View file

@ -99,6 +99,9 @@ void TextAttachmentsPreviewWidget::initTypeCombobox()
// Configure text browser to open external links // Configure text browser to open external links
m_ui->previewTextBrowser->setOpenExternalLinks(true); m_ui->previewTextBrowser->setOpenExternalLinks(true);
m_ui->previewTextBrowser->setTabStopDistance(
QFontMetrics(m_ui->previewTextBrowser->font()).horizontalAdvance(QString(4, ' ')));
m_ui->typeComboBox->setCurrentIndex(m_ui->typeComboBox->findData(PlainText)); m_ui->typeComboBox->setCurrentIndex(m_ui->typeComboBox->findData(PlainText));
onTypeChanged(m_ui->typeComboBox->currentIndex()); onTypeChanged(m_ui->typeComboBox->currentIndex());

View file

@ -61,11 +61,7 @@
</layout> </layout>
</item> </item>
<item> <item>
<widget class="QTextBrowser" name="previewTextBrowser"> <widget class="QTextBrowser" name="previewTextBrowser"/>
<property name="tabStopDistance">
<double>10.000000000000000</double>
</property>
</widget>
</item> </item>
</layout> </layout>
</widget> </widget>

View file

@ -121,8 +121,6 @@ void DarkStyle::polish(QWidget* widget)
palette.setColor(QPalette::Inactive, QPalette::Window, QRgb(0x2D2D2D)); palette.setColor(QPalette::Inactive, QPalette::Window, QRgb(0x2D2D2D));
palette.setColor(QPalette::Disabled, QPalette::Window, QRgb(0x2D2D2D)); palette.setColor(QPalette::Disabled, QPalette::Window, QRgb(0x2D2D2D));
} }
#elif defined(Q_OS_WIN)
palette.setColor(QPalette::All, QPalette::Window, QRgb(0x2F2F30));
#else #else
palette.setColor(QPalette::Active, QPalette::Window, QRgb(0x2F2F30)); palette.setColor(QPalette::Active, QPalette::Window, QRgb(0x2F2F30));
palette.setColor(QPalette::Inactive, QPalette::Window, QRgb(0x313133)); palette.setColor(QPalette::Inactive, QPalette::Window, QRgb(0x313133));

View file

@ -121,8 +121,6 @@ void LightStyle::polish(QWidget* widget)
palette.setColor(QPalette::Inactive, QPalette::Window, QRgb(0xF5F5F5)); palette.setColor(QPalette::Inactive, QPalette::Window, QRgb(0xF5F5F5));
palette.setColor(QPalette::Disabled, QPalette::Window, QRgb(0xF5F5F5)); palette.setColor(QPalette::Disabled, QPalette::Window, QRgb(0xF5F5F5));
} }
#elif defined(Q_OS_WIN)
palette.setColor(QPalette::All, QPalette::Window, QRgb(0xFFFFFF));
#else #else
palette.setColor(QPalette::Active, QPalette::Window, QRgb(0xEFF0F1)); palette.setColor(QPalette::Active, QPalette::Window, QRgb(0xEFF0F1));
palette.setColor(QPalette::Inactive, QPalette::Window, QRgb(0xEFF0F1)); palette.setColor(QPalette::Inactive, QPalette::Window, QRgb(0xEFF0F1));

View file

@ -216,7 +216,7 @@ add_unit_test(NAME testdatabase SOURCES TestDatabase.cpp
LIBS testsupport ${TEST_LIBRARIES}) LIBS testsupport ${TEST_LIBRARIES})
add_unit_test(NAME testtools SOURCES TestTools.cpp add_unit_test(NAME testtools SOURCES TestTools.cpp
LIBS ${TEST_LIBRARIES}) LIBS testsupport ${TEST_LIBRARIES})
add_unit_test(NAME testconfig SOURCES TestConfig.cpp add_unit_test(NAME testconfig SOURCES TestConfig.cpp
LIBS testsupport ${TEST_LIBRARIES}) LIBS testsupport ${TEST_LIBRARIES})

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org> * Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -854,3 +854,56 @@ void TestBrowser::testRestrictBrowserKey()
QCOMPARE(sorted[2]->url(), QString("https://example.com/2")); QCOMPARE(sorted[2]->url(), QString("https://example.com/2"));
QCOMPARE(sorted[3]->url(), QString("https://example.com/0")); QCOMPARE(sorted[3]->url(), QString("https://example.com/0"));
} }
void TestBrowser::testHideEntry()
{
const auto db = QSharedPointer<Database>::create();
auto* root = db->rootGroup();
const auto entry = new Entry();
entry->setGroup(root);
entry->beginUpdate();
entry->setUrl(QString("https://github.com/"));
entry->setUsername(QString("User 1"));
entry->setUuid(QUuid::createUuid());
entry->setTitle(QString("Name_ 1"));
entry->endUpdate();
// Entry should be found normally
auto result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session");
QCOMPARE(result.length(), 1);
QCOMPARE(result[0]->url(), QString("https://github.com/"));
// Hide entry from entry settings, group setting is inherited
entry->customData()->set(BrowserService::OPTION_HIDE_ENTRY, TRUE_STR);
result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session");
QCOMPARE(result.length(), 0);
// Disable hide from group settings, entry should be found
root->setCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY, Group::Disable);
result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session");
QCOMPARE(result.length(), 1);
// Enable hide from group setting, entry should not be found
root->setCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY, Group::Enable);
result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session");
QCOMPARE(result.length(), 0);
// Remove the hide settings from entry, return group setting to inherit
entry->customData()->set(BrowserService::OPTION_HIDE_ENTRY, FALSE_STR);
root->setCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY, Group::Inherit);
// Entry should be found again
result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session");
QCOMPARE(result.length(), 1);
// Enable hide from group setting, entry should not be found
root->setCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY, Group::Enable);
result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session");
QCOMPARE(result.length(), 0);
// Disable hide from group settings, entry should be found
root->setCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY, Group::Disable);
result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session");
QCOMPARE(result.length(), 1);
}

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org> * Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -51,6 +51,7 @@ private slots:
void testBestMatchingCredentials(); void testBestMatchingCredentials();
void testBestMatchingWithAdditionalURLs(); void testBestMatchingWithAdditionalURLs();
void testRestrictBrowserKey(); void testRestrictBrowserKey();
void testHideEntry();
private: private:
QList<Entry*> createEntries(QStringList& urls, Group* root, bool additionalUrl = false) const; QList<Entry*> createEntries(QStringList& urls, Group* root, bool additionalUrl = false) const;

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org> * Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -327,6 +327,106 @@ void TestImports::testBitwardenPasskey()
QStringLiteral("aTFtdmFnOHYtS2dxVEJ0by1rSFpLWGg0enlTVC1iUVJReDZ5czJXa3c2aw")); QStringLiteral("aTFtdmFnOHYtS2dxVEJ0by1rSFpLWGg0enlTVC1iUVJReDZ5czJXa3c2aw"));
} }
void TestImports::testBitwardenNestedFolders()
{
auto bitwardenPath =
QStringLiteral("%1/%2").arg(KEEPASSX_TEST_DATA_DIR, QStringLiteral("/bitwarden_nested_export.json"));
BitwardenReader reader;
auto db = reader.convert(bitwardenPath);
QVERIFY2(!reader.hasError(), qPrintable(reader.errorString()));
QVERIFY(db);
/* The group tree should be:
/
- Example
- Test Authentication
/SecondTest
- GMail entry
/Test
- Gmail test 2
/Subfolder
- Webauthn.io test 2
/Subfolder
- Test Account
/SubFolder
- WebAuthn.io test
/AnotherSubFolder
- Another test account
- Webauthn.io test 3
*/
// Verify groups
auto secondTestGroup = db->rootGroup()->findGroupByPath("/SecondTest");
QVERIFY(secondTestGroup);
auto testGroup = db->rootGroup()->findGroupByPath("/Test");
QVERIFY(testGroup);
auto testSubfolderLowercaseGroup = db->rootGroup()->findGroupByPath("/Test/Subfolder");
QVERIFY(testSubfolderLowercaseGroup);
auto testSubFolderGroup = db->rootGroup()->findGroupByPath("/Test/SubFolder");
QVERIFY(testSubFolderGroup);
auto longGroup = db->rootGroup()->findGroupByPath("/Test/SubFolder/AnotherSubFolder");
QVERIFY(longGroup);
// Verify entries and the groups they belong to
// GMail entry
auto entry = db->rootGroup()->findEntryByPath("/SecondTest/GMail entry");
QVERIFY(entry);
QCOMPARE(entry->username(), QStringLiteral("example@gmail.com"));
QCOMPARE(entry->group(), secondTestGroup);
// Test Authentication
entry = db->rootGroup()->findEntryByPath("/Test Authentication");
QVERIFY(entry);
QCOMPARE(entry->username(), QStringLiteral("test@testauthentication.com"));
QCOMPARE(entry->group(), db->rootGroup());
// Gmail test 2
entry = db->rootGroup()->findEntryByPath("/Test/Gmail test 2");
QVERIFY(entry);
QCOMPARE(entry->username(), QStringLiteral("example2@gmail.com"));
QCOMPARE(entry->group(), testGroup);
// Example
entry = db->rootGroup()->findEntryByPath("/Example");
QVERIFY(entry);
QCOMPARE(entry->username(), QStringLiteral("user@example.com"));
QCOMPARE(entry->group(), db->rootGroup());
// WebAuthn.io test
entry = db->rootGroup()->findEntryByPath("/Test/SubFolder/WebAuthn.io test");
QVERIFY(entry);
QCOMPARE(entry->username(), QStringLiteral("testUser"));
QCOMPARE(entry->group(), testSubFolderGroup);
// Webauthn.io test 2
entry = db->rootGroup()->findEntryByPath("/Test/Subfolder/Webauthn.io test 2");
QVERIFY(entry);
QCOMPARE(entry->username(), QStringLiteral("testUser2"));
QCOMPARE(entry->group(), testSubfolderLowercaseGroup);
// Webauthn.io test 3
entry = db->rootGroup()->findEntryByPath("/Test/SubFolder/AnotherSubFolder/Webauthn.io test 3");
QVERIFY(entry);
QCOMPARE(entry->username(), QStringLiteral("testUser3"));
QCOMPARE(entry->group(), longGroup);
// Test Account
// There are two groups with an identical name. The group for this entry should not be the same group with the
// Webauthn.io test 2, but we cannot distinguish these.
entry = db->rootGroup()->findEntryByPath("/Test/Subfolder/Test Account");
QVERIFY(entry);
QCOMPARE(entry->username(), QStringLiteral("test-account"));
QCOMPARE(entry->group(), testSubfolderLowercaseGroup);
// Another test account
entry = db->rootGroup()->findEntryByPath("/Test/SubFolder/AnotherSubFolder/Another test account");
QVERIFY(entry);
QCOMPARE(entry->username(), QStringLiteral("anotherUser"));
QCOMPARE(entry->group(), longGroup);
}
void TestImports::testProtonPass() void TestImports::testProtonPass()
{ {
auto protonPassPath = auto protonPassPath =

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org> * Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -31,6 +31,7 @@ private slots:
void testBitwarden(); void testBitwarden();
void testBitwardenEncrypted(); void testBitwardenEncrypted();
void testBitwardenPasskey(); void testBitwardenPasskey();
void testBitwardenNestedFolders();
void testProtonPass(); void testProtonPass();
}; };

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org> * Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -77,7 +77,7 @@ const QString PublicKeyCredential = R"(
"id": "yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8", "id": "yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8",
"rawId": "cabcc52799707294f060c39d5d29b11796f9718425a813336db53f77ea052cef", "rawId": "cabcc52799707294f060c39d5d29b11796f9718425a813336db53f77ea052cef",
"response": { "response": {
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFAAAAAP2xQbJdhEQ-ijVGmMIFpQIAIMq8xSeZcHKU8GDDnV0psReW-XGEJagTM221P3fqBSzvpQECAyYgASFYIAbsrzRbYpFhbRlZA6ZQKsoxxJWoaeXwh-XUuDLNCIXdIlgg4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M", "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBdAAAAAP2xQbJdhEQ-ijVGmMIFpQIAIMq8xSeZcHKU8GDDnV0psReW-XGEJagTM221P3fqBSzvpQECAyYgASFYIHK1iVimeR02UYipyiEKrKhhfhJRMew8EbDWGKtMZ2wUIlggbtZ70X11SLx17QFDWVAR3_qqk5OqrRS--Whc7hyw9YU",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoibFZlSHpWeFdzcjhNUXhNa1pGMHRpNkZYaGRnTWxqcUt6Z0EtcV96azJNbmlpM2VKNDdWRjk3c3FVb1lrdFZDODVXQVoxdUlBU20tYV9sREZad3NMZnciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ" "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoibFZlSHpWeFdzcjhNUXhNa1pGMHRpNkZYaGRnTWxqcUt6Z0EtcV96azJNbmlpM2VKNDdWRjk3c3FVb1lrdFZDODVXQVoxdUlBU20tYV9sREZad3NMZnciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ"
}, },
"type": "public-key" "type": "public-key"
@ -185,11 +185,13 @@ void TestPasskeys::testDecodeResponseData()
QCOMPARE(authData["rpIdHash"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); QCOMPARE(authData["rpIdHash"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA"));
QCOMPARE(flags["AT"], true); QCOMPARE(flags["AT"], true);
QCOMPARE(flags["UP"], true); QCOMPARE(flags["UP"], true);
QCOMPARE(flags["BE"], true);
QCOMPARE(flags["BS"], true);
QCOMPARE(publicKey["1"], 2); QCOMPARE(publicKey["1"], 2);
QCOMPARE(publicKey["3"], -7); QCOMPARE(publicKey["3"], -7);
QCOMPARE(publicKey["-1"], 1); QCOMPARE(publicKey["-1"], 1);
QCOMPARE(publicKey["-2"], QString("BuyvNFtikWFtGVkDplAqyjHElahp5fCH5dS4Ms0Ihd0")); QCOMPARE(publicKey["-2"], QString("crWJWKZ5HTZRiKnKIQqsqGF-ElEx7DwRsNYYq0xnbBQ"));
QCOMPARE(publicKey["-3"], QString("4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M")); QCOMPARE(publicKey["-3"], QString("btZ70X11SLx17QFDWVAR3_qqk5OqrRS--Whc7hyw9YU"));
} }
void TestPasskeys::testLoadingECPrivateKeyFromPem() void TestPasskeys::testLoadingECPrivateKeyFromPem()
@ -276,23 +278,27 @@ void TestPasskeys::testCreatingAttestationObjectWithEC()
auto rpIdHash = browserMessageBuilder()->getSha256HashAsBase64(QString("webauthn.io")); auto rpIdHash = browserMessageBuilder()->getSha256HashAsBase64(QString("webauthn.io"));
QCOMPARE(rpIdHash, QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); QCOMPARE(rpIdHash, QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA"));
TestingVariables testingVariables = {id, predefinedFirst, predefinedSecond}; TestingVariables testingVariables = {id, predefinedFirst, predefinedSecond, QString()};
const auto alg = browserPasskeys()->getAlgorithmFromPublicKey(credentialCreationOptions); const auto alg = browserPasskeys()->getAlgorithmFromPublicKey(credentialCreationOptions);
const auto credentialPrivateKey = const auto credentialPrivateKey = browserPasskeys()->buildCredentialPrivateKey(alg, testingVariables);
browserPasskeys()->buildCredentialPrivateKey(alg, predefinedFirst, predefinedSecond);
auto result = browserPasskeys()->buildAttestationObject( auto result = browserPasskeys()->buildAttestationObject(
credentialCreationOptions, "", id, credentialPrivateKey.cborEncodedPublicKey, testingVariables); credentialCreationOptions, "", id, credentialPrivateKey.cborEncodedPublicKey, testingVariables);
QCOMPARE( QCOMPARE(
result, result,
QString("\xA3" QString("\xA3"
"cfmtdnonegattStmt\xA0hauthDataX\xA4t\xA6\xEA\x92\x13\xC9\x9C/t\xB2$\x92\xB3 \xCF@&*\x94\xC1\xA9P\xA0" "cfmtdnonegattStmt\xA0hauthDataX\xA4t\xA6\xEA\x92\x13\xC9\x9C/t\xB2$\x92\xB3 \xCF@&*\x94\xC1\xA9P\xA0"
"9\x7F)%\x0B`\x84\x1E\xF0" "9\x7F)%\x0B`\x84\x1E\xF0]\x00\x00\x00\x00\xFD\xB1"
"E\x00\x00\x00\x01\x01\x02\x03\x04\x05\x06\x07\b\x01\x02\x03\x04\x05\x06\x07\b\x00 \x8B\xB0\xCA" "A\xB2]\x84"
"6\x17\xD6\xDE\x01\x11|\xEA\x94\r\xA0R\xC0\x80_\xF3r\xFBr\xB5\x02\x03:" "D>\x8A"
"\xBAr\x0Fi\x81\xFE\xA5\x01\x02\x03& \x01!X " "5F\x98\xC2\x05\xA5\x02\x00 \xCA\xBC\xC5'\x99pr\x94\xF0`\xC3\x9D])\xB1\x17\x96\xF9q\x84%\xA8\x13"
"e\xE2\xF2\x1F:cq\xD3G\xEA\xE0\xF7\x1F\xCF\xFA\\\xABO\xF6\x86\x88\x80\t\xAE\x81\x8BT\xB2\x9B\x15\x85~" "3m\xB5?w\xEA\x05,\xEF\xA5\x01\x02\x03& \x01!X \x06\xEC\xAF"
"\"X \\\x8E\x1E@\xDB\x97T-\xF8\x9B\xB0\xAD" "4[b\x91"
"5\xDC\x12^\xC3\x95\x05\xC6\xDF^\x03\xCB\xB4Q\x91\xFF|\xDB\x94\xB7")); "am\x19Y\x03\xA6P*\xCA"
"1\xC4\x95\xA8i\xE5\xF0\x87\xE5\xD4\xB8"
"2\xCD\b\x85\xDD\"X \xE2\xEE\x7F\xE9\x0F\x0E\xE9\x1D\x07\x83J\x03\t\xDB"
"B$\xB1\x0B\xD3%\xFF\x18"
"2\xE1S\x99\xB7\x1D"
"B\x04\xE7\x83"));
// Double check that the result can be decoded // Double check that the result can be decoded
BrowserCbor browserCbor; BrowserCbor browserCbor;
@ -313,6 +319,8 @@ void TestPasskeys::testCreatingAttestationObjectWithEC()
QCOMPARE(authData["rpIdHash"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); QCOMPARE(authData["rpIdHash"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA"));
QCOMPARE(flags["AT"], true); QCOMPARE(flags["AT"], true);
QCOMPARE(flags["UP"], true); QCOMPARE(flags["UP"], true);
QCOMPARE(flags["BE"], true);
QCOMPARE(flags["BS"], true);
QCOMPARE(publicKey["1"], WebAuthnCoseKeyType::EC2); QCOMPARE(publicKey["1"], WebAuthnCoseKeyType::EC2);
QCOMPARE(publicKey["3"], WebAuthnAlgorithms::ES256); QCOMPARE(publicKey["3"], WebAuthnAlgorithms::ES256);
QCOMPARE(publicKey["-1"], 1); QCOMPARE(publicKey["-1"], 1);
@ -344,10 +352,9 @@ void TestPasskeys::testCreatingAttestationObjectWithRSA()
auto rpIdHash = browserMessageBuilder()->getSha256HashAsBase64(QString("webauthn.io")); auto rpIdHash = browserMessageBuilder()->getSha256HashAsBase64(QString("webauthn.io"));
QCOMPARE(rpIdHash, QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); QCOMPARE(rpIdHash, QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA"));
TestingVariables testingVariables = {id, predefinedModulus, predefinedExponent}; TestingVariables testingVariables = {id, predefinedModulus, predefinedExponent, QString()};
const auto alg = browserPasskeys()->getAlgorithmFromPublicKey(credentialCreationOptions); const auto alg = browserPasskeys()->getAlgorithmFromPublicKey(credentialCreationOptions);
auto credentialPrivateKey = auto credentialPrivateKey = browserPasskeys()->buildCredentialPrivateKey(alg, testingVariables);
browserPasskeys()->buildCredentialPrivateKey(alg, predefinedModulus, predefinedExponent);
auto result = browserPasskeys()->buildAttestationObject( auto result = browserPasskeys()->buildAttestationObject(
credentialCreationOptions, "", id, credentialPrivateKey.cborEncodedPublicKey, testingVariables); credentialCreationOptions, "", id, credentialPrivateKey.cborEncodedPublicKey, testingVariables);
@ -370,6 +377,8 @@ void TestPasskeys::testCreatingAttestationObjectWithRSA()
QCOMPARE(authData["rpIdHash"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); QCOMPARE(authData["rpIdHash"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA"));
QCOMPARE(flags["AT"], true); QCOMPARE(flags["AT"], true);
QCOMPARE(flags["UP"], true); QCOMPARE(flags["UP"], true);
QCOMPARE(flags["BE"], true);
QCOMPARE(flags["BS"], true);
QCOMPARE(publicKey["1"], WebAuthnCoseKeyType::RSA); QCOMPARE(publicKey["1"], WebAuthnCoseKeyType::RSA);
QCOMPARE(publicKey["3"], WebAuthnAlgorithms::RS256); QCOMPARE(publicKey["3"], WebAuthnAlgorithms::RS256);
QCOMPARE(publicKey["-1"], predefinedModulus); QCOMPARE(publicKey["-1"], predefinedModulus);
@ -380,8 +389,7 @@ void TestPasskeys::testRegister()
{ {
// Predefined values for a desired outcome // Predefined values for a desired outcome
const auto predefinedId = QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8"); const auto predefinedId = QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8");
const auto predefinedX = QString("BuyvNFtikWFtGVkDplAqyjHElahp5fCH5dS4Ms0Ihd0"); const auto predefinedData = QString("0x4B0E8AB07B1E62CCD4CB7B9D5BC9DE7B6EED7A3C8A3D466DB12897755E3D7E6D");
const auto predefinedY = QString("4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M");
const auto origin = QString("https://webauthn.io"); const auto origin = QString("https://webauthn.io");
const auto testDataPublicKey = browserMessageBuilder()->getJsonObject(PublicKeyCredential.toUtf8()); const auto testDataPublicKey = browserMessageBuilder()->getJsonObject(PublicKeyCredential.toUtf8());
const auto testDataResponse = testDataPublicKey["response"]; const auto testDataResponse = testDataPublicKey["response"];
@ -392,7 +400,7 @@ void TestPasskeys::testRegister()
publicKeyCredentialOptions, origin, &credentialCreationOptions); publicKeyCredentialOptions, origin, &credentialCreationOptions);
QVERIFY(creationResult == 0); QVERIFY(creationResult == 0);
TestingVariables testingVariables = {predefinedId, predefinedX, predefinedY}; TestingVariables testingVariables = {predefinedId, QString(), QString(), predefinedData};
auto result = browserPasskeys()->buildRegisterPublicKeyCredential(credentialCreationOptions, testingVariables); auto result = browserPasskeys()->buildRegisterPublicKeyCredential(credentialCreationOptions, testingVariables);
auto publicKeyCredential = result.response; auto publicKeyCredential = result.response;
QCOMPARE(publicKeyCredential["type"], QString("public-key")); QCOMPARE(publicKeyCredential["type"], QString("public-key"));
@ -402,6 +410,9 @@ void TestPasskeys::testRegister()
auto response = publicKeyCredential["response"].toObject(); auto response = publicKeyCredential["response"].toObject();
auto attestationObject = response["attestationObject"].toString(); auto attestationObject = response["attestationObject"].toString();
auto clientDataJson = response["clientDataJSON"].toString(); auto clientDataJson = response["clientDataJSON"].toString();
QCOMPARE(response["publicKey"],
QString("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEcrWJWKZ5HTZRiKnKIQqsqGF-"
"ElEx7DwRsNYYq0xnbBRu1nvRfXVIvHXtAUNZUBHf-qqTk6qtFL75aFzuHLD1hQ"));
QCOMPARE(attestationObject, testDataResponse["attestationObject"].toString()); QCOMPARE(attestationObject, testDataResponse["attestationObject"].toString());
// Parse clientDataJSON // Parse clientDataJSON
@ -438,14 +449,14 @@ void TestPasskeys::testGet()
QCOMPARE(publicKeyCredential["id"].toString(), id); QCOMPARE(publicKeyCredential["id"].toString(), id);
auto response = publicKeyCredential["response"].toObject(); auto response = publicKeyCredential["response"].toObject();
QCOMPARE(response["authenticatorData"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvAFAAAAAA")); QCOMPARE(response["authenticatorData"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvAdAAAAAA"));
QCOMPARE(response["clientDataJSON"].toString(), QCOMPARE(response["clientDataJSON"].toString(),
QString("eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiOXozNnZUZlFUTDk1TGY3V25aZ3l0ZTdvaEdlRi1YUmlMeGtML" QString("eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiOXozNnZUZlFUTDk1TGY3V25aZ3l0ZTdvaEdlRi1YUmlMeGtML"
"Ux1R1Uxem9wUm1NSVVBMUxWd3pHcHlJbTFmT0JuMVFuUmEwUUgyN0FEQWFKR0h5c1EiLCJvcmlnaW4iOiJodHRwczovL3dlYm" "Ux1R1Uxem9wUm1NSVVBMUxWd3pHcHlJbTFmT0JuMVFuUmEwUUgyN0FEQWFKR0h5c1EiLCJvcmlnaW4iOiJodHRwczovL3dlYm"
"F1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ")); "F1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ"));
QCOMPARE( QCOMPARE(
response["signature"].toString(), response["signature"].toString(),
QString("MEYCIQCpbDaYJ4b2ofqWBxfRNbH3XCpsyao7Iui5lVuJRU9HIQIhAPl5moNZgJu5zmurkKK_P900Ct6wd3ahVIqCEqTeeRdE")); QString("MEUCIQCvg3nXO2fiNK9ockxscgPtoM9_u6ERaW2-F1L99YasOAIgNhYOjPJyKJ-W8roV531kC59ss1USas7jy8TfRnbJLtg"));
auto clientDataJson = response["clientDataJSON"].toString(); auto clientDataJson = response["clientDataJSON"].toString();
auto clientDataByteArray = browserMessageBuilder()->getArrayFromBase64(clientDataJson); auto clientDataByteArray = browserMessageBuilder()->getArrayFromBase64(clientDataJson);

View file

@ -19,6 +19,7 @@
#include "core/Clock.h" #include "core/Clock.h"
#include "core/Tools.h" #include "core/Tools.h"
#include "mock/MockClock.h"
#include <QFileInfo> #include <QFileInfo>
#include <QRegularExpression> #include <QRegularExpression>
@ -33,8 +34,23 @@ namespace
{ {
return wholes + QLocale().decimalPoint() + fractions + " " + unit; return wholes + QLocale().decimalPoint() + fractions + " " + unit;
} }
MockClock* s_clock = nullptr;
} // namespace } // namespace
void TestTools::initTestCase()
{
Q_ASSERT(s_clock == nullptr);
s_clock = new MockClock(2026, 3, 8, 21, 45, 05);
MockClock::setup(s_clock);
}
void TestTools::cleanupTestCase()
{
MockClock::teardown();
s_clock = nullptr;
}
void TestTools::testHumanReadableFileSize() void TestTools::testHumanReadableFileSize()
{ {
constexpr auto kibibyte = 1024u; constexpr auto kibibyte = 1024u;

View file

@ -24,6 +24,8 @@ class TestTools : public QObject
{ {
Q_OBJECT Q_OBJECT
private slots: private slots:
void initTestCase();
void cleanupTestCase();
void testHumanReadableFileSize(); void testHumanReadableFileSize();
void testIsHex(); void testIsHex();
void testIsBase64(); void testIsBase64();

View file

@ -0,0 +1,249 @@
{
"encrypted": false,
"folders": [
{
"id": "53f3c6e7-a167-47e2-91bb-b3f900a377a3",
"name": "SecondTest"
},
{
"id": "14f22922-b8ed-4e9c-814b-b3f900a36a92",
"name": "Test"
},
{
"id": "da442766-39b4-4fb1-a2f3-b3f900a3a36d",
"name": "Test/Subfolder"
},
{
"id": "0504d89b-00aa-41a5-9355-b3f900a3b43f",
"name": "Test/Subfolder"
},
{
"id": "c96cf0e9-fd44-4a7e-9619-b3f900a58e59",
"name": "Test/SubFolder"
},
{
"id": "5d262faf-329d-4197-9e8d-b3f900a3934c",
"name": "Test/SubFolder/AnotherSubFolder"
}
],
"items": [
{
"passwordHistory": [],
"revisionDate": "2026-02-22T09:57:23.033Z",
"creationDate": "2026-02-17T16:55:23.210Z",
"id": "6b154a7d-4b62-44aa-ae0d-b3f40116e266",
"folderId": "53f3c6e7-a167-47e2-91bb-b3f900a377a3",
"type": 1,
"reprompt": 0,
"name": "GMail entry",
"notes": null,
"favorite": false,
"fields": [],
"login": {
"uris": [
{
"uri": "https://accounts.google.com"
}
],
"fido2Credentials": [],
"username": "example@gmail.com",
"password": "examplePassword",
"totp": null
},
"collectionIds": null
},
{
"passwordHistory": [],
"revisionDate": "2026-02-20T17:45:32.670Z",
"creationDate": "2026-02-20T17:45:32.670Z",
"id": "6ccafb74-ecab-482f-88b2-b3f70124a91b",
"type": 1,
"reprompt": 0,
"name": "Test Authentication",
"notes": null,
"favorite": false,
"fields": [],
"login": {
"uris": [
{
"uri": "https://testauthentication.com/login"
}
],
"fido2Credentials": [],
"username": "test@testauthentication.com",
"password": "testPassword",
"totp": null
},
"collectionIds": null
},
{
"passwordHistory": [],
"revisionDate": "2026-02-22T09:57:15.540Z",
"creationDate": "2026-02-06T19:22:48.860Z",
"id": "a9f00893-346e-4be6-ac4e-b3e9013f6065",
"folderId": "14f22922-b8ed-4e9c-814b-b3f900a36a92",
"type": 1,
"reprompt": 0,
"name": "Gmail test 2",
"notes": null,
"favorite": false,
"fields": [],
"login": {
"uris": [
{
"uri": "https://accounts.google.com"
}
],
"fido2Credentials": [],
"username": "example2@gmail.com",
"password": "examplePassword2",
"totp": null
},
"collectionIds": null
},
{
"passwordHistory": [],
"revisionDate": "2026-02-09T13:15:16.370Z",
"creationDate": "2026-02-09T13:15:16.113Z",
"id": "b375fe89-756a-41be-bcec-b3ec00da6d55",
"type": 1,
"reprompt": 0,
"name": "Example",
"notes": null,
"favorite": false,
"fields": [],
"login": {
"uris": [
{
"uri": "https://www.example.com/"
}
],
"username": "user@example.com",
"password": "examplePassword",
"totp": null
},
"collectionIds": null
},
{
"passwordHistory": [],
"revisionDate": "2026-02-22T10:03:05.596Z",
"creationDate": "2026-02-09T13:15:45.020Z",
"id": "b42284e2-a103-4dd0-982c-b3ec00da8f36",
"folderId": "c96cf0e9-fd44-4a7e-9619-b3f900a58e59",
"type": 1,
"reprompt": 0,
"name": "WebAuthn.io test",
"notes": null,
"favorite": false,
"fields": [],
"login": {
"uris": [
{
"uri": "https://webauthn.io/"
}
],
"username": "testUser",
"password": "testPassword",
"totp": null
},
"collectionIds": null
},
{
"passwordHistory": [],
"revisionDate": "2026-02-22T09:56:58.923Z",
"creationDate": "2024-10-23T16:38:08.606Z",
"id": "a8e579f0-98c2-4ac9-a126-b212011225f8",
"folderId": "da442766-39b4-4fb1-a2f3-b3f900a3a36d",
"type": 1,
"reprompt": 0,
"name": "Webauthn.io test 2",
"notes": null,
"favorite": false,
"fields": [],
"login": {
"uris": [
{
"uri": "https://webauthn.io/"
}
],
"username": "testUser2",
"password": "testPassword2",
"totp": null
},
"collectionIds": null
},
{
"passwordHistory": [],
"revisionDate": "2026-02-22T09:56:46.026Z",
"creationDate": "2025-10-29T06:13:55.333Z",
"id": "a88363cf-9fea-43d8-a3dd-b3850066b36a",
"folderId": "5d262faf-329d-4197-9e8d-b3f900a3934c",
"type": 1,
"reprompt": 0,
"name": "Webauthn.io test 3",
"notes": null,
"favorite": false,
"fields": [],
"login": {
"uris": [
{
"uri": "https://webauthn.io/"
}
],
"username": "testUser3",
"password": "testPassword3",
"totp": null
},
"collectionIds": null
},
{
"passwordHistory": [],
"revisionDate": "2025-09-12T16:25:15.850Z",
"creationDate": "2025-09-12T16:25:15.850Z",
"id": "d2946603-1bfc-4eee-8805-b356010e9c65",
"folderId": "0504d89b-00aa-41a5-9355-b3f900a3b43f",
"type": 1,
"reprompt": 0,
"name": "Test Account",
"notes": null,
"favorite": false,
"fields": [],
"login": {
"uris": [
{
"uri": "https://testsite.com/"
}
],
"fido2Credentials": [],
"username": "test-account",
"password": "test",
"totp": null
},
"collectionIds": null
},
{
"passwordHistory": [],
"revisionDate": "2026-02-22T09:56:52.673Z",
"creationDate": "2024-10-23T19:17:45.433Z",
"id": "4e3b570e-3ead-4557-b5be-b212013dfcd0",
"folderId": "5d262faf-329d-4197-9e8d-b3f900a3934c",
"type": 1,
"reprompt": 0,
"name": "Another test account",
"notes": null,
"favorite": false,
"fields": [],
"login": {
"uris": [
{
"uri": "https://anothertestsite.org/"
}
],
"username": "anotherUser",
"password": "anotherPassword",
"totp": null
},
"collectionIds": null
}
]
}

View file

@ -346,8 +346,8 @@ void TestGui::testMergeDatabase()
fileDialog()->setNextFileName(QString(KEEPASSX_TEST_DATA_DIR).append("/MergeDatabase.kdbx")); fileDialog()->setNextFileName(QString(KEEPASSX_TEST_DATA_DIR).append("/MergeDatabase.kdbx"));
triggerAction("actionDatabaseMerge"); triggerAction("actionDatabaseMerge");
QTRY_COMPARE(QApplication::focusWidget()->objectName(), QString("passwordEdit")); QWidget* editPasswordMerge;
auto* editPasswordMerge = QApplication::focusWidget(); QTRY_VERIFY((editPasswordMerge = QApplication::focusWidget()) && editPasswordMerge->objectName() == "passwordEdit");
QVERIFY(editPasswordMerge->isVisible()); QVERIFY(editPasswordMerge->isVisible());
QTest::keyClicks(editPasswordMerge, "a"); QTest::keyClicks(editPasswordMerge, "a");
@ -1931,18 +1931,28 @@ void TestGui::testTrayRestoreHide()
trayIcon->activated(QSystemTrayIcon::Trigger); trayIcon->activated(QSystemTrayIcon::Trigger);
QTRY_VERIFY(m_mainWindow->isVisible()); QTRY_VERIFY(m_mainWindow->isVisible());
// Wait out window hide grace period before triggering tray icon again
int gracePeriod = 250;
#ifdef Q_OS_WIN
// Windows requires a shorter grace period
gracePeriod = 50;
#endif
Tools::wait(gracePeriod);
trayIcon->activated(QSystemTrayIcon::Trigger); trayIcon->activated(QSystemTrayIcon::Trigger);
QTRY_VERIFY(!m_mainWindow->isVisible()); QTRY_VERIFY(!m_mainWindow->isVisible());
trayIcon->activated(QSystemTrayIcon::MiddleClick); trayIcon->activated(QSystemTrayIcon::MiddleClick);
QTRY_VERIFY(m_mainWindow->isVisible()); QTRY_VERIFY(m_mainWindow->isVisible());
Tools::wait(gracePeriod);
trayIcon->activated(QSystemTrayIcon::MiddleClick); trayIcon->activated(QSystemTrayIcon::MiddleClick);
QTRY_VERIFY(!m_mainWindow->isVisible()); QTRY_VERIFY(!m_mainWindow->isVisible());
trayIcon->activated(QSystemTrayIcon::DoubleClick); trayIcon->activated(QSystemTrayIcon::DoubleClick);
QTRY_VERIFY(m_mainWindow->isVisible()); QTRY_VERIFY(m_mainWindow->isVisible());
Tools::wait(gracePeriod);
trayIcon->activated(QSystemTrayIcon::DoubleClick); trayIcon->activated(QSystemTrayIcon::DoubleClick);
QTRY_VERIFY(!m_mainWindow->isVisible()); QTRY_VERIFY(!m_mainWindow->isVisible());

View file

@ -43,10 +43,7 @@ void TestImageAttachmentsWidget::testFitInView()
auto zoomFactor = m_imageAttachmentsView->transform(); auto zoomFactor = m_imageAttachmentsView->transform();
m_widget->setMinimumSize(m_widget->size() + QSize{100, 100}); m_widget->setMinimumSize(m_widget->size() + QSize{100, 100});
QTRY_VERIFY(zoomFactor != m_imageAttachmentsView->transform());
QCoreApplication::processEvents();
QVERIFY(zoomFactor != m_imageAttachmentsView->transform());
} }
void TestImageAttachmentsWidget::testZoomCombobox() void TestImageAttachmentsWidget::testZoomCombobox()
@ -56,10 +53,7 @@ void TestImageAttachmentsWidget::testZoomCombobox()
QVERIFY(index != -1); QVERIFY(index != -1);
m_zoomCombobox->setCurrentIndex(index); m_zoomCombobox->setCurrentIndex(index);
QTRY_COMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(zoom, zoom));
QCoreApplication::processEvents();
QCOMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(zoom, zoom));
} }
} }
@ -67,10 +61,7 @@ void TestImageAttachmentsWidget::testEditZoomCombobox()
{ {
for (double i = 0.25; i < 5; i += 0.25) { for (double i = 0.25; i < 5; i += 0.25) {
m_zoomCombobox->setCurrentText(QString::number(i * 100)); m_zoomCombobox->setCurrentText(QString::number(i * 100));
QTRY_COMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(i, i));
QCoreApplication::processEvents();
QCOMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(i, i));
} }
} }
@ -79,19 +70,13 @@ void TestImageAttachmentsWidget::testEditWithPercentZoomCombobox()
// Example 100 % // Example 100 %
for (double i = 0.25; i < 5; i += 0.25) { for (double i = 0.25; i < 5; i += 0.25) {
m_zoomCombobox->setCurrentText(QString("%1 %").arg(i * 100)); m_zoomCombobox->setCurrentText(QString("%1 %").arg(i * 100));
QTRY_COMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(i, i));
QCoreApplication::processEvents();
QCOMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(i, i));
} }
// Example 100% // Example 100%
for (double i = 0.25; i < 5; i += 0.25) { for (double i = 0.25; i < 5; i += 0.25) {
m_zoomCombobox->setCurrentText(QString("%1%").arg(i * 100)); m_zoomCombobox->setCurrentText(QString("%1%").arg(i * 100));
QTRY_COMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(i, i));
QCoreApplication::processEvents();
QCOMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(i, i));
} }
} }
@ -108,10 +93,7 @@ void TestImageAttachmentsWidget::testInvalidValueZoomCombobox()
for (const auto& invalidValue : {"Help", "3,4", "", ".", "% 100"}) { for (const auto& invalidValue : {"Help", "3,4", "", ".", "% 100"}) {
m_zoomCombobox->setCurrentText(invalidValue); m_zoomCombobox->setCurrentText(invalidValue);
QTRY_COMPARE(m_imageAttachmentsView->transform(), expectedTransform);
QCoreApplication::processEvents();
QCOMPARE(m_imageAttachmentsView->transform(), expectedTransform);
} }
} }
@ -208,10 +190,7 @@ void TestImageAttachmentsWidget::testZoomLowerBound()
true); true);
QCoreApplication::sendEvent(m_imageAttachmentsView->viewport(), &event); QCoreApplication::sendEvent(m_imageAttachmentsView->viewport(), &event);
QTRY_COMPARE(m_imageAttachmentsView->transform(), expectTransform);
QCoreApplication::processEvents();
QCOMPARE(m_imageAttachmentsView->transform(), expectTransform);
} }
void TestImageAttachmentsWidget::testZoomUpperBound() void TestImageAttachmentsWidget::testZoomUpperBound()
@ -237,8 +216,5 @@ void TestImageAttachmentsWidget::testZoomUpperBound()
true); true);
QCoreApplication::sendEvent(m_imageAttachmentsView->viewport(), &event); QCoreApplication::sendEvent(m_imageAttachmentsView->viewport(), &event);
QTRY_COMPARE(m_imageAttachmentsView->transform(), expectTransform);
QCoreApplication::processEvents();
QCOMPARE(m_imageAttachmentsView->transform(), expectTransform);
} }

View file

@ -1,70 +0,0 @@
#!/usr/bin/env python3
from collections import defaultdict
import json
import sys
from pathlib import Path
from urllib import request
txrc = Path.home() / '.transifexrc'
if not txrc.exists():
print('No Transifex config found. Run tx init first.')
sys.exit(1)
org = 'o:keepassxc'
proj = f'{org}:p:keepassxc'
resource = f'{proj}:r:share-translations-keepassxc-en-ts--master'
token = [l for l in open(txrc, 'r') if l.startswith('token')][0].split('=', 1)[1].strip()
member_blacklist = ['u:droidmonkey', 'u:phoerious']
def get_url(url):
req = request.Request(url)
req.add_header('Content-Type', 'application/vnd.api+json')
req.add_header('Authorization', f'Bearer {token}')
with request.urlopen(req) as resp:
return json.load(resp)
print('Fetching languages...', file=sys.stderr)
languages_json = get_url(f'https://rest.api.transifex.com/projects/{proj}/languages')
languages = {}
for lang in languages_json['data']:
languages[lang['id']] = lang['attributes']['name']
print('Fetching language stats...', file=sys.stderr)
language_stats_json = get_url('https://rest.api.transifex.com/resource_language_stats?'
f'filter[project]={proj}&filter[resource]={resource}')
completion = {}
for stat in language_stats_json['data']:
completion = stat['attributes']['translated_strings'] / stat['attributes']['total_strings']
if completion < .6:
languages.pop(stat['relationships']['language']['data']['id'])
print('Fetching language members...', end='', file=sys.stderr)
members_json = get_url(f'https://rest.api.transifex.com/team_memberships?filter[organization]={org}')
members = defaultdict(set)
for member in members_json['data']:
print('.', end='', file=sys.stderr)
if member['relationships']['user']['data']['id'] in member_blacklist:
continue
lid = member['relationships']['language']['data']['id']
if lid not in languages:
continue
user = get_url(member['relationships']['user']['links']['related'])['data']['attributes']['username']
members[lid].add(user)
print(file=sys.stderr)
print('<ul>')
for lang in sorted(languages, key=lambda x: languages[x]):
if not members[lang]:
continue
lines = [f' <li><strong>{languages[lang]}:</strong> ']
for i, m in enumerate(sorted(members[lang], key=lambda x: x.lower())):
if len(lines[-1]) + len(m) >= 120:
lines.append(' ')
lines[-1] += m
if i < len(members[lang]) - 1:
lines[-1] += ', '
lines[-1] += '</li>'
print('\n'.join(lines))
print('</ul>')

View file

@ -1,7 +1,7 @@
{ {
"name": "keepassxc", "name": "keepassxc",
"version-string": "2.7.11", "version-string": "2.7.11",
"builtin-baseline": "dfb72f61c5a066ab75cd0bdcb2e007228bfc3270", "builtin-baseline": "66c0373dc7fca549e5803087b9487edfe3aca0a1",
"dependencies": [ "dependencies": [
{ {
"name": "argon2", "name": "argon2",