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; }
