Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package otpclient for openSUSE:Factory 
checked in at 2026-04-23 17:09:05
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/otpclient (Old)
 and      /work/SRC/openSUSE:Factory/.otpclient.new.11940 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "otpclient"

Thu Apr 23 17:09:05 2026 rev:45 rq:1348892 version:4.5.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/otpclient/otpclient.changes      2026-04-22 
17:02:02.926078533 +0200
+++ /work/SRC/openSUSE:Factory/.otpclient.new.11940/otpclient.changes   
2026-04-23 17:13:41.135078643 +0200
@@ -1,0 +2,61 @@
+Wed Apr 22 09:36:00 UTC 2026 - Paolo Stivanin <[email protected]>
+
+- Update to 4.5.0:
+Features
+  * Search-provider trigger keyword (default otp): only desktop
+    search queries whose first whitespace-separated token equals
+    the keyword surface OTP results, so they're no longer drowned
+    under file/app/web runner output. Configurable in Settings →
+    Integration. Empty keyword falls back to legacy unfiltered
+    behaviour. Daemon restart required after a change.
+
+Security
+  * KRunner Match subtitle no longer leaks the live OTP code. Any
+    process on the session bus could previously poll Match and read
+    codes without user action; the code is still delivered via the
+    Run notification.
+  * Argon2id header validation: refuse v2 databases whose iter /
+    memcost / parallelism fall outside safe ARGON2ID_MIN/MAX_*
+    bounds (would otherwise enable memory-exhaustion or
+    KDF-weakening DoS on unlock).
+  * Core-dump suppression: prctl(PR_SET_DUMPABLE,0) +
+    RLIMIT_CORE=0 in init_libs() so a crash with secrets in memory
+    cannot leak them to disk.
+  * KDF byte-length fix: gcry_kdf_* was being passed
+    g_utf8_strlen (character count) instead of byte count,
+    weakening keys for non-ASCII passwords. Fixed in
+    db-common.c, common.c (AuthPro), aegis.c, twofas.c, and
+    freeotp.c. Existing v2 databases unlock via a transparent
+    retry path and are silently re-encrypted with the corrected
+    length on the next write.
+  * O_NOFOLLOW everywhere: new path_open_safe_regular_file()
+    helper (open(O_RDONLY | O_NOFOLLOW | O_CLOEXEC) + fstat
+    S_ISREG check) applied to all importers (Aegis, AuthPro,
+    2FAS, FreeOTP+) and to both database read sites. Subsequent
+    reads run through /proc/self/fd/<n> so the inode stays bound
+    across the whole operation, closing the symlink-swap TOCTOU
+    window.
+  * chmod 0600 on database backups so a permissive umask cannot
+    leave .bak files group/world-readable.
+  * otpauth:// URI length capped at 4 KB to prevent multi-
+    gigabyte allocations from malformed input.
+  * CLI hard-refuses --password-file with group/world-readable
+    permissions instead of merely warning; recommends chmod 600.
+  * GUI clears the system clipboard on SIGINT / SIGTERM / SIGHUP
+    via g_unix_signal_add and at shutdown.
+
+Fixes
+  * 2FAS importer: NULL-deref crashes on missing
+    servicesEncrypted, malformed colon-separated payload, and
+    under-sized AEAD ciphertext; secure-buffer leaks on decrypt
+    and tag-check failure; silent acceptance of unauthenticated
+    plaintext on tag mismatch (now properly rejected).
+  * 2FAS exporter: NULL-deref on missing algo / type fields.
+  * Aegis importer: NULL-deref when header.slots is missing or
+    the password slot lacks key_params.
+  * FreeOTP+ exporter: unconditional g_object_unref(NULL) after a
+    failed g_file_replace; correct byte length passed to write.
+  * AuthPro importer: leaked GFile / GFileInputStream on the
+    plain-backup path (no password).
+
+-------------------------------------------------------------------

Old:
----
  v4.4.2.tar.gz
  v4.4.2.tar.gz.asc

New:
----
  v4.5.0.tar.gz
  v4.5.0.tar.gz.asc

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ otpclient.spec ++++++
--- /var/tmp/diff_new_pack.Z3pgP1/_old  2026-04-23 17:13:41.987113732 +0200
+++ /var/tmp/diff_new_pack.Z3pgP1/_new  2026-04-23 17:13:41.991113897 +0200
@@ -18,7 +18,7 @@
 
 %define uclname OTPClient
 Name:           otpclient
-Version:        4.4.2
+Version:        4.5.0
 Release:        0
 Summary:        Simple GTK+ client for managing TOTP and HOTP
 License:        GPL-3.0-or-later

++++++ _scmsync.obsinfo ++++++
--- /var/tmp/diff_new_pack.Z3pgP1/_old  2026-04-23 17:13:42.051116368 +0200
+++ /var/tmp/diff_new_pack.Z3pgP1/_new  2026-04-23 17:13:42.055116533 +0200
@@ -1,5 +1,5 @@
-mtime: 1776776267
-commit: d70d9ec83ceec5d66a7aa57ca86887acaa554f4de1bdfbee54c6af307de8f9ef
+mtime: 1776869666
+commit: ef53c8887d69f22f7e9817b852da52ae692f250789ff09723f92e44aa68c34b2
 url: https://src.opensuse.org/GNOME/otpclient
 revision: factory
 

++++++ build.specials.obscpio ++++++

++++++ build.specials.obscpio ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/.gitignore new/.gitignore
--- old/.gitignore      1970-01-01 01:00:00.000000000 +0100
+++ new/.gitignore      2026-04-22 16:54:26.000000000 +0200
@@ -0,0 +1,4 @@
+*.obscpio
+*.osc
+_build.*
+.pbuild


++++++ v4.4.2.tar.gz -> v4.5.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/OTPClient-4.4.2/CMakeLists.txt 
new/OTPClient-4.5.0/CMakeLists.txt
--- old/OTPClient-4.4.2/CMakeLists.txt  2026-04-17 15:34:59.000000000 +0200
+++ new/OTPClient-4.5.0/CMakeLists.txt  2026-04-22 10:21:17.000000000 +0200
@@ -1,5 +1,5 @@
 cmake_minimum_required(VERSION 3.16)
-project(OTPClient VERSION "4.4.2" LANGUAGES "C")
+project(OTPClient VERSION "4.5.0" LANGUAGES "C")
 include(GNUInstallDirs)
 
 configure_file("src/common/version.h.in" "version.h")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/OTPClient-4.4.2/data/com.github.paolostivanin.OTPClient.appdata.xml 
new/OTPClient-4.5.0/data/com.github.paolostivanin.OTPClient.appdata.xml
--- old/OTPClient-4.4.2/data/com.github.paolostivanin.OTPClient.appdata.xml     
2026-04-17 15:34:59.000000000 +0200
+++ new/OTPClient-4.5.0/data/com.github.paolostivanin.OTPClient.appdata.xml     
2026-04-22 10:21:17.000000000 +0200
@@ -89,6 +89,30 @@
   </content_rating>
 
   <releases>
+    <release version="4.5.0" date="2026-04-22">
+      <description>
+        <p>OTPClient 4.5.0 is the final 4.x release. It adds a search-provider 
trigger keyword and back-fills security and correctness fixes from the 5.x 
branch:</p>
+        <ul>
+          <li>ADDED: search-provider trigger keyword (default "otp") so OTP 
results are not buried under other KRunner / GNOME Shell runner output. 
Configurable in Settings → Integration. Empty keyword falls back to legacy 
unfiltered behaviour. Daemon restart required.</li>
+          <li>ADDED: auto-copy OTP to system clipboard on search-provider 
activation.</li>
+          <li>SECURITY: stop including the live OTP code in the KRunner Match 
subtitle. Any process on the session bus could previously poll Match and read 
codes without user action.</li>
+          <li>SECURITY: validate Argon2id parameters in the v2 database header 
against safe min/max bounds; refuse to open files that would cause 
memory-exhaustion or KDF-weakening DoS on unlock.</li>
+          <li>SECURITY: disable core dumps via prctl(PR_SET_DUMPABLE, 0) and 
RLIMIT_CORE so a crash with secrets in memory cannot leak them to disk.</li>
+          <li>SECURITY: fix KDF byte-length bug (passed character count 
instead of byte count to gcry_kdf_*); non-ASCII passwords previously derived a 
weakened key. Existing databases are migrated automatically on the next 
unlock.</li>
+          <li>SECURITY: open imports (Aegis, AuthPro, 2FAS, FreeOTP+) and the 
database itself with O_NOFOLLOW + fstat regular-file check; subsequent reads 
happen via /proc/self/fd to close the symlink-swap TOCTOU window.</li>
+          <li>SECURITY: chmod 0600 on database backups so a permissive umask 
cannot leave .bak files group/world-readable.</li>
+          <li>SECURITY: cap individual otpauth:// URI length at 4 KB to 
prevent multi-gigabyte allocations from malformed input.</li>
+          <li>SECURITY: refuse to open --password-file with 
group/world-readable permissions instead of merely warning.</li>
+          <li>SECURITY: clear the system clipboard on SIGINT/SIGTERM/SIGHUP 
and at app shutdown.</li>
+          <li>FIXED: 2FAS importer crashes / leaks on missing 
servicesEncrypted, malformed base64 splits, under-sized AEAD payload, wrong 
gpg_error variable in decrypt logging, leaked secure buffer on decrypt or 
tag-check failure (silently accepted unauthenticated plaintext).</li>
+          <li>FIXED: 2FAS exporter NULL-deref on missing algo / type 
fields.</li>
+          <li>FIXED: Aegis importer NULL-deref when header.slots is missing or 
the password slot lacks key_params.</li>
+          <li>FIXED: FreeOTP+ exporter unconditional unref of NULL out_stream 
when g_file_replace fails; correct byte length passed to write.</li>
+          <li>FIXED: AuthPro plain-backup path leaked the file handle opened 
for the encrypted path.</li>
+          <li>IMPROVED: get_algo_int_from_str now warns and defaults to SHA1 
(RFC 6238) on unknown algorithm strings instead of silently using SHA512.</li>
+        </ul>
+      </description>
+    </release>
     <release version="4.4.1" date="2026-03-03">
       <description>
         <p>OTPClient 4.4.1 includes the following fixes:</p>
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/OTPClient-4.4.2/src/cli/exec-action.c 
new/OTPClient-4.5.0/src/cli/exec-action.c
--- old/OTPClient-4.4.2/src/cli/exec-action.c   2026-04-17 15:34:59.000000000 
+0200
+++ new/OTPClient-4.5.0/src/cli/exec-action.c   2026-04-22 10:21:17.000000000 
+0200
@@ -79,9 +79,10 @@
         }
         GStatBuf st;
         if (g_stat (cmdline_opts->password_file, &st) == 0 && (st.st_mode & 
077) != 0) {
-            g_printerr ("Warning: password file '%s' has group/world 
permissions (mode %04o). "
-                         "Consider restricting to owner-only (chmod 600).\n",
-                         cmdline_opts->password_file, (unsigned)(st.st_mode & 
0777));
+            g_printerr ("Refusing to open password file '%s': it has 
group/world permissions (mode %04o). "
+                         "Restrict to owner-only with: chmod 600 '%s'\n",
+                         cmdline_opts->password_file, (unsigned)(st.st_mode & 
0777), cmdline_opts->password_file);
+            return FALSE;
         }
         password_fd = g_open (cmdline_opts->password_file, O_RDONLY, 0);
         if (password_fd < 0) {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/OTPClient-4.4.2/src/common/aegis.c 
new/OTPClient-4.5.0/src/common/aegis.c
--- old/OTPClient-4.4.2/src/common/aegis.c      2026-04-17 15:34:59.000000000 
+0200
+++ new/OTPClient-4.5.0/src/common/aegis.c      2026-04-22 10:21:17.000000000 
+0200
@@ -1,8 +1,10 @@
 #include <glib.h>
 #include <glib/gi18n.h>
+#include <glib/gstdio.h>
 #include <gio/gio.h>
 #include <gcrypt.h>
 #include <jansson.h>
+#include <string.h>
 #include <time.h>
 #include <uuid/uuid.h>
 #include "gquarks.h"
@@ -42,12 +44,17 @@
                 gsize            db_size,
                 GError         **err)
 {
-    if (g_file_test (path, G_FILE_TEST_IS_SYMLINK | G_FILE_TEST_IS_DIR) ) {
-        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Selected 
file is either a symlink or a directory.");
+    // Open via O_NOFOLLOW + fstat check so symlink/directory swaps cannot
+    // win the race between the test and the read. Subsequent file APIs
+    // operate on /proc/self/fd/<fd>, which keeps reads bound to the same
+    // inode we just verified.
+    gint safe_fd = path_open_safe_regular_file (path, err);
+    if (safe_fd < 0) {
         return NULL;
     }
+    g_autofree gchar *safe_path = g_strdup_printf ("/proc/self/fd/%d", 
safe_fd);
 
-    goffset input_size = get_file_size (path);
+    goffset input_size = get_file_size (safe_path);
     if (!is_secmem_available ((db_size + input_size)  * 
SECMEM_REQUIRED_MULTIPLIER, err)) {
         g_autofree gchar *msg = g_strdup_printf (_(
             "Your system's secure memory limit is not enough to securely 
import the data.\n"
@@ -56,10 +63,13 @@
             "This requires administrator privileges and is a system-wide 
setting that OTPClient cannot change automatically."
         ));
         g_set_error (err, secmem_alloc_error_gquark (), 
NO_SECMEM_AVAIL_ERRCODE, "%s", msg);
+        g_close (safe_fd, NULL);
         return NULL;
     }
 
-    return (password != NULL) ? get_otps_from_encrypted_backup (path, 
password, max_file_size, err) : get_otps_from_plain_backup (path, err);
+    GSList *result = (password != NULL) ? get_otps_from_encrypted_backup 
(safe_path, password, max_file_size, err) : get_otps_from_plain_backup 
(safe_path, err);
+    g_close (safe_fd, NULL);
+    return result;
 }
 
 
@@ -120,23 +130,50 @@
     }
 
     json_t *arr = json_object_get (json_object_get(json, "header"), "slots");
+    if (arr == NULL || !json_is_array (arr)) {
+        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE,
+                     "Aegis backup is missing or has malformed 
'header.slots'.");
+        json_decref (json);
+        json_set_alloc_funcs (gcry_malloc_secure, gcry_free);
+        return NULL;
+    }
     gint index = 0;
-    for (; index < json_array_size(arr); index++) {
-        json_t *j_type = json_object_get (json_array_get(arr, index), "type");
+    json_t *wanted_obj = NULL;
+    for (; index < (gint)json_array_size(arr); index++) {
+        json_t *slot = json_array_get (arr, index);
+        if (slot == NULL) continue;
+        json_t *j_type = json_object_get (slot, "type");
         json_int_t int_type = json_integer_value (j_type);
-        if (int_type == 1) break;
+        if (int_type == 1) {
+            wanted_obj = slot;
+            break;
+        }
+    }
+    if (wanted_obj == NULL) {
+        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE,
+                     "Aegis backup contains no password-protected slot 
(type==1).");
+        json_decref (json);
+        json_set_alloc_funcs (gcry_malloc_secure, gcry_free);
+        return NULL;
+    }
+    json_t *kp = json_object_get (wanted_obj, "key_params");
+    if (kp == NULL) {
+        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE,
+                     "Aegis slot is missing 'key_params'.");
+        json_decref (json);
+        json_set_alloc_funcs (gcry_malloc_secure, gcry_free);
+        return NULL;
     }
-    json_t *wanted_obj = json_array_get (arr, index);
     gint n = (gint)json_integer_value (json_object_get (wanted_obj, "n"));
     gint p = (gint)json_integer_value (json_object_get (wanted_obj, "p"));
     guchar *salt = hexstr_to_bytes (json_string_value (json_object_get 
(wanted_obj, "salt")));
     guchar *enc_key = hexstr_to_bytes(json_string_value (json_object_get 
(wanted_obj, "key")));
-    json_t *kp = json_object_get (wanted_obj, "key_params");
     guchar *key_nonce = hexstr_to_bytes (json_string_value (json_object_get 
(kp, "nonce")));
     guchar *key_tag = hexstr_to_bytes (json_string_value (json_object_get (kp, 
"tag")));
     json_t *dbp = json_object_get(json_object_get(json, "header"), "params");
     guchar *keybuf = gcry_malloc (AEGIS_KEY_SIZE);
-    if (gcry_kdf_derive (password, g_utf8_strlen (password, -1), 
GCRY_KDF_SCRYPT, n, salt, AEGIS_SALT_SIZE,  p, AEGIS_KEY_SIZE, keybuf) != 0) {
+    // gcry_kdf_derive expects the password length in BYTES, not Unicode 
characters.
+    if (gcry_kdf_derive (password, strlen (password), GCRY_KDF_SCRYPT, n, 
salt, AEGIS_SALT_SIZE,  p, AEGIS_KEY_SIZE, keybuf) != 0) {
         g_printerr ("Error while deriving the key.\n");
         g_set_error (err, key_deriv_gquark (), KEY_DERIVATION_ERRCODE, "Error 
while deriving the Aegis decryption key.");
         g_free (salt);
@@ -310,7 +347,8 @@
         gcry_create_nonce (key_nonce, AEGIS_NONCE_SIZE);
 
         derived_master_key = gcry_calloc_secure(AEGIS_KEY_SIZE, 1);
-        gpg_error_t gpg_err = gcry_kdf_derive (password, g_utf8_strlen 
(password, -1), GCRY_KDF_SCRYPT, 32768, salt, AEGIS_SALT_SIZE,  1, 
AEGIS_KEY_SIZE, derived_master_key);
+        // gcry_kdf_derive expects the password length in BYTES, not Unicode 
characters.
+        gpg_error_t gpg_err = gcry_kdf_derive (password, strlen (password), 
GCRY_KDF_SCRYPT, 32768, salt, AEGIS_SALT_SIZE,  1, AEGIS_KEY_SIZE, 
derived_master_key);
         if (gpg_err) {
             g_printerr ("Error while deriving the key\n");
             gcry_free (derived_master_key);
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/OTPClient-4.4.2/src/common/authpro.c 
new/OTPClient-4.5.0/src/common/authpro.c
--- old/OTPClient-4.4.2/src/common/authpro.c    2026-04-17 15:34:59.000000000 
+0200
+++ new/OTPClient-4.5.0/src/common/authpro.c    2026-04-22 10:21:17.000000000 
+0200
@@ -1,4 +1,5 @@
 #include <glib.h>
+#include <glib/gstdio.h>
 #include <gio/gio.h>
 #include <gcrypt.h>
 #include <glib/gi18n.h>
@@ -28,12 +29,15 @@
                   gsize         db_size,
                   GError      **err)
 {
-    if (g_file_test (path, G_FILE_TEST_IS_SYMLINK | G_FILE_TEST_IS_DIR) ) {
-        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Selected 
file is either a symlink or a directory.");
+    // Open via O_NOFOLLOW + fstat check so symlink/directory swaps cannot
+    // win the race between the test and the read.
+    gint safe_fd = path_open_safe_regular_file (path, err);
+    if (safe_fd < 0) {
         return NULL;
     }
+    g_autofree gchar *safe_path = g_strdup_printf ("/proc/self/fd/%d", 
safe_fd);
 
-    goffset input_size = get_file_size (path);
+    goffset input_size = get_file_size (safe_path);
     if (!is_secmem_available ((db_size + input_size)  * 
SECMEM_REQUIRED_MULTIPLIER, err)) {
         g_autofree gchar *msg = g_strdup_printf (_(
             "Your system's secure memory limit is not enough to securely 
import the data.\n"
@@ -42,17 +46,30 @@
             "This requires administrator privileges and is a system-wide 
setting that OTPClient cannot change automatically."
         ));
         g_set_error (err, secmem_alloc_error_gquark (), 
NO_SECMEM_AVAIL_ERRCODE, "%s", msg);
+        g_close (safe_fd, NULL);
         return NULL;
     }
 
-    GFile *in_file = g_file_new_for_path (path);
+    // The plain-backup path doesn't need a GFile/GFileInputStream — only the
+    // encrypted path consumes them. Open lazily so we don't leak fds when no
+    // password is provided.
+    if (password == NULL) {
+        GSList *result = get_otps_from_plain_backup (safe_path, err);
+        g_close (safe_fd, NULL);
+        return result;
+    }
+
+    GFile *in_file = g_file_new_for_path (safe_path);
     GFileInputStream *in_stream = g_file_read (in_file, NULL, err);
     if (*err != NULL) {
         g_object_unref (in_file);
+        g_close (safe_fd, NULL);
         return NULL;
     }
 
-    return (password != NULL) ? get_otps_from_encrypted_backup (path, 
password, max_file_size, in_file, in_stream, err) : get_otps_from_plain_backup 
(path, err);
+    GSList *result = get_otps_from_encrypted_backup (safe_path, password, 
max_file_size, in_file, in_stream, err);
+    g_close (safe_fd, NULL);
+    return result;
 }
 
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/OTPClient-4.4.2/src/common/common.c 
new/OTPClient-4.5.0/src/common/common.c
--- old/OTPClient-4.4.2/src/common/common.c     2026-04-17 15:34:59.000000000 
+0200
+++ new/OTPClient-4.5.0/src/common/common.c     2026-04-22 10:21:17.000000000 
+0200
@@ -1,7 +1,18 @@
+// O_NOFOLLOW / O_CLOEXEC and prctl/setrlimit live behind POSIX (and Linux)
+// feature-test macros; opt in explicitly because CMAKE_C_EXTENSIONS=OFF
+// otherwise strips us back to strict ISO C11.
+#define _POSIX_C_SOURCE 200809L
+#define _DEFAULT_SOURCE
+
 #include <glib.h>
 #include <glib/gi18n.h>
 #include <gio/gio.h>
 #include <sys/resource.h>
+#include <sys/prctl.h>
+#include <sys/stat.h>
+#include <fcntl.h>
+#include <unistd.h>
+#include <errno.h>
 #include <cotp.h>
 #include "gcrypt.h"
 #include "jansson.h"
@@ -33,6 +44,16 @@
 gchar *
 init_libs (gint32 max_file_size)
 {
+    // Prevent secrets from leaking into core dumps if the process crashes.
+    // Best-effort: a failure here is non-fatal but worth a warning.
+    if (prctl (PR_SET_DUMPABLE, 0, 0, 0, 0) != 0) {
+        g_warning ("Failed to disable core dumps via PR_SET_DUMPABLE; secrets 
may leak on crash.");
+    }
+    struct rlimit core_limit = { 0, 0 };
+    if (setrlimit (RLIMIT_CORE, &core_limit) != 0) {
+        g_warning ("Failed to set RLIMIT_CORE to 0; core dumps may still be 
produced on crash.");
+    }
+
     gcry_control(GCRYCTL_SET_PREFERRED_RNG_TYPE, GCRY_RNG_TYPE_SYSTEM);
     if (!gcry_check_version ("1.10.1")) {
         return g_strdup ("The required version of GCrypt is 1.10.1 or 
greater.");
@@ -52,16 +73,42 @@
 gint
 get_algo_int_from_str (const gchar *algo)
 {
-    gint algo_int;
-    if (g_strcmp0 (algo, "SHA1") == 0) {
-        algo_int = COTP_SHA1;
-    } else if (g_strcmp0 (algo, "SHA256") == 0) {
-        algo_int = COTP_SHA256;
-    } else {
-        algo_int = COTP_SHA512;
-    }
+    if (g_strcmp0 (algo, "SHA1") == 0)   return COTP_SHA1;
+    if (g_strcmp0 (algo, "SHA256") == 0) return COTP_SHA256;
+    if (g_strcmp0 (algo, "SHA512") == 0) return COTP_SHA512;
+    g_warning ("Unknown OTP algorithm '%s', defaulting to SHA1.", algo ? algo 
: "(null)");
+    return COTP_SHA1;
+}
 
-    return algo_int;
+
+gint
+path_open_safe_regular_file (const gchar  *path,
+                             GError      **err)
+{
+    if (path == NULL || path[0] == '\0') {
+        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Empty 
file path.");
+        return -1;
+    }
+    int fd = open (path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
+    if (fd < 0) {
+        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE,
+                     "Refusing to open '%s': %s", path, g_strerror (errno));
+        return -1;
+    }
+    struct stat st;
+    if (fstat (fd, &st) != 0) {
+        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE,
+                     "Failed to fstat '%s': %s", path, g_strerror (errno));
+        close (fd);
+        return -1;
+    }
+    if (!S_ISREG (st.st_mode)) {
+        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE,
+                     "Refusing to open '%s': not a regular file.", path);
+        close (fd);
+        return -1;
+    }
+    return fd;
 }
 
 
@@ -175,9 +222,10 @@
     // taglen, iterations, memory_cost (65536=64MiB), parallelism
     const unsigned long params[4] = {32, 3, 65536, 4};
     gcry_kdf_hd_t hd;
+    // gcry_kdf_open expects the password length in BYTES, not Unicode 
characters.
     if (gcry_kdf_open (&hd, GCRY_KDF_ARGON2, GCRY_KDF_ARGON2ID,
                        params, 4,
-                       password,  (gsize)g_utf8_strlen (password, -1),
+                       password, strlen (password),
                        salt, AUTHPRO_SALT_TAG,
                        NULL, 0, NULL, 0) != GPG_ERR_NO_ERROR) {
         g_printerr ("Error while opening the KDF handler\n");
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/OTPClient-4.4.2/src/common/common.h 
new/OTPClient-4.5.0/src/common/common.h
--- old/OTPClient-4.4.2/src/common/common.h     2026-04-17 15:34:59.000000000 
+0200
+++ new/OTPClient-4.5.0/src/common/common.h     2026-04-22 10:21:17.000000000 
+0200
@@ -90,4 +90,11 @@
 gboolean          is_secmem_available            (gsize               
required_size,
                                                   GError            **err);
 
+// Opens `path` with O_RDONLY|O_NOFOLLOW|O_CLOEXEC and verifies via fstat that
+// it is a regular file. Returns the open fd on success, or -1 with **err set.
+// Caller must close(fd). Use /proc/self/fd/<fd> to feed a path to GIO APIs
+// while the underlying file descriptor stays bound to the original inode.
+gint              path_open_safe_regular_file    (const gchar        *path,
+                                                  GError            **err);
+
 G_END_DECLS
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/OTPClient-4.4.2/src/common/db-common.c 
new/OTPClient-4.5.0/src/common/db-common.c
--- old/OTPClient-4.4.2/src/common/db-common.c  2026-04-17 15:34:59.000000000 
+0200
+++ new/OTPClient-4.5.0/src/common/db-common.c  2026-04-22 10:21:17.000000000 
+0200
@@ -15,6 +15,15 @@
 static guchar   *get_db_derived_key (DatabaseData     *db_data,
                                      gint32            db_version,
                                      const guint8     *salt,
+                                     gboolean          use_legacy_length,
+                                     GError          **err);
+
+static gchar    *try_decrypt_v2     (DatabaseData     *db_data,
+                                     DbHeaderData_v2  *header_data,
+                                     guchar           *enc_buf,
+                                     gsize             enc_buf_size,
+                                     const guchar     *tag,
+                                     gboolean          use_legacy_length,
                                      GError          **err);
 
 static gchar    *decrypt_db         (DatabaseData     *db_data,
@@ -94,6 +103,14 @@
         guint32 hash = json_object_get_hash (obj);
         db_data->objects_hash = g_slist_append (db_data->objects_hash, 
g_memdup2 (&hash, sizeof (guint32)));
     }
+
+    // Opportunistic KDF byte-length migration: if decrypt only succeeded with
+    // the legacy g_utf8_strlen length, silently re-encrypt now with the
+    // corrected strlen length. encrypt_db clears the flag on success.
+    if (db_data->needs_legacy_kdf_migration) {
+        g_message ("Migrating database to corrected KDF password byte 
length.");
+        update_db (db_data, err);
+    }
 }
 
 
@@ -190,11 +207,22 @@
 get_db_version (const gchar *db_path)
 {
     GError *err = NULL;
-    GFile *in_file = g_file_new_for_path (db_path);
+    // Open through O_NOFOLLOW + fstat so a symlinked DB path can't be swapped
+    // for another file between the existence check and the read.
+    gint safe_fd = path_open_safe_regular_file (db_path, &err);
+    if (safe_fd < 0) {
+        g_printerr ("%s\n", err->message);
+        g_clear_error (&err);
+        return -1;
+    }
+    g_autofree gchar *safe_path = g_strdup_printf ("/proc/self/fd/%d", 
safe_fd);
+
+    GFile *in_file = g_file_new_for_path (safe_path);
     GFileInputStream *in_stream = g_file_read (in_file, NULL, &err);
     if (!in_stream) {
         g_printerr ("%s\n", err->message);
         cleanup_db_gfile (in_file, NULL, err);
+        g_close (safe_fd, NULL);
         return -1;
     }
 
@@ -203,11 +231,15 @@
         g_printerr ("%s\n", err->message);
         g_free (header_name);
         cleanup_db_gfile (in_file, in_stream, err);
+        g_close (safe_fd, NULL);
         return -1;
     }
 
     gint32 version = (g_strcmp0 (DB_HEADER_NAME, header_name) == 0) ? 
DB_VERSION : 1;
     g_free (header_name);
+    g_object_unref (in_stream);
+    g_object_unref (in_file);
+    g_close (safe_fd, NULL);
 
     return version;
 }
@@ -217,10 +249,25 @@
 get_db_derived_key (DatabaseData  *db_data,
                     gint32         db_version,
                     const guint8  *salt,
+                    gboolean       use_legacy_length,
                     GError       **err)
 {
+    // gcry_kdf_* expects the password length in BYTES, but historically this
+    // code passed g_utf8_strlen (CHARACTER count), truncating non-ASCII 
passwords
+    // mid-byte and weakening the KDF. strlen is correct; 
use_legacy_length=TRUE
+    // is only set on the retry path used to read databases written by older
+    // OTPClient versions (see try_decrypt_v2 / decrypt_db).
+    gsize pwd_len = use_legacy_length
+                    ? (gsize) g_utf8_strlen (db_data->key, -1)
+                    : strlen (db_data->key);
+
     guchar *derived_key = NULL;
     if (db_version < 2) {
+        // v1 (PBKDF2) databases were always written with the legacy length, so
+        // their decrypt path keeps the legacy behavior. After successful load
+        // the caller migrates the DB to v2 with the corrected length.
+        pwd_len = (gsize) g_utf8_strlen (db_data->key, -1);
+
         gsize key_len = gcry_cipher_get_algo_keylen (GCRY_CIPHER_AES256);
 
         derived_key = gcry_malloc_secure (key_len);
@@ -229,7 +276,7 @@
             return NULL;
         }
 
-        if (gcry_kdf_derive (db_data->key, (gsize)g_utf8_strlen (db_data->key, 
-1),
+        if (gcry_kdf_derive (db_data->key, pwd_len,
                              GCRY_KDF_PBKDF2, GCRY_MD_SHA512,
                              salt, KDF_SALT_SIZE,
                              KDF_ITERATIONS, key_len, derived_key) != 
GPG_ERR_NO_ERROR) {
@@ -243,7 +290,7 @@
         gcry_kdf_hd_t hd;
         if (gcry_kdf_open (&hd, GCRY_KDF_ARGON2, GCRY_KDF_ARGON2ID,
                            params, 4,
-                           db_data->key, (gsize)g_utf8_strlen (db_data->key, 
-1),
+                           db_data->key, pwd_len,
                            salt, KDF_SALT_SIZE,
                            NULL, 0, NULL, 0) != GPG_ERR_NO_ERROR) {
             gcry_free (derived_key);
@@ -270,16 +317,83 @@
 
 
 static gchar *
+try_decrypt_v2 (DatabaseData    *db_data,
+                DbHeaderData_v2 *header_data,
+                guchar          *enc_buf,
+                gsize            enc_buf_size,
+                const guchar    *tag,
+                gboolean         use_legacy_length,
+                GError         **err)
+{
+    guchar *derived_key = get_db_derived_key (db_data, 
db_data->current_db_version, header_data->salt, use_legacy_length, err);
+    if (derived_key == NULL) {
+        return NULL;
+    }
+
+    gcry_cipher_hd_t hd = open_cipher_and_set_data (derived_key, 
header_data->iv, IV_SIZE);
+    if (!hd) {
+        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Error 
while opening and setting the cipher data.");
+        gcry_free (derived_key);
+        return NULL;
+    }
+
+    if (gcry_cipher_authenticate (hd, header_data, sizeof(DbHeaderData_v2)) != 
GPG_ERR_NO_ERROR) {
+        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Error 
while processing the authenticated data.");
+        gcry_cipher_close (hd);
+        gcry_free (derived_key);
+        return NULL;
+    }
+
+    gchar *dec_buf = gcry_calloc_secure (enc_buf_size, 1);
+    if (!dec_buf) {
+        g_set_error (err, secmem_alloc_error_gquark (), SECMEM_ALLOC_ERRCODE, 
"Error while allocating secure memory.");
+        gcry_cipher_close (hd);
+        gcry_free (derived_key);
+        return NULL;
+    }
+
+    if (gcry_cipher_decrypt (hd, dec_buf, enc_buf_size, enc_buf, enc_buf_size) 
!= GPG_ERR_NO_ERROR) {
+        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Error 
while decrypting the data.");
+        gcry_cipher_close (hd);
+        gcry_free (derived_key);
+        gcry_free (dec_buf);
+        return NULL;
+    }
+
+    if (gcry_err_code (gcry_cipher_checktag (hd, tag, TAG_SIZE)) == 
GPG_ERR_CHECKSUM) {
+        g_set_error (err, bad_tag_gquark (), BAD_TAG_ERRCODE, "The tag doesn't 
match. Either the password is wrong or the file is corrupted.");
+        gcry_cipher_close (hd);
+        gcry_free (derived_key);
+        gcry_free (dec_buf);
+        return NULL;
+    }
+
+    gcry_cipher_close (hd);
+    gcry_free (derived_key);
+    return dec_buf;
+}
+
+
+static gchar *
 decrypt_db (DatabaseData *db_data,
             GError      **err)
 {
     g_return_val_if_fail (err == NULL || *err == NULL, NULL);
 
-    GFile *in_file = g_file_new_for_path (db_data->db_path);
+    // Open through O_NOFOLLOW + fstat so a symlinked DB path can't be swapped
+    // for another file between get_db_version and decrypt_db.
+    gint safe_fd = path_open_safe_regular_file (db_data->db_path, err);
+    if (safe_fd < 0) {
+        return NULL;
+    }
+    g_autofree gchar *safe_path = g_strdup_printf ("/proc/self/fd/%d", 
safe_fd);
+
+    GFile *in_file = g_file_new_for_path (safe_path);
     GFileInputStream *in_stream = g_file_read (in_file, NULL, NULL);
     if (!in_stream) {
         g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Failed to 
read the database file.");
         g_object_unref (in_file);
+        g_close (safe_fd, NULL);
         return NULL;
     }
 
@@ -304,16 +418,34 @@
         g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Failed to 
read the header data.");
         cleanup_db_gfile (in_file, in_stream, NULL);
         free_db_resources(NULL, NULL, NULL, NULL, header_data_v1, 
header_data_v2);
+        g_close (safe_fd, NULL);
         return NULL;
     }
 
-    goffset input_file_size = get_file_size (db_data->db_path);
+    if (db_data->current_db_version >= 2) {
+        if (db_data->argon2id_iter < ARGON2ID_MIN_ITER || 
db_data->argon2id_iter > ARGON2ID_MAX_ITER ||
+            db_data->argon2id_memcost < ARGON2ID_MIN_MC || 
db_data->argon2id_memcost > ARGON2ID_MAX_MC ||
+            db_data->argon2id_parallelism < ARGON2ID_MIN_PARAL || 
db_data->argon2id_parallelism > ARGON2ID_MAX_PARAL) {
+            g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE,
+                         "Database header contains out-of-range Argon2id 
parameters "
+                         "(iter=%d, memcost=%d KiB, parallelism=%d). Refusing 
to open: the file may be tampered or corrupted.",
+                         db_data->argon2id_iter, db_data->argon2id_memcost, 
db_data->argon2id_parallelism);
+            cleanup_db_gfile (in_file, in_stream, NULL);
+            free_db_resources(NULL, NULL, NULL, NULL, header_data_v1, 
header_data_v2);
+            g_close (safe_fd, NULL);
+            return NULL;
+        }
+    }
+
+    // Use safe_path so the size is bound to the same inode the fd is open on.
+    goffset input_file_size = get_file_size (safe_path);
     guchar tag[TAG_SIZE];
     if (!g_seekable_seek (G_SEEKABLE(in_stream), input_file_size - TAG_SIZE, 
G_SEEK_SET, NULL, NULL) ||
         g_input_stream_read (G_INPUT_STREAM(in_stream), tag, TAG_SIZE, NULL, 
NULL) == -1) {
             g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, 
"Failed to read the stored tag.");
             cleanup_db_gfile (in_file, in_stream, NULL);
             free_db_resources(NULL, NULL, NULL, NULL, header_data_v1, 
header_data_v2);
+            g_close (safe_fd, NULL);
             return NULL;
     }
 
@@ -324,42 +456,53 @@
             g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, 
"Failed to read the encrypted data.");
             cleanup_db_gfile (in_file, in_stream, NULL);
             free_db_resources(NULL, NULL, enc_buf, NULL, header_data_v1, 
header_data_v2);
+            g_close (safe_fd, NULL);
             return NULL;
     }
 
     g_object_unref (in_stream);
     g_object_unref (in_file);
+    g_close (safe_fd, NULL);
 
-    guchar *derived_key = NULL;
     if (db_data->current_db_version >= 2) {
-        derived_key = get_db_derived_key (db_data, 
db_data->current_db_version, header_data_v2->salt, err);
-    } else {
-        derived_key = get_db_derived_key (db_data, 
db_data->current_db_version, header_data_v1->salt, err);
+        // Modern path: try the corrected (strlen) password length first.
+        gchar *dec_buf = try_decrypt_v2 (db_data, header_data_v2, enc_buf, 
enc_buf_size, tag, FALSE, err);
+
+        if (dec_buf == NULL && err != NULL && *err != NULL && (*err)->domain 
== bad_tag_gquark ()) {
+            // BAD_TAG with the corrected length: the DB may have been written
+            // by an older OTPClient that used g_utf8_strlen. Skip the retry
+            // for ASCII passwords where both lengths agree (no point in
+            // running Argon2id twice on every wrong-password attempt).
+            if (strlen (db_data->key) != (gsize) g_utf8_strlen (db_data->key, 
-1)) {
+                g_clear_error (err);
+                dec_buf = try_decrypt_v2 (db_data, header_data_v2, enc_buf, 
enc_buf_size, tag, TRUE, err);
+                if (dec_buf != NULL) {
+                    db_data->needs_legacy_kdf_migration = TRUE;
+                }
+            }
+        }
+
+        free_db_resources (NULL, NULL, enc_buf, NULL, header_data_v1, 
header_data_v2);
+        return dec_buf;
     }
+
+    // v1 (PBKDF2) path: always written with the legacy length, so decrypt with
+    // that. The caller migrates v1 -> v2 right after, which is when the
+    // corrected length starts being used.
+    guchar *derived_key = get_db_derived_key (db_data, 
db_data->current_db_version, header_data_v1->salt, FALSE, err);
     if (derived_key == NULL) {
         free_db_resources (NULL, NULL, enc_buf, NULL, header_data_v1, 
header_data_v2);
         return NULL;
     }
 
-    gcry_cipher_hd_t hd;
-    if (db_data->current_db_version >= 2) {
-        hd = open_cipher_and_set_data (derived_key, header_data_v2->iv, 
IV_SIZE);
-    } else {
-        hd = open_cipher_and_set_data (derived_key, header_data_v1->iv, 
IV_SIZE);
-    }
+    gcry_cipher_hd_t hd = open_cipher_and_set_data (derived_key, 
header_data_v1->iv, IV_SIZE);
     if (!hd) {
         g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Error 
while opening and setting the cipher data.");
         free_db_resources (NULL, derived_key, enc_buf, NULL, header_data_v1, 
header_data_v2);
         return NULL;
     }
 
-    gpg_error_t gpg_err;
-    if (db_data->current_db_version >= 2) {
-        gpg_err = gcry_cipher_authenticate (hd, header_data_v2, 
header_data_size);
-    } else {
-        gpg_err = gcry_cipher_authenticate (hd, header_data_v1, 
header_data_size);
-    }
-    if (gpg_err != GPG_ERR_NO_ERROR) {
+    if (gcry_cipher_authenticate (hd, header_data_v1, header_data_size) != 
GPG_ERR_NO_ERROR) {
         g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Error 
while processing the authenticated data.");
         free_db_resources (hd, derived_key, enc_buf, NULL, header_data_v1, 
header_data_v2);
         return NULL;
@@ -422,7 +565,10 @@
         return;
     }
 
-    guchar *derived_key = get_db_derived_key (db_data, 
header_data->db_version, header_data->salt, err);
+    // encrypt_db unconditionally uses the corrected (strlen) password byte 
length.
+    // The legacy g_utf8_strlen length is only used on the decrypt retry path
+    // when reading older databases (see decrypt_db / try_decrypt_v2).
+    guchar *derived_key = get_db_derived_key (db_data, 
header_data->db_version, header_data->salt, FALSE, err);
     if (derived_key == NULL) {
         cleanup_db_gfile (out_file, out_stream, NULL);
         g_free (header_data);
@@ -481,6 +627,8 @@
     cleanup_db_gfile (out_file, out_stream, NULL);
 
     db_data->current_db_version = DB_VERSION;
+    // The just-written file uses the corrected password byte length.
+    db_data->needs_legacy_kdf_migration = FALSE;
 }
 
 
@@ -503,16 +651,20 @@
     GFile *src = g_file_new_for_path (src_path);
     GFile *dst = g_file_new_for_path (dst_path);
 
-    g_free (src_path);
-    g_free (dst_path);
-
     if (!g_file_copy (src, dst, G_FILE_COPY_OVERWRITE | 
G_FILE_COPY_NOFOLLOW_SYMLINKS, NULL, NULL, NULL, &err)) {
         g_printerr ("Couldn't %s: %s\n", is_backup ? "create the backup" : 
"restore the backup", err->message);
         g_clear_error (&err);
     } else {
+        // Backup contains the same secrets as the main DB; force owner-only
+        // permissions so a permissive umask doesn't leave it 
group/world-readable.
+        if (g_chmod (dst_path, 0600) != 0) {
+            g_warning ("Failed to chmod 0600 on '%s'", dst_path);
+        }
         g_print("%s\n", is_backup ? _("Backup copy successfully created.") : 
_("Backup copy successfully restored."));
     }
 
+    g_free (src_path);
+    g_free (dst_path);
     g_object_unref (src);
     g_object_unref (dst);
 }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/OTPClient-4.4.2/src/common/db-common.h 
new/OTPClient-4.5.0/src/common/db-common.h
--- old/OTPClient-4.4.2/src/common/db-common.h  2026-04-17 15:34:59.000000000 
+0200
+++ new/OTPClient-4.5.0/src/common/db-common.h  2026-04-22 10:21:17.000000000 
+0200
@@ -25,6 +25,16 @@
 #define ARGON2ID_DEFAULT_MC    131072  //128 MiB
 #define ARGON2ID_DEFAULT_PARAL      4
 
+// Bounds enforced when reading Argon2id parameters from a 
(potentially-tampered) DB header.
+// Below these floors the KDF would be cryptographically weak; above the 
ceilings
+// the open path is a denial-of-service vector (excessive memory / CPU).
+#define ARGON2ID_MIN_ITER           1
+#define ARGON2ID_MAX_ITER         100
+#define ARGON2ID_MIN_MC          8192   // 8 MiB
+#define ARGON2ID_MAX_MC       4194304   // 4 GiB
+#define ARGON2ID_MIN_PARAL          1
+#define ARGON2ID_MAX_PARAL         64
+
 
 typedef struct db_header_data_v1_t {
     guint8 iv[IV_SIZE];
@@ -66,6 +76,8 @@
     gint32 argon2id_iter;
     gint32 argon2id_memcost;
     gint32 argon2id_parallelism;
+
+    gboolean needs_legacy_kdf_migration;
 } DatabaseData;
 
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/OTPClient-4.4.2/src/common/freeotp.c 
new/OTPClient-4.5.0/src/common/freeotp.c
--- old/OTPClient-4.4.2/src/common/freeotp.c    2026-04-17 15:34:59.000000000 
+0200
+++ new/OTPClient-4.5.0/src/common/freeotp.c    2026-04-22 10:21:17.000000000 
+0200
@@ -1,6 +1,7 @@
 #include <glib.h>
 #include <gio/gio.h>
 #include <jansson.h>
+#include <string.h>
 #include <time.h>
 #include <glib/gi18n.h>
 
@@ -49,19 +50,18 @@
     GError *err = NULL;
     GFile *out_gfile = g_file_new_for_path (export_path);
     GFileOutputStream *out_stream = g_file_replace (out_gfile, NULL, FALSE, 
G_FILE_CREATE_REPLACE_DESTINATION | G_FILE_CREATE_PRIVATE, NULL, &err);
-    if (err == NULL) {
+    if (out_stream != NULL) {
         json_array_foreach (json_db_data, index, db_obj) {
             gchar *uri = get_otpauth_uri (db_obj);
-            if (g_output_stream_write (G_OUTPUT_STREAM(out_stream), uri, 
g_utf8_strlen (uri, -1), NULL, &err) == -1) {
+            if (g_output_stream_write (G_OUTPUT_STREAM(out_stream), uri, 
strlen (uri), NULL, &err) == -1) {
                 g_set_error (&err, generic_error_gquark (), GENERIC_ERRCODE, 
"couldn't dump json data to file");
             }
             g_free (uri);
         }
-    } else {
+        g_object_unref (out_stream);
+    } else if (err == NULL) {
         g_set_error (&err, generic_error_gquark (), GENERIC_ERRCODE, "couldn't 
create the file object");
     }
-
-    g_object_unref (out_stream);
     g_object_unref (out_gfile);
 
     return (err != NULL ? g_strdup (err->message) : NULL);
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/OTPClient-4.4.2/src/common/parse-uri.c 
new/OTPClient-4.5.0/src/common/parse-uri.c
--- old/OTPClient-4.4.2/src/common/parse-uri.c  2026-04-17 15:34:59.000000000 
+0200
+++ new/OTPClient-4.5.0/src/common/parse-uri.c  2026-04-22 10:21:17.000000000 
+0200
@@ -1,8 +1,16 @@
+// strnlen is POSIX 2008; opt in explicitly because CMAKE_C_EXTENSIONS=OFF
+// otherwise strips us back to strict ISO C11.
+#define _POSIX_C_SOURCE 200809L
+
 #include <glib.h>
+#include <glib/gstdio.h>
+#include <string.h>
 #include "common.h"
 #include "file-size.h"
 #include "gquarks.h"
 
+#define MAX_OTPAUTH_URI_LEN 4096
+
 static void   parse_uri            (const gchar   *uri,
                                     GSList       **otps);
 
@@ -116,24 +124,38 @@
                   GError      **err)
 {
     GSList *otps = NULL;
-    goffset fs = get_file_size (path);
+
+    // Open via O_NOFOLLOW + fstat check so symlink/directory swaps cannot
+    // win the race between the test and the read. Subsequent file APIs
+    // operate on /proc/self/fd/<fd>.
+    gint safe_fd = path_open_safe_regular_file (path, err);
+    if (safe_fd < 0) {
+        return NULL;
+    }
+    g_autofree gchar *safe_path = g_strdup_printf ("/proc/self/fd/%d", 
safe_fd);
+
+    goffset fs = get_file_size (safe_path);
     if (fs < 10) {
-        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Couldn't 
get the file size (file doesn't exit or wrong file selected.");
+        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Couldn't 
get the file size (file doesn't exist or wrong file selected).");
+        g_close (safe_fd, NULL);
         return NULL;
     }
     if (fs > max_file_size) {
         g_set_error (err, file_too_big_gquark (), FILE_TOO_BIG_ERRCODE, 
FILE_SIZE_SECMEM_MSG);
+        g_close (safe_fd, NULL);
         return NULL;
     }
 
     gchar *file_buf = NULL;
-    if (!g_file_get_contents (path, &file_buf, NULL, err)) {
+    if (!g_file_get_contents (safe_path, &file_buf, NULL, err)) {
+        g_close (safe_fd, NULL);
         return NULL;
     }
 
     set_otps_from_uris (file_buf, &otps);
 
     g_free (file_buf);
+    g_close (safe_fd, NULL);
 
     return otps;
 }
@@ -147,6 +169,12 @@
     if (g_ascii_strncasecmp (uri_copy, "otpauth://", 10) != 0) {
         return;
     }
+    // Cap individual URI length to prevent multi-gigabyte allocations from
+    // pathologically-long tokens in attacker-supplied input.
+    if (strnlen (uri_copy, MAX_OTPAUTH_URI_LEN + 1) > MAX_OTPAUTH_URI_LEN) {
+        g_warning ("Skipping otpauth:// URI longer than %d bytes.", 
MAX_OTPAUTH_URI_LEN);
+        return;
+    }
     uri_copy += 10;
 
     otp_t *otp = g_new0 (otp_t, 1);
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/OTPClient-4.4.2/src/common/twofas.c 
new/OTPClient-4.5.0/src/common/twofas.c
--- old/OTPClient-4.4.2/src/common/twofas.c     2026-04-17 15:34:59.000000000 
+0200
+++ new/OTPClient-4.5.0/src/common/twofas.c     2026-04-22 10:21:17.000000000 
+0200
@@ -1,7 +1,9 @@
 #include <glib.h>
+#include <glib/gstdio.h>
 #include <gio/gio.h>
 #include <jansson.h>
 #include <gcrypt.h>
+#include <string.h>
 #include <glib/gi18n.h>
 
 #include "gquarks.h"
@@ -50,12 +52,15 @@
                  gsize         db_size,
                  GError      **err)
 {
-    if (g_file_test (path, G_FILE_TEST_IS_SYMLINK | G_FILE_TEST_IS_DIR) ) {
-        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Selected 
file is either a symlink or a directory.");
+    // Open via O_NOFOLLOW + fstat check so symlink/directory swaps cannot
+    // win the race between the test and the read.
+    gint safe_fd = path_open_safe_regular_file (path, err);
+    if (safe_fd < 0) {
         return NULL;
     }
+    g_autofree gchar *safe_path = g_strdup_printf ("/proc/self/fd/%d", 
safe_fd);
 
-    goffset input_size = get_file_size (path);
+    goffset input_size = get_file_size (safe_path);
     if (!is_secmem_available ((db_size + input_size)  * 
SECMEM_REQUIRED_MULTIPLIER, err)) {
         g_autofree gchar *msg = g_strdup_printf (_(
             "Your system's secure memory limit is not enough to securely 
import the data.\n"
@@ -64,10 +69,13 @@
             "This requires administrator privileges and is a system-wide 
setting that OTPClient cannot change automatically."
         ));
         g_set_error (err, secmem_alloc_error_gquark (), 
NO_SECMEM_AVAIL_ERRCODE, "%s", msg);
+        g_close (safe_fd, NULL);
         return NULL;
     }
 
-    return (password != NULL) ? get_otps_from_encrypted_backup (path, 
password, err) : get_otps_from_plain_backup (path, err);
+    GSList *result = (password != NULL) ? get_otps_from_encrypted_backup 
(safe_path, password, err) : get_otps_from_plain_backup (safe_path, err);
+    g_close (safe_fd, NULL);
+    return result;
 }
 
 
@@ -123,14 +131,16 @@
             json_object_set (otp_obj, "account", json_string (label));
         }
 
-        gchar *algo = g_ascii_strup (json_string_value (json_object_get 
(db_obj, "algo")), -1);
+        const gchar *algo_str = json_string_value (json_object_get (db_obj, 
"algo"));
+        gchar *algo = g_ascii_strup (algo_str ? algo_str : "SHA1", -1);
         json_object_set (otp_obj, "algorithm", json_string (algo));
         g_free (algo);
 
         json_object_set (otp_obj, "digits", json_object_get (db_obj, 
"digits"));
         json_object_set (otp_obj, "source", json_string ("Manual"));
 
-        if (g_ascii_strcasecmp (json_string_value (json_object_get (db_obj, 
"type")), "TOTP") == 0) {
+        const gchar *type_str = json_string_value (json_object_get (db_obj, 
"type"));
+        if (type_str != NULL && g_ascii_strcasecmp (type_str, "TOTP") == 0) {
             json_object_set (otp_obj, "period", json_object_get (db_obj, 
"period"));
             json_object_set (otp_obj, "tokenType", json_string ("TOTP"));
         } else {
@@ -160,7 +170,8 @@
         guchar *iv = g_malloc0 (TWOFAS_IV);
         gcry_create_nonce (iv, TWOFAS_IV);
         guchar *derived_key = gcry_malloc_secure (32);
-        gpg_error_t g_err = gcry_kdf_derive (password, (gsize)g_utf8_strlen 
(password, -1), GCRY_KDF_PBKDF2, GCRY_MD_SHA256,
+        // gcry_kdf_derive expects the password length in BYTES, not Unicode 
characters.
+        gpg_error_t g_err = gcry_kdf_derive (password, strlen (password), 
GCRY_KDF_PBKDF2, GCRY_MD_SHA256,
                                              salt, TWOFAS_SALT, 
TWOFAS_KDF_ITERS, 32, derived_key);
         if (g_err != GPG_ERR_NO_ERROR) {
             g_printerr ("Failed to derive key: %s/%s\n", gcry_strsource 
(g_err), gcry_strerror (g_err));
@@ -259,7 +270,23 @@
     GSList *otps = NULL;
 
     json_t *root = get_json_root (path);
-    gchar **b64_encoded_data = g_strsplit (json_string_value (json_object_get 
(root, "servicesEncrypted")), ":", 3);
+    const gchar *enc_str = json_string_value (json_object_get (root, 
"servicesEncrypted"));
+    if (enc_str == NULL) {
+        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE,
+                     "2FAS backup is missing the 'servicesEncrypted' field.");
+        g_free (twofas_data);
+        json_decref (root);
+        return NULL;
+    }
+    gchar **b64_encoded_data = g_strsplit (enc_str, ":", 3);
+    if (g_strv_length (b64_encoded_data) < 3) {
+        g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE,
+                     "2FAS backup 'servicesEncrypted' is malformed (expected 3 
colon-separated fields).");
+        g_strfreev (b64_encoded_data);
+        g_free (twofas_data);
+        json_decref (root);
+        return NULL;
+    }
     decrypt_data ((const gchar **)b64_encoded_data, password, twofas_data);
     if (twofas_data->json_data != NULL) {
         otps = parse_twofas_json_data (twofas_data->json_data, err);
@@ -318,11 +345,19 @@
               const gchar *pwd,
               TwofasData   *twofas_data)
 {
-    gsize enc_data_with_tag_size, salt_out_len, iv_out_len;
+    gsize enc_data_with_tag_size = 0, salt_out_len = 0, iv_out_len = 0;
     guchar *enc_data_with_tag = g_base64_decode (b64_data[0], 
&enc_data_with_tag_size);
     twofas_data->salt = g_base64_decode (b64_data[1], &salt_out_len);
     twofas_data->iv = g_base64_decode (b64_data[2], &iv_out_len);
 
+    // Defend against under-sized ciphertext: the tag occupies the last
+    // TWOFAS_TAG bytes, so anything shorter would underflow enc_buf_size.
+    if (enc_data_with_tag_size < TWOFAS_TAG) {
+        g_printerr ("2FAS encrypted payload is shorter than the AEAD tag.\n");
+        g_free (enc_data_with_tag);
+        return;
+    }
+
     guchar tag[TWOFAS_TAG];
     gsize enc_buf_size = enc_data_with_tag_size - TWOFAS_TAG;
     guchar *enc_data = g_malloc0 (enc_buf_size);
@@ -331,7 +366,8 @@
     g_free (enc_data_with_tag);
 
     guchar *derived_key = gcry_malloc_secure (32);
-    gpg_error_t g_err = gcry_kdf_derive (pwd, (gsize)g_utf8_strlen (pwd, -1), 
GCRY_KDF_PBKDF2, GCRY_MD_SHA256,
+    // gcry_kdf_derive expects the password length in BYTES, not Unicode 
characters.
+    gpg_error_t g_err = gcry_kdf_derive (pwd, strlen (pwd), GCRY_KDF_PBKDF2, 
GCRY_MD_SHA256,
                                          twofas_data->salt, salt_out_len, 
TWOFAS_KDF_ITERS, 32, derived_key);
     if (g_err != GPG_ERR_NO_ERROR) {
         g_printerr ("Failed to derive key: %s/%s\n", gcry_strsource (g_err), 
gcry_strerror (g_err));
@@ -350,7 +386,9 @@
     twofas_data->json_data = gcry_calloc_secure (enc_buf_size, 1);
     gpg_error_t gpg_err = gcry_cipher_decrypt (hd, twofas_data->json_data, 
enc_buf_size, enc_data, enc_buf_size);
     if (gpg_err) {
-        g_printerr ("Failed to decrypt data: %s/%s\n", gcry_strsource (g_err), 
gcry_strerror (g_err));
+        g_printerr ("Failed to decrypt data: %s/%s\n", gcry_strsource 
(gpg_err), gcry_strerror (gpg_err));
+        gcry_free (twofas_data->json_data);
+        twofas_data->json_data = NULL;
         gcry_free (derived_key);
         g_free (enc_data);
         gcry_cipher_close (hd);
@@ -359,7 +397,11 @@
 
     gpg_err = gcry_cipher_checktag (hd, tag, TWOFAS_TAG);
     if (gpg_err) {
-        g_printerr ("Failed to verify the tag: %s/%s\n", gcry_strsource 
(g_err), gcry_strerror (g_err));
+        // Tag mismatch: discard the decrypted plaintext so the caller
+        // (which only checks json_data != NULL) does not parse 
unauthenticated data.
+        g_printerr ("Failed to verify the tag: %s/%s\n", gcry_strsource 
(gpg_err), gcry_strerror (gpg_err));
+        gcry_free (twofas_data->json_data);
+        twofas_data->json_data = NULL;
     }
 
     gcry_cipher_close (hd);
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/OTPClient-4.4.2/src/gui/data.h 
new/OTPClient-4.5.0/src/gui/data.h
--- old/OTPClient-4.4.2/src/gui/data.h  2026-04-17 15:34:59.000000000 +0200
+++ new/OTPClient-4.5.0/src/gui/data.h  2026-04-22 10:21:17.000000000 +0200
@@ -65,6 +65,8 @@
 
     gboolean search_provider_enabled;
 
+    gchar *search_provider_keyword;
+
     GtkWidget *diag_rcdb;
     GtkFileChooserAction open_db_file_action;
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/OTPClient-4.4.2/src/gui/otpclient-application.c 
new/OTPClient-4.5.0/src/gui/otpclient-application.c
--- old/OTPClient-4.4.2/src/gui/otpclient-application.c 2026-04-17 
15:34:59.000000000 +0200
+++ new/OTPClient-4.5.0/src/gui/otpclient-application.c 2026-04-22 
10:21:17.000000000 +0200
@@ -3,6 +3,7 @@
 #include <jansson.h>
 #include <libsecret/secret.h>
 #include <glib/gi18n.h>
+#include <glib-unix.h>
 #include "otpclient-application.h"
 #include "otpclient-window.h"
 #include "otpclient.h"
@@ -49,8 +50,14 @@
 
 static void     cleanup_app_data       (AppData            *app_data);
 
+static void     otpclient_application_startup  (GApplication *app);
+
 static void     otpclient_application_shutdown (GApplication *app);
 
+static void     clear_session_clipboard        (void);
+
+static gboolean dispatch_sighandler            (gpointer      user_data);
+
 static void     load_validity_colors   (GKeyFile           *kf,
                                         AppData            *app_data);
 
@@ -392,6 +399,7 @@
     GApplicationClass *app_class   = G_APPLICATION_CLASS (klass);
 
     object_class->finalize = otpclient_application_finalize;
+    app_class->startup     = otpclient_application_startup;
     app_class->activate    = otpclient_application_activate;
     app_class->shutdown    = otpclient_application_shutdown;
 }
@@ -451,6 +459,15 @@
         } else {
             app_data->search_provider_enabled = TRUE;
         }
+        g_free (app_data->search_provider_keyword);
+        if (g_key_file_has_key (kf, "config", "search_provider_keyword", 
NULL)) {
+            app_data->search_provider_keyword =
+                g_key_file_get_string (kf, "config", 
"search_provider_keyword", NULL);
+            if (app_data->search_provider_keyword == NULL)
+                app_data->search_provider_keyword = g_strdup ("otp");
+        } else {
+            app_data->search_provider_keyword = g_strdup ("otp");
+        }
         load_validity_colors (kf, app_data);
         g_object_set (gtk_settings_get_default (),
                       "gtk-application-prefer-dark-theme", 
app_data->use_dark_theme, NULL);
@@ -553,6 +570,7 @@
     app_data->is_reorder_active      = FALSE;
     app_data->use_tray               = FALSE;
     app_data->search_provider_enabled = TRUE;
+    app_data->search_provider_keyword = g_strdup ("otp");
     app_data->open_db_file_action    = GTK_FILE_CHOOSER_ACTION_SAVE;
     app_data->window_width           = 0;
     app_data->window_height          = 0;
@@ -611,14 +629,31 @@
         g_free (app_data->db_data);
     }
 
+    g_clear_pointer (&app_data->search_provider_keyword, g_free);
+
     g_free (app_data);
 }
 
 static void
+otpclient_application_startup (GApplication *app)
+{
+    G_APPLICATION_CLASS (otpclient_application_parent_class)->startup (app);
+
+    /* Wipe any OTP we may have written to the system clipboard if the
+     * process is killed via SIGINT/SIGTERM/SIGHUP. Best-effort: failures
+     * here are non-fatal. */
+    g_unix_signal_add (SIGINT,  dispatch_sighandler, app);
+    g_unix_signal_add (SIGTERM, dispatch_sighandler, app);
+    g_unix_signal_add (SIGHUP,  dispatch_sighandler, app);
+}
+
+static void
 otpclient_application_shutdown (GApplication *app)
 {
     OtpclientApplication *self = OTPCLIENT_APPLICATION (app);
 
+    clear_session_clipboard ();
+
     if (self->app_data != NULL) {
         destroy_cb (NULL, self->app_data);
         /* destroy_cb frees app_data; clear the pointer so finalize
@@ -628,3 +663,25 @@
 
     G_APPLICATION_CLASS (otpclient_application_parent_class)->shutdown (app);
 }
+
+static void
+clear_session_clipboard (void)
+{
+    /* Best-effort: only do this if a default display exists. Headless
+     * processes (e.g. test runs) and processes that exit before any UI
+     * activates can trip on gtk_clipboard_get without one. */
+    if (gdk_display_get_default () == NULL) return;
+    GtkClipboard *cb = gtk_clipboard_get (GDK_SELECTION_CLIPBOARD);
+    if (cb != NULL) {
+        gtk_clipboard_set_text (cb, "", -1);
+    }
+}
+
+static gboolean
+dispatch_sighandler (gpointer user_data)
+{
+    GApplication *app = G_APPLICATION (user_data);
+    clear_session_clipboard ();
+    g_application_quit (app);
+    return G_SOURCE_REMOVE;
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/OTPClient-4.4.2/src/gui/settings-cb.c 
new/OTPClient-4.5.0/src/gui/settings-cb.c
--- old/OTPClient-4.4.2/src/gui/settings-cb.c   2026-04-17 15:34:59.000000000 
+0200
+++ new/OTPClient-4.5.0/src/gui/settings-cb.c   2026-04-22 10:21:17.000000000 
+0200
@@ -19,6 +19,7 @@
     GtkWidget *validity_color_btn;
     GtkWidget *validity_warning_color_btn;
     GtkWidget *search_provider_switch;
+    GtkWidget *search_provider_keyword_entry;
     AppData *app_data;
 } SettingsData;
 
@@ -46,6 +47,10 @@
                                                gboolean     state,
                                                gpointer     user_data);
 
+static gboolean handle_search_provider_switch (GtkSwitch   *sw,
+                                               gboolean     state,
+                                               gpointer     user_data);
+
 
 void
 settings_dialog_cb (GSimpleAction *simple UNUSED,
@@ -82,6 +87,8 @@
         g_key_file_set_boolean (kf, "config", "use_secret_service", 
app_data->use_secret_service);
         g_key_file_set_boolean (kf, "config", "use_tray", app_data->use_tray);
         g_key_file_set_boolean (kf, "config", "search_provider_enabled", 
app_data->search_provider_enabled);
+        g_key_file_set_string (kf, "config", "search_provider_keyword",
+                               app_data->search_provider_keyword ? 
app_data->search_provider_keyword : "otp");
         if (!g_key_file_save_to_file (kf, cfg_file_path, &err)) {
             gchar *msg = g_strconcat (_("Couldn't save default settings: "), 
err->message, NULL);
             show_message_dialog (app_data->main_window, msg, 
GTK_MESSAGE_WARNING);
@@ -118,6 +125,11 @@
         if (search_err != NULL) {
             g_clear_error (&search_err);
         }
+        g_free (app_data->search_provider_keyword);
+        app_data->search_provider_keyword = g_key_file_get_string (kf, 
"config", "search_provider_keyword", NULL);
+        if (app_data->search_provider_keyword == NULL) {
+            app_data->search_provider_keyword = g_strdup ("otp");
+        }
         if (err != NULL && g_error_matches (err, G_KEY_FILE_ERROR, 
G_KEY_FILE_ERROR_KEY_NOT_FOUND)) {
             // if the key is not found, we set it to TRUE and save it to the 
config file.
             app_data->use_secret_service = TRUE;
@@ -142,6 +154,8 @@
     g_signal_connect (settings_data->dss_switch, "state-set", 
G_CALLBACK(handle_autolock), settings_data);
     settings_data->tray_switch = GTK_WIDGET(gtk_builder_get_object (builder, 
"tray_switch_id"));
     settings_data->search_provider_switch = GTK_WIDGET(gtk_builder_get_object 
(builder, "search_provider_switch_id"));
+    settings_data->search_provider_keyword_entry = 
GTK_WIDGET(gtk_builder_get_object (builder, 
"search_provider_keyword_entry_id"));
+    g_signal_connect (settings_data->search_provider_switch, "state-set", 
G_CALLBACK(handle_search_provider_switch), settings_data);
     #ifdef ENABLE_MINIMIZE_TO_TRAY
     g_signal_connect (settings_data->tray_switch, "state-set", 
G_CALLBACK(handle_tray_switch), settings_data);
     #else
@@ -162,6 +176,12 @@
     gtk_switch_set_active (GTK_SWITCH(settings_data->dss_switch), 
app_data->use_secret_service);
     gtk_switch_set_active (GTK_SWITCH(settings_data->tray_switch), 
app_data->use_tray);
     gtk_switch_set_active (GTK_SWITCH(settings_data->search_provider_switch), 
app_data->search_provider_enabled);
+    if (settings_data->search_provider_keyword_entry != NULL) {
+        gtk_entry_set_max_length 
(GTK_ENTRY(settings_data->search_provider_keyword_entry), 32);
+        gtk_entry_set_text 
(GTK_ENTRY(settings_data->search_provider_keyword_entry),
+                            app_data->search_provider_keyword ? 
app_data->search_provider_keyword : "otp");
+        gtk_widget_set_sensitive 
(settings_data->search_provider_keyword_entry, 
app_data->search_provider_enabled);
+    }
     gchar *active_id_string = g_strdup_printf ("%d", 
app_data->inactivity_timeout);
     gtk_combo_box_set_active_id (GTK_COMBO_BOX(settings_data->inactivity_cb), 
active_id_string);
     g_free (active_id_string);
@@ -187,6 +207,13 @@
             app_data->use_secret_service = gtk_switch_get_active 
(GTK_SWITCH(settings_data->dss_switch));
             app_data->use_tray = gtk_switch_get_active 
(GTK_SWITCH(settings_data->tray_switch));
             app_data->search_provider_enabled = gtk_switch_get_active 
(GTK_SWITCH(settings_data->search_provider_switch));
+            if (settings_data->search_provider_keyword_entry != NULL) {
+                const gchar *kw_text = gtk_entry_get_text 
(GTK_ENTRY(settings_data->search_provider_keyword_entry));
+                gchar *trimmed = g_strdup (kw_text ? kw_text : "");
+                g_strstrip (trimmed);
+                g_free (app_data->search_provider_keyword);
+                app_data->search_provider_keyword = trimmed;
+            }
             g_key_file_set_boolean (kf, "config", "show_next_otp", 
app_data->show_next_otp);
             g_key_file_set_boolean (kf, "config", "notifications", 
app_data->disable_notifications);
             g_key_file_set_boolean (kf, "config", "show_validity_seconds", 
app_data->show_validity_seconds);
@@ -202,6 +229,8 @@
             g_key_file_set_boolean (kf, "config", "use_secret_service", 
app_data->use_secret_service);
             g_key_file_set_boolean (kf, "config", "use_tray", 
app_data->use_tray);
             g_key_file_set_boolean (kf, "config", "search_provider_enabled", 
app_data->search_provider_enabled);
+            g_key_file_set_string (kf, "config", "search_provider_keyword",
+                                   app_data->search_provider_keyword ? 
app_data->search_provider_keyword : "");
             if (old_ss_value == TRUE && app_data->use_secret_service == FALSE) 
{
                 // secret service was just disabled, so we have to clear the 
password from the keyring
                 secret_password_clear (OTPCLIENT_SCHEMA, NULL, 
on_password_cleared, NULL, "string", "main_pwd", NULL);
@@ -370,4 +399,18 @@
     gtk_switch_set_state (sw, state);
 
     return TRUE;
+}
+
+static gboolean
+handle_search_provider_switch (GtkSwitch *sw,
+                               gboolean   state,
+                               gpointer   user_data)
+{
+    CAST_USER_DATA(SettingsData, settings_data, user_data);
+    if (settings_data->search_provider_keyword_entry != NULL) {
+        gtk_widget_set_sensitive 
(settings_data->search_provider_keyword_entry, state);
+    }
+    gtk_switch_set_state (sw, state);
+
+    return TRUE;
 }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/OTPClient-4.4.2/src/gui/ui/otpclient.ui 
new/OTPClient-4.5.0/src/gui/ui/otpclient.ui
--- old/OTPClient-4.4.2/src/gui/ui/otpclient.ui 2026-04-17 15:34:59.000000000 
+0200
+++ new/OTPClient-4.5.0/src/gui/ui/otpclient.ui 2026-04-22 10:21:17.000000000 
+0200
@@ -2431,10 +2431,30 @@
               </packing>
             </child>
             <child>
-              <placeholder/>
+              <object class="GtkLabel">
+                <property name="visible">True</property>
+                <property name="can-focus">False</property>
+                <property name="label" translatable="yes">Search provider 
keyword</property>
+              </object>
+              <packing>
+                <property name="left-attach">0</property>
+                <property name="top-attach">11</property>
+              </packing>
             </child>
             <child>
-              <placeholder/>
+              <object class="GtkEntry" id="search_provider_keyword_entry_id">
+                <property name="visible">True</property>
+                <property name="can-focus">True</property>
+                <property name="halign">center</property>
+                <property name="valign">center</property>
+                <property name="max-length">32</property>
+                <property name="width-chars">12</property>
+                <property name="placeholder-text" 
translatable="yes">otp</property>
+              </object>
+              <packing>
+                <property name="left-attach">1</property>
+                <property name="top-attach">11</property>
+              </packing>
             </child>
             <child>
               <placeholder/>
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/OTPClient-4.4.2/src/search-provider/search-provider.c 
new/OTPClient-4.5.0/src/search-provider/search-provider.c
--- old/OTPClient-4.4.2/src/search-provider/search-provider.c   2026-04-17 
15:34:59.000000000 +0200
+++ new/OTPClient-4.5.0/src/search-provider/search-provider.c   2026-04-22 
10:21:17.000000000 +0200
@@ -18,8 +18,18 @@
 #define GNOME_BUS "com.github.paolostivanin.OTPClient.SearchProvider"
 #define GNOME_PATH "/com/github/paolostivanin/OTPClient/SearchProvider"
 
+#define OTPCLIENT_SEARCH_KEYWORD_MAX_LEN 32
+
 static gint32 global_max_file_size = 0;
 
+/* Trigger keyword loaded once at startup. The daemon ignores any query whose
+ * first whitespace-separated token doesn't equal g_keyword (case-insensitive).
+ * An empty keyword disables the filter and falls back to plain substring
+ * matching. Changes to the config key only take effect after the daemon
+ * is restarted. */
+static gchar *g_keyword = NULL;
+static gchar *g_keyword_fold = NULL;
+
 typedef struct otp_search_entry_t {
     gchar *id;
     gchar *label;
@@ -37,6 +47,9 @@
 static gchar *get_db_path (void);
 static gboolean get_use_secret_service (void);
 static gboolean get_search_provider_enabled (void);
+static gchar *get_search_provider_keyword (void);
+static void load_keyword_config (void);
+static gboolean strip_keyword_or_skip (gchar **terms, gchar ***out_terms);
 
 /* --- Introspection XML --- */
 static const gchar *krunner_introspection_xml =
@@ -61,65 +74,110 @@
 
 /* --- Helpers Implementation --- */
 
-static gchar *get_db_path (void) {
-    gchar *db_path = NULL;
-    gchar *cfg_file_path = NULL;
-    GKeyFile *kf = g_key_file_new ();
+/* Returns a freshly-loaded GKeyFile from the user config, or NULL
+ * if the file is missing or unreadable. Caller must g_key_file_free(). */
+static GKeyFile *open_user_config (void) {
 #ifdef IS_FLATPAK
-    cfg_file_path = g_build_filename (g_get_user_data_dir (), "otpclient.cfg", 
NULL);
+    g_autofree gchar *cfg_file_path = g_build_filename (g_get_user_data_dir 
(), "otpclient.cfg", NULL);
 #else
-    cfg_file_path = g_build_filename (g_get_user_config_dir (), 
"otpclient.cfg", NULL);
+    g_autofree gchar *cfg_file_path = g_build_filename (g_get_user_config_dir 
(), "otpclient.cfg", NULL);
 #endif
-    if (g_file_test (cfg_file_path, G_FILE_TEST_EXISTS)) {
-        if (g_key_file_load_from_file (kf, cfg_file_path, G_KEY_FILE_NONE, 
NULL)) {
-            db_path = g_key_file_get_string (kf, "config", "db_path", NULL);
-        }
+    if (!g_file_test (cfg_file_path, G_FILE_TEST_EXISTS)) return NULL;
+    GKeyFile *kf = g_key_file_new ();
+    if (!g_key_file_load_from_file (kf, cfg_file_path, G_KEY_FILE_NONE, NULL)) 
{
+        g_key_file_free (kf);
+        return NULL;
     }
+    return kf;
+}
+
+static gchar *get_db_path (void) {
+    GKeyFile *kf = open_user_config ();
+    if (kf == NULL) return NULL;
+    gchar *db_path = g_key_file_get_string (kf, "config", "db_path", NULL);
     g_key_file_free (kf);
-    g_free (cfg_file_path);
     return db_path;
 }
 
 static gboolean get_use_secret_service (void) {
-    gboolean use_secret_service = TRUE;
-    GKeyFile *kf = g_key_file_new ();
-    gchar *cfg_file_path = NULL;
-#ifdef IS_FLATPAK
-    cfg_file_path = g_build_filename (g_get_user_data_dir (), "otpclient.cfg", 
NULL);
-#else
-    cfg_file_path = g_build_filename (g_get_user_config_dir (), 
"otpclient.cfg", NULL);
-#endif
-    if (g_file_test (cfg_file_path, G_FILE_TEST_EXISTS)) {
-        if (g_key_file_load_from_file (kf, cfg_file_path, G_KEY_FILE_NONE, 
NULL)) {
-            use_secret_service = g_key_file_get_boolean (kf, "config", 
"use_secret_service", NULL);
-        }
-    }
+    GKeyFile *kf = open_user_config ();
+    if (kf == NULL) return TRUE;
+    gboolean use_secret_service = g_key_file_get_boolean (kf, "config", 
"use_secret_service", NULL);
     g_key_file_free (kf);
-    g_free (cfg_file_path);
     return use_secret_service;
 }
 
 static gboolean get_search_provider_enabled (void) {
-    gboolean enabled = TRUE;
-    gchar *cfg_file_path = NULL;
-    GKeyFile *kf = g_key_file_new ();
-#ifdef IS_FLATPAK
-    cfg_file_path = g_build_filename (g_get_user_data_dir (), "otpclient.cfg", 
NULL);
-#else
-    cfg_file_path = g_build_filename (g_get_user_config_dir (), 
"otpclient.cfg", NULL);
-#endif
-    if (g_file_test (cfg_file_path, G_FILE_TEST_EXISTS)) {
-        if (g_key_file_load_from_file (kf, cfg_file_path, G_KEY_FILE_NONE, 
NULL)) {
-            GError *err = NULL;
-            enabled = g_key_file_get_boolean (kf, "config", 
"search_provider_enabled", &err);
-            if (err) { enabled = TRUE; g_error_free(err); }
-        }
-    }
+    GKeyFile *kf = open_user_config ();
+    if (kf == NULL) return TRUE;
+    GError *err = NULL;
+    gboolean enabled = g_key_file_get_boolean (kf, "config", 
"search_provider_enabled", &err);
+    if (err) { enabled = TRUE; g_clear_error (&err); }
     g_key_file_free (kf);
-    g_free (cfg_file_path);
     return enabled;
 }
 
+static gchar *get_search_provider_keyword (void) {
+    GKeyFile *kf = open_user_config ();
+    if (kf == NULL) return g_strdup ("otp");
+    gchar *keyword = g_key_file_get_string (kf, "config", 
"search_provider_keyword", NULL);
+    g_key_file_free (kf);
+    if (keyword == NULL) keyword = g_strdup ("otp");
+    return keyword;
+}
+
+static void load_keyword_config (void) {
+    g_free (g_keyword);
+    g_free (g_keyword_fold);
+    g_keyword = get_search_provider_keyword ();
+    if (g_keyword == NULL) g_keyword = g_strdup ("");
+    g_strstrip (g_keyword);
+    /* Defense in depth against arbitrary config edits: a runaway-length
+     * keyword would still get casefolded and compared on every query.
+     * Truncate by UTF-8 character count so we don't slice a multi-byte
+     * sequence. */
+    glong char_len = g_utf8_strlen (g_keyword, -1);
+    if (char_len > OTPCLIENT_SEARCH_KEYWORD_MAX_LEN) {
+        const gchar *cut = g_utf8_offset_to_pointer (g_keyword, 
OTPCLIENT_SEARCH_KEYWORD_MAX_LEN);
+        *((gchar *) cut) = '\0';
+    }
+    g_keyword_fold = g_utf8_casefold (g_keyword, -1);
+}
+
+/* Returns TRUE and writes the post-keyword tail into *out_terms (caller frees
+ * with g_strfreev) when the first non-empty term equals the configured
+ * keyword AND there is at least one further non-empty term. Returns FALSE and
+ * leaves *out_terms NULL otherwise. If the keyword is empty/disabled, behaves
+ * transparently: returns TRUE with the original terms duplicated. */
+static gboolean strip_keyword_or_skip (gchar **terms, gchar ***out_terms) {
+    *out_terms = NULL;
+    if (terms == NULL) return FALSE;
+
+    if (g_keyword_fold == NULL || g_keyword_fold[0] == '\0') {
+        *out_terms = g_strdupv (terms);
+        return TRUE;
+    }
+
+    gsize i = 0;
+    while (terms[i] != NULL && terms[i][0] == '\0') i++;
+    if (terms[i] == NULL) return FALSE;
+
+    g_autofree gchar *first_fold = g_utf8_casefold (terms[i], -1);
+    if (g_strcmp0 (first_fold, g_keyword_fold) != 0) return FALSE;
+
+    GPtrArray *tail = g_ptr_array_new ();
+    for (gsize j = i + 1; terms[j] != NULL; j++) {
+        if (terms[j][0] != '\0') g_ptr_array_add (tail, g_strdup (terms[j]));
+    }
+    if (tail->len == 0) {
+        g_ptr_array_free (tail, TRUE);
+        return FALSE;
+    }
+    g_ptr_array_add (tail, NULL);
+    *out_terms = (gchar **) g_ptr_array_free (tail, FALSE);
+    return TRUE;
+}
+
 static void otp_search_entry_free (OtpSearchEntry *entry) {
     if (!entry) return;
     g_free (entry->id); g_free (entry->label); g_free (entry->issuer); g_free 
(entry->otp_value);
@@ -279,12 +337,16 @@
         }
         GVariantBuilder builder;
         g_variant_builder_init (&builder, G_VARIANT_TYPE ("as"));
-        GPtrArray *entries = load_entries ();
-        for (guint i = 0; i < entries->len; i++) {
-            OtpSearchEntry *e = g_ptr_array_index (entries, i);
-            if (entry_matches_terms (e, terms, g_strv_length(terms))) 
g_variant_builder_add (&builder, "s", e->id);
+        g_auto(GStrv) stripped = NULL;
+        if (strip_keyword_or_skip (terms, &stripped)) {
+            GPtrArray *entries = load_entries ();
+            gsize stripped_len = g_strv_length (stripped);
+            for (guint i = 0; i < entries->len; i++) {
+                OtpSearchEntry *e = g_ptr_array_index (entries, i);
+                if (entry_matches_terms (e, stripped, stripped_len)) 
g_variant_builder_add (&builder, "s", e->id);
+            }
+            g_ptr_array_free (entries, TRUE);
         }
-        g_ptr_array_free (entries, TRUE);
         g_dbus_method_invocation_return_value (inv, g_variant_new ("(as)", 
&builder));
         g_strfreev (terms);
     } else if (g_strcmp0 (method, "GetResultMetas") == 0) {
@@ -341,20 +403,27 @@
         g_variant_builder_init (&builder, G_VARIANT_TYPE ("a(sssida{sv})"));
         if (query && *query) {
             g_auto(GStrv) terms = g_strsplit_set (query, " \t", -1);
-            gsize terms_len = g_strv_length (terms);
-            GPtrArray *entries = load_entries ();
-            for (guint i = 0; i < entries->len; i++) {
-                OtpSearchEntry *e = g_ptr_array_index (entries, i);
-                if (!entry_matches_terms (e, terms, terms_len)) continue;
-                if (!e->otp_value) continue;
-                GVariantBuilder props;
-                g_variant_builder_init (&props, G_VARIANT_TYPE ("a{sv}"));
-                g_autofree gchar *sub = (e->issuer && *e->issuer) ? 
g_strdup_printf ("%s • %s", e->issuer, e->otp_value) : g_strdup (e->otp_value);
-                g_variant_builder_add (&props, "{sv}", "subtext", 
g_variant_new_string (sub));
-                g_variant_builder_add (&props, "{sv}", "category", 
g_variant_new_string ("OTPClient"));
-                g_variant_builder_add (&builder, "(sssida{sv})", e->id, 
e->label, "com.github.paolostivanin.OTPClient", (gint32)0, (gdouble)1.0, 
&props);
+            g_auto(GStrv) stripped = NULL;
+            if (strip_keyword_or_skip (terms, &stripped)) {
+                gsize stripped_len = g_strv_length (stripped);
+                GPtrArray *entries = load_entries ();
+                for (guint i = 0; i < entries->len; i++) {
+                    OtpSearchEntry *e = g_ptr_array_index (entries, i);
+                    if (!entry_matches_terms (e, stripped, stripped_len)) 
continue;
+                    if (!e->otp_value) continue;
+                    GVariantBuilder props;
+                    g_variant_builder_init (&props, G_VARIANT_TYPE ("a{sv}"));
+                    // Deliberately do NOT include the OTP in the subtitle:
+                    // any process on the session bus can poll Match. The code
+                    // is only delivered via Run, where the user sees the
+                    // notification.
+                    g_autofree gchar *sub = g_strdup (e->issuer ? e->issuer : 
"");
+                    g_variant_builder_add (&props, "{sv}", "subtext", 
g_variant_new_string (sub));
+                    g_variant_builder_add (&props, "{sv}", "category", 
g_variant_new_string ("OTPClient"));
+                    g_variant_builder_add (&builder, "(sssida{sv})", e->id, 
e->label, "com.github.paolostivanin.OTPClient", (gint32)0, (gdouble)1.0, 
&props);
+                }
+                g_ptr_array_free (entries, TRUE);
             }
-            g_ptr_array_free (entries, TRUE);
         }
         GVariant *res = g_variant_builder_end (&builder);
         g_dbus_method_invocation_return_value (inv, g_variant_new_tuple (&res, 
1));
@@ -411,6 +480,9 @@
         else if (g_strcmp0 (argv[i], "--gnome") == 0) force_gnome = TRUE;
     }
     if (!get_search_provider_enabled ()) return 0;
+
+    load_keyword_config ();
+
     if (!force_kde && !force_gnome) {
         const gchar *desktop = g_getenv ("XDG_CURRENT_DESKTOP");
         if (desktop) {
@@ -437,5 +509,7 @@
     if (force_gnome) g_bus_own_name (G_BUS_TYPE_SESSION, GNOME_BUS, 
G_BUS_NAME_OWNER_FLAGS_NONE, on_gnome_bus_acquired, NULL, on_name_lost, NULL, 
NULL);
     g_main_loop_run (main_loop);
     g_main_loop_unref (main_loop);
+    g_clear_pointer (&g_keyword, g_free);
+    g_clear_pointer (&g_keyword_fold, g_free);
     return 0;
 }

Reply via email to